diff --git a/packages/firestore/src/local/document_overlay_cache.ts b/packages/firestore/src/local/document_overlay_cache.ts index b0d011a7e38..d30e125049a 100644 --- a/packages/firestore/src/local/document_overlay_cache.ts +++ b/packages/firestore/src/local/document_overlay_cache.ts @@ -43,6 +43,15 @@ export interface DocumentOverlayCache { key: DocumentKey ): PersistencePromise; + /** + * Gets the saved overlay mutation for the given document keys. Skips keys for + * which there are no overlays. + */ + getOverlays( + transaction: PersistenceTransaction, + keys: DocumentKeySet + ): PersistencePromise; + /** * Saves the given document mutation map to persistence as overlays. * All overlays will have their largest batch id set to `largestBatchId`. diff --git a/packages/firestore/src/local/indexeddb_document_overlay_cache.ts b/packages/firestore/src/local/indexeddb_document_overlay_cache.ts index 9821530c8a8..0bea0f9bf84 100644 --- a/packages/firestore/src/local/indexeddb_document_overlay_cache.ts +++ b/packages/firestore/src/local/indexeddb_document_overlay_cache.ts @@ -81,6 +81,24 @@ export class IndexedDbDocumentOverlayCache implements DocumentOverlayCache { }); } + getOverlays( + transaction: PersistenceTransaction, + keys: DocumentKeySet + ): PersistencePromise { + const result = newOverlayMap(); + const promises: Array> = []; + keys.forEach(key => { + promises.push( + this.getOverlay(transaction, key).next(overlay => { + if (overlay !== null) { + result.set(key, overlay); + } + }) + ); + }); + return PersistencePromise.waitFor(promises).next(() => result); + } + saveOverlays( transaction: PersistenceTransaction, largestBatchId: number, diff --git a/packages/firestore/src/local/local_documents_view.ts b/packages/firestore/src/local/local_documents_view.ts index 3e0edb081dd..8281344de2b 100644 --- a/packages/firestore/src/local/local_documents_view.ts +++ b/packages/firestore/src/local/local_documents_view.ts @@ -22,20 +22,34 @@ import { Query, queryMatches } from '../core/query'; +import { Timestamp } from '../lite-api/timestamp'; import { DocumentKeySet, + OverlayMap, DocumentMap, + MutableDocumentMap, + newDocumentKeyMap, + newMutationMap, + newOverlayMap, documentMap, - MutableDocumentMap + mutableDocumentMap, + documentKeySet } from '../model/collections'; import { Document, MutableDocument } from '../model/document'; import { DocumentKey } from '../model/document_key'; import { IndexOffset } from '../model/field_index'; -import { mutationApplyToLocalView } from '../model/mutation'; -import { MutationBatch } from '../model/mutation_batch'; +import { FieldMask } from '../model/field_mask'; +import { + calculateOverlayMutation, + mutationApplyToLocalView, + PatchMutation +} from '../model/mutation'; +import { Overlay } from '../model/overlay'; import { ResourcePath } from '../model/path'; import { debugAssert } from '../util/assert'; +import { SortedMap } from '../util/sorted_map'; +import { DocumentOverlayCache } from './document_overlay_cache'; import { IndexManager } from './index_manager'; import { MutationQueue } from './mutation_queue'; import { PersistencePromise } from './persistence_promise'; @@ -52,6 +66,7 @@ export class LocalDocumentsView { constructor( readonly remoteDocumentCache: RemoteDocumentCache, readonly mutationQueue: MutationQueue, + readonly documentOverlayCache: DocumentOverlayCache, readonly indexManager: IndexManager ) {} @@ -65,36 +80,24 @@ export class LocalDocumentsView { transaction: PersistenceTransaction, key: DocumentKey ): PersistencePromise { - return this.mutationQueue - .getAllMutationBatchesAffectingDocumentKey(transaction, key) - .next(batches => this.getDocumentInternal(transaction, key, batches)); - } - - /** Internal version of `getDocument` that allows reusing batches. */ - private getDocumentInternal( - transaction: PersistenceTransaction, - key: DocumentKey, - inBatches: MutationBatch[] - ): PersistencePromise { - return this.remoteDocumentCache.getEntry(transaction, key).next(doc => { - for (const batch of inBatches) { - batch.applyToLocalView(doc); - } - return doc as Document; - }); - } - - // Returns the view of the given `docs` as they would appear after applying - // all mutations in the given `batches`. - private applyLocalMutationsToDocuments( - docs: MutableDocumentMap, - batches: MutationBatch[] - ): void { - docs.forEach((key, localView) => { - for (const batch of batches) { - batch.applyToLocalView(localView); - } - }); + let overlay: Overlay | null = null; + return this.documentOverlayCache + .getOverlay(transaction, key) + .next(value => { + overlay = value; + return this.getBaseDocument(transaction, key, overlay); + }) + .next(document => { + if (overlay !== null) { + mutationApplyToLocalView( + overlay.mutation, + document, + null, + Timestamp.now() + ); + } + return document as Document; + }); } /** @@ -110,23 +113,181 @@ export class LocalDocumentsView { return this.remoteDocumentCache .getEntries(transaction, keys) .next(docs => - this.applyLocalViewToDocuments(transaction, docs).next( + this.getLocalViewOfDocuments(transaction, docs, documentKeySet()).next( () => docs as DocumentMap ) ); } /** - * Applies the local view the given `baseDocs` without retrieving documents - * from the local store. + * Similar to `getDocuments`, but creates the local view from the given + * `baseDocs` without retrieving documents from the local store. + * + * @param transaction - The transaction this operation is scoped to. + * @param docs - The documents to apply local mutations to get the local views. + * @param existenceStateChanged - The set of document keys whose existence state + * is changed. This is useful to determine if some documents overlay needs + * to be recalculated. */ - applyLocalViewToDocuments( + getLocalViewOfDocuments( transaction: PersistenceTransaction, - baseDocs: MutableDocumentMap + docs: MutableDocumentMap, + existenceStateChanged: DocumentKeySet = documentKeySet() + ): PersistencePromise { + const overlays = newOverlayMap(); + return this.populateOverlays(transaction, overlays, docs).next(() => { + return this.computeViews( + transaction, + docs, + overlays, + existenceStateChanged + ); + }); + } + + /** + * Fetches the overlays for {@code docs} and adds them to provided overlay map + * if the map does not already contain an entry for the given document key. + */ + private populateOverlays( + transaction: PersistenceTransaction, + overlays: OverlayMap, + docs: MutableDocumentMap ): PersistencePromise { + let missingOverlays = documentKeySet(); + docs.forEach(key => { + if (!overlays.has(key)) { + missingOverlays = missingOverlays.add(key); + } + }); + return this.documentOverlayCache + .getOverlays(transaction, missingOverlays) + .next(result => { + result.forEach((key, val) => { + overlays.set(key, val); + }); + }); + } + + /** + * Computes the local view for documents, applying overlays from both + * `memoizedOverlays` and the overlay cache. + */ + computeViews( + transaction: PersistenceTransaction, + docs: MutableDocumentMap, + overlays: OverlayMap, + existenceStateChanged: DocumentKeySet + ): PersistencePromise { + let results = documentMap(); + let recalculateDocuments = mutableDocumentMap(); + docs.forEach((_, doc) => { + const overlay = overlays.get(doc.key); + // Recalculate an overlay if the document's existence state changed due to + // a remote event *and* the overlay is a PatchMutation. This is because + // document existence state can change if some patch mutation's + // preconditions are met. + // NOTE: we recalculate when `overlay` is undefined as well, because there + // might be a patch mutation whose precondition does not match before the + // change (hence overlay is undefined), but would now match. + if ( + existenceStateChanged.has(doc.key) && + (overlay === undefined || overlay.mutation instanceof PatchMutation) + ) { + recalculateDocuments = recalculateDocuments.insert(doc.key, doc); + } else if (overlay !== undefined) { + mutationApplyToLocalView(overlay.mutation, doc, null, Timestamp.now()); + } + }); + + return this.recalculateAndSaveOverlays( + transaction, + recalculateDocuments + ).next(() => { + docs.forEach((key, value) => { + results = results.insert(key, value); + }); + return results; + }); + } + + private recalculateAndSaveOverlays( + transaction: PersistenceTransaction, + docs: MutableDocumentMap + ): PersistencePromise { + const masks = newDocumentKeyMap(); + // A reverse lookup map from batch id to the documents within that batch. + let documentsByBatchId = new SortedMap( + (key1: number, key2: number) => key1 - key2 + ); + let processed = documentKeySet(); return this.mutationQueue - .getAllMutationBatchesAffectingDocumentKeys(transaction, baseDocs) - .next(batches => this.applyLocalMutationsToDocuments(baseDocs, batches)); + .getAllMutationBatchesAffectingDocumentKeys(transaction, docs) + .next(batches => { + for (const batch of batches) { + batch.keys().forEach(key => { + const baseDoc = docs.get(key); + if (baseDoc === null) { + return; + } + let mask: FieldMask | null = masks.get(key) || FieldMask.empty(); + mask = batch.applyToLocalView(baseDoc, mask); + masks.set(key, mask); + const newSet = ( + documentsByBatchId.get(batch.batchId) || documentKeySet() + ).add(key); + documentsByBatchId = documentsByBatchId.insert( + batch.batchId, + newSet + ); + }); + } + }) + .next(() => { + const promises: Array> = []; + // Iterate in descending order of batch IDs, and skip documents that are + // already saved. + const iter = documentsByBatchId.getReverseIterator(); + while (iter.hasNext()) { + const entry = iter.getNext(); + const batchId = entry.key; + const keys = entry.value; + const overlays = newMutationMap(); + keys.forEach(key => { + if (!processed.has(key)) { + const overlayMutation = calculateOverlayMutation( + docs.get(key)!, + masks.get(key)! + ); + if (overlayMutation !== null) { + overlays.set(key, overlayMutation); + } + processed = processed.add(key); + } + }); + promises.push( + this.documentOverlayCache.saveOverlays( + transaction, + batchId, + overlays + ) + ); + } + return PersistencePromise.waitFor(promises); + }); + } + + /** + * Recalculates overlays by reading the documents from remote document cache + * first, and saves them after they are calculated. + */ + recalculateAndSaveOverlaysForDocumentKeys( + transaction: PersistenceTransaction, + documentKeys: DocumentKeySet + ): PersistencePromise { + return this.remoteDocumentCache + .getEntries(transaction, documentKeys) + .next(docs => this.recalculateAndSaveOverlays(transaction, docs)); } /** @@ -214,43 +375,59 @@ export class LocalDocumentsView { offset: IndexOffset ): PersistencePromise { // Query the remote documents and overlay mutations. - let results: MutableDocumentMap; + let remoteDocuments: MutableDocumentMap; return this.remoteDocumentCache .getAllFromCollection(transaction, query.path, offset) .next(queryResults => { - results = queryResults; - return this.mutationQueue.getAllMutationBatchesAffectingQuery( + remoteDocuments = queryResults; + return this.documentOverlayCache.getOverlaysForCollection( transaction, - query + query.path, + offset.largestBatchId ); }) - .next(mutationBatches => { - for (const batch of mutationBatches) { - for (const mutation of batch.mutations) { - const key = mutation.key; - let document = results.get(key); - if (document == null) { - // Create invalid document to apply mutations on top of - document = MutableDocument.newInvalidDocument(key); - results = results.insert(key, document); - } - mutationApplyToLocalView(mutation, document, batch.localWriteTime); - if (!document.isFoundDocument()) { - results = results.remove(key); - } - } - } - }) - .next(() => { - // Finally, filter out any documents that don't actually match - // the query. - results.forEach((key, doc) => { - if (!queryMatches(query, doc)) { - results = results.remove(key); + .next(overlays => { + // As documents might match the query because of their overlay we need to + // include documents for all overlays in the initial document set. + overlays.forEach((_, overlay) => { + const key = overlay.getKey(); + if (remoteDocuments.get(key) === null) { + remoteDocuments = remoteDocuments.insert( + key, + MutableDocument.newInvalidDocument(key) + ); } }); - return results as DocumentMap; + // Apply the overlays and match against the query. + let results = documentMap(); + remoteDocuments.forEach((key, document) => { + const overlay = overlays.get(key); + if (overlay !== undefined) { + mutationApplyToLocalView( + overlay.mutation, + document, + null, + Timestamp.now() + ); + } + // Finally, insert the documents that still match the query + if (queryMatches(query, document)) { + results = results.insert(key, document); + } + }); + return results; }); } + + /** Returns a base document that can be used to apply `overlay`. */ + private getBaseDocument( + transaction: PersistenceTransaction, + key: DocumentKey, + overlay: Overlay | null + ): PersistencePromise { + return overlay === null || overlay.mutation instanceof PatchMutation + ? this.remoteDocumentCache.getEntry(transaction, key) + : PersistencePromise.resolve(MutableDocument.newInvalidDocument(key)); + } } diff --git a/packages/firestore/src/local/local_store_impl.ts b/packages/firestore/src/local/local_store_impl.ts index 6bc4ad356f7..36b967838eb 100644 --- a/packages/firestore/src/local/local_store_impl.ts +++ b/packages/firestore/src/local/local_store_impl.ts @@ -64,6 +64,7 @@ import { SortedMap } from '../util/sorted_map'; import { BATCHID_UNKNOWN } from '../util/types'; import { BundleCache } from './bundle_cache'; +import { DocumentOverlayCache } from './document_overlay_cache'; import { IndexManager } from './index_manager'; import { IndexedDbMutationQueue } from './indexeddb_mutation_queue'; import { IndexedDbPersistence } from './indexeddb_persistence'; @@ -130,6 +131,12 @@ class LocalStoreImpl implements LocalStore { */ mutationQueue!: MutationQueue; + /** + * The overlays that can be used to short circuit applying all mutations from + * mutation queue. + */ + documentOverlayCache!: DocumentOverlayCache; + /** The set of all cached remote documents. */ remoteDocuments: RemoteDocumentCache; @@ -192,6 +199,7 @@ class LocalStoreImpl implements LocalStore { initializeUserComponents(user: User): void { // TODO(indexing): Add spec tests that test these components change after a // user change + this.documentOverlayCache = this.persistence.getDocumentOverlayCache(user); this.indexManager = this.persistence.getIndexManager(user); this.mutationQueue = this.persistence.getMutationQueue( user, @@ -200,6 +208,7 @@ class LocalStoreImpl implements LocalStore { this.localDocuments = new LocalDocumentsView( this.remoteDocuments, this.mutationQueue, + this.documentOverlayCache, this.indexManager ); this.remoteDocuments.setIndexManager(this.indexManager); @@ -215,6 +224,11 @@ class LocalStoreImpl implements LocalStore { } } +interface DocumentChangeResult { + changedDocuments: MutableDocumentMap; + existenceChangedKeys: DocumentKeySet; +} + export function newLocalStore( /** Manages our in-memory or durable persistence. */ persistence: Persistence, @@ -302,14 +316,37 @@ export function localStoreWriteLocally( const keys = mutations.reduce((keys, m) => keys.add(m.key), documentKeySet()); let existingDocs: DocumentMap; + let mutationBatch: MutationBatch; return localStoreImpl.persistence .runTransaction('Locally write mutations', 'readwrite', txn => { - // Load and apply all existing mutations. This lets us compute the - // current base state for all non-idempotent transforms before applying - // any additional user-provided writes. - return localStoreImpl.localDocuments - .getDocuments(txn, keys) + // Figure out which keys do not have a remote version in the cache, this + // is needed to create the right overlay mutation: if no remote version + // presents, we do not need to create overlays as patch mutations. + // TODO(Overlay): Is there a better way to determine this? Using the + // document version does not work because local mutations set them back + // to 0. + let remoteDocs = mutableDocumentMap(); + let docsWithoutRemoteVersion = documentKeySet(); + return localStoreImpl.remoteDocuments + .getEntries(txn, keys) + .next(docs => { + remoteDocs = docs; + remoteDocs.forEach((key, doc) => { + if (!doc.isValidDocument()) { + docsWithoutRemoteVersion = docsWithoutRemoteVersion.add(key); + } + }); + }) + .next(() => { + // Load and apply all existing mutations. This lets us compute the + // current base state for all non-idempotent transforms before applying + // any additional user-provided writes. + return localStoreImpl.localDocuments.getLocalViewOfDocuments( + txn, + remoteDocs + ); + }) .next(docs => { existingDocs = docs; @@ -346,11 +383,22 @@ export function localStoreWriteLocally( baseMutations, mutations ); + }) + .next(batch => { + mutationBatch = batch; + const overlays = batch.applyToLocalDocumentSet( + existingDocs, + docsWithoutRemoteVersion + ); + return localStoreImpl.documentOverlayCache.saveOverlays( + txn, + batch.batchId, + overlays + ); }); }) - .then(batch => { - batch.applyToLocalDocumentSet(existingDocs); - return { batchId: batch.batchId, changes: existingDocs }; + .then(() => { + return { batchId: mutationBatch.batchId, changes: existingDocs }; }); } @@ -389,11 +437,38 @@ export function localStoreAcknowledgeBatch( ) .next(() => documentBuffer.apply(txn)) .next(() => localStoreImpl.mutationQueue.performConsistencyCheck(txn)) + .next(() => + localStoreImpl.documentOverlayCache.removeOverlaysForBatchId( + txn, + affected, + batchResult.batch.batchId + ) + ) + .next(() => + localStoreImpl.localDocuments.recalculateAndSaveOverlaysForDocumentKeys( + txn, + getKeysWithTransformResults(batchResult) + ) + ) .next(() => localStoreImpl.localDocuments.getDocuments(txn, affected)); } ); } +function getKeysWithTransformResults( + batchResult: MutationBatchResult +): DocumentKeySet { + let result = documentKeySet(); + + for (let i = 0; i < batchResult.mutationResults.length; ++i) { + const mutationResult = batchResult.mutationResults[i]; + if (mutationResult.transformResults.length > 0) { + result = result.add(batchResult.batch.mutations[i].key); + } + } + return result; +} + /** * Removes mutations from the MutationQueue for the specified batch; * LocalDocuments will be recalculated. @@ -418,6 +493,19 @@ export function localStoreRejectBatch( return localStoreImpl.mutationQueue.removeMutationBatch(txn, batch); }) .next(() => localStoreImpl.mutationQueue.performConsistencyCheck(txn)) + .next(() => + localStoreImpl.documentOverlayCache.removeOverlaysForBatchId( + txn, + affectedKeys, + batchId + ) + ) + .next(() => + localStoreImpl.localDocuments.recalculateAndSaveOverlaysForDocumentKeys( + txn, + affectedKeys + ) + ) .next(() => localStoreImpl.localDocuments.getDocuments(txn, affectedKeys) ); @@ -536,6 +624,7 @@ export function localStoreApplyRemoteEventToLocalCache( }); let changedDocs = mutableDocumentMap(); + let existenceChangedKeys = documentKeySet(); remoteEvent.documentUpdates.forEach(key => { if (remoteEvent.resolvedLimboDocuments.has(key)) { promises.push( @@ -547,15 +636,16 @@ export function localStoreApplyRemoteEventToLocalCache( } }); - // Each loop iteration only affects its "own" doc, so it's safe to get all the remote - // documents in advance in a single call. + // Each loop iteration only affects its "own" doc, so it's safe to get all + // the remote documents in advance in a single call. promises.push( populateDocumentChangeBuffer( txn, documentBuffer, remoteEvent.documentUpdates ).next(result => { - changedDocs = result; + changedDocs = result.changedDocuments; + existenceChangedKeys = result.existenceChangedKeys; }) ); @@ -586,9 +676,10 @@ export function localStoreApplyRemoteEventToLocalCache( return PersistencePromise.waitFor(promises) .next(() => documentBuffer.apply(txn)) .next(() => - localStoreImpl.localDocuments.applyLocalViewToDocuments( + localStoreImpl.localDocuments.getLocalViewOfDocuments( txn, - changedDocs + changedDocs, + existenceChangedKeys ) ) .next(() => changedDocs); @@ -601,32 +692,32 @@ export function localStoreApplyRemoteEventToLocalCache( /** * Populates document change buffer with documents from backend or a bundle. - * Returns the document changes resulting from applying those documents. + * Returns the document changes resulting from applying those documents, and + * also a set of documents whose existence state are changed as a result. * * @param txn - Transaction to use to read existing documents from storage. * @param documentBuffer - Document buffer to collect the resulted changes to be * applied to storage. * @param documents - Documents to be applied. - * @param globalVersion - A `SnapshotVersion` representing the read time if all - * documents have the same read time. - * @param documentVersions - A DocumentKey-to-SnapshotVersion map if documents - * have their own read time. - * - * Note: this function will use `documentVersions` if it is defined; - * when it is not defined, resorts to `globalVersion`. */ function populateDocumentChangeBuffer( txn: PersistenceTransaction, documentBuffer: RemoteDocumentChangeBuffer, documents: MutableDocumentMap -): PersistencePromise { +): PersistencePromise { let updatedKeys = documentKeySet(); + let existenceChangedKeys = documentKeySet(); documents.forEach(k => (updatedKeys = updatedKeys.add(k))); return documentBuffer.getEntries(txn, updatedKeys).next(existingDocs => { - let changedDocs = mutableDocumentMap(); + let changedDocuments = mutableDocumentMap(); documents.forEach((key, doc) => { const existingDoc = existingDocs.get(key)!; + // Check if see if there is a existence state change for this document. + if (doc.isFoundDocument() !== existingDoc.isFoundDocument()) { + existenceChangedKeys = existenceChangedKeys.add(key); + } + // Note: The order of the steps below is important, since we want // to ensure that rejected limbo resolutions (which fabricate // NoDocuments with SnapshotVersion.min()) never add documents to @@ -636,7 +727,7 @@ function populateDocumentChangeBuffer( // events. We remove these documents from cache since we lost // access. documentBuffer.removeEntry(key, doc.readTime); - changedDocs = changedDocs.insert(key, doc); + changedDocuments = changedDocuments.insert(key, doc); } else if ( !existingDoc.isValidDocument() || doc.version.compareTo(existingDoc.version) > 0 || @@ -648,7 +739,7 @@ function populateDocumentChangeBuffer( 'Cannot add a document when the remote version is zero' ); documentBuffer.addEntry(doc); - changedDocs = changedDocs.insert(key, doc); + changedDocuments = changedDocuments.insert(key, doc); } else { logDebug( LOG_TAG, @@ -661,7 +752,7 @@ function populateDocumentChangeBuffer( ); } }); - return changedDocs; + return { changedDocuments, existenceChangedKeys }; }); } @@ -1242,11 +1333,11 @@ export async function localStoreApplyBundledDocuments( 'readwrite', txn => { return populateDocumentChangeBuffer(txn, documentBuffer, documentMap) - .next(changedDocs => { + .next(documentChangeResult => { documentBuffer.apply(txn); - return changedDocs; + return documentChangeResult; }) - .next(changedDocs => { + .next(documentChangeResult => { return localStoreImpl.targetCache .removeMatchingKeysForTargetId(txn, umbrellaTargetData.targetId) .next(() => @@ -1257,12 +1348,13 @@ export async function localStoreApplyBundledDocuments( ) ) .next(() => - localStoreImpl.localDocuments.applyLocalViewToDocuments( + localStoreImpl.localDocuments.getLocalViewOfDocuments( txn, - changedDocs + documentChangeResult.changedDocuments, + documentChangeResult.existenceChangedKeys ) ) - .next(() => changedDocs); + .next(() => documentChangeResult.changedDocuments); }); } ); diff --git a/packages/firestore/src/local/memory_document_overlay_cache.ts b/packages/firestore/src/local/memory_document_overlay_cache.ts index adfa45adbbc..9e7983b2a28 100644 --- a/packages/firestore/src/local/memory_document_overlay_cache.ts +++ b/packages/firestore/src/local/memory_document_overlay_cache.ts @@ -50,6 +50,24 @@ export class MemoryDocumentOverlayCache implements DocumentOverlayCache { return PersistencePromise.resolve(this.overlays.get(key)); } + getOverlays( + transaction: PersistenceTransaction, + keys: DocumentKeySet + ): PersistencePromise { + const result = newOverlayMap(); + const promises: Array> = []; + keys.forEach(key => { + promises.push( + this.getOverlay(transaction, key).next(overlay => { + if (overlay !== null) { + result.set(key, overlay); + } + }) + ); + }); + return PersistencePromise.waitFor(promises).next(() => result); + } + saveOverlays( transaction: PersistenceTransaction, largestBatchId: number, @@ -152,10 +170,6 @@ export class MemoryDocumentOverlayCache implements DocumentOverlayCache { largestBatchId: number, mutation: Mutation ): void { - if (mutation === null) { - return; - } - // Remove the association of the overlay to its batch id. const existing = this.overlays.get(mutation.key); if (existing !== null) { diff --git a/packages/firestore/src/model/collections.ts b/packages/firestore/src/model/collections.ts index de760c6e00b..9440c7f78a7 100644 --- a/packages/firestore/src/model/collections.ts +++ b/packages/firestore/src/model/collections.ts @@ -66,6 +66,14 @@ export function newMutationMap(): MutationMap { ); } +export type DocumentKeyMap = ObjectMap; +export function newDocumentKeyMap(): DocumentKeyMap { + return new ObjectMap( + key => key.toString(), + (l, r) => l.isEqual(r) + ); +} + export type DocumentVersionMap = SortedMap; const EMPTY_DOCUMENT_VERSION_MAP = new SortedMap( DocumentKey.comparator diff --git a/packages/firestore/src/model/document.ts b/packages/firestore/src/model/document.ts index 5ab81c4e6d5..279f5031371 100644 --- a/packages/firestore/src/model/document.ts +++ b/packages/firestore/src/model/document.ts @@ -285,11 +285,8 @@ export class MutableDocument implements Document { } setHasLocalMutations(): MutableDocument { - debugAssert( - this.isFoundDocument(), - 'Only found documents can have local mutations' - ); this.documentState = DocumentState.HAS_LOCAL_MUTATIONS; + this.version = SnapshotVersion.min(); return this; } diff --git a/packages/firestore/src/model/field_mask.ts b/packages/firestore/src/model/field_mask.ts index 4c1d146b36e..f33c075c633 100644 --- a/packages/firestore/src/model/field_mask.ts +++ b/packages/firestore/src/model/field_mask.ts @@ -17,6 +17,7 @@ import { debugAssert } from '../util/assert'; import { arrayEquals } from '../util/misc'; +import { SortedSet } from '../util/sorted_set'; import { FieldPath } from './path'; @@ -42,6 +43,25 @@ export class FieldMask { ); } + static empty(): FieldMask { + return new FieldMask([]); + } + + /** + * Returns a new FieldMask object that is the result of adding all the given + * fields paths to this field mask. + */ + unionWith(extraFields: FieldPath[]): FieldMask { + let mergedMaskSet = new SortedSet(FieldPath.comparator); + for (const fieldPath of this.fields) { + mergedMaskSet = mergedMaskSet.add(fieldPath); + } + for (const fieldPath of extraFields) { + mergedMaskSet = mergedMaskSet.add(fieldPath); + } + return new FieldMask(mergedMaskSet.toArray()); + } + /** * Verifies that `fieldPath` is included by at least one field in this field * mask. diff --git a/packages/firestore/src/model/mutation.ts b/packages/firestore/src/model/mutation.ts index 80d30dd39a7..3c5c20f8c64 100644 --- a/packages/firestore/src/model/mutation.ts +++ b/packages/firestore/src/model/mutation.ts @@ -20,6 +20,7 @@ import { Timestamp } from '../lite-api/timestamp'; import { Value as ProtoValue } from '../protos/firestore_proto_api'; import { debugAssert, hardAssert } from '../util/assert'; import { arrayEquals } from '../util/misc'; +import { SortedSet } from '../util/sorted_set'; import { Document, MutableDocument } from './document'; import { DocumentKey } from './document_key'; @@ -214,6 +215,63 @@ export abstract class Mutation { abstract readonly fieldTransforms: FieldTransform[]; } +/** + * A utility method to calculate a `Mutation` representing the overlay from the + * final state of the document, and a `FieldMask` representing the fields that + * are mutated by the local mutations. + */ +export function calculateOverlayMutation( + doc: MutableDocument, + mask: FieldMask | null +): Mutation | null { + if (!doc.hasLocalMutations || (mask && mask!.fields.length === 0)) { + return null; + } + + // mask is null when sets or deletes are applied to the current document. + if (mask === null) { + if (doc.isNoDocument()) { + return new DeleteMutation(doc.key, Precondition.none()); + } else { + return new SetMutation(doc.key, doc.data, Precondition.none()); + } + } else { + const docValue = doc.data; + const patchValue = ObjectValue.empty(); + let maskSet = new SortedSet(FieldPath.comparator); + for (let path of mask.fields) { + if (!maskSet.has(path)) { + let value = docValue.field(path); + // If we are deleting a nested field, we take the immediate parent as + // the mask used to construct the resulting mutation. + // Justification: Nested fields can create parent fields implicitly. If + // only a leaf entry is deleted in later mutations, the parent field + // should still remain, but we may have lost this information. + // Consider mutation (foo.bar 1), then mutation (foo.bar delete()). + // This leaves the final result (foo, {}). Despite the fact that `doc` + // has the correct result, `foo` is not in `mask`, and the resulting + // mutation would miss `foo`. + if (value === null && path.length > 1) { + path = path.popLast(); + value = docValue.field(path); + } + if (value === null) { + patchValue.delete(path); + } else { + patchValue.set(path, value); + } + maskSet = maskSet.add(path); + } + } + return new PatchMutation( + doc.key, + patchValue, + new FieldMask(maskSet.toArray()), + Precondition.none() + ); + } +} + /** * Applies this mutation to the given document for the purposes of computing a * new remote document. If the input document doesn't match the expected state @@ -254,26 +312,39 @@ export function mutationApplyToRemoteDocument( * @param document - The document to mutate. The input document can be an * invalid document if the client has no knowledge of the pre-mutation state * of the document. + * @param previousMask - The fields that have been updated before applying this mutation. * @param localWriteTime - A timestamp indicating the local write time of the * batch this mutation is a part of. + * @returns A `FieldMask` representing the fields that are changed by applying this mutation. */ export function mutationApplyToLocalView( mutation: Mutation, document: MutableDocument, + previousMask: FieldMask | null, localWriteTime: Timestamp -): void { +): FieldMask | null { mutationVerifyKeyMatches(mutation, document); if (mutation instanceof SetMutation) { - setMutationApplyToLocalView(mutation, document, localWriteTime); + return setMutationApplyToLocalView( + mutation, + document, + previousMask, + localWriteTime + ); } else if (mutation instanceof PatchMutation) { - patchMutationApplyToLocalView(mutation, document, localWriteTime); + return patchMutationApplyToLocalView( + mutation, + document, + previousMask, + localWriteTime + ); } else { debugAssert( mutation instanceof DeleteMutation, 'Unexpected mutation type: ' + mutation ); - deleteMutationApplyToLocalView(mutation, document); + return deleteMutationApplyToLocalView(mutation, document, previousMask); } } @@ -306,7 +377,7 @@ export function mutationExtractBaseValue( ); if (coercedValue != null) { - if (baseObject == null) { + if (baseObject === null) { baseObject = ObjectValue.empty(); } baseObject.set(fieldTransform.field, coercedValue); @@ -358,16 +429,6 @@ function mutationVerifyKeyMatches( ); } -/** - * Returns the version from the given document for use as the result of a - * mutation. Mutations are defined to return the version of the base document - * only if it is an existing document. Deleted and unknown documents have a - * post-mutation version of SnapshotVersion.min(). - */ -function getPostMutationVersion(document: MutableDocument): SnapshotVersion { - return document.isFoundDocument() ? document.version : SnapshotVersion.min(); -} - /** * A mutation that creates or replaces the document at the given key with the * object value contents. @@ -408,12 +469,13 @@ function setMutationApplyToRemoteDocument( function setMutationApplyToLocalView( mutation: SetMutation, document: MutableDocument, + previousMask: FieldMask | null, localWriteTime: Timestamp -): void { +): FieldMask | null { if (!preconditionIsValidForDocument(mutation.precondition, document)) { // The mutation failed to apply (e.g. a document ID created with add() // caused a name collision). - return; + return previousMask; } const newData = mutation.value.clone(); @@ -424,8 +486,9 @@ function setMutationApplyToLocalView( ); newData.setAll(transformResults); document - .convertToFoundDocument(getPostMutationVersion(document), newData) + .convertToFoundDocument(document.version, newData) .setHasLocalMutations(); + return null; // SetMutation overwrites all fields. } /** @@ -485,10 +548,11 @@ function patchMutationApplyToRemoteDocument( function patchMutationApplyToLocalView( mutation: PatchMutation, document: MutableDocument, + previousMask: FieldMask | null, localWriteTime: Timestamp -): void { +): FieldMask | null { if (!preconditionIsValidForDocument(mutation.precondition, document)) { - return; + return previousMask; } const transformResults = localTransformResults( @@ -500,8 +564,16 @@ function patchMutationApplyToLocalView( newData.setAll(getPatch(mutation)); newData.setAll(transformResults); document - .convertToFoundDocument(getPostMutationVersion(document), newData) + .convertToFoundDocument(document.version, newData) .setHasLocalMutations(); + + if (previousMask === null) { + return null; + } + + return previousMask + .unionWith(mutation.fieldMask.fields) + .unionWith(mutation.fieldTransforms.map(transform => transform.field)); } /** @@ -565,8 +637,7 @@ function serverTransformResults( * @param fieldTransforms - The field transforms to apply the result to. * @param localWriteTime - The local time of the mutation (used to * generate ServerTimestampValues). - * @param mutableDocument - The current state of the document after applying all - * previous mutations. + * @param mutableDocument - The document to apply transforms on. * @returns The transform results list. */ function localTransformResults( @@ -621,17 +692,18 @@ function deleteMutationApplyToRemoteDocument( function deleteMutationApplyToLocalView( mutation: DeleteMutation, - document: MutableDocument -): void { + document: MutableDocument, + previousMask: FieldMask | null +): FieldMask | null { debugAssert( document.key.isEqual(mutation.key), 'Can only apply mutation to document with same key' ); if (preconditionIsValidForDocument(mutation.precondition, document)) { - // We don't call `setHasLocalMutations()` since we want to be backwards - // compatible with the existing SDK behavior. - document.convertToNoDocument(SnapshotVersion.min()); + document.convertToNoDocument(document.version).setHasLocalMutations(); + return null; } + return previousMask; } /** diff --git a/packages/firestore/src/model/mutation_batch.ts b/packages/firestore/src/model/mutation_batch.ts index 82dd01bfd9a..5b8c2c18a28 100644 --- a/packages/firestore/src/model/mutation_batch.ts +++ b/packages/firestore/src/model/mutation_batch.ts @@ -24,12 +24,16 @@ import { arrayEquals } from '../util/misc'; import { documentKeySet, DocumentKeySet, + MutationMap, DocumentMap, DocumentVersionMap, - documentVersionMap + documentVersionMap, + newMutationMap } from './collections'; import { MutableDocument } from './document'; +import { FieldMask } from './field_mask'; import { + calculateOverlayMutation, Mutation, mutationApplyToLocalView, mutationApplyToRemoteDocument, @@ -95,42 +99,74 @@ export class MutationBatch { * batch. * * @param document - The document to apply mutations to. + * @param mutatedFields - Fields that have been updated before applying this mutation batch. + * @returns A `FieldMask` representing all the fields that are mutated. */ - applyToLocalView(document: MutableDocument): void { + applyToLocalView( + document: MutableDocument, + mutatedFields: FieldMask | null = FieldMask.empty() + ): FieldMask | null { // First, apply the base state. This allows us to apply non-idempotent // transform against a consistent set of values. for (const mutation of this.baseMutations) { if (mutation.key.isEqual(document.key)) { - mutationApplyToLocalView(mutation, document, this.localWriteTime); + mutatedFields = mutationApplyToLocalView( + mutation, + document, + mutatedFields, + this.localWriteTime + ); } } // Second, apply all user-provided mutations. for (const mutation of this.mutations) { if (mutation.key.isEqual(document.key)) { - mutationApplyToLocalView(mutation, document, this.localWriteTime); + mutatedFields = mutationApplyToLocalView( + mutation, + document, + mutatedFields, + this.localWriteTime + ); } } + return mutatedFields; } /** * Computes the local view for all provided documents given the mutations in - * this batch. + * this batch. Returns a `DocumentKey` to `Mutation` map which can be used to + * replace all the mutation applications. */ - applyToLocalDocumentSet(documentMap: DocumentMap): void { + applyToLocalDocumentSet( + documentMap: DocumentMap, + documentsWithoutRemoteVersion: DocumentKeySet + ): MutationMap { // TODO(mrschmidt): This implementation is O(n^2). If we apply the mutations // directly (as done in `applyToLocalView()`), we can reduce the complexity // to O(n). + const overlays = newMutationMap(); this.mutations.forEach(m => { const document = documentMap.get(m.key)!; // TODO(mutabledocuments): This method should take a MutableDocumentMap // and we should remove this cast. const mutableDocument = document as MutableDocument; - this.applyToLocalView(mutableDocument); + let mutatedFields = this.applyToLocalView(mutableDocument); + // Set mutatedFields to null if the document is only from local mutations. + // This creates a Set or Delete mutation, instead of trying to create a + // patch mutation as the overlay. + mutatedFields = documentsWithoutRemoteVersion.has(m.key) + ? null + : mutatedFields; + const overlay = calculateOverlayMutation(mutableDocument, mutatedFields); + if (overlay !== null) { + overlays.set(m.key, overlay); + } if (!document.isValidDocument()) { mutableDocument.convertToNoDocument(SnapshotVersion.min()); } }); + return overlays; } keys(): DocumentKeySet { diff --git a/packages/firestore/test/unit/local/counting_query_engine.ts b/packages/firestore/test/unit/local/counting_query_engine.ts index 784625e9cd3..ccc98475629 100644 --- a/packages/firestore/test/unit/local/counting_query_engine.ts +++ b/packages/firestore/test/unit/local/counting_query_engine.ts @@ -83,6 +83,7 @@ export class CountingQueryEngine extends QueryEngine { const view = new LocalDocumentsView( this.wrapRemoteDocumentCache(localDocuments.remoteDocumentCache), this.wrapMutationQueue(localDocuments.mutationQueue), + localDocuments.documentOverlayCache, localDocuments.indexManager ); diff --git a/packages/firestore/test/unit/local/document_overlay_cache.test.ts b/packages/firestore/test/unit/local/document_overlay_cache.test.ts index 614cc5c9960..d332a43456b 100644 --- a/packages/firestore/test/unit/local/document_overlay_cache.test.ts +++ b/packages/firestore/test/unit/local/document_overlay_cache.test.ts @@ -327,4 +327,29 @@ function genericDocumentOverlayCacheTests(): void { ); expect(await overlayCache.getOverlay(key('coll/doc'))).to.equal(null); }); + + it('skips non-existing overlay in batch lookup', async () => { + const result = await overlayCache.getOverlays( + documentKeySet(key('coll/doc1')) + ); + expect(result.isEmpty()).to.equal(true); + }); + + it('supports empty batch in batch lookup', async () => { + const result = await overlayCache.getOverlays(documentKeySet()); + expect(result.isEmpty()).to.equal(true); + }); + + it('can read saved overlays in batches', async () => { + const m1 = setMutation('coll/a', { 'a': 1 }); + const m2 = setMutation('coll/b', { 'b': 2 }); + const m3 = setMutation('coll/c', { 'c': 3 }); + await saveOverlaysForMutations(3, m1, m2, m3); + const overlays = await overlayCache.getOverlays( + documentKeySet(key('coll/a'), key('coll/b'), key('coll/c')) + ); + verifyEqualMutations(overlays.get(key('coll/a'))!.mutation, m1); + verifyEqualMutations(overlays.get(key('coll/b'))!.mutation, m2); + verifyEqualMutations(overlays.get(key('coll/c'))!.mutation, m3); + }); } diff --git a/packages/firestore/test/unit/local/local_store.test.ts b/packages/firestore/test/unit/local/local_store.test.ts index b8428cc9d20..8de65a93f8a 100644 --- a/packages/firestore/test/unit/local/local_store.test.ts +++ b/packages/firestore/test/unit/local/local_store.test.ts @@ -240,24 +240,19 @@ class LocalStoreTester { afterAcknowledgingMutation(options: { documentVersion: TestSnapshotVersion; - transformResult?: api.Value; + transformResults?: api.Value[]; }): LocalStoreTester { this.prepareNextStep(); this.promiseChain = this.promiseChain .then(() => { const batch = this.batches.shift()!; - expect(batch.mutations.length).to.equal( - 1, - 'Acknowledging more than one mutation not supported.' - ); const ver = version(options.documentVersion); - const mutationResults = [ - new MutationResult( - ver, - options.transformResult ? [options.transformResult] : [] - ) - ]; + const mutationResults = options.transformResults + ? options.transformResults.map( + value => new MutationResult(ver, [value]) + ) + : [new MutationResult(ver, [])]; const write = MutationBatchResult.from(batch, ver, mutationResults); return localStoreAcknowledgeBatch(this.localStore, write); @@ -799,7 +794,7 @@ function genericLocalStoreTests( return expectLocalStore() .after(deleteMutation('foo/bar')) .toReturnRemoved('foo/bar') - .toContain(deletedDoc('foo/bar', 0)) + .toContain(deletedDoc('foo/bar', 0).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 1 }) .toReturnRemoved('foo/bar') .toNotContainIfEager(deletedDoc('foo/bar', 1).setHasCommittedMutations()) @@ -817,7 +812,7 @@ function genericLocalStoreTests( .toContain(doc('foo/bar', 1, { it: 'base' })) .after(deleteMutation('foo/bar')) .toReturnRemoved('foo/bar') - .toContain(deletedDoc('foo/bar', 0)) + .toContain(deletedDoc('foo/bar', 0).setHasLocalMutations()) // remove the mutation so only the mutation is pinning the doc .afterReleasingTarget(2) .afterAcknowledgingMutation({ documentVersion: 2 }) @@ -837,10 +832,10 @@ function genericLocalStoreTests( .toReturnTargetId(2) .after(deleteMutation('foo/bar')) .toReturnRemoved('foo/bar') - .toContain(deletedDoc('foo/bar', 0)) + .toContain(deletedDoc('foo/bar', 0).setHasLocalMutations()) .after(docUpdateRemoteEvent(doc('foo/bar', 1, { it: 'base' }), [2])) .toReturnRemoved('foo/bar') - .toContain(deletedDoc('foo/bar', 0)) + .toContain(deletedDoc('foo/bar', 0).setHasLocalMutations()) // Don't need to keep doc pinned anymore .afterReleasingTarget(2) .afterAcknowledgingMutation({ documentVersion: 2 }) @@ -946,13 +941,13 @@ function genericLocalStoreTests( return expectLocalStore() .after(deleteMutation('foo/bar')) .toReturnRemoved('foo/bar') - .toContain(deletedDoc('foo/bar', 0)) + .toContain(deletedDoc('foo/bar', 0).setHasLocalMutations()) .after(patchMutation('foo/bar', { foo: 'bar' })) .toReturnRemoved('foo/bar') - .toContain(deletedDoc('foo/bar', 0)) + .toContain(deletedDoc('foo/bar', 0).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 2 }) // delete mutation .toReturnRemoved('foo/bar') - .toContain(deletedDoc('foo/bar', 2).setHasCommittedMutations()) + .toContain(deletedDoc('foo/bar', 2).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 3 }) // patch mutation .toReturnChanged(unknownDoc('foo/bar', 3)) .toNotContainIfEager(unknownDoc('foo/bar', 3)) @@ -1005,15 +1000,15 @@ function genericLocalStoreTests( .after(deleteMutation('foo/baz')) .toContain(doc('foo/bar', 1, { foo: 'bar' }).setHasLocalMutations()) .toContain(doc('foo/bah', 0, { foo: 'bah' }).setHasLocalMutations()) - .toContain(deletedDoc('foo/baz', 0)) + .toContain(deletedDoc('foo/baz', 0).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 3 }) .toNotContain('foo/bar') .toContain(doc('foo/bah', 0, { foo: 'bah' }).setHasLocalMutations()) - .toContain(deletedDoc('foo/baz', 0)) + .toContain(deletedDoc('foo/baz', 0).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 4 }) .toNotContain('foo/bar') .toNotContain('foo/bah') - .toContain(deletedDoc('foo/baz', 0)) + .toContain(deletedDoc('foo/baz', 0).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 5 }) .toNotContain('foo/bar') .toNotContain('foo/bah') @@ -1039,15 +1034,15 @@ function genericLocalStoreTests( .after(deleteMutation('foo/baz')) .toContain(doc('foo/bar', 1, { foo: 'bar' }).setHasLocalMutations()) .toContain(doc('foo/bah', 0, { foo: 'bah' }).setHasLocalMutations()) - .toContain(deletedDoc('foo/baz', 0)) + .toContain(deletedDoc('foo/baz', 0).setHasLocalMutations()) .afterRejectingMutation() // patch mutation .toNotContain('foo/bar') .toContain(doc('foo/bah', 0, { foo: 'bah' }).setHasLocalMutations()) - .toContain(deletedDoc('foo/baz', 0)) + .toContain(deletedDoc('foo/baz', 0).setHasLocalMutations()) .afterRejectingMutation() // set mutation .toNotContain('foo/bar') .toNotContain('foo/bah') - .toContain(deletedDoc('foo/baz', 0)) + .toContain(deletedDoc('foo/baz', 0).setHasLocalMutations()) .afterRejectingMutation() // delete mutation .toNotContain('foo/bar') .toNotContain('foo/bah') @@ -1179,36 +1174,40 @@ function genericLocalStoreTests( const firstQuery = query('foo'); const secondQuery = query('foo', filter('matches', '==', true)); - return expectLocalStore() - .afterAllocatingQuery(firstQuery) - .toReturnTargetId(2) - .after( - docAddedRemoteEvent( - [ - doc('foo/bar', 10, { matches: true }), - doc('foo/baz', 20, { matches: true }) - ], - [2] + return ( + expectLocalStore() + .afterAllocatingQuery(firstQuery) + .toReturnTargetId(2) + .after( + docAddedRemoteEvent( + [ + doc('foo/bar', 10, { matches: true }), + doc('foo/baz', 20, { matches: true }) + ], + [2] + ) ) - ) - .toReturnChanged( - doc('foo/bar', 10, { matches: true }), - doc('foo/baz', 20, { matches: true }) - ) - .after(setMutation('foo/bonk', { matches: true })) - .toReturnChanged( - doc('foo/bonk', 0, { matches: true }).setHasLocalMutations() - ) - .afterAllocatingQuery(secondQuery) - .toReturnTargetId(4) - .afterExecutingQuery(secondQuery) - .toReturnChanged( - doc('foo/bar', 10, { matches: true }), - doc('foo/baz', 20, { matches: true }), - doc('foo/bonk', 0, { matches: true }).setHasLocalMutations() - ) - .toHaveRead({ documentsByCollection: 2, mutationsByCollection: 1 }) - .finish(); + .toReturnChanged( + doc('foo/bar', 10, { matches: true }), + doc('foo/baz', 20, { matches: true }) + ) + .after(setMutation('foo/bonk', { matches: true })) + .toReturnChanged( + doc('foo/bonk', 0, { matches: true }).setHasLocalMutations() + ) + .afterAllocatingQuery(secondQuery) + .toReturnTargetId(4) + .afterExecutingQuery(secondQuery) + .toReturnChanged( + doc('foo/bar', 10, { matches: true }), + doc('foo/baz', 20, { matches: true }), + doc('foo/bonk', 0, { matches: true }).setHasLocalMutations() + ) + // TODO(overlays): implement overlaysReadByKey and overlaysReadByCollection + // No mutations are read because only overlay is needed. + .toHaveRead({ documentsByCollection: 2, mutationsByCollection: 0 }) + .finish() + ); }); // eslint-disable-next-line no-restricted-properties @@ -1338,7 +1337,7 @@ function genericLocalStoreTests( .toContain(doc('foo/bar', 1, { sum: 1 }).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 2, - transformResult: { integerValue: 1 } + transformResults: [{ integerValue: 1 }] }) .toReturnChanged( doc('foo/bar', 2, { sum: 1 }).setHasCommittedMutations() @@ -1383,13 +1382,13 @@ function genericLocalStoreTests( .toContain(doc('foo/bar', 2, { sum: 3 }).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 3, - transformResult: { integerValue: 1 } + transformResults: [{ integerValue: 1 }] }) .toReturnChanged(doc('foo/bar', 3, { sum: 3 }).setHasLocalMutations()) .toContain(doc('foo/bar', 3, { sum: 3 }).setHasLocalMutations()) .afterAcknowledgingMutation({ documentVersion: 4, - transformResult: { integerValue: 1339 } + transformResults: [{ integerValue: 1339 }] }) .toReturnChanged( doc('foo/bar', 4, { sum: 1339 }).setHasCommittedMutations() @@ -1450,9 +1449,28 @@ function genericLocalStoreTests( .toReturnChanged( doc('foo/bar', 2, { sum: 1, - arrayUnion: ['bar', 'foo'] + arrayUnion: ['foo'] }).setHasLocalMutations() ) + .afterAcknowledgingMutation({ + documentVersion: 3, + transformResults: [ + { integerValue: 1338 }, + { + arrayValue: { + values: [{ stringValue: 'bar' }, { stringValue: 'foo' }] + } + } + ] + }) + .toReturnChanged( + doc('foo/bar', 3, { + sum: 1338, + arrayUnion: ['bar', 'foo'] + }) + .setReadTime(SnapshotVersion.fromTimestamp(new Timestamp(0, 3000))) + .setHasCommittedMutations() + ) .finish() ); }); @@ -1636,6 +1654,27 @@ function genericLocalStoreTests( .finish(); }); + it('add then update while offline', () => { + return expectLocalStore() + .afterMutations([ + setMutation('foo/bar', { 'foo': 'foo-value', 'bar': 1 }) + ]) + .toContain( + doc('foo/bar', 0, { + 'foo': 'foo-value', + 'bar': 1 + }).setHasLocalMutations() + ) + .afterMutations([patchMutation('foo/bar', { 'bar': 2 })]) + .toContain( + doc('foo/bar', 0, { + 'foo': 'foo-value', + 'bar': 2 + }).setHasLocalMutations() + ) + .finish(); + }); + it('handles saving and loading named queries', async () => { return expectLocalStore() .after( @@ -1781,6 +1820,25 @@ function genericLocalStoreTests( ); }); + it('can handle batch Ack when pending batches have other docs', () => { + // Prepare two batches, the first one will get rejected by the backend. + // When the first batch is rejected, overlay is recalculated with only the + // second batch, even though it has more documents than what is being + // rejected. + return expectLocalStore() + .afterMutations([patchMutation('foo/bar', { 'foo': 'bar' })]) + .afterMutations([ + setMutation('foo/bar', { 'foo': 'bar-set' }), + setMutation('foo/another', { 'foo': 'another' }) + ]) + .afterRejectingMutation() + .toContain(doc('foo/bar', 0, { 'foo': 'bar-set' }).setHasLocalMutations()) + .toContain( + doc('foo/another', 0, { 'foo': 'another' }).setHasLocalMutations() + ) + .finish(); + }); + it('uses target mapping to execute queries', () => { if (gcIsEager) { return; diff --git a/packages/firestore/test/unit/local/query_engine.test.ts b/packages/firestore/test/unit/local/query_engine.test.ts index f4a829c7e75..4c2b5f2f03e 100644 --- a/packages/firestore/test/unit/local/query_engine.test.ts +++ b/packages/firestore/test/unit/local/query_engine.test.ts @@ -17,19 +17,26 @@ import { expect } from 'chai'; +import { Timestamp } from '../../../src'; import { User } from '../../../src/auth/user'; import { LimitType, Query, queryWithLimit } from '../../../src/core/query'; import { SnapshotVersion } from '../../../src/core/snapshot_version'; import { View } from '../../../src/core/view'; +import { DocumentOverlayCache } from '../../../src/local/document_overlay_cache'; import { LocalDocumentsView } from '../../../src/local/local_documents_view'; import { MemoryIndexManager } from '../../../src/local/memory_index_manager'; +import { MutationQueue } from '../../../src/local/mutation_queue'; import { Persistence } from '../../../src/local/persistence'; import { PersistencePromise } from '../../../src/local/persistence_promise'; import { PersistenceTransaction } from '../../../src/local/persistence_transaction'; import { QueryEngine } from '../../../src/local/query_engine'; import { RemoteDocumentCache } from '../../../src/local/remote_document_cache'; import { TargetCache } from '../../../src/local/target_cache'; -import { documentKeySet, DocumentMap } from '../../../src/model/collections'; +import { + documentKeySet, + DocumentMap, + newMutationMap +} from '../../../src/model/collections'; import { Document, MutableDocument } from '../../../src/model/document'; import { DocumentKey } from '../../../src/model/document_key'; import { DocumentSet } from '../../../src/model/document_set'; @@ -37,8 +44,17 @@ import { IndexOffset, indexOffsetComparator } from '../../../src/model/field_index'; +import { Mutation } from '../../../src/model/mutation'; import { debugAssert } from '../../../src/util/assert'; -import { doc, filter, key, orderBy, query, version } from '../../util/helpers'; +import { + deleteMutation, + doc, + filter, + key, + orderBy, + query, + version +} from '../../util/helpers'; import { testMemoryEagerPersistence } from './persistence_test_helpers'; @@ -88,6 +104,8 @@ class TestLocalDocumentsView extends LocalDocumentsView { describe('QueryEngine', () => { let persistence!: Persistence; let remoteDocumentCache!: RemoteDocumentCache; + let mutationQueue!: MutationQueue; + let documentOverlayCache!: DocumentOverlayCache; let targetCache!: TargetCache; let queryEngine!: QueryEngine; let localDocuments!: TestLocalDocumentsView; @@ -115,6 +133,23 @@ describe('QueryEngine', () => { }); } + /** Adds a mutation to the mutation queue. */ + function addMutation(mutation: Mutation): Promise { + return persistence.runTransaction('addMutation', 'readwrite', txn => { + return mutationQueue + .addMutationBatch(txn, Timestamp.now(), [], [mutation]) + .next(batch => { + const overlayMap = newMutationMap(); + overlayMap.set(mutation.key, mutation); + return documentOverlayCache.saveOverlays( + txn, + batch.batchId, + overlayMap + ); + }); + }); + } + async function expectOptimizedCollectionQuery( op: () => Promise ): Promise { @@ -178,10 +213,17 @@ describe('QueryEngine', () => { const indexManager = persistence.getIndexManager(User.UNAUTHENTICATED); remoteDocumentCache = persistence.getRemoteDocumentCache(); remoteDocumentCache.setIndexManager(indexManager); - + mutationQueue = persistence.getMutationQueue( + User.UNAUTHENTICATED, + indexManager + ); + documentOverlayCache = persistence.getDocumentOverlayCache( + User.UNAUTHENTICATED + ); localDocuments = new TestLocalDocumentsView( remoteDocumentCache, - persistence.getMutationQueue(User.UNAUTHENTICATED, indexManager), + mutationQueue, + documentOverlayCache, new MemoryIndexManager() ); queryEngine.setLocalDocumentsView(localDocuments); @@ -403,6 +445,19 @@ describe('QueryEngine', () => { doc('coll/b', 1, { order: 3 }) ]); }); + + it('does not include documents deleted by mutation', async () => { + const query1 = query('coll'); + await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); + await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); + + // Add an unacknowledged mutation + await addMutation(deleteMutation('coll/b')); + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_A]); + }); }); function verifyResult(actualDocs: DocumentSet, expectedDocs: Document[]): void { diff --git a/packages/firestore/test/unit/local/test_document_overlay_cache.ts b/packages/firestore/test/unit/local/test_document_overlay_cache.ts index df982b0dbad..9c5c214651b 100644 --- a/packages/firestore/test/unit/local/test_document_overlay_cache.ts +++ b/packages/firestore/test/unit/local/test_document_overlay_cache.ts @@ -50,6 +50,12 @@ export class TestDocumentOverlayCache { }); } + getOverlays(keys: DocumentKeySet): Promise { + return this.persistence.runTransaction('getOverlays', 'readonly', txn => { + return this.cache.getOverlays(txn, keys); + }); + } + getOverlayMutation(docKey: string): Promise { return this.getOverlay(key(docKey)).then(value => { if (!value) { diff --git a/packages/firestore/test/unit/model/mutation.test.ts b/packages/firestore/test/unit/model/mutation.test.ts index e5765805e6f..10a85a031a0 100644 --- a/packages/firestore/test/unit/model/mutation.test.ts +++ b/packages/firestore/test/unit/model/mutation.test.ts @@ -26,13 +26,15 @@ import { deleteField } from '../../../src'; import { MutableDocument } from '../../../src/model/document'; +import { FieldMask } from '../../../src/model/field_mask'; import { mutationApplyToLocalView, mutationApplyToRemoteDocument, mutationExtractBaseValue, Mutation, MutationResult, - Precondition + Precondition, + calculateOverlayMutation } from '../../../src/model/mutation'; import { serverTimestamp as serverTimestampInternal } from '../../../src/model/server_timestamps'; import { @@ -42,12 +44,15 @@ import { import { Dict } from '../../../src/util/obj'; import { addEqualityMatcher } from '../../util/equality_matcher'; import { + computeCombinations, + computePermutations, deletedDoc, deleteMutation, doc, field, invalidDoc, key, + mergeMutation, mutationResult, patchMutation, setMutation, @@ -62,12 +67,93 @@ describe('Mutation', () => { const timestamp = Timestamp.now(); + /** + * For each document in `docs`, calculate the overlay mutations of each + * possible permutation, check whether this holds: + * document + overlay_mutation = document + mutation_list + * Returns how many cases it has run. + */ + function runPermutationTests( + docs: MutableDocument[], + mutations: Mutation[] + ): number { + let testCases = 0; + const permutations = computePermutations(mutations); + docs.forEach(doc => { + permutations.forEach(permutation => { + verifyOverlayRoundTrips(doc, ...permutation); + testCases++; + }); + }); + return testCases; + } + + function getDescription( + document: MutableDocument, + mutations: Mutation[], + overlay: Mutation | null + ): string { + let result = 'Overlay Mutation failed with:\n'; + result += 'document:\n'; + result += document.toString(); + result += '\n\n'; + + result += 'mutations:\n'; + mutations.forEach(mutation => { + result += mutation.toString() + '\n'; + }); + result += '\n'; + + result += 'overlay:\n'; + result += overlay === null ? 'null' : overlay.toString(); + result += '\n\n'; + return result; + } + + function verifyOverlayRoundTrips( + doc: MutableDocument, + ...mutations: Mutation[] + ): void { + const docForMutations = doc.mutableCopy(); + const docForOverlay = doc.mutableCopy(); + + let mask: FieldMask | null = null; + for (const mutation of mutations) { + mask = mutationApplyToLocalView( + mutation, + docForMutations, + mask, + timestamp + ); + } + + const overlay = calculateOverlayMutation(docForMutations, mask); + if (overlay !== null) { + mutationApplyToLocalView( + overlay, + docForOverlay, + /* previousMask= */ null, + timestamp + ); + } + + expect(docForOverlay).to.deep.equal( + docForMutations, + getDescription(doc, mutations, overlay) + ); + } + it('can apply sets to documents', () => { const docData = { foo: 'foo-value', baz: 'baz-value' }; const document = doc('collection/key', 0, docData); const set = setMutation('collection/key', { bar: 'bar-value' }); - mutationApplyToLocalView(set, document, timestamp); + mutationApplyToLocalView( + set, + document, + /* previousMask= */ null, + timestamp + ); expect(document).to.deep.equal( doc('collection/key', 0, { bar: 'bar-value' }).setHasLocalMutations() ); @@ -81,7 +167,12 @@ describe('Mutation', () => { 'foo.bar': 'new-bar-value' }); - mutationApplyToLocalView(patch, document, timestamp); + mutationApplyToLocalView( + patch, + document, + /* previousMask= */ null, + timestamp + ); expect(document).to.deep.equal( doc('collection/key', 0, { foo: { bar: 'new-bar-value' }, @@ -100,7 +191,12 @@ describe('Mutation', () => { Precondition.none() ); - mutationApplyToLocalView(patch, document, timestamp); + mutationApplyToLocalView( + patch, + document, + /* previousMask= */ null, + timestamp + ); expect(document).to.deep.equal( doc('collection/key', 0, { foo: { bar: 'new-bar-value' } @@ -118,7 +214,12 @@ describe('Mutation', () => { Precondition.none() ); - mutationApplyToLocalView(patch, document, timestamp); + mutationApplyToLocalView( + patch, + document, + /* previousMask= */ null, + timestamp + ); expect(document).to.deep.equal( doc('collection/key', 0, { foo: { bar: 'new-bar-value' } @@ -134,7 +235,12 @@ describe('Mutation', () => { 'foo.bar': deleteField() }); - mutationApplyToLocalView(patch, document, timestamp); + mutationApplyToLocalView( + patch, + document, + /* previousMask= */ null, + timestamp + ); expect(document).to.deep.equal( doc('collection/key', 0, { foo: { baz: 'baz-value' } @@ -151,7 +257,12 @@ describe('Mutation', () => { 'foo.bar': 'new-bar-value' }); - mutationApplyToLocalView(patch, document, timestamp); + mutationApplyToLocalView( + patch, + document, + /* previousMask= */ null, + timestamp + ); expect(document).to.deep.equal( doc('collection/key', 0, { foo: { bar: 'new-bar-value' }, @@ -163,7 +274,12 @@ describe('Mutation', () => { it('patching a NoDocument yields a NoDocument', () => { const document = deletedDoc('collection/key', 0); const patch = patchMutation('collection/key', { foo: 'bar' }); - mutationApplyToLocalView(patch, document, timestamp); + mutationApplyToLocalView( + patch, + document, + /* previousMask= */ null, + timestamp + ); expect(document).to.deep.equal(deletedDoc('collection/key', 0)); }); @@ -175,7 +291,12 @@ describe('Mutation', () => { 'foo.bar': serverTimestamp() }); - mutationApplyToLocalView(transform, document, timestamp); + mutationApplyToLocalView( + transform, + document, + /* previousMask= */ null, + timestamp + ); // Server timestamps aren't parsed, so we manually insert it. const data = wrapObject({ @@ -193,13 +314,13 @@ describe('Mutation', () => { // test once we have integration tests. it('can create arrayUnion() transform.', () => { const transform = patchMutation('collection/key', { - foo: arrayUnion('tag'), + a: arrayUnion('tag'), 'bar.baz': arrayUnion(true, { nested: { a: [1, 2] } }) }); expect(transform.fieldTransforms).to.have.lengthOf(2); const first = transform.fieldTransforms[0]; - expect(first.field).to.deep.equal(field('foo')); + expect(first.field).to.deep.equal(field('a')); expect(first.transform).to.deep.equal( new ArrayUnionTransformOperation([wrap('tag')]) ); @@ -340,7 +461,12 @@ describe('Mutation', () => { for (const transformData of transforms) { const transform = patchMutation('collection/key', transformData); - mutationApplyToLocalView(transform, document, timestamp); + mutationApplyToLocalView( + transform, + document, + /* previousMask= */ null, + timestamp + ); } const expectedDoc = doc( @@ -479,8 +605,15 @@ describe('Mutation', () => { const document = doc('collection/key', 0, { foo: 'bar' }); const mutation = deleteMutation('collection/key'); - mutationApplyToLocalView(mutation, document, Timestamp.now()); - expect(document).to.deep.equal(deletedDoc('collection/key', 0)); + mutationApplyToLocalView( + mutation, + document, + /* previousMask= */ null, + Timestamp.now() + ); + expect(document).to.deep.equal( + deletedDoc('collection/key', 0).setHasLocalMutations() + ); }); it('can apply sets with mutation results', () => { @@ -627,10 +760,333 @@ describe('Mutation', () => { const inc = { sum: increment(1) }; const transform = setMutation('collection/key', inc); - mutationApplyToLocalView(transform, document, Timestamp.now()); - mutationApplyToLocalView(transform, document, Timestamp.now()); + mutationApplyToLocalView( + transform, + document, + /* previousMask= */ null, + Timestamp.now() + ); + mutationApplyToLocalView( + transform, + document, + /* previousMask= */ null, + Timestamp.now() + ); expect(document.isFoundDocument()).to.be.true; expect(document.data.field(field('sum'))).to.deep.equal(wrap(2)); }); + + // Mutation Overlay tests + + it('overlay with no mutation', () => { + const doc1 = doc('collection/key', 1, { + 'foo': 'foo-value', + 'baz': 'baz-value' + }); + verifyOverlayRoundTrips(doc1); + }); + + it('overlay with mutations fail by preconditions', () => { + verifyOverlayRoundTrips( + deletedDoc('collection/key', 1), + patchMutation('collection/key', { 'foo': 'bar' }), + patchMutation('collection/key', { 'a': 1 }) + ); + }); + + it('overlay with patch on invalid document', () => { + verifyOverlayRoundTrips( + MutableDocument.newInvalidDocument(key('collection/key')), + patchMutation('collection/key', { 'a': 1 }) + ); + }); + + it('overlay with one set mutation', () => { + const doc1 = doc('collection/key', 1, { + 'foo': 'foo-value', + 'baz': 'baz-value' + }); + verifyOverlayRoundTrips( + doc1, + setMutation('collection/key', { 'bar': 'bar-value' }) + ); + }); + + it('overlay with one patch mutation', () => { + const doc1 = doc('collection/key', 1, { + 'foo': { 'bar': 'bar-value' }, + 'baz': 'baz-value' + }); + verifyOverlayRoundTrips( + doc1, + patchMutation('collection/key', { 'foo.bar': 'new-bar-value' }) + ); + }); + + it('overlay with patch then merge', () => { + const upsert = mergeMutation( + 'collection/key', + { 'foo.bar': 'new-bar-value' }, + [field('foo.bar')] + ); + + verifyOverlayRoundTrips(deletedDoc('collection/key', 1), upsert); + }); + + it('overlay with delete then patch', () => { + const doc1 = doc('collection/key', 1, { 'foo': 1 }); + const deleteMutation1 = deleteMutation('collection/key'); + const patchMutation1 = patchMutation('collection/key', { + 'foo.bar': 'new-bar-value' + }); + + verifyOverlayRoundTrips(doc1, deleteMutation1, patchMutation1); + }); + + it('overlay with delete then merge', () => { + const doc1 = doc('collection/key', 1, { 'foo': 1 }); + const deleteMutation1 = deleteMutation('collection/key'); + const mergeMutation1 = mergeMutation( + 'collection/key', + { 'foo.bar': 'new-bar-value' }, + [field('foo.bar')] + ); + + verifyOverlayRoundTrips(doc1, deleteMutation1, mergeMutation1); + }); + + it('overlay with patch then patch to delete field', () => { + const doc1 = doc('collection/key', 1, { 'foo': 1 }); + const patch = patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': increment(1) + }); + const patchToDeleteField = patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': deleteField() + }); + + verifyOverlayRoundTrips(doc1, patch, patchToDeleteField); + }); + + it('overlay with patch then merge with array union', () => { + const doc1 = doc('collection/key', 1, { 'foo': 1 }); + const patch = patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': increment(1) + }); + const merge = mergeMutation( + 'collection/key', + { 'arrays': arrayUnion(1, 2, 3) }, + [field('arrays')] + ); + + verifyOverlayRoundTrips(doc1, patch, merge); + }); + + it('overlay with array union then remove', () => { + const doc1 = doc('collection/key', 1, { 'foo': 1 }); + const union = mergeMutation( + 'collection/key', + { 'arrays': arrayUnion(1, 2, 3) }, + [] + ); + const remove = mergeMutation( + 'collection/key', + { 'foo': 'xxx', 'arrays': arrayRemove(2) }, + [field('foo')] + ); + + verifyOverlayRoundTrips(doc1, union, remove); + }); + + it('overlay with set then increment', () => { + const doc1 = doc('collection/key', 1, { 'foo': 1 }); + const set = setMutation('collection/key', { 'foo': 2 }); + const update = patchMutation('collection/key', { 'foo': increment(2) }); + + verifyOverlayRoundTrips(doc1, set, update); + }); + + it('overlay with set then patch on deleted doc', () => { + const doc1 = deletedDoc('collection/key', 1); + const set = setMutation('collection/key', { 'bar': 'bar-value' }); + const patch = patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': serverTimestamp() + }); + + verifyOverlayRoundTrips(doc1, set, patch); + }); + + it('overlay with field deletion of nested field', () => { + const doc1 = doc('collection/key', 1, { 'foo': 1 }); + const patch1 = patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': increment(1) + }); + const patch2 = patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': serverTimestamp() + }); + const patch3 = patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': deleteField() + }); + + verifyOverlayRoundTrips(doc1, patch1, patch2, patch3); + }); + + it('overlay created from empty set with merge', () => { + const doc1 = deletedDoc('collection/key', 1); + const merge = mergeMutation('collection/key', {}, []); + verifyOverlayRoundTrips(doc1, merge); + + const doc2 = doc('collection/key', 1, { 'foo': 'foo-value' }); + verifyOverlayRoundTrips(doc2, merge); + }); + + // Below tests run on automatically generated mutation list, they are + // deterministic, but hard to debug when they fail. They will print the + // failure case, and the best way to debug is recreate the case manually in a + // separate test. + + it('overlay with mutation with multiple deletes', () => { + const docs = [ + doc('collection/key', 1, { 'foo': 'foo-value', 'bar.baz': 1 }), + deletedDoc('collection/key', 1), + unknownDoc('collection/key', 1) + ]; + + const mutations = [ + setMutation('collection/key', { 'bar': 'bar-value' }), + deleteMutation('collection/key'), + deleteMutation('collection/key'), + patchMutation('collection/key', { + 'foo': 'foo-patched-value', + 'bar.baz': serverTimestamp() + }) + ]; + + const testCases = runPermutationTests(docs, mutations); + + // There are 4! * 3 cases + expect(testCases).to.equal(72); + }); + + it('overlay by combinations and permutations', () => { + const docs: MutableDocument[] = [ + doc('collection/key', 1, { 'foo': 'foo-value', 'bar': 1 }), + deletedDoc('collection/key', 1), + unknownDoc('collection/key', 1) + ]; + + const mutations: Mutation[] = [ + setMutation('collection/key', { 'bar': 'bar-value' }), + setMutation('collection/key', { 'bar.rab': 'bar.rab-value' }), + deleteMutation('collection/key'), + patchMutation('collection/key', { + 'foo': 'foo-patched-value-incr', + 'bar': increment(1) + }), + patchMutation('collection/key', { + 'foo': 'foo-patched-value-delete', + 'bar': deleteField() + }), + patchMutation('collection/key', { + 'foo': 'foo-patched-value-st', + 'bar': serverTimestamp() + }), + mergeMutation('collection/key', { 'arrays': arrayUnion(1, 2, 3) }, [ + field('arrays') + ]) + ]; + + // Take all possible combinations of the subsets of the mutation list, run each combination for + // all possible permutation, for all 3 different type of documents. + let testCases = 0; + computeCombinations(mutations).forEach(combination => { + testCases += runPermutationTests(docs, combination); + }); + + // There are (0! + 7*1! + 21*2! + 35*3! + 35*4! + 21*5! + 7*6! + 7!) * 3 = 41100 cases. + expect(testCases).to.equal(41100); + }); + + it('overlay by combinations and permutations for array transforms', () => { + const docs: MutableDocument[] = [ + doc('collection/key', 1, { 'foo': 'foo-value', 'bar.baz': 1 }), + deletedDoc('collection/key', 1), + unknownDoc('collection/key', 1) + ]; + + const mutations: Mutation[] = [ + setMutation('collection/key', { 'bar': 'bar-value' }), + mergeMutation( + 'collection/key', + { 'foo': 'xxx', 'arrays': arrayRemove(2) }, + [field('foo')] + ), + deleteMutation('collection/key'), + patchMutation('collection/key', { + 'foo': 'foo-patched-value-1', + 'arrays': arrayUnion(4, 5) + }), + patchMutation('collection/key', { + 'foo': 'foo-patched-value-2', + 'arrays': arrayRemove(5, 6) + }), + mergeMutation( + 'collection/key', + { 'foo': 'yyy', 'arrays': arrayUnion(1, 2, 3, 999) }, + [field('foo')] + ) + ]; + + let testCases = 0; + computeCombinations(mutations).forEach(combination => { + testCases += runPermutationTests(docs, combination); + }); + + // There are (0! + 6*1! + 15*2! + 20*3! + 15*4! + 6*5! + 6!) * 3 = 5871 cases. + expect(testCases).to.equal(5871); + }); + + it('overlay by combinations and permutations for increments', () => { + const docs: MutableDocument[] = [ + doc('collection/key', 1, { 'foo': 'foo-value', 'bar': 1 }), + deletedDoc('collection/key', 1), + unknownDoc('collection/key', 1) + ]; + + const mutations: Mutation[] = [ + setMutation('collection/key', { 'bar': 'bar-value' }), + mergeMutation( + 'collection/key', + { 'foo': 'foo-merge', 'bar': increment(2) }, + [field('foo')] + ), + deleteMutation('collection/key'), + patchMutation('collection/key', { + 'foo': 'foo-patched-value-1', + 'bar': increment(-1.4) + }), + patchMutation('collection/key', { + 'foo': 'foo-patched-value-2', + 'bar': increment(3.3) + }), + mergeMutation('collection/key', { 'foo': 'yyy', 'bar': increment(-41) }, [ + field('foo') + ]) + ]; + + let testCases = 0; + computeCombinations(mutations).forEach(combination => { + testCases += runPermutationTests(docs, combination); + }); + + // There are (0! + 6*1! + 15*2! + 20*3! + 15*4! + 6*5! + 6!) * 3 = 5871 cases. + expect(testCases).to.equal(5871); + }); }); diff --git a/packages/firestore/test/util/helpers.ts b/packages/firestore/test/util/helpers.ts index f7970ef872c..c7f0504f5e6 100644 --- a/packages/firestore/test/util/helpers.ts +++ b/packages/firestore/test/util/helpers.ts @@ -81,7 +81,8 @@ import { MutationResult, PatchMutation, Precondition, - SetMutation + SetMutation, + FieldTransform } from '../../src/model/mutation'; import { JsonObject, ObjectValue } from '../../src/model/object_value'; import { FieldPath, ResourcePath } from '../../src/model/path'; @@ -285,6 +286,23 @@ export function patchMutation( if (precondition === undefined) { precondition = Precondition.exists(true); } + return patchMutationHelper(keyStr, json, precondition, /* updateMask */ null); +} + +export function mergeMutation( + keyStr: string, + json: JsonObject, + updateMask: FieldPath[] +): PatchMutation { + return patchMutationHelper(keyStr, json, Precondition.none(), updateMask); +} + +function patchMutationHelper( + keyStr: string, + json: JsonObject, + precondition: Precondition, + updateMask: FieldPath[] | null +): PatchMutation { // Replace '' from JSON with FieldValue forEach(json, (k, v) => { if (v === '') { @@ -298,12 +316,31 @@ export function patchMutation( patchKey, json ); + + // `mergeMutation()` provides an update mask for the merged fields, whereas + // `patchMutation()` requires the update mask to be parsed from the values. + const mask = updateMask ? updateMask : parsed.fieldMask.fields; + + // We sort the fieldMaskPaths to make the order deterministic in tests. + // (Otherwise, when we flatten a Set to a proto repeated field, we'll end up + // comparing in iterator order and possibly consider {foo,bar} != {bar,foo}.) + let fieldMaskPaths = new SortedSet(FieldPath.comparator); + mask.forEach(value => (fieldMaskPaths = fieldMaskPaths.add(value))); + + // The order of the transforms doesn't matter, but we sort them so tests can + // assume a particular order. + const fieldTransforms: FieldTransform[] = []; + fieldTransforms.push(...parsed.fieldTransforms); + fieldTransforms.sort((lhs, rhs) => + FieldPath.comparator(lhs.field, rhs.field) + ); + return new PatchMutation( patchKey, parsed.data, - parsed.fieldMask, + new FieldMask(fieldMaskPaths.toArray()), precondition, - parsed.fieldTransforms + fieldTransforms ); } @@ -970,3 +1007,50 @@ export function forEachNumber( } } } + +/** + * Returns all possible permutations of the given array. + * For `[a, b]`, this method returns `[[a, b], [b, a]]`. + */ +export function computePermutations(input: T[]): T[][] { + if (input.length === 0) { + return [[]]; + } + + const result: T[][] = []; + for (let i = 0; i < input.length; ++i) { + const rest = computePermutations( + input.slice(0, i).concat(input.slice(i + 1)) + ); + + if (rest.length === 0) { + result.push([input[i]]); + } else { + for (let j = 0; j < rest.length; ++j) { + result.push([input[i]].concat(rest[j])); + } + } + } + return result; +} + +/** + * Returns all possible combinations of the given array, including an empty + * array. For `[a, b, c]` this method returns + * `[[], [a], [a, b], [a, c], [b, c], [a, b, c]`. + */ +export function computeCombinations(input: T[]): T[][] { + const computeNonEmptyCombinations = (input: T[]): T[][] => { + if (input.length === 1) { + return [input]; + } else { + const first = input[0]; + const rest = computeNonEmptyCombinations(input.slice(1)); + return rest.concat( + rest.map(e => e.concat(first)), + [[first]] + ); + } + }; + return computeNonEmptyCombinations(input).concat([[]]); +}