diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalDocumentsView.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalDocumentsView.java index 6f82a815ddd..8db4d4b1a6c 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalDocumentsView.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalDocumentsView.java @@ -69,6 +69,21 @@ private MaybeDocument getDocument(DocumentKey key, List inBatches return document; } + // Returns the view of the given {@code docs} as they would appear after applying all mutations in + // the given {@code batches}. + private Map applyLocalMutationsToDocuments( + Map docs, List batches) { + for (Map.Entry base : docs.entrySet()) { + MaybeDocument localView = base.getValue(); + for (MutationBatch batch : batches) { + localView = batch.applyToLocalView(base.getKey(), localView); + } + base.setValue(localView); + } + + return docs; + } + /** * Gets the local view of the documents identified by {@code keys}. * @@ -76,13 +91,24 @@ private MaybeDocument getDocument(DocumentKey key, List inBatches * for that key in the resulting set. */ ImmutableSortedMap getDocuments(Iterable keys) { + Map docs = remoteDocumentCache.getAll(keys); + return getLocalViewOfDocuments(docs); + } + + /** + * Similar to {@code #getDocuments}, but creates the local view from the given {@code baseDocs} + * without retrieving documents from the local store. + */ + ImmutableSortedMap getLocalViewOfDocuments( + Map baseDocs) { ImmutableSortedMap results = emptyMaybeDocumentMap(); - List batches = mutationQueue.getAllMutationBatchesAffectingDocumentKeys(keys); - for (DocumentKey key : keys) { - // TODO: PERF: Consider fetching all remote documents at once rather than - // one-by-one. - MaybeDocument maybeDoc = getDocument(key, batches); + List batches = + mutationQueue.getAllMutationBatchesAffectingDocumentKeys(baseDocs.keySet()); + Map docs = applyLocalMutationsToDocuments(baseDocs, batches); + for (Map.Entry entry : docs.entrySet()) { + DocumentKey key = entry.getKey(); + MaybeDocument maybeDoc = entry.getValue(); // TODO: Don't conflate missing / deleted. if (maybeDoc == null) { maybeDoc = new NoDocument(key, SnapshotVersion.NONE, /*hasCommittedMutations=*/ false); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalSerializer.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalSerializer.java index 55c8bd146ab..3805ecffa6a 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalSerializer.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalSerializer.java @@ -48,13 +48,19 @@ public LocalSerializer(RemoteSerializer rpcSerializer) { com.google.firebase.firestore.proto.MaybeDocument encodeMaybeDocument(MaybeDocument document) { com.google.firebase.firestore.proto.MaybeDocument.Builder builder = com.google.firebase.firestore.proto.MaybeDocument.newBuilder(); + if (document instanceof NoDocument) { NoDocument noDocument = (NoDocument) document; builder.setNoDocument(encodeNoDocument(noDocument)); builder.setHasCommittedMutations(noDocument.hasCommittedMutations()); } else if (document instanceof Document) { Document existingDocument = (Document) document; - builder.setDocument(encodeDocument(existingDocument)); + // Use the memoized encoded form if it exists. + if (existingDocument.getProto() != null) { + builder.setDocument(existingDocument.getProto()); + } else { + builder.setDocument(encodeDocument(existingDocument)); + } builder.setHasCommittedMutations(existingDocument.hasCommittedMutations()); } else if (document instanceof UnknownDocument) { builder.setUnknownDocument(encodeUnknownDocument((UnknownDocument) document)); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java index 6c203edd3ad..46d2d480b62 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java @@ -35,6 +35,7 @@ import com.google.firebase.firestore.remote.TargetChange; import com.google.firebase.firestore.util.Logger; import com.google.protobuf.ByteString; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -329,14 +330,19 @@ public ImmutableSortedMap applyRemoteEvent(RemoteEve } } - Set changedDocKeys = new HashSet<>(); + Map changedDocs = new HashMap<>(); Map documentUpdates = remoteEvent.getDocumentUpdates(); Set limboDocuments = remoteEvent.getResolvedLimboDocuments(); + // Each loop iteration only affects its "own" doc, so it's safe to get all the remote + // documents in advance in a single call. + Map existingDocs = + remoteDocuments.getAll(documentUpdates.keySet()); + for (Entry entry : documentUpdates.entrySet()) { DocumentKey key = entry.getKey(); MaybeDocument doc = entry.getValue(); - changedDocKeys.add(key); - MaybeDocument existingDoc = remoteDocuments.get(key); + MaybeDocument existingDoc = existingDocs.get(key); + // If a document update isn't authoritative, make sure we don't // apply an old document version to the remote cache. We make an // exception for SnapshotVersion.MIN which can happen for @@ -347,6 +353,7 @@ public ImmutableSortedMap applyRemoteEvent(RemoteEve || (authoritativeUpdates.contains(doc.getKey()) && !existingDoc.hasPendingWrites()) || doc.getVersion().compareTo(existingDoc.getVersion()) >= 0) { remoteDocuments.add(doc); + changedDocs.put(key, doc); } else { Logger.debug( "LocalStore", @@ -376,7 +383,7 @@ public ImmutableSortedMap applyRemoteEvent(RemoteEve queryCache.setLastRemoteSnapshotVersion(remoteVersion); } - return localDocuments.getDocuments(changedDocKeys); + return localDocuments.getLocalViewOfDocuments(changedDocs); }); } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java index f4209da214a..86c99a56b5f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java @@ -23,6 +23,7 @@ import com.google.firebase.firestore.model.DocumentKey; import com.google.firebase.firestore.model.MaybeDocument; import com.google.firebase.firestore.model.ResourcePath; +import java.util.HashMap; import java.util.Iterator; import java.util.Map; import javax.annotation.Nullable; @@ -53,6 +54,19 @@ public MaybeDocument get(DocumentKey key) { return docs.get(key); } + @Override + public Map getAll(Iterable keys) { + Map result = new HashMap<>(); + + for (DocumentKey key : keys) { + // Make sure each key has a corresponding entry, which is null in case the document is not + // found. + result.put(key, get(key)); + } + + return result; + } + @Override public ImmutableSortedMap getAllDocumentsMatchingQuery(Query query) { ImmutableSortedMap result = emptyDocumentMap(); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/RemoteDocumentCache.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/RemoteDocumentCache.java index f3e689f221f..1acd0de8df8 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/RemoteDocumentCache.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/RemoteDocumentCache.java @@ -19,6 +19,7 @@ import com.google.firebase.firestore.model.Document; import com.google.firebase.firestore.model.DocumentKey; import com.google.firebase.firestore.model.MaybeDocument; +import java.util.Map; import javax.annotation.Nullable; /** @@ -51,6 +52,15 @@ interface RemoteDocumentCache { @Nullable MaybeDocument get(DocumentKey documentKey); + /** + * Looks up a set of entries in the cache. + * + * @param documentKeys The keys of the entries to look up. + * @return The cached Document or NoDocument entries indexed by key. If an entry is not cached, + * the corresponding key will be mapped to a null value. + */ + Map getAll(Iterable documentKeys); + /** * Executes a query against the cached Document entries * diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteMutationQueue.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteMutationQueue.java index ccf5bb70490..41d74c1b1d3 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteMutationQueue.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteMutationQueue.java @@ -32,9 +32,9 @@ import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.MessageLite; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; -import java.util.Iterator; import java.util.List; import java.util.Set; import javax.annotation.Nullable; @@ -277,46 +277,29 @@ public List getAllMutationBatchesAffectingDocumentKey(DocumentKey @Override public List getAllMutationBatchesAffectingDocumentKeys( Iterable documentKeys) { - List result = new ArrayList<>(); - if (!documentKeys.iterator().hasNext()) { - return result; + List args = new ArrayList<>(); + for (DocumentKey key : documentKeys) { + args.add(EncodedPath.encode(key.getPath())); } - // SQLite limits maximum number of host parameters to 999 (see - // https://www.sqlite.org/limits.html). To work around this, split the given keys into several - // smaller sets and issue a separate query for each. - int limit = 900; - Iterator keyIter = documentKeys.iterator(); + SQLitePersistence.LongQuery longQuery = + new SQLitePersistence.LongQuery( + db, + "SELECT DISTINCT dm.batch_id, m.mutations FROM document_mutations dm, mutations m " + + "WHERE dm.uid = ? " + + "AND dm.path IN (", + Arrays.asList(uid), + args, + ") " + + "AND dm.uid = m.uid " + + "AND dm.batch_id = m.batch_id " + + "ORDER BY dm.batch_id"); + + List result = new ArrayList<>(); Set uniqueBatchIds = new HashSet<>(); - int queriesPerformed = 0; - while (keyIter.hasNext()) { - ++queriesPerformed; - StringBuilder placeholdersBuilder = new StringBuilder(); - List args = new ArrayList<>(); - args.add(uid); - - for (int i = 0; keyIter.hasNext() && i < limit; i++) { - DocumentKey key = keyIter.next(); - - if (i > 0) { - placeholdersBuilder.append(", "); - } - placeholdersBuilder.append("?"); - - args.add(EncodedPath.encode(key.getPath())); - } - String placeholders = placeholdersBuilder.toString(); - - db.query( - "SELECT DISTINCT dm.batch_id, m.mutations FROM document_mutations dm, mutations m " - + "WHERE dm.uid = ? " - + "AND dm.path IN (" - + placeholders - + ") " - + "AND dm.uid = m.uid " - + "AND dm.batch_id = m.batch_id " - + "ORDER BY dm.batch_id") - .binding(args.toArray()) + while (longQuery.hasMoreSubqueries()) { + longQuery + .performNextSubquery() .forEach( row -> { int batchId = row.getInt(0); @@ -330,7 +313,7 @@ public List getAllMutationBatchesAffectingDocumentKeys( // If more than one query was issued, batches might be in an unsorted order (batches are ordered // within one query's results, but not across queries). It's likely to be rare, so don't impose // performance penalty on the normal case. - if (queriesPerformed > 1) { + if (longQuery.getSubqueriesPerformed() > 1) { Collections.sort( result, (MutationBatch lhs, MutationBatch rhs) -> diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLitePersistence.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLitePersistence.java index 891aa091729..09498366ead 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLitePersistence.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLitePersistence.java @@ -35,6 +35,10 @@ import com.google.firebase.firestore.util.Supplier; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; import javax.annotation.Nullable; /** @@ -455,6 +459,140 @@ private Cursor startQuery() { } } + /** + * Encapsulates a query whose parameter list is so long that it might exceed SQLite limit. + * + *

