diff --git a/.changeset/five-yaks-travel.md b/.changeset/five-yaks-travel.md new file mode 100644 index 00000000000..4a4b57ce393 --- /dev/null +++ b/.changeset/five-yaks-travel.md @@ -0,0 +1,5 @@ +--- +'@firebase/firestore': patch +--- + +Add internal implementation of setIndexConfiguration diff --git a/packages/firestore/package.json b/packages/firestore/package.json index 6abbbb90a8d..35e02e8c2a1 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -99,6 +99,7 @@ "@rollup/plugin-json": "4.1.0", "@types/eslint": "7.29.0", "@types/json-stable-stringify": "1.0.34", + "chai-exclude": "2.1.0", "json-stable-stringify": "1.0.1", "protobufjs": "6.11.3", "rollup": "2.72.1", diff --git a/packages/firestore/src/api/credentials.ts b/packages/firestore/src/api/credentials.ts index 215c6ae3d9f..ae74774806c 100644 --- a/packages/firestore/src/api/credentials.ts +++ b/packages/firestore/src/api/credentials.ts @@ -473,24 +473,25 @@ export class FirebaseAppCheckTokenProvider asyncQueue: AsyncQueue, changeListener: CredentialChangeListener ): void { - const onTokenChanged: (tokenResult: AppCheckTokenResult) => Promise = - tokenResult => { - if (tokenResult.error != null) { - logDebug( - 'FirebaseAppCheckTokenProvider', - `Error getting App Check token; using placeholder token instead. Error: ${tokenResult.error.message}` - ); - } - const tokenUpdated = tokenResult.token !== this.latestAppCheckToken; - this.latestAppCheckToken = tokenResult.token; + const onTokenChanged: ( + tokenResult: AppCheckTokenResult + ) => Promise = tokenResult => { + if (tokenResult.error != null) { logDebug( 'FirebaseAppCheckTokenProvider', - `Received ${tokenUpdated ? 'new' : 'existing'} token.` + `Error getting App Check token; using placeholder token instead. Error: ${tokenResult.error.message}` ); - return tokenUpdated - ? changeListener(tokenResult.token) - : Promise.resolve(); - }; + } + const tokenUpdated = tokenResult.token !== this.latestAppCheckToken; + this.latestAppCheckToken = tokenResult.token; + logDebug( + 'FirebaseAppCheckTokenProvider', + `Received ${tokenUpdated ? 'new' : 'existing'} token.` + ); + return tokenUpdated + ? changeListener(tokenResult.token) + : Promise.resolve(); + }; this.tokenListener = (tokenResult: AppCheckTokenResult) => { asyncQueue.enqueueRetryable(() => onTokenChanged(tokenResult)); diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index 91bb1c70c9b..7a71071b54b 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -56,6 +56,7 @@ import { AsyncQueue } from '../util/async_queue'; import { newAsyncQueue } from '../util/async_queue_impl'; import { Code, FirestoreError } from '../util/error'; import { cast } from '../util/input_validation'; +import { logWarn } from '../util/log'; import { Deferred } from '../util/promise'; import { LoadBundleTask } from './bundle'; @@ -332,7 +333,7 @@ function setPersistenceProviders( if (!canFallbackFromIndexedDbError(error)) { throw error; } - console.warn( + logWarn( 'Error enabling offline persistence. Falling back to ' + 'persistence disabled: ' + error diff --git a/packages/firestore/src/api/index_configuration.ts b/packages/firestore/src/api/index_configuration.ts index 9b22704025c..918c8956441 100644 --- a/packages/firestore/src/api/index_configuration.ts +++ b/packages/firestore/src/api/index_configuration.ts @@ -15,7 +15,9 @@ * limitations under the License. */ +import { getLocalStore } from '../core/firestore_client'; import { fieldPathFromDotSeparatedString } from '../lite-api/user_data_reader'; +import { localStoreConfigureFieldIndexes } from '../local/local_store_impl'; import { FieldIndex, IndexKind, @@ -24,6 +26,7 @@ import { } from '../model/field_index'; import { Code, FirestoreError } from '../util/error'; import { cast } from '../util/input_validation'; +import { logWarn } from '../util/log'; import { ensureFirestoreConfigured, Firestore } from './database'; @@ -150,17 +153,29 @@ export function setIndexConfiguration( jsonOrConfiguration: string | IndexConfiguration ): Promise { firestore = cast(firestore, Firestore); - ensureFirestoreConfigured(firestore); + const client = ensureFirestoreConfigured(firestore); + // PORTING NOTE: We don't return an error if the user has not enabled + // persistence since `enableIndexeddbPersistence()` can fail on the Web. + if (!client.offlineComponents?.indexBackfillerScheduler) { + logWarn('Cannot enable indexes when persistence is disabled'); + return Promise.resolve(); + } + const parsedIndexes = parseIndexes(jsonOrConfiguration); + return getLocalStore(client).then(localStore => + localStoreConfigureFieldIndexes(localStore, parsedIndexes) + ); +} + +export function parseIndexes( + jsonOrConfiguration: string | IndexConfiguration +): FieldIndex[] { const indexConfiguration = typeof jsonOrConfiguration === 'string' ? (tryParseJson(jsonOrConfiguration) as IndexConfiguration) : jsonOrConfiguration; const parsedIndexes: FieldIndex[] = []; - // PORTING NOTE: We don't return an error if the user has not enabled - // persistence since `enableIndexeddbPersistence()` can fail on the Web. - if (Array.isArray(indexConfiguration.indexes)) { for (const index of indexConfiguration.indexes) { const collectionGroup = tryGetString(index, 'collectionGroup'); @@ -194,9 +209,7 @@ export function setIndexConfiguration( ); } } - - // TODO(indexing): Configure indexes - return Promise.resolve(); + return parsedIndexes; } function tryParseJson(json: string): Record { @@ -205,7 +218,7 @@ function tryParseJson(json: string): Record { } catch (e) { throw new FirestoreError( Code.INVALID_ARGUMENT, - 'Failed to parse JSON:' + (e as Error)?.message + 'Failed to parse JSON: ' + (e as Error)?.message ); } } diff --git a/packages/firestore/src/local/index_backfiller.ts b/packages/firestore/src/local/index_backfiller.ts index 725c0f218cb..a3de25b38d6 100644 --- a/packages/firestore/src/local/index_backfiller.ts +++ b/packages/firestore/src/local/index_backfiller.ts @@ -25,7 +25,6 @@ import { debugAssert } from '../util/assert'; import { AsyncQueue, DelayedOperation, TimerId } from '../util/async_queue'; import { logDebug } from '../util/log'; -import { INDEXING_ENABLED } from './indexeddb_schema'; import { ignoreIfPrimaryLeaseLoss, LocalStore } from './local_store'; import { LocalWriteResult } from './local_store_impl'; import { Persistence, Scheduler } from './persistence'; @@ -60,9 +59,7 @@ export class IndexBackfillerScheduler implements Scheduler { this.task === null, 'Cannot start an already started IndexBackfillerScheduler' ); - if (INDEXING_ENABLED) { - this.schedule(INITIAL_BACKFILL_DELAY_MS); - } + this.schedule(INITIAL_BACKFILL_DELAY_MS); } stop(): void { diff --git a/packages/firestore/src/local/indexeddb_schema.ts b/packages/firestore/src/local/indexeddb_schema.ts index e0d3c07570e..c4373f3881e 100644 --- a/packages/firestore/src/local/indexeddb_schema.ts +++ b/packages/firestore/src/local/indexeddb_schema.ts @@ -28,11 +28,6 @@ import { import { EncodedResourcePath } from './encoded_resource_path'; import { DbTimestampKey } from './indexeddb_sentinels'; -// TODO(indexing): Remove this constant -export const INDEXING_ENABLED = false; - -export const INDEXING_SCHEMA_VERSION = 15; - /** * Schema Version for the Web client: * 1. Initial version including Mutation Queue, Query Cache, and Remote @@ -58,7 +53,7 @@ export const INDEXING_SCHEMA_VERSION = 15; * 15. Add indexing support. */ -export const SCHEMA_VERSION = INDEXING_ENABLED ? INDEXING_SCHEMA_VERSION : 14; +export const SCHEMA_VERSION = 15; /** * Wrapper class to store timestamps (seconds and nanos) in IndexedDb objects. diff --git a/packages/firestore/src/local/indexeddb_schema_converter.ts b/packages/firestore/src/local/indexeddb_schema_converter.ts index 5edca57151a..3509db11a14 100644 --- a/packages/firestore/src/local/indexeddb_schema_converter.ts +++ b/packages/firestore/src/local/indexeddb_schema_converter.ts @@ -45,7 +45,7 @@ import { DbTarget, DbTargetDocument, DbTargetGlobal, - INDEXING_SCHEMA_VERSION + SCHEMA_VERSION } from './indexeddb_schema'; import { DbRemoteDocument as DbRemoteDocumentLegacy, @@ -146,7 +146,7 @@ export class SchemaConverter implements SimpleDbSchemaConverter { debugAssert( fromVersion < toVersion && fromVersion >= 0 && - toVersion <= INDEXING_SCHEMA_VERSION, + toVersion <= SCHEMA_VERSION, `Unexpected schema upgrade from v${fromVersion} to v${toVersion}.` ); diff --git a/packages/firestore/src/local/local_store_impl.ts b/packages/firestore/src/local/local_store_impl.ts index ba91a7f22ca..19c7df1f5c7 100644 --- a/packages/firestore/src/local/local_store_impl.ts +++ b/packages/firestore/src/local/local_store_impl.ts @@ -39,6 +39,8 @@ import { import { Document } from '../model/document'; import { DocumentKey } from '../model/document_key'; import { + FieldIndex, + fieldIndexSemanticComparator, INITIAL_LARGEST_BATCH_ID, newIndexOffsetSuccessorFromReadTime } from '../model/field_index'; @@ -57,6 +59,7 @@ import { } from '../protos/firestore_bundle_proto'; import { RemoteEvent, TargetChange } from '../remote/remote_event'; import { fromVersion, JsonProtoSerializer } from '../remote/serializer'; +import { diffArrays } from '../util/array'; import { debugAssert, debugCast, hardAssert } from '../util/assert'; import { ByteString } from '../util/byte_string'; import { logDebug } from '../util/log'; @@ -1483,3 +1486,37 @@ export async function localStoreSaveNamedQuery( } ); } + +export async function localStoreConfigureFieldIndexes( + localStore: LocalStore, + newFieldIndexes: FieldIndex[] +): Promise { + const localStoreImpl = debugCast(localStore, LocalStoreImpl); + const indexManager = localStoreImpl.indexManager; + const promises: Array> = []; + return localStoreImpl.persistence.runTransaction( + 'Configure indexes', + 'readwrite', + transaction => + indexManager + .getFieldIndexes(transaction) + .next(oldFieldIndexes => + diffArrays( + oldFieldIndexes, + newFieldIndexes, + fieldIndexSemanticComparator, + fieldIndex => { + promises.push( + indexManager.addFieldIndex(transaction, fieldIndex) + ); + }, + fieldIndex => { + promises.push( + indexManager.deleteFieldIndex(transaction, fieldIndex) + ); + } + ) + ) + .next(() => PersistencePromise.waitFor(promises)) + ); +} diff --git a/packages/firestore/src/local/query_engine.ts b/packages/firestore/src/local/query_engine.ts index f6b89aed66d..7728e9d7aa7 100644 --- a/packages/firestore/src/local/query_engine.ts +++ b/packages/firestore/src/local/query_engine.ts @@ -43,7 +43,6 @@ import { Iterable } from '../util/misc'; import { SortedSet } from '../util/sorted_set'; import { IndexManager, IndexType } from './index_manager'; -import { INDEXING_ENABLED } from './indexeddb_schema'; import { LocalDocumentsView } from './local_documents_view'; import { PersistencePromise } from './persistence_promise'; import { PersistenceTransaction } from './persistence_transaction'; @@ -134,10 +133,6 @@ export class QueryEngine { transaction: PersistenceTransaction, query: Query ): PersistencePromise { - if (!INDEXING_ENABLED) { - return PersistencePromise.resolve(null); - } - if (queryMatchesAllDocuments(query)) { // Queries that match all documents don't benefit from using // key-based lookups. It is more efficient to scan all documents in a diff --git a/packages/firestore/src/util/array.ts b/packages/firestore/src/util/array.ts index 9ac333e5e05..f09f67da33b 100644 --- a/packages/firestore/src/util/array.ts +++ b/packages/firestore/src/util/array.ts @@ -55,3 +55,59 @@ export function findIndex( } return null; } + +/** + * Compares two array for equality using comparator. The method computes the + * intersection and invokes `onAdd` for every element that is in `after` but not + * `before`. `onRemove` is invoked for every element in `before` but missing + * from `after`. + * + * The method creates a copy of both `before` and `after` and runs in O(n log + * n), where n is the size of the two lists. + * + * @param before - The elements that exist in the original array. + * @param after - The elements to diff against the original array. + * @param comparator - The comparator for the elements in before and after. + * @param onAdd - A function to invoke for every element that is part of ` + * after` but not `before`. + * @param onRemove - A function to invoke for every element that is part of + * `before` but not `after`. + */ +export function diffArrays( + before: T[], + after: T[], + comparator: (l: T, r: T) => number, + onAdd: (entry: T) => void, + onRemove: (entry: T) => void +): void { + before = [...before]; + after = [...after]; + before.sort(comparator); + after.sort(comparator); + + const bLen = before.length; + const aLen = after.length; + let a = 0; + let b = 0; + while (a < aLen && b < bLen) { + const cmp = comparator(before[b], after[a]); + if (cmp < 0) { + // The element was removed if the next element in our ordered + // walkthrough is only in `before`. + onRemove(before[b++]); + } else if (cmp > 0) { + // The element was added if the next element in our ordered walkthrough + // is only in `after`. + onAdd(after[a++]); + } else { + a++; + b++; + } + } + while (a < aLen) { + onAdd(after[a++]); + } + while (b < bLen) { + onRemove(before[b++]); + } +} diff --git a/packages/firestore/src/util/async_observer.ts b/packages/firestore/src/util/async_observer.ts index 4ba16215e85..da08e15d52d 100644 --- a/packages/firestore/src/util/async_observer.ts +++ b/packages/firestore/src/util/async_observer.ts @@ -18,6 +18,7 @@ import { Observer } from '../core/event_manager'; import { FirestoreError } from './error'; +import { logError } from './log'; import { EventHandler } from './misc'; /* @@ -44,7 +45,7 @@ export class AsyncObserver implements Observer { if (this.observer.error) { this.scheduleEvent(this.observer.error, error); } else { - console.error('Uncaught Error in snapshot listener:', error); + logError('Uncaught Error in snapshot listener:', error); } } diff --git a/packages/firestore/test/integration/api/index_configuration.test.ts b/packages/firestore/test/integration/api/index_configuration.test.ts index ea4e9881314..cf6adf6a9ba 100644 --- a/packages/firestore/test/integration/api/index_configuration.test.ts +++ b/packages/firestore/test/integration/api/index_configuration.test.ts @@ -22,7 +22,7 @@ import { apiDescribe, withTestDb } from '../util/helpers'; apiDescribe('Index Configuration:', (persistence: boolean) => { it('supports JSON', () => { - return withTestDb(persistence, db => { + return withTestDb(persistence, async db => { return setIndexConfiguration( db, '{\n' + @@ -59,7 +59,7 @@ apiDescribe('Index Configuration:', (persistence: boolean) => { }); it('supports schema', () => { - return withTestDb(persistence, db => { + return withTestDb(persistence, async db => { return setIndexConfiguration(db, { indexes: [ { @@ -79,14 +79,18 @@ apiDescribe('Index Configuration:', (persistence: boolean) => { it('bad JSON does not crash client', () => { return withTestDb(persistence, async db => { - expect(() => setIndexConfiguration(db, '{,}')).to.throw( - 'Failed to parse JSON' - ); + const action = (): Promise => setIndexConfiguration(db, '{,}'); + if (persistence) { + expect(action).to.throw(/Failed to parse JSON/); + } else { + // Silently do nothing. Parsing is not done and therefore no error is thrown. + await action(); + } }); }); it('bad index does not crash client', () => { - return withTestDb(persistence, db => { + return withTestDb(persistence, async db => { return setIndexConfiguration( db, '{\n' + diff --git a/packages/firestore/test/unit/local/counting_query_engine.ts b/packages/firestore/test/unit/local/counting_query_engine.ts index 4eacc0b5b3d..a6aaf7493aa 100644 --- a/packages/firestore/test/unit/local/counting_query_engine.ts +++ b/packages/firestore/test/unit/local/counting_query_engine.ts @@ -17,6 +17,7 @@ import { Query } from '../../../src/core/query'; import { SnapshotVersion } from '../../../src/core/snapshot_version'; +import { DocumentOverlayCache } from '../../../src/local/document_overlay_cache'; import { IndexManager } from '../../../src/local/index_manager'; import { LocalDocumentsView } from '../../../src/local/local_documents_view'; import { MutationQueue } from '../../../src/local/mutation_queue'; @@ -24,7 +25,14 @@ 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 { DocumentKeySet, DocumentMap } from '../../../src/model/collections'; +import { + DocumentKeySet, + DocumentMap, + OverlayMap +} from '../../../src/model/collections'; +import { DocumentKey } from '../../../src/model/document_key'; +import { Overlay } from '../../../src/model/overlay'; +import { ResourcePath } from '../../../src/model/path'; /** * A test-only query engine that forwards all API calls and exposes the number @@ -58,11 +66,25 @@ export class CountingQueryEngine extends QueryEngine { */ documentsReadByKey = 0; + /** + * The number of documents returned by the OverlayCache's `getOverlays()` + * API (since the last call to `resetCounts()`) + */ + overlaysReadByCollection = 0; + + /** + * The number of documents returned by the OverlayCache's `getOverlay()` + * APIs (since the last call to `resetCounts()`) + */ + overlaysReadByKey = 0; + resetCounts(): void { this.mutationsReadByCollection = 0; this.mutationsReadByKey = 0; this.documentsReadByCollection = 0; this.documentsReadByKey = 0; + this.overlaysReadByCollection = 0; + this.overlaysReadByKey = 0; } getDocumentsMatchingQuery( @@ -86,7 +108,7 @@ export class CountingQueryEngine extends QueryEngine { const view = new LocalDocumentsView( this.wrapRemoteDocumentCache(localDocuments.remoteDocumentCache), this.wrapMutationQueue(localDocuments.mutationQueue), - localDocuments.documentOverlayCache, + this.wrapDocumentOverlayCache(localDocuments.documentOverlayCache), localDocuments.indexManager ); return super.initialize(view, indexManager); @@ -127,13 +149,17 @@ export class CountingQueryEngine extends QueryEngine { }, getEntries: (transaction, documentKeys) => { return subject.getEntries(transaction, documentKeys).next(result => { - this.documentsReadByKey += result.size; + result.forEach((key, doc) => { + if (doc.isValidDocument()) { + this.documentsReadByKey++; + } + }); return result; }); }, getEntry: (transaction, documentKey) => { return subject.getEntry(transaction, documentKey).next(result => { - this.documentsReadByKey += result ? 1 : 0; + this.documentsReadByKey += result?.isValidDocument() ? 1 : 0; return result; }); }, @@ -187,4 +213,57 @@ export class CountingQueryEngine extends QueryEngine { removeMutationBatch: subject.removeMutationBatch }; } + + private wrapDocumentOverlayCache( + subject: DocumentOverlayCache + ): DocumentOverlayCache { + return { + getOverlay: ( + transaction: PersistenceTransaction, + key: DocumentKey + ): PersistencePromise => { + this.overlaysReadByKey++; + return subject.getOverlay(transaction, key); + }, + getOverlays: ( + transaction: PersistenceTransaction, + keys: DocumentKey[] + ): PersistencePromise => { + this.overlaysReadByKey += keys.length; + return subject.getOverlays(transaction, keys); + }, + getOverlaysForCollection: ( + transaction: PersistenceTransaction, + collection: ResourcePath, + sinceBatchId: number + ): PersistencePromise => { + return subject + .getOverlaysForCollection(transaction, collection, sinceBatchId) + .next(result => { + this.overlaysReadByCollection += result.size(); + return result; + }); + }, + getOverlaysForCollectionGroup: ( + transaction: PersistenceTransaction, + collectionGroup: string, + sinceBatchId: number, + count: number + ): PersistencePromise => { + return subject + .getOverlaysForCollectionGroup( + transaction, + collectionGroup, + sinceBatchId, + count + ) + .next(result => { + this.overlaysReadByCollection += result.size(); + return result; + }); + }, + removeOverlaysForBatchId: subject.removeOverlaysForBatchId, + saveOverlays: subject.saveOverlays + }; + } } diff --git a/packages/firestore/test/unit/local/index_backfiller.test.ts b/packages/firestore/test/unit/local/index_backfiller.test.ts index 23c5a7c4959..6a92099bfd8 100644 --- a/packages/firestore/test/unit/local/index_backfiller.test.ts +++ b/packages/firestore/test/unit/local/index_backfiller.test.ts @@ -21,7 +21,6 @@ import { User } from '../../../src/auth/user'; import { Query, queryToTarget } from '../../../src/core/query'; import { IndexBackfiller } from '../../../src/local/index_backfiller'; import { IndexedDbPersistence } from '../../../src/local/indexeddb_persistence'; -import { INDEXING_ENABLED } from '../../../src/local/indexeddb_schema'; import { LocalStore } from '../../../src/local/local_store'; import { newLocalStore } from '../../../src/local/local_store_impl'; import { Persistence } from '../../../src/local/persistence'; @@ -49,9 +48,6 @@ import { TestDocumentOverlayCache } from './test_document_overlay_cache'; import { TestIndexManager } from './test_index_manager'; describe('IndexedDb IndexBackfiller', () => { - if (!INDEXING_ENABLED) { - return; - } if (!IndexedDbPersistence.isAvailable()) { console.warn('No IndexedDB. Skipping IndexedDb IndexBackfiller tests.'); return; @@ -168,9 +164,9 @@ function genericIndexBackfillerTests( }) ); - // Documents before read time should not be fetched. await addDocs(Helpers.doc('coll1/docA', 9, { ['foo']: 1 })); + // Documents before read time should not be fetched. { const documentsProcessed = await backfiller.backfill(); expect(documentsProcessed).to.equal(0); @@ -249,6 +245,39 @@ function genericIndexBackfillerTests( ); }); + it('Uses DocumentKey Offset for large Snapshots', async () => { + await addFieldIndex('coll1', 'foo'); + + await addDocs( + Helpers.doc('coll1/docA', 1, { ['foo']: 1 }), + Helpers.doc('coll1/docB', 1, { ['foo']: 1 }), + Helpers.doc('coll1/docC', 1, { ['foo']: 1 }) + ); + + { + const documentsProcessed = await backfiller.backfill(2); + expect(documentsProcessed).to.equal(2); + } + + await expectQueryResults( + Helpers.query('coll1', Helpers.orderBy('foo')), + 'coll1/docA', + 'coll1/docB' + ); + + { + const documentsProcessed = await backfiller.backfill(2); + expect(documentsProcessed).to.equal(1); + } + + await expectQueryResults( + Helpers.query('coll1', Helpers.orderBy('foo')), + 'coll1/docA', + 'coll1/docB', + 'coll1/docC' + ); + }); + it('Updates collection groups', async () => { await addFieldIndex('coll1', 'foo'); await addFieldIndex('coll2', 'foo'); diff --git a/packages/firestore/test/unit/local/index_manager.test.ts b/packages/firestore/test/unit/local/index_manager.test.ts index f2882775a4e..dc8c7225901 100644 --- a/packages/firestore/test/unit/local/index_manager.test.ts +++ b/packages/firestore/test/unit/local/index_manager.test.ts @@ -32,7 +32,6 @@ import { import { FieldFilter } from '../../../src/core/target'; import { IndexType } from '../../../src/local/index_manager'; import { IndexedDbPersistence } from '../../../src/local/indexeddb_persistence'; -import { INDEXING_SCHEMA_VERSION } from '../../../src/local/indexeddb_schema'; import { Persistence } from '../../../src/local/persistence'; import { documentMap } from '../../../src/model/collections'; import { Document } from '../../../src/model/document'; @@ -73,9 +72,7 @@ describe('IndexedDbIndexManager', async () => { let persistencePromise: Promise; beforeEach(async () => { - persistencePromise = persistenceHelpers.testIndexedDbPersistence({ - schemaVersion: INDEXING_SCHEMA_VERSION - }); + persistencePromise = persistenceHelpers.testIndexedDbPersistence(); }); async function getIndexManager( diff --git a/packages/firestore/test/unit/local/local_store_indexeddb.test.ts b/packages/firestore/test/unit/local/local_store_indexeddb.test.ts new file mode 100644 index 00000000000..e84128a52cc --- /dev/null +++ b/packages/firestore/test/unit/local/local_store_indexeddb.test.ts @@ -0,0 +1,447 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; + +import { serverTimestamp, Timestamp } from '../../../src'; +import { User } from '../../../src/auth/user'; +import { BundleConverterImpl } from '../../../src/core/bundle_impl'; +import { + LimitType, + Query, + queryToTarget, + queryWithLimit +} from '../../../src/core/query'; +import { Target } from '../../../src/core/target'; +import { TargetId } from '../../../src/core/types'; +import { IndexBackfiller } from '../../../src/local/index_backfiller'; +import { LocalStore } from '../../../src/local/local_store'; +import { + localStoreAllocateTarget, + localStoreApplyRemoteEventToLocalCache, + localStoreConfigureFieldIndexes, + localStoreExecuteQuery, + localStoreWriteLocally, + newLocalStore +} from '../../../src/local/local_store_impl'; +import { Persistence } from '../../../src/local/persistence'; +import { DocumentMap } from '../../../src/model/collections'; +import { DocumentKey } from '../../../src/model/document_key'; +import { + FieldIndex, + IndexKind, + IndexOffset +} from '../../../src/model/field_index'; +import { Mutation } from '../../../src/model/mutation'; +import { MutationBatch } from '../../../src/model/mutation_batch'; +import { RemoteEvent } from '../../../src/remote/remote_event'; +import { + deletedDoc, + deleteMutation, + doc, + docAddedRemoteEvent, + docUpdateRemoteEvent, + fieldIndex, + filter, + key, + orderBy, + query, + setMutation, + version +} from '../../util/helpers'; + +import { CountingQueryEngine } from './counting_query_engine'; +import * as persistenceHelpers from './persistence_test_helpers'; +import { JSON_SERIALIZER } from './persistence_test_helpers'; + +class AsyncLocalStoreTester { + private bundleConverter: BundleConverterImpl; + private indexBackfiller: IndexBackfiller; + + private lastChanges: DocumentMap | null = null; + private lastTargetId: TargetId | null = null; + private batches: MutationBatch[] = []; + + constructor( + public localStore: LocalStore, + private readonly persistence: Persistence, + private readonly queryEngine: CountingQueryEngine, + readonly gcIsEager: boolean + ) { + this.bundleConverter = new BundleConverterImpl(JSON_SERIALIZER); + this.indexBackfiller = new IndexBackfiller(localStore, persistence); + } + + private prepareNextStep(): void { + this.lastChanges = null; + this.lastTargetId = null; + this.queryEngine.resetCounts(); + } + + async executeQuery(query: Query): Promise { + this.prepareNextStep(); + const result = await localStoreExecuteQuery(this.localStore, query, true); + this.lastChanges = result.documents; + } + + async allocateQuery(query: Query): Promise { + return this.allocateTarget(queryToTarget(query)); + } + + async allocateTarget(target: Target): Promise { + const result = await localStoreAllocateTarget(this.localStore, target); + this.lastTargetId = result.targetId; + return this.lastTargetId; + } + + async applyRemoteEvent(remoteEvent: RemoteEvent): Promise { + this.prepareNextStep(); + this.lastChanges = await localStoreApplyRemoteEventToLocalCache( + this.localStore, + remoteEvent + ); + } + + async writeMutations(...mutations: Mutation[]): Promise { + this.prepareNextStep(); + const result = await localStoreWriteLocally(this.localStore, mutations); + this.batches.push( + new MutationBatch(result.batchId, Timestamp.now(), [], mutations) + ); + this.lastChanges = result.changes; + } + + async configureAndAssertFieldsIndexes( + ...indexes: FieldIndex[] + ): Promise { + await this.configureFieldsIndexes(...indexes); + await this.assertFieldsIndexes(...indexes); + } + + async configureFieldsIndexes(...indexes: FieldIndex[]): Promise { + await localStoreConfigureFieldIndexes(this.localStore, indexes); + } + + async assertFieldsIndexes(...indexes: FieldIndex[]): Promise { + const fieldIndexes: FieldIndex[] = await this.persistence.runTransaction( + 'getFieldIndexes ', + 'readonly', + transaction => this.localStore.indexManager.getFieldIndexes(transaction) + ); + expect(fieldIndexes).to.have.deep.members(indexes); + } + + assertRemoteDocumentsRead(byKey: number, byCollection: number): void { + expect(this.queryEngine.documentsReadByCollection).to.equal( + byCollection, + 'Remote documents read (by collection)' + ); + expect(this.queryEngine.documentsReadByKey).to.equal( + byKey, + 'Remote documents read (by key)' + ); + } + + assertOverlaysRead(byKey: number, byCollection: number): void { + expect(this.queryEngine.overlaysReadByCollection).to.equal( + byCollection, + 'Overlays read (by collection)' + ); + expect(this.queryEngine.overlaysReadByKey).to.equal( + byKey, + 'Overlays read (by key)' + ); + } + + assertQueryReturned(...keys: string[]): void { + expect(this.lastChanges).to.exist; + for (const k of keys) { + expect(this.lastChanges?.get(key(k))).to.exist; + } + } + + async backfillIndexes(): Promise { + await this.indexBackfiller.backfill(); + } +} + +describe('LocalStore w/ IndexedDB Persistence (Non generic)', () => { + let persistence: Persistence; + let test: AsyncLocalStoreTester; + + beforeEach(async () => { + const queryEngine = new CountingQueryEngine(); + persistence = await persistenceHelpers.testIndexedDbPersistence(); + const localStore = newLocalStore( + persistence, + queryEngine, + User.UNAUTHENTICATED, + JSON_SERIALIZER + ); + test = new AsyncLocalStoreTester( + localStore, + persistence, + queryEngine, + false + ); + }); + + afterEach(async () => { + await persistence.shutdown(); + await persistenceHelpers.clearTestPersistence(); + }); + + it('Adds Indexes', async () => { + const indexA = fieldIndex('coll', { + id: 1, + fields: [['a', IndexKind.ASCENDING]] + }); + const indexB = fieldIndex('coll', { + id: 2, + fields: [['b', IndexKind.DESCENDING]] + }); + const indexC = fieldIndex('coll', { + id: 3, + fields: [ + ['c1', IndexKind.DESCENDING], + ['c2', IndexKind.CONTAINS] + ] + }); + await test.configureAndAssertFieldsIndexes(indexA, indexB, indexC); + }); + + it('Removes Indexes', async () => { + const indexA = fieldIndex('coll', { + id: 1, + fields: [['a', IndexKind.ASCENDING]] + }); + const indexB = fieldIndex('coll', { + id: 2, + fields: [['b', IndexKind.DESCENDING]] + }); + await test.configureAndAssertFieldsIndexes(indexA, indexB); + await test.configureAndAssertFieldsIndexes(indexA); + }); + + it('Does Not Reset Index When Same Index Is Added', async () => { + const indexA = fieldIndex('coll', { + id: 1, + fields: [['a', IndexKind.ASCENDING]] + }); + const updatedIndexA = fieldIndex('coll', { + id: 1, + fields: [['a', IndexKind.ASCENDING]], + offset: new IndexOffset(version(10), DocumentKey.fromPath('coll/a'), -1), + sequenceNumber: 1 + }); + + await test.configureAndAssertFieldsIndexes(indexA); + + const targetId = await test.allocateQuery( + query('coll', filter('a', '==', 1)) + ); + await test.applyRemoteEvent( + docUpdateRemoteEvent(doc('coll/a', 10, { a: 1 }), [targetId]) + ); + + await test.backfillIndexes(); + await test.assertFieldsIndexes(updatedIndexA); + + // Re-add the same index. We do not reset the index to its initial state. + await test.configureFieldsIndexes(indexA); + await test.assertFieldsIndexes(updatedIndexA); + }); + + it('Deleted Document Removes Index', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['matches', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + const queryMatches = query('coll', filter('matches', '==', true)); + const targetId = await test.allocateQuery(queryMatches); + await test.applyRemoteEvent( + docAddedRemoteEvent(doc('coll/a', 10, { matches: true }), [targetId]) + ); + + // Add the document to the index + await test.backfillIndexes(); + + await test.executeQuery(queryMatches); + test.assertRemoteDocumentsRead(1, 0); + test.assertQueryReturned('coll/a'); + + await test.applyRemoteEvent( + docUpdateRemoteEvent(deletedDoc('coll/a', 0), [targetId]) + ); + + // No backfill needed for deleted document. + await test.executeQuery(queryMatches); + test.assertRemoteDocumentsRead(0, 0); + test.assertQueryReturned(); + }); + + it('Uses Indexes', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['matches', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + const queryMatches = query('coll', filter('matches', '==', true)); + const targetId = await test.allocateQuery(queryMatches); + await test.applyRemoteEvent( + docAddedRemoteEvent(doc('coll/a', 10, { matches: true }), [targetId]) + ); + + await test.backfillIndexes(); + + await test.executeQuery(queryMatches); + test.assertRemoteDocumentsRead(1, 0); + test.assertQueryReturned('coll/a'); + }); + + it('Uses Partially Indexed Remote Documents When Available', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['matches', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + const queryMatches = query('coll', filter('matches', '==', true)); + const targetId = await test.allocateQuery(queryMatches); + + await test.applyRemoteEvent( + docAddedRemoteEvent(doc('coll/a', 10, { matches: true }), [targetId]) + ); + await test.backfillIndexes(); + + await test.applyRemoteEvent( + docAddedRemoteEvent(doc('coll/b', 20, { matches: true }), [targetId]) + ); + + await test.executeQuery(queryMatches); + test.assertRemoteDocumentsRead(1, 1); + test.assertQueryReturned('coll/a', 'coll/b'); + }); + + it('Uses Partially Indexed Overlays When Available', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['matches', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + await test.writeMutations(setMutation('coll/a', { matches: true })); + await test.backfillIndexes(); + + await test.writeMutations(setMutation('coll/b', { matches: true })); + + const queryMatches = query('coll', filter('matches', '==', true)); + await test.executeQuery(queryMatches); + test.assertOverlaysRead(1, 1); + test.assertQueryReturned('coll/a', 'coll/b'); + }); + + it('Does Not Use Limit When Index Is Outdated', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['count', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + const queryCount = queryWithLimit( + query('coll', orderBy('count')), + 2, + LimitType.First + ); + const targetId = await test.allocateQuery(queryCount); + + await test.applyRemoteEvent( + docAddedRemoteEvent( + [ + doc('coll/a', 10, { count: 1 }), + doc('coll/b', 10, { count: 2 }), + doc('coll/c', 10, { count: 3 }) + ], + [targetId] + ) + ); + + await test.backfillIndexes(); + + await test.writeMutations(deleteMutation('coll/b')); + + // The query engine first reads the documents by key and then re-runs the query without limit. + await test.executeQuery(queryCount); + test.assertRemoteDocumentsRead(5, 0); + test.assertOverlaysRead(5, 1); + test.assertQueryReturned('coll/a', 'coll/c'); + }); + + it('Uses Index For Limit Query When Index Is Updated', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['count', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + const queryCount = queryWithLimit( + query('coll', orderBy('count')), + 2, + LimitType.First + ); + const targetId = await test.allocateQuery(queryCount); + + await test.applyRemoteEvent( + docAddedRemoteEvent( + [ + doc('coll/a', 10, { count: 1 }), + doc('coll/b', 10, { count: 2 }), + doc('coll/c', 10, { count: 3 }) + ], + [targetId] + ) + ); + await test.writeMutations(deleteMutation('coll/b')); + await test.backfillIndexes(); + + await test.executeQuery(queryCount); + test.assertRemoteDocumentsRead(2, 0); + test.assertOverlaysRead(2, 0); + test.assertQueryReturned('coll/a', 'coll/c'); + }); + + it('Indexes Server Timestamps', async () => { + const index = fieldIndex('coll', { + id: 1, + fields: [['time', IndexKind.ASCENDING]] + }); + await test.configureFieldsIndexes(index); + + await test.writeMutations( + setMutation('coll/a', { time: serverTimestamp() }) + ); + await test.backfillIndexes(); + + const queryTime = query('coll', orderBy('time', 'asc')); + await test.executeQuery(queryTime); + test.assertOverlaysRead(1, 0); + test.assertQueryReturned('coll/a'); + }); +}); diff --git a/packages/firestore/test/unit/local/query_engine.test.ts b/packages/firestore/test/unit/local/query_engine.test.ts index b7b6b5c90e6..44f22e8b234 100644 --- a/packages/firestore/test/unit/local/query_engine.test.ts +++ b/packages/firestore/test/unit/local/query_engine.test.ts @@ -30,10 +30,6 @@ import { SnapshotVersion } from '../../../src/core/snapshot_version'; import { View } from '../../../src/core/view'; import { DocumentOverlayCache } from '../../../src/local/document_overlay_cache'; import { IndexedDbPersistence } from '../../../src/local/indexeddb_persistence'; -import { - INDEXING_ENABLED, - INDEXING_SCHEMA_VERSION -} from '../../../src/local/indexeddb_schema'; import { LocalDocumentsView } from '../../../src/local/local_documents_view'; import { MutationQueue } from '../../../src/local/mutation_queue'; import { Persistence } from '../../../src/local/persistence'; @@ -125,9 +121,7 @@ describe('IndexedDbQueryEngine', async () => { let persistencePromise: Promise; beforeEach(async () => { - persistencePromise = persistenceHelpers.testIndexedDbPersistence({ - schemaVersion: INDEXING_SCHEMA_VERSION - }); + persistencePromise = persistenceHelpers.testIndexedDbPersistence(); }); genericQueryEngineTest(/* durable= */ true, () => persistencePromise); @@ -521,10 +515,6 @@ function genericQueryEngineTest( return; } - if (!INDEXING_ENABLED) { - return; - } - it('combines indexed with non-indexed results', async () => { debugAssert(durable, 'Test requires durable persistence'); diff --git a/packages/firestore/test/unit/specs/index_spec.test.ts b/packages/firestore/test/unit/specs/index_spec.test.ts new file mode 100644 index 00000000000..6e7c47c6b6b --- /dev/null +++ b/packages/firestore/test/unit/specs/index_spec.test.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IndexConfiguration } from '../../../src/api/index_configuration'; +import { IndexKind } from '../../../src/model/field_index'; +import * as Helpers from '../../util/helpers'; + +import { describeSpec, specTest } from './describe_spec'; +import { client } from './spec_builder'; + +describeSpec('Client Side Index', [], () => { + const config: IndexConfiguration = { + indexes: [ + { + collectionGroup: 'restaurants', + queryScope: 'COLLECTION', + fields: [ + { + fieldPath: 'price', + order: 'ASCENDING' + } + ] + } + ] + }; + const expectedIndexes = [ + Helpers.fieldIndex('restaurants', { + fields: [['price', IndexKind.ASCENDING]] + }) + ]; + + specTest('Index Creation visible on all clients', ['multi-client'], () => { + return client(0) + .expectPrimaryState(true) + .setIndexConfiguration(config) + .expectIndexes(expectedIndexes) + .client(1) + .expectPrimaryState(false) + .expectIndexes(expectedIndexes); + }); + + specTest( + 'Index Creation succeeds even if not primary', + ['multi-client'], + () => { + return client(0) + .expectPrimaryState(true) + .client(1) + .expectPrimaryState(false) + .setIndexConfiguration(config) + .expectIndexes(expectedIndexes) + .client(0) + .expectIndexes(expectedIndexes); + } + ); +}); diff --git a/packages/firestore/test/unit/specs/spec_builder.ts b/packages/firestore/test/unit/specs/spec_builder.ts index 87b16734af8..6f17689a882 100644 --- a/packages/firestore/test/unit/specs/spec_builder.ts +++ b/packages/firestore/test/unit/specs/spec_builder.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { IndexConfiguration } from '../../../src/api/index_configuration'; import { ExpUserDataWriter } from '../../../src/api/reference_impl'; import { LimitType, @@ -34,6 +35,7 @@ import { TargetIdGenerator } from '../../../src/core/target_id_generator'; import { TargetId } from '../../../src/core/types'; import { Document } from '../../../src/model/document'; import { DocumentKey } from '../../../src/model/document_key'; +import { FieldIndex } from '../../../src/model/field_index'; import { JsonObject } from '../../../src/model/object_value'; import { ResourcePath } from '../../../src/model/path'; import { @@ -386,6 +388,16 @@ export class SpecBuilder { return this; } + setIndexConfiguration( + jsonOrConfiguration: string | IndexConfiguration + ): this { + this.nextStep(); + this.currentStep = { + setIndexConfiguration: jsonOrConfiguration + }; + return this; + } + // PORTING NOTE: Only used by web multi-tab tests. becomeHidden(): this { this.nextStep(); @@ -508,6 +520,15 @@ export class SpecBuilder { return this; } + /** Expects indexes to exist (in any order) */ + expectIndexes(indexes: FieldIndex[]): this { + this.assertStep('Indexes expectation requires previous step'); + const currentStep = this.currentStep!; + currentStep.expectedState = currentStep.expectedState || {}; + currentStep.expectedState.indexes = indexes; + return this; + } + /** Overrides the currently expected set of active targets. */ expectActiveTargets( ...targets: Array<{ diff --git a/packages/firestore/test/unit/specs/spec_test_runner.ts b/packages/firestore/test/unit/specs/spec_test_runner.ts index 51fdf815832..6a649bb213f 100644 --- a/packages/firestore/test/unit/specs/spec_test_runner.ts +++ b/packages/firestore/test/unit/specs/spec_test_runner.ts @@ -15,13 +15,18 @@ * limitations under the License. */ -import { expect } from 'chai'; +import { assert, expect, use } from 'chai'; +import chaiExclude from 'chai-exclude'; import { LoadBundleTask } from '../../../src/api/bundle'; import { EmptyAppCheckTokenProvider, EmptyAuthCredentialsProvider } from '../../../src/api/credentials'; +import { + IndexConfiguration, + parseIndexes +} from '../../../src/api/index_configuration'; import { User } from '../../../src/auth/user'; import { ComponentConfiguration } from '../../../src/core/component_provider'; import { DatabaseInfo } from '../../../src/core/database_info'; @@ -72,6 +77,7 @@ import { DbPrimaryClientStore } from '../../../src/local/indexeddb_sentinels'; import { LocalStore } from '../../../src/local/local_store'; +import { localStoreConfigureFieldIndexes } from '../../../src/local/local_store_impl'; import { ClientId, SharedClientState @@ -79,6 +85,7 @@ import { import { SimpleDb } from '../../../src/local/simple_db'; import { TargetData, TargetPurpose } from '../../../src/local/target_data'; import { DocumentKey } from '../../../src/model/document_key'; +import { FieldIndex } from '../../../src/model/field_index'; import { Mutation } from '../../../src/model/mutation'; import { JsonObject } from '../../../src/model/object_value'; import { encodeBase64 } from '../../../src/platform/base64'; @@ -168,6 +175,8 @@ import { SharedWriteTracker } from './spec_test_components'; +use(chaiExclude); + const ARBITRARY_SEQUENCE_NUMBER = 2; interface DocumentOptions { @@ -392,6 +401,8 @@ abstract class TestRunner { return this.doRemoveSnapshotsInSyncListener(); } else if ('loadBundle' in step) { return this.doLoadBundle(step.loadBundle!); + } else if ('setIndexConfiguration' in step) { + return this.doSetIndexConfiguration(step.setIndexConfiguration!); } else if ('watchAck' in step) { return this.doWatchAck(step.watchAck!); } else if ('watchCurrent' in step) { @@ -550,6 +561,15 @@ abstract class TestRunner { }); } + private async doSetIndexConfiguration( + jsonOrConfiguration: string | IndexConfiguration + ): Promise { + return this.queue.enqueue(async () => { + const parsedIndexes = parseIndexes(jsonOrConfiguration); + return localStoreConfigureFieldIndexes(this.localStore, parsedIndexes); + }); + } + private doMutations(mutations: Mutation[]): Promise { const documentKeys = mutations.map(val => val.key.path.toString()); const syncEngineCallback = new Deferred(); @@ -939,6 +959,21 @@ abstract class TestRunner { if ('isShutdown' in expectedState) { expect(this.started).to.equal(!expectedState.isShutdown); } + if ('indexes' in expectedState) { + const fieldIndexes: FieldIndex[] = + await this.persistence.runTransaction( + 'getFieldIndexes ', + 'readonly', + transaction => + this.localStore.indexManager.getFieldIndexes(transaction) + ); + + assert.deepEqualExcluding( + fieldIndexes, + expectedState.indexes!, + 'indexId' + ); + } } if (expectedState && expectedState.userCallbacks) { @@ -1384,6 +1419,11 @@ export interface SpecStep { /** Fails the listed database actions. */ failDatabase?: false | PersistenceAction[]; + /** + * Set Index Configuration + */ + setIndexConfiguration?: string | IndexConfiguration; + /** * Run a queued timer task (without waiting for the delay to expire). See * TimerId enum definition for possible values). @@ -1634,6 +1674,8 @@ export interface StateExpectation { acknowledgedDocs: string[]; rejectedDocs: string[]; }; + /** Indexes */ + indexes?: FieldIndex[]; } async function clearCurrentPrimaryLease(): Promise { diff --git a/packages/firestore/test/unit/util/array.test.ts b/packages/firestore/test/unit/util/array.test.ts new file mode 100644 index 00000000000..f6f97a19a00 --- /dev/null +++ b/packages/firestore/test/unit/util/array.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from 'chai'; + +import { diffArrays } from '../../../src/util/array'; + +describe('diffArrays', () => { + it('Missing Element', () => { + validateDiffArray(['a', 'b', 'c'], ['a', 'b']); + validateDiffArray(['a', 'b', 'c', 'd'], ['a', 'b']); + }); + + it('AddedElement', () => { + validateDiffArray(['a', 'b'], ['a', 'b', 'c']); + validateDiffArray(['a', 'b'], ['a', 'b', 'c', 'd']); + }); + + it('Without Ordering', () => { + validateDiffArray(['a', 'b'], ['b', 'a']); + validateDiffArray(['a', 'b', 'c'], ['c', 'b', 'a']); + }); + + it('Empty Lists', () => { + validateDiffArray(['a'], []); + validateDiffArray([], ['a']); + validateDiffArray([], []); + }); + + function validateDiffArray(before: string[], after: string[]): void { + const result = new Set(before); + diffArrays( + before, + after, + (a, b) => a.localeCompare(b), + v => { + assert.notInclude(before, v); + assert.include(after, v); + result.add(v); + }, + v => { + assert.include(before, v); + assert.notInclude(after, v); + result.delete(v); + } + ); + assert.equal(result.size, after.length); + for (const v of after) { + assert.include(result, v); + } + } +}); diff --git a/yarn.lock b/yarn.lock index 46124969440..47264eef17c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5388,6 +5388,13 @@ chai-as-promised@7.1.1: dependencies: check-error "^1.0.2" +chai-exclude@2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/chai-exclude/-/chai-exclude-2.1.0.tgz#653d1218144eafb49b563684ad90b76d12bbc3f9" + integrity sha512-IBnm50Mvl3O1YhPpTgbU8MK0Gw7NHcb18WT2TxGdPKOMtdtZVKLHmQwdvOF7mTlHVQStbXuZKFwkevFtbHjpVg== + dependencies: + fclone "^1.0.11" + chai@4.3.6: version "4.3.6" resolved "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz#ffe4ba2d9fa9d6680cc0b370adae709ec9011e9c" @@ -7813,6 +7820,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fclone@^1.0.11: + version "1.0.11" + resolved "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz#10e85da38bfea7fc599341c296ee1d77266ee640" + integrity sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw== + fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"