mirror of
https://github.com/hanzoai/firebase-js-sdk.git
synced 2026-09-24 16:59:10 +00:00
468 lines
20 KiB
TypeScript
468 lines
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2017 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 { addEqualityMatcher } from '../../util/equality_matcher';
|
|
import { it } from '../../util/mocha_extensions';
|
|
import {
|
|
deleteDoc,
|
|
doc,
|
|
DocumentData,
|
|
getDocs,
|
|
query,
|
|
where,
|
|
writeBatch
|
|
} from '../util/firebase_export';
|
|
import {
|
|
apiDescribe,
|
|
RetryError,
|
|
PERSISTENCE_MODE_UNSPECIFIED,
|
|
withRetry,
|
|
withTestCollection,
|
|
withTestDb
|
|
} from '../util/helpers';
|
|
import { USE_EMULATOR } from '../util/settings';
|
|
import { captureExistenceFilterMismatches } from '../util/testing_hooks_util';
|
|
|
|
addEqualityMatcher();
|
|
|
|
apiDescribe('Queries', persistence => {
|
|
// TODO(b/291365820): Stop skipping this test when running against the
|
|
// Firestore emulator once the emulator is improved to include a bloom filter
|
|
// in the existence filter messages that it sends.
|
|
// eslint-disable-next-line no-restricted-properties
|
|
(USE_EMULATOR ? it.skip : it)(
|
|
'resuming a query should use bloom filter to avoid full requery',
|
|
async () => {
|
|
// Prepare the names and contents of the 100 documents to create.
|
|
const testDocs: { [key: string]: object } = {};
|
|
for (let i = 0; i < 100; i++) {
|
|
testDocs['doc' + (1000 + i)] = { key: 42 };
|
|
}
|
|
|
|
// Ensure that the local cache is configured to use LRU garbage
|
|
// collection (rather than eager garbage collection) so that the resume
|
|
// token and document data does not get prematurely evicted.
|
|
const lruPersistence = persistence.toLruGc();
|
|
|
|
return withRetry(async attemptNumber => {
|
|
return withTestCollection(
|
|
lruPersistence,
|
|
testDocs,
|
|
async (coll, db) => {
|
|
// Run a query to populate the local cache with the 100 documents
|
|
// and a resume token.
|
|
const snapshot1 = await getDocs(coll);
|
|
expect(snapshot1.size, 'snapshot1.size').to.equal(100);
|
|
const createdDocuments = snapshot1.docs.map(
|
|
snapshot => snapshot.ref
|
|
);
|
|
|
|
// Delete 50 of the 100 documents. Use a different Firestore
|
|
// instance to avoid affecting the local cache.
|
|
const deletedDocumentIds = new Set<string>();
|
|
await withTestDb(PERSISTENCE_MODE_UNSPECIFIED, async db2 => {
|
|
const batch = writeBatch(db2);
|
|
for (let i = 0; i < createdDocuments.length; i += 2) {
|
|
const documentToDelete = doc(db2, createdDocuments[i].path);
|
|
batch.delete(documentToDelete);
|
|
deletedDocumentIds.add(documentToDelete.id);
|
|
}
|
|
await batch.commit();
|
|
});
|
|
|
|
// Wait for 10 seconds, during which Watch will stop tracking the
|
|
// query and will send an existence filter rather than "delete"
|
|
// events when the query is resumed.
|
|
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
|
|
// Resume the query and save the resulting snapshot for
|
|
// verification. Use some internal testing hooks to "capture" the
|
|
// existence filter mismatches to verify that Watch sent a bloom
|
|
// filter, and it was used to avert a full requery.
|
|
const [existenceFilterMismatches, snapshot2] =
|
|
await captureExistenceFilterMismatches(() => getDocs(coll));
|
|
|
|
// Verify that the snapshot from the resumed query contains the
|
|
// expected documents; that is, that it contains the 50 documents
|
|
// that were _not_ deleted.
|
|
const actualDocumentIds = snapshot2.docs
|
|
.map(documentSnapshot => documentSnapshot.ref.id)
|
|
.sort();
|
|
const expectedDocumentIds = createdDocuments
|
|
.filter(documentRef => !deletedDocumentIds.has(documentRef.id))
|
|
.map(documentRef => documentRef.id)
|
|
.sort();
|
|
expect(actualDocumentIds, 'snapshot2.docs').to.deep.equal(
|
|
expectedDocumentIds
|
|
);
|
|
|
|
// Verify that Watch sent an existence filter with the correct
|
|
// counts when the query was resumed.
|
|
expect(
|
|
existenceFilterMismatches,
|
|
'existenceFilterMismatches'
|
|
).to.have.length(1);
|
|
const { localCacheCount, existenceFilterCount, bloomFilter } =
|
|
existenceFilterMismatches[0];
|
|
expect(localCacheCount, 'localCacheCount').to.equal(100);
|
|
expect(existenceFilterCount, 'existenceFilterCount').to.equal(50);
|
|
|
|
// Verify that Watch sent a valid bloom filter.
|
|
if (!bloomFilter) {
|
|
expect.fail(
|
|
'The existence filter should have specified a bloom filter ' +
|
|
'in its `unchanged_names` field.'
|
|
);
|
|
throw new Error('should never get here');
|
|
}
|
|
|
|
expect(bloomFilter.hashCount, 'bloomFilter.hashCount').to.be.above(
|
|
0
|
|
);
|
|
expect(
|
|
bloomFilter.bitmapLength,
|
|
'bloomFilter.bitmapLength'
|
|
).to.be.above(0);
|
|
expect(bloomFilter.padding, 'bloomFilterPadding').to.be.above(0);
|
|
expect(bloomFilter.padding, 'bloomFilterPadding').to.be.below(8);
|
|
|
|
// Verify that the bloom filter was successfully used to avert a
|
|
// full requery. If a false positive occurred then retry the entire
|
|
// test. Although statistically rare, false positives are expected
|
|
// to happen occasionally. When a false positive _does_ happen, just
|
|
// retry the test with a different set of documents. If that retry
|
|
// also_ experiences a false positive, then fail the test because
|
|
// that is so improbable that something must have gone wrong.
|
|
if (attemptNumber === 1 && !bloomFilter.applied) {
|
|
throw new RetryError();
|
|
}
|
|
|
|
expect(
|
|
bloomFilter.applied,
|
|
`bloomFilter.applied with attemptNumber=${attemptNumber}`
|
|
).to.be.true;
|
|
}
|
|
);
|
|
});
|
|
}
|
|
).timeout('90s');
|
|
|
|
// TODO(b/291365820): Stop skipping this test when running against the
|
|
// Firestore emulator once the emulator is improved to include a bloom filter
|
|
// in the existence filter messages that it sends.
|
|
// eslint-disable-next-line no-restricted-properties
|
|
(USE_EMULATOR ? it.skip : it)(
|
|
'bloom filter should avert a full re-query when documents were added, ' +
|
|
'deleted, removed, updated, and unchanged since the resume token',
|
|
async () => {
|
|
// Prepare the names and contents of the 20 documents to create.
|
|
const testDocs: { [key: string]: object } = {};
|
|
for (let i = 0; i < 20; i++) {
|
|
testDocs['doc' + (1000 + i)] = {
|
|
key: 42,
|
|
removed: false
|
|
};
|
|
}
|
|
|
|
// Ensure that the local cache is configured to use LRU garbage
|
|
// collection (rather than eager garbage collection) so that the resume
|
|
// token and document data does not get prematurely evicted.
|
|
const lruPersistence = persistence.toLruGc();
|
|
|
|
return withRetry(async attemptNumber => {
|
|
return withTestCollection(lruPersistence, testDocs, async coll => {
|
|
// Run a query to populate the local cache with the 20 documents
|
|
// and a resume token.
|
|
const snapshot1 = await getDocs(
|
|
query(coll, where('removed', '==', false))
|
|
);
|
|
expect(snapshot1.size, 'snapshot1.size').to.equal(20);
|
|
const createdDocuments = snapshot1.docs.map(snapshot => snapshot.ref);
|
|
|
|
// Out of the 20 existing documents, leave 5 docs untouched, delete 5 docs,
|
|
// remove 5 docs, update 5 docs, and add 15 new docs.
|
|
const deletedDocumentIds = new Set<string>();
|
|
const removedDocumentIds = new Set<string>();
|
|
const updatedDocumentIds = new Set<string>();
|
|
const addedDocumentIds: string[] = [];
|
|
|
|
// Use a different Firestore instance to avoid affecting the local cache.
|
|
await withTestDb(PERSISTENCE_MODE_UNSPECIFIED, async db2 => {
|
|
const batch = writeBatch(db2);
|
|
|
|
for (let i = 0; i < createdDocuments.length; i += 4) {
|
|
const documentToDelete = doc(db2, createdDocuments[i].path);
|
|
batch.delete(documentToDelete);
|
|
deletedDocumentIds.add(documentToDelete.id);
|
|
}
|
|
expect(deletedDocumentIds.size).to.equal(5);
|
|
|
|
// Update 5 documents to no longer match the query.
|
|
for (let i = 1; i < createdDocuments.length; i += 4) {
|
|
const documentToModify = doc(db2, createdDocuments[i].path);
|
|
batch.update(documentToModify, {
|
|
removed: true
|
|
});
|
|
removedDocumentIds.add(documentToModify.id);
|
|
}
|
|
expect(removedDocumentIds.size).to.equal(5);
|
|
|
|
// Update 5 documents, but ensure they still match the query.
|
|
for (let i = 2; i < createdDocuments.length; i += 4) {
|
|
const documentToModify = doc(db2, createdDocuments[i].path);
|
|
batch.update(documentToModify, {
|
|
key: 43
|
|
});
|
|
updatedDocumentIds.add(documentToModify.id);
|
|
}
|
|
expect(updatedDocumentIds.size).to.equal(5);
|
|
|
|
for (let i = 0; i < 15; i += 1) {
|
|
const documentToAdd = doc(
|
|
db2,
|
|
coll.path + '/newDoc' + (1000 + i)
|
|
);
|
|
batch.set(documentToAdd, {
|
|
key: 42,
|
|
removed: false
|
|
});
|
|
addedDocumentIds.push(documentToAdd.id);
|
|
}
|
|
|
|
// Ensure the sets above are disjoint.
|
|
const mergedSet = new Set<string>();
|
|
[
|
|
deletedDocumentIds,
|
|
removedDocumentIds,
|
|
updatedDocumentIds,
|
|
addedDocumentIds
|
|
].forEach(set => {
|
|
set.forEach(documentId => mergedSet.add(documentId));
|
|
});
|
|
expect(mergedSet.size).to.equal(30);
|
|
|
|
await batch.commit();
|
|
});
|
|
|
|
// Wait for 10 seconds, during which Watch will stop tracking the
|
|
// query and will send an existence filter rather than "delete"
|
|
// events when the query is resumed.
|
|
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
|
|
// Resume the query and save the resulting snapshot for
|
|
// verification. Use some internal testing hooks to "capture" the
|
|
// existence filter mismatches to verify that Watch sent a bloom
|
|
// filter, and it was used to avert a full requery.
|
|
const [existenceFilterMismatches, snapshot2] =
|
|
await captureExistenceFilterMismatches(() =>
|
|
getDocs(query(coll, where('removed', '==', false)))
|
|
);
|
|
|
|
// Verify that the snapshot from the resumed query contains the
|
|
// expected documents; that is, 10 existing documents that still
|
|
// match the query, and 15 documents that are newly added.
|
|
const actualDocumentIds = snapshot2.docs
|
|
.map(documentSnapshot => documentSnapshot.ref.id)
|
|
.sort();
|
|
const expectedDocumentIds = createdDocuments
|
|
.map(documentRef => documentRef.id)
|
|
.filter(documentId => !deletedDocumentIds.has(documentId))
|
|
.filter(documentId => !removedDocumentIds.has(documentId))
|
|
.concat(addedDocumentIds)
|
|
.sort();
|
|
|
|
expect(actualDocumentIds, 'snapshot2.docs').to.deep.equal(
|
|
expectedDocumentIds
|
|
);
|
|
expect(actualDocumentIds.length).to.equal(25);
|
|
|
|
// Verify that Watch sent an existence filter with the correct
|
|
// counts when the query was resumed.
|
|
expect(
|
|
existenceFilterMismatches,
|
|
'existenceFilterMismatches'
|
|
).to.have.length(1);
|
|
const { localCacheCount, existenceFilterCount, bloomFilter } =
|
|
existenceFilterMismatches[0];
|
|
expect(localCacheCount, 'localCacheCount').to.equal(35);
|
|
expect(existenceFilterCount, 'existenceFilterCount').to.equal(25);
|
|
|
|
// Verify that Watch sent a valid bloom filter.
|
|
if (!bloomFilter) {
|
|
expect.fail(
|
|
'The existence filter should have specified a bloom filter ' +
|
|
'in its `unchanged_names` field.'
|
|
);
|
|
throw new Error('should never get here');
|
|
}
|
|
|
|
// Verify that the bloom filter was successfully used to avert a
|
|
// full requery. If a false positive occurred then retry the entire
|
|
// test. Although statistically rare, false positives are expected
|
|
// to happen occasionally. When a false positive _does_ happen, just
|
|
// retry the test with a different set of documents. If that retry
|
|
// also_ experiences a false positive, then fail the test because
|
|
// that is so improbable that something must have gone wrong.
|
|
if (attemptNumber === 1 && !bloomFilter.applied) {
|
|
throw new RetryError();
|
|
}
|
|
|
|
expect(
|
|
bloomFilter.applied,
|
|
`bloomFilter.applied with attemptNumber=${attemptNumber}`
|
|
).to.be.true;
|
|
});
|
|
});
|
|
}
|
|
).timeout('90s');
|
|
|
|
// TODO(b/291365820): Stop skipping this test when running against the
|
|
// Firestore emulator once the emulator is improved to include a bloom filter
|
|
// in the existence filter messages that it sends.
|
|
// eslint-disable-next-line no-restricted-properties
|
|
(USE_EMULATOR ? it.skip : it)(
|
|
'bloom filter should correctly encode complex Unicode characters',
|
|
async () => {
|
|
// Firestore does not do any Unicode normalization on the document IDs.
|
|
// Therefore, two document IDs that are canonically-equivalent (i.e. they
|
|
// visually appear identical) but are represented by a different sequence
|
|
// of Unicode code points are treated as distinct document IDs.
|
|
const testDocIds = [
|
|
'DocumentToDelete',
|
|
// The next two strings both end with "e" with an accent: the first uses
|
|
// the dedicated Unicode code point for this character, while the second
|
|
// uses the standard lowercase "e" followed by the accent combining
|
|
// character.
|
|
'LowercaseEWithAcuteAccent_\u00E9',
|
|
'LowercaseEWithAcuteAccent_\u0065\u0301',
|
|
// The next two strings both end with an "e" with two different accents
|
|
// applied via the following two combining characters. The combining
|
|
// characters are specified in a different order and Firestore treats
|
|
// these document IDs as unique, despite the order of the combining
|
|
// characters being irrelevant.
|
|
'LowercaseEWithMultipleAccents_\u0065\u0301\u0327',
|
|
'LowercaseEWithMultipleAccents_\u0065\u0327\u0301',
|
|
// The next string contains a character outside the BMP (the "basic
|
|
// multilingual plane"); that is, its code point is greater than 0xFFFF.
|
|
// In UTF-16 (which JavaScript uses to store Unicode strings) this
|
|
// requires a surrogate pair, two 16-bit code units, to represent this
|
|
// character. Make sure that its presence is correctly tested in the
|
|
// bloom filter, which uses UTF-8 encoding.
|
|
'Smiley_\u{1F600}'
|
|
];
|
|
|
|
// Verify assumptions about the equivalence of strings in `testDocIds`.
|
|
expect(testDocIds[1].normalize()).equals(testDocIds[2].normalize());
|
|
expect(testDocIds[3].normalize()).equals(testDocIds[4].normalize());
|
|
expect(testDocIds[5]).equals('Smiley_\uD83D\uDE00');
|
|
|
|
// Create the mapping from document ID to document data for the document
|
|
// IDs specified in `testDocIds`.
|
|
const testDocs = testDocIds.reduce((map, docId) => {
|
|
map[docId] = { foo: 42 };
|
|
return map;
|
|
}, {} as { [key: string]: DocumentData });
|
|
|
|
// Ensure that the local cache is configured to use LRU garbage collection
|
|
// (rather than eager garbage collection) so that the resume token and
|
|
// document data does not get prematurely evicted.
|
|
const lruPersistence = persistence.toLruGc();
|
|
|
|
return withTestCollection(lruPersistence, testDocs, async (coll, db) => {
|
|
// Run a query to populate the local cache with documents that have
|
|
// names with complex Unicode characters.
|
|
const snapshot1 = await getDocs(coll);
|
|
const snapshot1DocumentIds = snapshot1.docs.map(
|
|
documentSnapshot => documentSnapshot.id
|
|
);
|
|
expect(snapshot1DocumentIds, 'snapshot1DocumentIds').to.have.members(
|
|
testDocIds
|
|
);
|
|
|
|
// Delete one of the documents so that the next call to getDocs() will
|
|
// experience an existence filter mismatch. Use a different Firestore
|
|
// instance to avoid affecting the local cache.
|
|
const documentToDelete = doc(coll, 'DocumentToDelete');
|
|
await withTestDb(PERSISTENCE_MODE_UNSPECIFIED, async db2 => {
|
|
await deleteDoc(doc(db2, documentToDelete.path));
|
|
});
|
|
|
|
// Wait for 10 seconds, during which Watch will stop tracking the query
|
|
// and will send an existence filter rather than "delete" events when
|
|
// the query is resumed.
|
|
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
|
|
// Resume the query and save the resulting snapshot for verification.
|
|
// Use some internal testing hooks to "capture" the existence filter
|
|
// mismatches.
|
|
const [existenceFilterMismatches, snapshot2] =
|
|
await captureExistenceFilterMismatches(() => getDocs(coll));
|
|
const snapshot2DocumentIds = snapshot2.docs.map(
|
|
documentSnapshot => documentSnapshot.id
|
|
);
|
|
const testDocIdsMinusDeletedDocId = testDocIds.filter(
|
|
documentId => documentId !== documentToDelete.id
|
|
);
|
|
expect(snapshot2DocumentIds, 'snapshot2DocumentIds').to.have.members(
|
|
testDocIdsMinusDeletedDocId
|
|
);
|
|
|
|
// Verify that Watch sent an existence filter with the correct counts.
|
|
expect(
|
|
existenceFilterMismatches,
|
|
'existenceFilterMismatches'
|
|
).to.have.length(1);
|
|
const existenceFilterMismatch = existenceFilterMismatches[0];
|
|
expect(
|
|
existenceFilterMismatch.localCacheCount,
|
|
'localCacheCount'
|
|
).to.equal(testDocIds.length);
|
|
expect(
|
|
existenceFilterMismatch.existenceFilterCount,
|
|
'existenceFilterCount'
|
|
).to.equal(testDocIds.length - 1);
|
|
|
|
// Verify that we got a bloom filter from Watch.
|
|
const bloomFilter = existenceFilterMismatch.bloomFilter!;
|
|
expect(bloomFilter?.mightContain, 'bloomFilter.mightContain').to.not.be
|
|
.undefined;
|
|
|
|
// The bloom filter application should statistically be successful
|
|
// almost every time; the _only_ time when it would _not_ be successful
|
|
// is if there is a false positive when testing for 'DocumentToDelete'
|
|
// in the bloom filter. So verify that the bloom filter application is
|
|
// successful, unless there was a false positive.
|
|
const isFalsePositive = bloomFilter.mightContain(documentToDelete);
|
|
expect(bloomFilter.applied, 'bloomFilter.applied').to.equal(
|
|
!isFalsePositive
|
|
);
|
|
|
|
// Verify that the bloom filter contains the document paths with complex
|
|
// Unicode characters.
|
|
for (const testDoc of snapshot2.docs.map(snapshot => snapshot.ref)) {
|
|
expect(
|
|
bloomFilter.mightContain(testDoc),
|
|
`bloomFilter.mightContain('${testDoc.path}')`
|
|
).to.be.true;
|
|
}
|
|
});
|
|
}
|
|
).timeout('90s');
|
|
});
|