SQLite limits maximum number of host parameters to 999 (see + * https://www.sqlite.org/limits.html). This class wraps most of the messy details of splitting a + * large query into several smaller ones. + * + *

The class is configured to contain a "template" for each subquery: + * + *

    + *
  1. head -- the beginning of the query, will be the same for each subquery + *
  2. tail -- the end of the query, also the same for each subquery + *
+ * + *

Then the host parameters will be inserted in-between head and tail; if there are too many + * arguments for a single query, several subqueries will be issued. Each subquery which will have + * the following form: + * + *

[head][an auto-generated comma-separated list of '?' placeholders][tail] + * + *

To use this class, keep calling {@link #performNextSubquery}, which will issue the next + * subquery, as long as {@link #hasMoreSubqueries} returns true. Note that if the parameter list + * is empty, not even a single query will be issued. + * + *

For example, imagine for demonstration purposes that the limit were 2, and the {@code + * LongQuery} was created like this: + * + *

+   *     String[] args = {"foo", "bar", "baz", "spam", "eggs"};
+   *     LongQuery longQuery = new LongQuery(
+   *         db,
+   *         "SELECT name WHERE id in (",
+   *         Arrays.asList(args),
+   *         ")"
+   *     );
+   * 
+ * + *

Assuming limit of 2, this query will issue three subqueries: + * + *

+   *     query.performNextSubquery(); // "SELECT name WHERE id in (?, ?)", binding "foo" and "bar"
+   *     query.performNextSubquery(); // "SELECT name WHERE id in (?, ?)", binding "baz" and "spam"
+   *     query.performNextSubquery(); // "SELECT name WHERE id in (?)", binding "eggs"
+   * 
+ */ + static class LongQuery { + private final SQLitePersistence db; + // The non-changing beginning of each subquery. + private final String head; + // The non-changing end of each subquery. + private final String tail; + // Arguments that will be prepended in each subquery before the main argument list. + private final List argsHead; + + private int subqueriesPerformed = 0; + private final Iterator argsIter; + + // Limit for the number of host parameters beyond which a query will be split into several + // subqueries. Deliberately set way below 999 as a safety measure because this class doesn't + // attempt to check for placeholders in the query {@link head}; if it only relied on the number + // of placeholders it itself generates, in that situation it would still exceed the SQLite + // limit. + private static final int LIMIT = 900; + + /** + * Creates a new {@code LongQuery} with parameters that describe a template for creating each + * subquery. + * + * @param db The database on which to execute the query. + * @param head The non-changing beginning of the query; each subquery will begin with this. + * @param allArgs The list of host parameters to bind. If the list size exceeds the limit, + * several subqueries will be issued, and the correct number of placeholders will be + * generated for each subquery. + * @param tail The non-changing end of the query; each subquery will end with this. + */ + LongQuery(SQLitePersistence db, String head, List allArgs, String tail) { + this.db = db; + this.head = head; + this.argsHead = Collections.emptyList(); + this.tail = tail; + + argsIter = allArgs.iterator(); + } + + /** + * The longer version of the constructor additionally takes {@code argsHead} parameter that + * contains parameters that will be reissued in each subquery, i.e. subqueries take the form: + * + *

[head][argsHead][an auto-generated comma-separated list of '?' placeholders][tail] + */ + LongQuery( + SQLitePersistence db, + String head, + List argsHead, + List allArgs, + String tail) { + this.db = db; + this.head = head; + this.argsHead = argsHead; + this.tail = tail; + + argsIter = allArgs.iterator(); + } + + /** Whether {@link #performNextSubquery} can be called. */ + boolean hasMoreSubqueries() { + return argsIter.hasNext(); + } + + /** Performs the next subquery and returns a {@link Query} object for method chaining. */ + Query performNextSubquery() { + ++subqueriesPerformed; + + List subqueryArgs = new ArrayList<>(argsHead); + StringBuilder placeholdersBuilder = new StringBuilder(); + for (int i = 0; argsIter.hasNext() && i < LIMIT - argsHead.size(); i++) { + if (i > 0) { + placeholdersBuilder.append(", "); + } + placeholdersBuilder.append("?"); + + subqueryArgs.add(argsIter.next()); + } + String placeholders = placeholdersBuilder.toString(); + + return db.query(head + placeholders + tail).binding(subqueryArgs.toArray()); + } + + /** How many subqueries were performed. */ + int getSubqueriesPerformed() { + return subqueriesPerformed; + } + } + /** * Binds the given arguments to the given SQLite statement or query. * diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteRemoteDocumentCache.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteRemoteDocumentCache.java index 49171428705..67f1524cb71 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteRemoteDocumentCache.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteRemoteDocumentCache.java @@ -24,7 +24,9 @@ import com.google.firebase.firestore.model.ResourcePath; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.MessageLite; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import javax.annotation.Nullable; @@ -66,6 +68,40 @@ public MaybeDocument get(DocumentKey documentKey) { .firstValue(row -> decodeMaybeDocument(row.getBlob(0))); } + @Override + public Map getAll(Iterable documentKeys) { + List args = new ArrayList<>(); + for (DocumentKey key : documentKeys) { + args.add(EncodedPath.encode(key.getPath())); + } + + Map results = new HashMap<>(); + for (DocumentKey key : documentKeys) { + // Make sure each key has a corresponding entry, which is null in case the document is not + // found. + results.put(key, null); + } + + SQLitePersistence.LongQuery longQuery = + new SQLitePersistence.LongQuery( + db, + "SELECT contents FROM remote_documents " + "WHERE path IN (", + args, + ") ORDER BY path"); + + while (longQuery.hasMoreSubqueries()) { + longQuery + .performNextSubquery() + .forEach( + row -> { + MaybeDocument decoded = decodeMaybeDocument(row.getBlob(0)); + results.put(decoded.getKey(), decoded); + }); + } + + return results; + } + @Override public ImmutableSortedMap getAllDocumentsMatchingQuery(Query query) { // Use the query path as a prefix for testing if a document matches the query. diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/Document.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/Document.java index 83eeaa28412..a0be1936ef6 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/Document.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/Document.java @@ -52,11 +52,34 @@ public static Comparator keyComparator() { private final DocumentState documentState; + /** + * Memoized serialized form of the document for optimization purposes (avoids repeated + * serialization). Might be null. + */ + private final com.google.firestore.v1beta1.Document proto; + + public @Nullable com.google.firestore.v1beta1.Document getProto() { + return proto; + } + public Document( DocumentKey key, SnapshotVersion version, ObjectValue data, DocumentState documentState) { super(key, version); this.data = data; this.documentState = documentState; + this.proto = null; + } + + public Document( + DocumentKey key, + SnapshotVersion version, + ObjectValue data, + DocumentState documentState, + com.google.firestore.v1beta1.Document proto) { + super(key, version); + this.data = data; + this.documentState = documentState; + this.proto = proto; } public ObjectValue getData() { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java index 1cd3d7d1c43..4eede2ee92f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/AbstractStream.java @@ -109,11 +109,13 @@ public void onHeaders(Metadata headers) { public void onNext(RespT response) { dispatcher.run( () -> { - Logger.debug( - AbstractStream.this.getClass().getSimpleName(), - "(%x) Stream received: %s", - System.identityHashCode(AbstractStream.this), - response); + if (Logger.isDebugEnabled()) { + Logger.debug( + AbstractStream.this.getClass().getSimpleName(), + "(%x) Stream received: %s", + System.identityHashCode(AbstractStream.this), + response); + } AbstractStream.this.onNext(response); }); } @@ -203,6 +205,7 @@ public void run() { this.idleTimerId = idleTimerId; this.listener = listener; this.idleTimeoutRunnable = new IdleTimeoutRunnable(); + backoff = new ExponentialBackoff( workerQueue, diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java index fc4eb36bd30..8a58768f1a6 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java @@ -409,7 +409,7 @@ private Document decodeFoundDocument(BatchGetDocumentsResponse response) { SnapshotVersion version = decodeVersion(response.getFound().getUpdateTime()); hardAssert( !version.equals(SnapshotVersion.NONE), "Got a document response with no snapshot version"); - return new Document(key, version, value, Document.DocumentState.SYNCED); + return new Document(key, version, value, Document.DocumentState.SYNCED, response.getFound()); } private NoDocument decodeMissingDocument(BatchGetDocumentsResponse response) { @@ -1014,7 +1014,11 @@ public WatchChange decodeWatchChange(ListenResponse protoChange) { hardAssert( !version.equals(SnapshotVersion.NONE), "Got a document change without an update time"); ObjectValue data = decodeFields(docChange.getDocument().getFieldsMap()); - Document document = new Document(key, version, data, Document.DocumentState.SYNCED); + // The document may soon be re-serialized back to protos in order to store it in local + // persistence. Memoize the encoded form to avoid encoding it again. + Document document = + new Document( + key, version, data, Document.DocumentState.SYNCED, docChange.getDocument()); watchChange = new WatchChange.DocumentChange(added, removed, document.getKey(), document); break; case DOCUMENT_DELETE: diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/local/RemoteDocumentCacheTestCase.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/local/RemoteDocumentCacheTestCase.java index 4340b5f51d2..06c38e7ba7b 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/local/RemoteDocumentCacheTestCase.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/local/RemoteDocumentCacheTestCase.java @@ -32,6 +32,9 @@ import com.google.firebase.firestore.model.DocumentKey; import com.google.firebase.firestore.model.MaybeDocument; import com.google.firebase.firestore.model.NoDocument; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import javax.annotation.Nullable; @@ -82,6 +85,51 @@ public void testSetAndReadDocument() { } } + @Test + public void testSetAndReadSeveralDocuments() { + String[] paths = {"a/b", "a/b/c/d/e/f"}; + Map written = new HashMap<>(); + for (String path : paths) { + written.put(DocumentKey.fromPathString(path), addTestDocumentAtPath(path)); + } + + Map read = getAll(Arrays.asList(paths)); + assertEquals(written, read); + } + + @Test + public void testReadSeveralDocumentsIncludingMissingDocument() { + String[] paths = {"foo/1", "foo/2"}; + Map written = new HashMap<>(); + for (String path : paths) { + written.put(DocumentKey.fromPathString(path), addTestDocumentAtPath(path)); + } + written.put(DocumentKey.fromPathString("foo/nonexistent"), null); + + List keys = new ArrayList(Arrays.asList(paths)); + keys.add("foo/nonexistent"); + Map read = getAll(keys); + assertEquals(written, read); + } + + // PORTING NOTE: this test only applies to Android, because it's the only platform where the + // implementation of getAll might split the input into several queries. + @Test + public void testSetAndReadLotsOfDocuments() { + // Make sure to force SQLite implementation to split the large query into several smaller ones. + int lotsOfDocuments = 2000; + List paths = new ArrayList<>(); + Map expected = new HashMap<>(); + for (int i = 0; i < lotsOfDocuments; i++) { + String path = "foo/" + String.valueOf(i); + paths.add(path); + expected.put(DocumentKey.fromPathString(path), addTestDocumentAtPath(path)); + } + + Map read = getAll(paths); + assertEquals(expected, read); + } + @Test public void testSetAndReadDeletedDocument() { String path = "a/b"; @@ -147,6 +195,16 @@ private MaybeDocument get(String path) { return remoteDocumentCache.get(key(path)); } + private Map getAll(Iterable paths) { + List keys = new ArrayList<>(); + + for (String path : paths) { + keys.add(key(path)); + } + + return remoteDocumentCache.getAll(keys); + } + private void remove(String path) { persistence.runTransaction("remove entry", () -> remoteDocumentCache.remove(key(path))); }