Files

3045 lines
99 KiB
TypeScript

/**
* @license
* Copyright 2020 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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { initializeApp } from '@firebase/app';
import { expect, use } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import {
aggregateQuerySnapshotEqual,
getCount,
getAggregate,
count,
sum,
average
} from '../../src/lite-api/aggregate';
import { Bytes } from '../../src/lite-api/bytes';
import {
Firestore,
getFirestore,
initializeFirestore,
terminate
} from '../../src/lite-api/database';
import { FieldPath } from '../../src/lite-api/field_path';
import { FieldValue } from '../../src/lite-api/field_value';
import {
arrayRemove,
arrayUnion,
deleteField,
increment,
maximum,
minimum,
serverTimestamp,
vector
} from '../../src/lite-api/field_value_impl';
import {
endAt,
endBefore,
limit,
limitToLast,
orderBy,
query,
startAfter,
startAt,
where
} from '../../src/lite-api/query';
import {
collection,
CollectionReference,
doc,
DocumentReference,
refEqual,
queryEqual,
collectionGroup,
SetOptions,
DocumentData,
WithFieldValue,
PartialWithFieldValue,
UpdateData
} from '../../src/lite-api/reference';
import {
addDoc,
deleteDoc,
getDoc,
getDocs,
setDoc,
updateDoc
} from '../../src/lite-api/reference_impl';
import {
FirestoreDataConverter,
snapshotEqual,
QuerySnapshot,
QueryDocumentSnapshot
} from '../../src/lite-api/snapshot';
import { Timestamp } from '../../src/lite-api/timestamp';
import { runTransaction } from '../../src/lite-api/transaction';
import { writeBatch } from '../../src/lite-api/write_batch';
import { apiDescribe } from '../integration/util/helpers';
import {
DEFAULT_PROJECT_ID,
DEFAULT_SETTINGS,
USE_EMULATOR
} from '../integration/util/settings';
import { it, describe } from '../util/mocha_extensions';
import {
Post,
postConverter,
postConverterMerge,
withTestCollection,
withTestCollectionAndInitialData,
withTestDb,
withTestDbSettings,
withTestDoc,
withTestDocAndInitialData
} from './helpers';
use(chaiAsPromised);
describe('Firestore', () => {
it('can provide setting', () => {
const app = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-initializeFirestore'
);
const fs1 = initializeFirestore(app, { host: 'localhost', ssl: false });
expect(fs1).to.be.an.instanceOf(Firestore);
});
it('returns same default instance from named app', () => {
const app = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-getFirestore'
);
const fs1 = getFirestore(app);
const fs2 = getFirestore(app);
const fs3 = getFirestore(app, '(default)');
expect(fs1).to.be.equal(fs2).and.equal(fs3);
});
it('returns different instance from named app', () => {
const app = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-getFirestore'
);
const fs1 = initializeFirestore(app, DEFAULT_SETTINGS, 'init1');
const fs2 = initializeFirestore(app, DEFAULT_SETTINGS, 'init2');
const fs3 = getFirestore(app);
const fs4 = getFirestore(app, 'name1');
const fs5 = getFirestore(app, 'name2');
expect(fs1).to.not.be.equal(fs2);
expect(fs1).to.not.be.equal(fs3);
expect(fs1).to.not.be.equal(fs4);
expect(fs1).to.not.be.equal(fs5);
expect(fs2).to.not.be.equal(fs3);
expect(fs2).to.not.be.equal(fs4);
expect(fs2).to.not.be.equal(fs5);
expect(fs3).to.not.be.equal(fs4);
expect(fs3).to.not.be.equal(fs5);
expect(fs4).to.not.be.equal(fs5);
});
it('returns same default instance from default app', () => {
const app = initializeApp({
apiKey: 'fake-api-key',
projectId: 'test-project'
});
const fs1 = initializeFirestore(app, DEFAULT_SETTINGS);
const fs2 = getFirestore();
const fs3 = getFirestore(app);
const fs4 = getFirestore('(default)');
const fs5 = getFirestore(app, '(default)');
expect(fs1).to.be.equal(fs2);
expect(fs1).to.be.equal(fs3);
expect(fs1).to.be.equal(fs4);
expect(fs1).to.be.equal(fs5);
});
it('returns different instance from different named app', () => {
initializeApp({ apiKey: 'fake-api-key', projectId: 'test-project' });
const app1 = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-getFirestore-1'
);
const app2 = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-getFirestore-2'
);
const fs1 = getFirestore();
const fs2 = getFirestore(app1);
const fs3 = getFirestore(app2);
expect(fs1).to.not.be.equal(fs2);
expect(fs1).to.not.be.equal(fs3);
expect(fs2).to.not.be.equal(fs3);
});
it('cannot call initializeFirestore() twice', () => {
const app = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-initializeFirestore-twice'
);
initializeFirestore(app, {});
expect(() => {
initializeFirestore(app, {});
}).to.throw('Firestore can only be initialized once per app.');
});
it('cannot use once terminated', () => {
const app = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-terminated'
);
const firestore = initializeFirestore(app, {
host: 'localhost',
ssl: false
});
// We don't await the Promise. Any operation enqueued after should be
// rejected.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
terminate(firestore);
try {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
getDoc(doc(firestore, 'coll/doc'));
expect.fail();
} catch (e) {
expect((e as Error)?.message).to.equal(
'The client has already been terminated.'
);
}
});
it('can call terminate() multiple times', () => {
const app = initializeApp(
{ apiKey: 'fake-api-key', projectId: 'test-project' },
'test-app-multi-terminate'
);
const firestore = initializeFirestore(app, {
host: 'localhost',
ssl: false
});
return terminate(firestore).then(() => terminate(firestore));
});
});
describe('doc', () => {
it('can be used relative to Firestore root', () => {
return withTestDb(db => {
const result = doc(db, 'coll/doc');
expect(result).to.be.an.instanceOf(DocumentReference);
expect(result.id).to.equal('doc');
expect(result.path).to.equal('coll/doc');
});
});
it('can be used relative to collection', () => {
return withTestDb(db => {
const result = doc(collection(db, 'coll'), 'doc');
expect(result).to.be.an.instanceOf(DocumentReference);
expect(result.id).to.equal('doc');
expect(result.path).to.equal('coll/doc');
});
});
it('can be used with multiple arguments', () => {
return withTestDb(db => {
const result = doc(db, 'coll1/doc1', 'coll2', 'doc2');
expect(result).to.be.an.instanceOf(DocumentReference);
expect(result.id).to.equal('doc2');
expect(result.path).to.equal('coll1/doc1/coll2/doc2');
});
});
it('strips leading and trailing slashes', () => {
return withTestDb(db => {
const result = doc(db, '/coll', 'doc/');
expect(result).to.be.an.instanceOf(DocumentReference);
expect(result.id).to.equal('doc');
expect(result.path).to.equal('coll/doc');
});
});
it('can be relative to doc', () => {
return withTestDb(db => {
const result = doc(doc(db, 'coll/doc'), 'subcoll/subdoc');
expect(result).to.be.an.instanceOf(DocumentReference);
expect(result.id).to.equal('subdoc');
expect(result.path).to.equal('coll/doc/subcoll/subdoc');
});
});
it('validates path', () => {
return withTestDb(db => {
expect(() =>
// @ts-expect-error
doc({}, 'coll/doc')
).to.throw(
'Expected first argument to doc() to be a CollectionReference, a DocumentReference or FirebaseFirestore'
);
expect(() => doc(db, 'coll')).to.throw(
'Invalid document reference. Document references must have an even ' +
'number of segments, but coll has 1.'
);
expect(() => doc(db, '')).to.throw(
'Function doc() cannot be called with an empty path.'
);
expect(() => doc(collection(db, 'coll'), 'doc/coll')).to.throw(
'Invalid document reference. Document references must have an even ' +
'number of segments, but coll/doc/coll has 3.'
);
expect(() => doc(db, 'coll//doc')).to.throw(
'Invalid segment (coll//doc). Paths must not contain // in them.'
);
});
});
it('supports AutoId', () => {
return withTestDb(db => {
const coll = collection(db, 'coll');
const ref = doc(coll);
expect(ref.id.length).to.equal(20);
});
});
});
describe('collection', () => {
it('can be used relative to Firestore root', () => {
return withTestDb(db => {
const result = collection(db, 'coll/doc/subcoll');
expect(result).to.be.an.instanceOf(CollectionReference);
expect(result.id).to.equal('subcoll');
expect(result.path).to.equal('coll/doc/subcoll');
});
});
it('can be used relative to Firestore root with multiple arguments', () => {
return withTestDb(db => {
const result = collection(db, 'coll1/doc1', '/coll2', 'doc2/', '/coll3/');
expect(result).to.be.an.instanceOf(CollectionReference);
expect(result.id).to.equal('coll3');
expect(result.path).to.equal('coll1/doc1/coll2/doc2/coll3');
});
});
it('can be used relative to collection', () => {
return withTestDb(db => {
const result = collection(collection(db, 'coll'), 'doc/subcoll');
expect(result).to.be.an.instanceOf(CollectionReference);
expect(result.id).to.equal('subcoll');
expect(result.path).to.equal('coll/doc/subcoll');
});
});
it('can be used relative to doc', () => {
return withTestDb(db => {
const result = collection(doc(db, 'coll/doc'), 'subcoll');
expect(result).to.be.an.instanceOf(CollectionReference);
expect(result.id).to.equal('subcoll');
expect(result.path).to.equal('coll/doc/subcoll');
});
});
it('can be used relative to collection with multiple arguments', () => {
return withTestDb(db => {
const col = collection(db, 'coll1');
const result = collection(
col,
'/doc1/coll2/doc2/',
'/coll3',
'doc3/',
'/coll4/'
);
expect(result).to.be.an.instanceOf(CollectionReference);
expect(result.id).to.equal('coll4');
expect(result.path).to.equal('coll1/doc1/coll2/doc2/coll3/doc3/coll4');
});
});
it('validates path', () => {
return withTestDb(db => {
expect(() => collection(db, 'coll/doc')).to.throw(
'Invalid collection reference. Collection references must have an odd ' +
'number of segments, but coll/doc has 2.'
);
expect(() => collection(doc(db, 'coll/doc'), '')).to.throw(
'Function collection() cannot be called with an empty path'
);
expect(() => collection(doc(db, 'coll/doc'), 'coll/doc')).to.throw(
'Invalid collection reference. Collection references must have an odd ' +
'number of segments, but coll/doc/coll/doc has 4.'
);
});
});
});
describe('parent', () => {
it('returns CollectionReferences for DocumentReferences', () => {
return withTestDb(db => {
const coll = collection(db, 'coll/doc/coll');
const result = coll.parent;
expect(result).to.be.an.instanceOf(DocumentReference);
expect(result!.path).to.equal('coll/doc');
});
});
it('returns DocumentReferences for CollectionReferences', () => {
return withTestDb(db => {
const coll = doc(db, 'coll/doc');
const result = coll.parent;
expect(result).to.be.an.instanceOf(CollectionReference);
expect(result.path).to.equal('coll');
});
});
it('returns null for root collection', () => {
return withTestDb(db => {
const coll = collection(db, 'coll');
const result = coll.parent;
expect(result).to.be.null;
});
});
});
describe('getDoc()', () => {
it('can get a non-existing document', () => {
return withTestDoc(async docRef => {
const docSnap = await getDoc(docRef);
expect(docSnap.exists()).to.be.false;
});
});
it('can get an existing document', () => {
return withTestDocAndInitialData({ val: 1 }, async docRef => {
const docSnap = await getDoc(docRef);
expect(docSnap.exists()).to.be.true;
});
});
it('can get doc with a deserialized reference', () => {
return withTestDocAndInitialData({ val: 1 }, async docRef => {
const docSnap = await getDoc(docRef);
expect(docSnap.exists()).to.be.true;
const json = docRef.toJSON();
const deserializedDocRef = DocumentReference.fromJSON(
docSnap._firestore,
json
);
const docSnap2 = await getDoc(deserializedDocRef);
expect(docSnap2.exists()).to.be.true;
});
});
});
/**
* Shared test class that is used to verify the WriteBatch, Transaction and
* DocumentReference-based mutation API.
*/
interface MutationTester {
set<AppModelType, DbModelType extends DocumentData>(
documentRef: DocumentReference<AppModelType, DbModelType>,
data: WithFieldValue<AppModelType>
): Promise<void>;
set<AppModelType, DbModelType extends DocumentData>(
documentRef: DocumentReference<AppModelType, DbModelType>,
data: PartialWithFieldValue<AppModelType>,
options: SetOptions
): Promise<void>;
update<AppModelType, DbModelType extends DocumentData>(
documentRef: DocumentReference<AppModelType, DbModelType>,
data: UpdateData<DbModelType>
): Promise<void>;
update<AppModelType, DbModelType extends DocumentData>(
documentRef: DocumentReference<AppModelType, DbModelType>,
field: string | FieldPath,
value: unknown,
...moreFieldsAndValues: unknown[]
): Promise<void>;
delete<AppModelType, DbModelType extends DocumentData>(
documentRef: DocumentReference<AppModelType, DbModelType>
): Promise<void>;
}
genericMutationTests({
set: setDoc,
update: updateDoc,
delete: deleteDoc
});
describe('WriteBatch', () => {
class WriteBatchTester implements MutationTester {
delete<AppModelType, DbModelType extends DocumentData>(
ref: DocumentReference<AppModelType, DbModelType>
): Promise<void> {
const batch = writeBatch(ref.firestore);
batch.delete(ref);
return batch.commit();
}
set<AppModelType, DbModelType extends DocumentData>(
ref: DocumentReference<AppModelType, DbModelType>,
data: PartialWithFieldValue<AppModelType>,
options?: SetOptions
): Promise<void> {
const batch = writeBatch(ref.firestore);
// TODO(mrschmidt): Find a way to remove the `any` cast here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(batch.set as any).apply(batch, Array.from(arguments));
return batch.commit();
}
update<AppModelType, DbModelType extends DocumentData>(
ref: DocumentReference<AppModelType, DbModelType>,
dataOrField: UpdateData<DbModelType> | string | FieldPath,
value?: unknown,
...moreFieldsAndValues: unknown[]
): Promise<void> {
const batch = writeBatch(ref.firestore);
// TODO(mrschmidt): Find a way to remove the `any` cast here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(batch.update as any).apply(batch, Array.from(arguments));
return batch.commit();
}
}
genericMutationTests(new WriteBatchTester());
it('can add multiple operations', () => {
return withTestCollection(async coll => {
const batch = writeBatch(coll.firestore);
batch.set(doc(coll), { doc: 1 });
batch.set(doc(coll), { doc: 2 });
await batch.commit();
// TODO(firestorelite): Verify collection contents once getDocs is added
});
});
it('cannot add write after commit', () => {
return withTestDoc(async doc => {
const batch = writeBatch(doc.firestore);
batch.set(doc, { doc: 1 });
const op = batch.commit();
expect(() => batch.delete(doc)).to.throw(
'A write batch can no longer be used after commit() has been called.'
);
await op;
expect(() => batch.delete(doc)).to.throw(
'A write batch can no longer be used after commit() has been called.'
);
});
});
});
describe('Transaction', () => {
class TransactionTester implements MutationTester {
delete<AppModelType, DbModelType extends DocumentData>(
ref: DocumentReference<AppModelType, DbModelType>
): Promise<void> {
return runTransaction(ref.firestore, async transaction => {
transaction.delete(ref);
});
}
set<AppModelType, DbModelType extends DocumentData>(
ref: DocumentReference<AppModelType, DbModelType>,
data: PartialWithFieldValue<AppModelType>,
options?: SetOptions
): Promise<void> {
return runTransaction(ref.firestore, async transaction => {
if (options) {
transaction.set(ref, data, options);
} else {
transaction.set(ref, data as WithFieldValue<AppModelType>);
}
});
}
update<AppModelType, DbModelType extends DocumentData>(
ref: DocumentReference<AppModelType, DbModelType>,
dataOrField: UpdateData<DbModelType> | string | FieldPath,
value?: unknown,
...moreFieldsAndValues: unknown[]
): Promise<void> {
return runTransaction(ref.firestore, async transaction => {
if (value) {
transaction.update(
ref,
dataOrField as string | FieldPath,
value,
...moreFieldsAndValues
);
} else {
transaction.update(ref, dataOrField as UpdateData<DbModelType>);
}
});
}
}
genericMutationTests(
new TransactionTester(),
/* testRunnerMayUseBackoff= */ true,
/* validationUsesPromises= */ true
);
it('can read and then write', () => {
return withTestDocAndInitialData({ counter: 1 }, async doc => {
await runTransaction(doc.firestore, async transaction => {
const snap = await transaction.get(doc);
transaction.update(doc, 'counter', snap.get('counter') + 1);
});
const result = await getDoc(doc);
expect(result.get('counter')).to.equal(2);
});
});
it('can read non-existing doc then write', () => {
return withTestDoc(async doc => {
await runTransaction(doc.firestore, async transaction => {
const snap = await transaction.get(doc);
expect(snap.exists()).to.be.false;
transaction.set(doc, { counter: 1 });
});
const result = await getDoc(doc);
expect(result.get('counter')).to.equal(1);
});
});
// This test is identical to the test above, except that a nonexistent
// document is replaced by a deleted document, to guard against regression of
// https://github.com/firebase/firebase-js-sdk/issues/5871, where transactions
// would incorrectly fail with FAILED_PRECONDITION when operations were
// performed on a deleted document (rather than a nonexistent document).
it('can read deleted doc then write', () => {
return withTestDocAndInitialData({ counter: 1 }, async doc => {
await deleteDoc(doc);
await runTransaction(doc.firestore, async transaction => {
const snap = await transaction.get(doc);
expect(snap.exists()).to.be.false;
transaction.set(doc, { counter: 1 });
});
const result = await getDoc(doc);
expect(result.get('counter')).to.equal(1);
});
});
it('retries when document is modified', () => {
return withTestDoc(async doc => {
let retryCounter = 0;
await runTransaction(doc.firestore, async transaction => {
++retryCounter;
await transaction.get(doc);
if (retryCounter === 1) {
// Out of band modification that doesn't use the transaction
await setDoc(doc, { counter: 'invalid' });
}
transaction.set(doc, { counter: 1 });
});
expect(retryCounter).to.equal(2);
const result = await getDoc(doc);
expect(result.get('counter')).to.equal(1);
});
});
});
function genericMutationTests(
op: MutationTester,
testRunnerMayUseBackoff = false,
validationUsesPromises = false
): void {
const setDoc = op.set;
const updateDoc = op.update;
const deleteDoc = op.delete;
describe('delete', () => {
it('can delete a non-existing document', () => {
return withTestDoc(docRef => deleteDoc(docRef));
});
it('can delete an existing document', () => {
return withTestDoc(async docRef => {
await setDoc(docRef, {});
await deleteDoc(docRef);
const docSnap = await getDoc(docRef);
expect(docSnap.exists()).to.be.false;
});
});
});
describe('set', () => {
it('can set a new document', () => {
return withTestDoc(async docRef => {
await setDoc(docRef, { val: 1 });
const docSnap = await getDoc(docRef);
expect(docSnap.data()).to.deep.equal({ val: 1 });
});
});
it('can merge a document', () => {
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
await setDoc(docRef, { bar: 2 }, { merge: true });
const docSnap = await getDoc(docRef);
expect(docSnap.data()).to.deep.equal({ foo: 1, bar: 2 });
});
});
it('can merge a document with mergeFields', () => {
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
await setDoc(
docRef,
{ foo: 'ignored', bar: 2, baz: { foobar: 3 } },
{ mergeFields: ['bar', new FieldPath('baz', 'foobar')] }
);
const docSnap = await getDoc(docRef);
expect(docSnap.data()).to.deep.equal({
foo: 1,
bar: 2,
baz: { foobar: 3 }
});
});
});
it('supports partials with merge', async () => {
return withTestCollection(async coll => {
const ref = doc(coll, 'post').withConverter(postConverterMerge);
await setDoc(ref, new Post('walnut', 'author'));
await setDoc(
ref,
{ title: 'olive', id: increment(2) },
{ merge: true }
);
const postDoc = await getDoc(ref);
expect(postDoc.get('title')).to.equal('olive');
expect(postDoc.get('author')).to.equal('author');
});
});
it('supports partials with mergeFields', async () => {
return withTestCollection(async coll => {
const ref = doc(coll, 'post').withConverter(postConverterMerge);
await setDoc(ref, new Post('walnut', 'author'));
await setDoc(ref, { title: 'olive' }, { mergeFields: ['title'] });
const postDoc = await getDoc(ref);
expect(postDoc.get('title')).to.equal('olive');
expect(postDoc.get('author')).to.equal('author');
});
});
it('throws when user input fails validation', () => {
return withTestDoc(async docRef => {
if (validationUsesPromises) {
return expect(
setDoc(docRef, { val: undefined })
).to.eventually.be.rejectedWith(
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val in document .*\)/
);
} else {
expect(() => setDoc(docRef, { val: undefined })).to.throw(
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val in document .*\)/
);
}
});
});
it("can ignore 'undefined'", () => {
return withTestDbSettings(
DEFAULT_PROJECT_ID,
{ ...DEFAULT_SETTINGS, ignoreUndefinedProperties: true },
async db => {
const docRef = doc(collection(db, 'test-collection'));
await setDoc(docRef, { val: undefined });
const docSnap = await getDoc(docRef);
expect(docSnap.data()).to.deep.equal({});
}
);
});
});
describe('update', () => {
it('can update a document', () => {
return withTestDocAndInitialData({ foo: 1, bar: 1 }, async docRef => {
await updateDoc(docRef, { foo: 2, baz: 2 });
const docSnap = await getDoc(docRef);
expect(docSnap.data()).to.deep.equal({ foo: 2, bar: 1, baz: 2 });
});
});
it('can update a document (using varargs)', () => {
return withTestDocAndInitialData({ foo: 1, bar: 1 }, async docRef => {
await updateDoc(docRef, 'foo', 2, new FieldPath('baz'), 2);
const docSnap = await getDoc(docRef);
expect(docSnap.data()).to.deep.equal({ foo: 2, bar: 1, baz: 2 });
});
});
// The Transaction tests use backoff for updates that fail with failed
// preconditions. This leads to test timeouts.
// eslint-disable-next-line no-restricted-properties
(testRunnerMayUseBackoff ? it.skip : it)(
'enforces that document exists',
() => {
return withTestDoc(async docRef => {
await expect(updateDoc(docRef, { foo: 2, baz: 2 })).to.eventually.be
.rejected;
});
}
);
it('throws when user input fails validation', () => {
return withTestDoc(async docRef => {
if (validationUsesPromises) {
return expect(
updateDoc(docRef, { val: undefined })
).to.eventually.be.rejectedWith(
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val in document .*\)/
);
} else {
expect(() => updateDoc(docRef, { val: undefined })).to.throw(
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val in document .*\)/
);
}
});
});
});
}
describe('addDoc()', () => {
it('can add a document', () => {
return withTestCollection(async collRef => {
const docRef = await addDoc(collRef, { val: 1 });
const docSnap = await getDoc(docRef);
expect(docSnap.data()).to.deep.equal({ val: 1 });
});
});
it('throws when user input fails validation', () => {
return withTestCollection(async collRef => {
expect(() => addDoc(collRef, { val: undefined })).to.throw(
/Function addDoc\(\) called with invalid data. Unsupported field value: undefined \(found in field val in document .*\)/
);
});
});
});
describe('DocumentSnapshot', () => {
it('can represent missing data', () => {
return withTestDoc(async docRef => {
const docSnap = await getDoc(docRef);
expect(docSnap.exists()).to.be.false;
expect(docSnap.data()).to.be.undefined;
});
});
it('can return data', () => {
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
const docSnap = await getDoc(docRef);
expect(docSnap.exists()).to.be.true;
expect(docSnap.data()).to.deep.equal({ foo: 1 });
});
});
it('can return single field', () => {
return withTestDocAndInitialData({ foo: 1, bar: 2 }, async docRef => {
const docSnap = await getDoc(docRef);
expect(docSnap.get('foo')).to.equal(1);
expect(docSnap.get(new FieldPath('bar'))).to.equal(2);
});
});
it('can return nested field', () => {
return withTestDocAndInitialData({ foo: { bar: 1 } }, async docRef => {
const docSnap = await getDoc(docRef);
expect(docSnap.get('foo.bar')).to.equal(1);
expect(docSnap.get(new FieldPath('foo', 'bar'))).to.equal(1);
});
});
it('is properly typed', () => {
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
const docSnap = await getDoc(docRef);
let documentData = docSnap.data()!; // "data" is typed as nullable
if (docSnap.exists()) {
documentData = docSnap.data(); // "data" is typed as non-null
}
expect(documentData).to.deep.equal({ foo: 1 });
});
});
it('returns Bytes', () => {
return withTestDocAndInitialData(
{ bytes: Bytes.fromBase64String('aa==') },
async docRef => {
const docSnap = await getDoc(docRef);
const bytes = docSnap.get('bytes');
expect(bytes.constructor.name).to.equal('Bytes');
}
);
});
});
describe('deleteDoc()', () => {
it('can delete a non-existing document', () => {
return withTestDoc(docRef => deleteDoc(docRef));
});
});
describe('FieldValue', () => {
it('support equality checking with isEqual()', () => {
expect(deleteField().isEqual(deleteField())).to.be.true;
expect(serverTimestamp().isEqual(serverTimestamp())).to.be.true;
expect(deleteField().isEqual(serverTimestamp())).to.be.false;
expect(arrayUnion().isEqual(arrayUnion())).to.be.true;
expect(arrayUnion('a').isEqual(arrayUnion('a'))).to.be.true;
expect(arrayUnion('a').isEqual(arrayUnion('b'))).to.be.false;
expect(arrayUnion('a', 'b').isEqual(arrayUnion('b', 'a'))).to.be.false;
expect(arrayRemove().isEqual(arrayRemove())).to.be.true;
expect(arrayRemove('a').isEqual(arrayRemove('a'))).to.be.true;
expect(arrayRemove('a').isEqual(arrayRemove('b'))).to.be.false;
expect(arrayRemove('a', 'b').isEqual(arrayRemove('b', 'a'))).to.be.false;
expect(increment(1).isEqual(increment(1))).to.be.true;
expect(increment(1).isEqual(increment(2))).to.be.false;
expect(minimum(1).isEqual(minimum(1))).to.be.true;
expect(minimum(1).isEqual(minimum(2))).to.be.false;
expect(maximum(1).isEqual(maximum(1))).to.be.true;
expect(maximum(1).isEqual(maximum(2))).to.be.false;
// Test NaN equality
expect(minimum(NaN).isEqual(minimum(NaN))).to.be.true;
expect(maximum(NaN).isEqual(maximum(NaN))).to.be.true;
});
it('support instanceof checks', () => {
expect(deleteField()).to.be.an.instanceOf(FieldValue);
expect(serverTimestamp()).to.be.an.instanceOf(FieldValue);
expect(increment(1)).to.be.an.instanceOf(FieldValue);
expect(arrayUnion('a')).to.be.an.instanceOf(FieldValue);
expect(arrayRemove('a')).to.be.an.instanceOf(FieldValue);
expect(minimum(1)).to.be.an.instanceOf(FieldValue);
expect(maximum(1)).to.be.an.instanceOf(FieldValue);
});
it('can apply arrayUnion', () => {
return withTestDocAndInitialData({ 'val': ['foo'] }, async docRef => {
await updateDoc(docRef, 'val', arrayUnion('bar'));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': ['foo', 'bar'] });
});
});
it('can apply arrayRemove', () => {
return withTestDocAndInitialData(
{ 'val': ['foo', 'bar'] },
async docRef => {
await updateDoc(docRef, 'val', arrayRemove('bar'));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': ['foo'] });
}
);
});
it('can apply serverTimestamp', () => {
return withTestDocAndInitialData({ 'val': null }, async docRef => {
await updateDoc(docRef, 'val', serverTimestamp());
const snap = await getDoc(docRef);
expect(snap.get('val')).to.be.an.instanceOf(Timestamp);
});
});
it('can apply minimum', () => {
return withTestDocAndInitialData({ 'val': 2 }, async docRef => {
await updateDoc(docRef, 'val', minimum(1));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': 1 });
});
});
it('can apply minimum (noop)', () => {
return withTestDocAndInitialData({ 'val': 1 }, async docRef => {
await updateDoc(docRef, 'val', minimum(2));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': 1 });
});
});
it('can apply maximum', () => {
return withTestDocAndInitialData({ 'val': 1 }, async docRef => {
await updateDoc(docRef, 'val', maximum(2));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': 2 });
});
});
it('can apply maximum (noop)', () => {
return withTestDocAndInitialData({ 'val': 2 }, async docRef => {
await updateDoc(docRef, 'val', maximum(1));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': 2 });
});
});
it('can apply minimum against non-numeric', () => {
return withTestDocAndInitialData({ 'val': 'string' }, async docRef => {
await updateDoc(docRef, 'val', minimum(1));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': 1 });
});
});
it('can apply maximum against non-numeric', () => {
return withTestDocAndInitialData({ 'val': 'string' }, async docRef => {
await updateDoc(docRef, 'val', maximum(1));
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({ 'val': 1 });
});
});
it('can delete field', () => {
return withTestDocAndInitialData({ 'val': 'foo' }, async docRef => {
await updateDoc(docRef, 'val', deleteField());
const snap = await getDoc(docRef);
expect(snap.data()).to.deep.equal({});
});
});
});
describe('Query', () => {
function verifyResults(
actual: QuerySnapshot<DocumentData>,
...expected: DocumentData[]
): void {
expect(actual.empty).to.equal(expected.length === 0);
expect(actual.size).to.equal(expected.length);
for (let i = 0; i < expected.length; ++i) {
expect(actual.docs[i].data()).to.deep.equal(expected[i]);
}
}
it('supports default query', () => {
return withTestCollectionAndInitialData([{ foo: 1 }], async collRef => {
const result = await getDocs(collRef);
verifyResults(result, { foo: 1 });
});
});
it('supports empty results', () => {
return withTestCollectionAndInitialData([], async collRef => {
const result = await getDocs(collRef);
verifyResults(result);
});
});
it('supports filtered query', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, where('foo', '==', 1));
const result = await getDocs(query1);
verifyResults(result, { foo: 1 });
}
);
});
it('supports filtered query (with FieldPath)', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, where(new FieldPath('foo'), '==', 1));
const result = await getDocs(query1);
verifyResults(result, { foo: 1 });
}
);
});
it('supports ordered query (with default order)', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo'));
const result = await getDocs(query1);
verifyResults(result, { foo: 1 }, { foo: 2 });
}
);
});
it('supports ordered query (with asc)', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo', 'asc'));
const result = await getDocs(query1);
verifyResults(result, { foo: 1 }, { foo: 2 });
}
);
});
it('supports ordered query (with desc)', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo', 'desc'));
const result = await getDocs(query1);
verifyResults(result, { foo: 2 }, { foo: 1 });
}
);
});
it('supports limit query', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo'), limit(1));
const result = await getDocs(query1);
verifyResults(result, { foo: 1 });
}
);
});
it('supports limitToLast query', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }, { foo: 3 }],
async collRef => {
const query1 = query(collRef, orderBy('foo'), limitToLast(2));
const result = await getDocs(query1);
verifyResults(result, { foo: 2 }, { foo: 3 });
}
);
});
it('supports startAt', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo'), startAt(2));
const result = await getDocs(query1);
verifyResults(result, { foo: 2 });
}
);
});
it('supports startAfter', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo'), startAfter(1));
const result = await getDocs(query1);
verifyResults(result, { foo: 2 });
}
);
});
it('supports endAt', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo'), endAt(1));
const result = await getDocs(query1);
verifyResults(result, { foo: 1 });
}
);
});
it('supports endBefore', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1 = query(collRef, orderBy('foo'), endBefore(2));
const result = await getDocs(query1);
verifyResults(result, { foo: 1 });
}
);
});
it('supports pagination', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
let query1 = query(collRef, orderBy('foo'), limit(1));
let result = await getDocs(query1);
verifyResults(result, { foo: 1 });
// Pass the document snapshot from the previous result
query1 = query(query1, startAfter(result.docs[0]));
result = await getDocs(query1);
verifyResults(result, { foo: 2 });
}
);
});
it('supports collection groups', () => {
return withTestCollection(async collRef => {
const collectionGroupId = `${collRef.id}group`;
const fooDoc = doc(
collRef.firestore,
`${collRef.id}/foo/${collectionGroupId}/doc1`
);
const barDoc = doc(
collRef.firestore,
`${collRef.id}/bar/baz/boo/${collectionGroupId}/doc2`
);
await setDoc(fooDoc, { foo: 1 });
await setDoc(barDoc, { bar: 1 });
const query1 = collectionGroup(collRef.firestore, collectionGroupId);
const result = await getDocs(query1);
verifyResults(result, { bar: 1 }, { foo: 1 });
});
});
it('validates collection groups', () => {
return withTestDb(firestore => {
expect(() => collectionGroup(firestore, '')).to.throw(
'Function collectionGroup() cannot be called with an empty collection id.'
);
expect(() => collectionGroup(firestore, '/')).to.throw(
"Invalid collection ID '/' passed to function collectionGroup(). Collection IDs must not contain '/'."
);
});
});
it('supports query over collection path with special characters', () => {
return withTestCollection(async collRef => {
const docWithSpecials = doc(collRef, 'so!@#$%^&*()_+special');
const collectionWithSpecials = collection(
docWithSpecials,
'so!@#$%^&*()_+special'
);
await addDoc(collectionWithSpecials, { foo: 1 });
await addDoc(collectionWithSpecials, { foo: 2 });
const result = await getDocs(
query(collectionWithSpecials, orderBy('foo', 'asc'))
);
verifyResults(result, { foo: 1 }, { foo: 2 });
});
});
});
describe('equality', () => {
it('for collection references', () => {
return withTestDb(firestore => {
const coll1a = collection(firestore, 'a');
const coll1b = doc(firestore, 'a/b').parent;
const coll2 = collection(firestore, 'c');
expect(refEqual(coll1a, coll1b)).to.be.true;
expect(refEqual(coll1a, coll2)).to.be.false;
const coll1c = collection(firestore, 'a').withConverter({
toFirestore: (data: DocumentData) => data as DocumentData,
fromFirestore: snap => snap.data()
});
expect(refEqual(coll1a, coll1c)).to.be.false;
expect(refEqual(coll1a, doc(firestore, 'a/b'))).to.be.false;
});
});
it('for document references', () => {
return withTestDb(firestore => {
const doc1a = doc(firestore, 'a/b');
const doc1b = doc(collection(firestore, 'a'), 'b');
const doc2 = doc(firestore, 'a/c');
expect(refEqual(doc1a, doc1b)).to.be.true;
expect(refEqual(doc1a, doc2)).to.be.false;
const doc1c = collection(firestore, 'a').withConverter({
toFirestore: (data: DocumentData) => data as DocumentData,
fromFirestore: snap => snap.data()
});
expect(refEqual(doc1a, doc1c)).to.be.false;
expect(refEqual(doc1a, collection(firestore, 'a'))).to.be.false;
});
});
it('for queries', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1a = query(collRef, orderBy('foo'));
const query1b = query(collRef, orderBy('foo', 'asc'));
const query2 = query(collRef, orderBy('foo', 'desc'));
const query3 = query(collection(collRef, 'a/b'), orderBy('foo'));
expect(queryEqual(query1a, query1b)).to.be.true;
expect(queryEqual(query1a, query2)).to.be.false;
expect(queryEqual(query1a, query3)).to.be.false;
}
);
});
it('for query snapshots', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const query1a = query(collRef, limit(10));
const query1b = query(collRef, limit(10));
const query2 = query(collRef, limit(100));
const snap1a = await getDocs(query1a);
const snap1b = await getDocs(query1b);
const snap2 = await getDocs(query2);
expect(snapshotEqual(snap1a, snap1b)).to.be.true;
expect(snapshotEqual(snap1a, snap2)).to.be.false;
// Re-run the query with an additional result.
await addDoc(collRef, { foo: 3 });
const snap1c = await getDocs(query1a);
expect(snapshotEqual(snap1a, snap1c)).to.be.false;
}
);
});
it('for document snapshots', () => {
return withTestCollectionAndInitialData(
[{ foo: 1 }, { foo: 2 }],
async collRef => {
const snap1a = await getDocs(collRef);
const snap1b = await getDocs(collRef);
expect(snapshotEqual(snap1a.docs[0], snap1b.docs[0])).to.be.true;
expect(snapshotEqual(snap1a.docs[0], snap1a.docs[0])).to.be.true;
// Modify the document and obtain the snapshot again.
await updateDoc(snap1a.docs[0].ref, { foo: 3 });
const snap3 = await getDocs(collRef);
expect(snapshotEqual(snap1a.docs[0], snap3.docs[0])).to.be.false;
}
);
});
});
describe('withConverter() support', () => {
it('for DocumentReference.withConverter()', () => {
return withTestDoc(async docRef => {
docRef = docRef.withConverter(postConverter);
await setDoc(docRef, new Post('post', 'author'));
const postData = await getDoc(docRef);
const post = postData.data();
expect(post).to.not.equal(undefined);
expect(post!.byline()).to.equal('post, by author');
});
});
it('for DocumentReference.withConverter(null) applies default converter', () => {
return withTestCollection(async coll => {
coll = coll.withConverter(postConverter).withConverter(null);
expect(() =>
setDoc(doc(coll, 'post1'), new Post('post', 'author'))
).to.throw();
});
});
it('for CollectionReference.withConverter()', () => {
return withTestCollection(async coll => {
coll = coll.withConverter(postConverter);
const docRef = await addDoc(coll, new Post('post', 'author'));
const postData = await getDoc(docRef);
const post = postData.data();
expect(post).to.not.equal(undefined);
expect(post!.byline()).to.equal('post, by author');
});
});
it('for CollectionReference.withConverter(null) applies default converter', () => {
return withTestDoc(async doc => {
doc = doc.withConverter(postConverter).withConverter(null);
expect(() => setDoc(doc, new Post('post', 'author'))).to.throw();
});
});
it('for Query.withConverter()', () => {
return withTestCollectionAndInitialData(
[{ title: 'post', author: 'author' }],
async collRef => {
let query1 = query(collRef, where('title', '==', 'post'));
query1 = query1.withConverter(postConverter);
const result = await getDocs(query1);
expect(result.docs[0].data()).to.be.an.instanceOf(Post);
expect(result.docs[0].data()!.byline()).to.equal('post, by author');
}
);
});
it('for Query.withConverter(null) applies default converter', () => {
return withTestCollectionAndInitialData(
[{ title: 'post', author: 'author' }],
async collRef => {
let query1 = query(collRef, where('title', '==', 'post'));
query1 = query1.withConverter(postConverter).withConverter(null);
const result = await getDocs(query1);
expect(result.docs[0]).to.not.be.an.instanceOf(Post);
}
);
});
it('keeps the converter when calling parent() with a DocumentReference', () => {
return withTestDb(async db => {
const coll = doc(db, 'root/doc').withConverter(postConverter);
const typedColl = coll.parent!;
expect(
refEqual(typedColl, collection(db, 'root').withConverter(postConverter))
).to.be.true;
});
});
it('drops the converter when calling parent() with a CollectionReference', () => {
return withTestDb(async db => {
const coll = collection(db, 'root/doc/parent').withConverter(
postConverter
);
const untypedDoc = coll.parent!;
expect(refEqual(untypedDoc, doc(db, 'root/doc'))).to.be.true;
});
});
it('checks converter when comparing with isEqual()', () => {
return withTestDb(async db => {
const postConverter2 = { ...postConverter };
const postsCollection = collection(db, 'users/user1/posts').withConverter(
postConverter
);
const postsCollection2 = collection(
db,
'users/user1/posts'
).withConverter(postConverter2);
expect(refEqual(postsCollection, postsCollection2)).to.be.false;
const docRef = doc(db, 'some/doc').withConverter(postConverter);
const docRef2 = doc(db, 'some/doc').withConverter(postConverter2);
expect(refEqual(docRef, docRef2)).to.be.false;
});
});
it('requires the correct converter for Partial usage', async () => {
return withTestDb(async db => {
const coll = collection(db, 'posts');
const ref = doc(coll, 'post').withConverter(postConverter);
const batch = writeBatch(db);
expect(() =>
batch.set(ref, { title: 'olive' }, { merge: true })
).to.throw(
'Function WriteBatch.set() called with invalid data ' +
'(via `toFirestore()`). Unsupported field value: undefined ' +
'(found in field author in document posts/post)'
);
});
});
it('supports primitive types with valid converter', () => {
type Primitive = number;
const primitiveConverter = {
toFirestore(value: Primitive): DocumentData {
return { value };
},
fromFirestore(snapshot: QueryDocumentSnapshot): Primitive {
const data = snapshot.data();
return data.value;
}
};
type ArrayValue = number[];
const arrayConverter = {
toFirestore(value: ArrayValue): DocumentData {
return { values: value };
},
fromFirestore(snapshot: QueryDocumentSnapshot): ArrayValue {
const data = snapshot.data();
return data.values;
}
};
return withTestCollection(async coll => {
const ref = doc(coll, 'number').withConverter(primitiveConverter);
await setDoc(ref, 3);
const result = await getDoc(ref);
expect(result.data()).to.equal(3);
const ref2 = doc(coll, 'array').withConverter(arrayConverter);
await setDoc(ref2, [1, 2, 3]);
const result2 = await getDoc(ref2);
expect(result2.data()).to.deep.equal([1, 2, 3]);
});
});
describe('types test', () => {
class TestObject {
constructor(
readonly outerString: string,
readonly outerArr: string[],
readonly nested: {
innerNested: {
innerNestedNum: number;
};
innerArr: number[];
timestamp: Timestamp;
}
) {}
}
const testConverter: FirestoreDataConverter<TestObject, TestObject> = {
toFirestore(testObj: WithFieldValue<TestObject>) {
return { ...testObj };
},
fromFirestore(snapshot: QueryDocumentSnapshot): TestObject {
const data = snapshot.data();
return new TestObject(data.outerString, data.outerArr, data.nested);
}
};
const initialData = {
outerString: 'foo',
outerArr: [],
nested: {
innerNested: {
innerNestedNum: 2
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
};
describe('nested partial support', () => {
const testConverterMerge = {
toFirestore(
testObj: PartialWithFieldValue<TestObject>,
options?: SetOptions
) {
return { ...testObj };
},
fromFirestore(snapshot: QueryDocumentSnapshot): TestObject {
const data = snapshot.data();
return new TestObject(data.outerString, data.outerArr, data.nested);
}
};
it('supports FieldValues', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverterMerge);
// Allow Field Values in nested partials.
await setDoc(
ref,
{
outerString: deleteField(),
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
},
{ merge: true }
);
// Allow setting FieldValue on entire object field.
await setDoc(
ref,
{
nested: deleteField()
},
{ merge: true }
);
});
});
it('validates types in outer and inner fields', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverterMerge);
// Check top-level fields.
await setDoc(
ref,
{
// @ts-expect-error
outerString: 3,
// @ts-expect-error
outerArr: null
},
{ merge: true }
);
// Check nested fields.
await setDoc(
ref,
{
nested: {
innerNested: {
// @ts-expect-error
innerNestedNum: 'string'
},
// @ts-expect-error
innerArr: null
}
},
{ merge: true }
);
await setDoc(
ref,
{
// @ts-expect-error
nested: 3
},
{ merge: true }
);
});
});
it('checks for nonexistent properties', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverterMerge);
// Top-level property.
await setDoc(
ref,
{
// @ts-expect-error
nonexistent: 'foo'
},
{ merge: true }
);
// Nested property
await setDoc(
ref,
{
nested: {
// @ts-expect-error
nonexistent: 'foo'
}
},
{ merge: true }
);
});
});
it('allows omitting fields', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverterMerge);
// Omit outer fields
await setDoc(
ref,
{
outerString: deleteField(),
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
},
{ merge: true }
);
// Omit inner fields
await setDoc(
ref,
{
outerString: deleteField(),
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
timestamp: serverTimestamp()
}
},
{ merge: true }
);
});
});
});
describe('WithFieldValue', () => {
it('supports FieldValues', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverter);
// Allow Field Values and nested partials.
await setDoc(ref, {
outerString: 'foo',
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
});
});
it('requires all outer fields to be present', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverter);
// Allow Field Values and nested partials.
// @ts-expect-error
await setDoc(ref, {
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
});
});
it('requires all nested fields to be present', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverter);
await setDoc(ref, {
outerString: 'foo',
outerArr: [],
// @ts-expect-error
nested: {
innerNested: {
innerNestedNum: increment(1)
},
timestamp: serverTimestamp()
}
});
});
});
it('validates inner and outer fields', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverter);
await setDoc(ref, {
outerString: 'foo',
// @ts-expect-error
outerArr: 2,
nested: {
innerNested: {
// @ts-expect-error
innerNestedNum: 'string'
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
});
});
it('checks for nonexistent properties', async () => {
return withTestDoc(async doc => {
const ref = doc.withConverter(testConverter);
// Top-level nonexistent fields should error
await setDoc(ref, {
outerString: 'foo',
// @ts-expect-error
outerNum: 3,
outerArr: [],
nested: {
innerNested: {
innerNestedNum: 2
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
// Nested nonexistent fields should error
await setDoc(ref, {
outerString: 'foo',
outerNum: 3,
outerArr: [],
nested: {
innerNested: {
// @ts-expect-error
nonexistent: 'string',
innerNestedNum: 2
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
});
});
it('allows certain types but not others', () => {
const withTryCatch = async (fn: () => Promise<void>): Promise<void> => {
try {
await fn();
} catch {}
};
// These tests exist to establish which object types are allowed to be
// passed in by default when `T = DocumentData`. Some objects extend
// the JavaScript `{}`, which is why they're allowed whereas others
// throw an error.
return withTestDoc(async doc => {
// @ts-expect-error
await withTryCatch(() => setDoc(doc, 1));
// @ts-expect-error
await withTryCatch(() => setDoc(doc, 'foo'));
// @ts-expect-error
await withTryCatch(() => setDoc(doc, false));
await withTryCatch(() => setDoc(doc, undefined));
await withTryCatch(() => setDoc(doc, null));
await withTryCatch(() => setDoc(doc, [0]));
await withTryCatch(() => setDoc(doc, new Set<string>()));
await withTryCatch(() => setDoc(doc, new Map<string, number>()));
});
});
describe('used as a type', () => {
class ObjectWrapper<T> {
withFieldValueT(value: WithFieldValue<T>): WithFieldValue<T> {
return value;
}
withPartialFieldValueT(
value: PartialWithFieldValue<T>
): PartialWithFieldValue<T> {
return value;
}
// Wrapper to avoid having Firebase types in non-Firebase code.
withT(value: T): void {
this.withFieldValueT(value);
}
// Wrapper to avoid having Firebase types in non-Firebase code.
withPartialT(value: Partial<T>): void {
this.withPartialFieldValueT(value);
}
}
it('supports passing in the object as `T`', () => {
interface Foo {
id: string;
foo: number;
}
const foo = new ObjectWrapper<Foo>();
foo.withFieldValueT({ id: '', foo: increment(1) });
foo.withPartialFieldValueT({ foo: increment(1) });
foo.withT({ id: '', foo: 1 });
foo.withPartialT({ foo: 1 });
});
it('does not allow primitive types to use FieldValue', () => {
type Bar = number;
const bar = new ObjectWrapper<Bar>();
// @ts-expect-error
bar.withFieldValueT(increment(1));
// @ts-expect-error
bar.withPartialFieldValueT(increment(1));
});
});
});
describe('UpdateData', () => {
it('supports FieldValues', () => {
return withTestDocAndInitialData(initialData, async docRef => {
await updateDoc(docRef.withConverter(testConverter), {
outerString: deleteField(),
nested: {
innerNested: {
innerNestedNum: increment(2)
},
innerArr: arrayUnion(3)
}
});
});
});
it('validates inner and outer fields', async () => {
return withTestDocAndInitialData(initialData, async docRef => {
await updateDoc(docRef.withConverter(testConverter), {
// @ts-expect-error
outerString: 3,
nested: {
innerNested: {
// @ts-expect-error
innerNestedNum: 'string'
},
// @ts-expect-error
innerArr: 2
}
});
});
});
it('supports string-separated fields', () => {
return withTestDocAndInitialData(initialData, async docRef => {
const testDocRef = docRef.withConverter(testConverter);
await updateDoc(testDocRef, {
// @ts-expect-error
outerString: 3,
// @ts-expect-error
'nested.innerNested.innerNestedNum': 'string',
// @ts-expect-error
'nested.innerArr': 3,
'nested.timestamp': serverTimestamp()
});
// String comprehension works in nested fields.
await updateDoc(testDocRef, {
nested: {
innerNested: {
// @ts-expect-error
'innerNestedNum': 'string'
},
// @ts-expect-error
'innerArr': 3
}
});
});
});
it('supports optional fields', () => {
interface TestObjectOptional {
optionalStr?: string;
nested?: {
requiredStr: string;
};
}
const testConverterOptional = {
toFirestore(testObj: WithFieldValue<TestObjectOptional>) {
return { ...testObj };
},
fromFirestore(snapshot: QueryDocumentSnapshot): TestObjectOptional {
const data = snapshot.data();
return {
optionalStr: data.optionalStr,
nested: data.nested
};
}
};
return withTestDocAndInitialData(initialData, async docRef => {
const testDocRef: DocumentReference<TestObjectOptional> =
docRef.withConverter(testConverterOptional);
await updateDoc(testDocRef, {
optionalStr: 'foo'
});
await updateDoc(testDocRef, {
'optionalStr': 'foo'
});
await updateDoc(testDocRef, {
nested: {
requiredStr: 'foo'
}
});
await updateDoc(testDocRef, {
'nested.requiredStr': 'foo'
});
});
});
it('supports null fields', () => {
interface TestObjectOptional {
optionalStr?: string;
nested?: {
strOrNull: string | null;
};
}
const testConverterOptional = {
toFirestore(testObj: WithFieldValue<TestObjectOptional>) {
return { ...testObj };
},
fromFirestore(snapshot: QueryDocumentSnapshot): TestObjectOptional {
const data = snapshot.data();
return {
optionalStr: data.optionalStr,
nested: data.nested
};
}
};
return withTestDocAndInitialData(initialData, async docRef => {
const testDocRef: DocumentReference<TestObjectOptional> =
docRef.withConverter(testConverterOptional);
await updateDoc(testDocRef, {
nested: {
strOrNull: null
}
});
await updateDoc(testDocRef, {
'nested.strOrNull': null
});
});
});
it('supports union fields', () => {
interface TestObjectUnion {
optionalStr?: string;
nested?:
| {
requiredStr: string;
}
| { requiredNumber: number };
}
const testConverterUnion: FirestoreDataConverter<
TestObjectUnion,
TestObjectUnion
> = {
toFirestore(testObj: WithFieldValue<TestObjectUnion>) {
return { ...testObj };
},
fromFirestore(snapshot: QueryDocumentSnapshot): TestObjectUnion {
const data = snapshot.data();
return {
optionalStr: data.optionalStr,
nested: data.nested
};
}
};
return withTestDocAndInitialData(initialData, async docRef => {
const testDocRef = docRef.withConverter(testConverterUnion);
await updateDoc(testDocRef, {
nested: {
requiredStr: 'foo'
}
});
await updateDoc(testDocRef, {
'nested.requiredStr': 'foo'
});
await updateDoc(testDocRef, {
// @ts-expect-error
'nested.requiredStr': 1
});
await updateDoc(testDocRef, {
'nested.requiredNumber': 1
});
await updateDoc(testDocRef, {
// @ts-expect-error
'nested.requiredNumber': 'foo'
});
await updateDoc(testDocRef, {
// @ts-expect-error
'nested.requiredNumber': null
});
});
});
it('checks for nonexistent fields', () => {
return withTestDocAndInitialData(initialData, async docRef => {
const testDocRef = docRef.withConverter(testConverter);
// Top-level fields.
await updateDoc(testDocRef, {
// @ts-expect-error
nonexistent: 'foo'
});
// Nested Fields.
await updateDoc(testDocRef, {
nested: {
// @ts-expect-error
nonexistent: 'foo'
}
});
// String fields.
await updateDoc(testDocRef, {
// @ts-expect-error
'nonexistent': 'foo'
});
await updateDoc(testDocRef, {
// @ts-expect-error
'nested.nonexistent': 'foo'
});
});
});
});
describe('methods', () => {
it('addDoc()', () => {
return withTestDb(async db => {
const ref = collection(db, 'testobj').withConverter(testConverter);
// Requires all fields to be present
// @ts-expect-error
await addDoc(ref, {
outerArr: [],
nested: {
innerNested: {
innerNestedNum: 2
},
innerArr: [],
timestamp: serverTimestamp()
}
});
});
});
it('WriteBatch.set()', () => {
return withTestDb(async db => {
const ref = doc(collection(db, 'testobj')).withConverter(
testConverter
);
const batch = writeBatch(db);
// Requires full object if {merge: true} is not set.
// @ts-expect-error
batch.set(ref, {
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
batch.set(
ref,
{
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
},
{ merge: true }
);
});
});
it('WriteBatch.update()', () => {
return withTestDb(async db => {
const ref = doc(collection(db, 'testobj')).withConverter(
testConverter
);
const batch = writeBatch(db);
batch.update(ref, {
outerArr: [],
nested: {
'innerNested.innerNestedNum': increment(1),
'innerArr': arrayUnion(2),
timestamp: serverTimestamp()
}
});
});
});
it('Transaction.set()', () => {
return withTestDb(async db => {
const ref = doc(collection(db, 'testobj')).withConverter(
testConverter
);
return runTransaction(db, async tx => {
// Requires full object if {merge: true} is not set.
// @ts-expect-error
tx.set(ref, {
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
tx.set(
ref,
{
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
},
{ merge: true }
);
});
});
});
it('Transaction.update()', () => {
return withTestDb(async db => {
const ref = doc(collection(db, 'testobj')).withConverter(
testConverter
);
await setDoc(ref, {
outerString: 'foo',
outerArr: [],
nested: {
innerNested: {
innerNestedNum: 2
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
return runTransaction(db, async tx => {
tx.update(ref, {
outerArr: [],
nested: {
innerNested: {
innerNestedNum: increment(1)
},
innerArr: arrayUnion(2),
timestamp: serverTimestamp()
}
});
});
});
});
});
});
});
// eslint-disable-next-line no-restricted-properties
describe('Count queries', () => {
it('AggregateQuerySnapshot inherits the original query', () => {
return withTestCollection(async coll => {
const query_ = query(coll);
const snapshot = await getCount(query_);
expect(snapshot.query).to.equal(query_);
});
});
['so!@#$%^&*()_+special/sub', 'b1/so!@#$%^&*()_+special'].forEach(
documentPath => {
it(
'can run count query getCount with special chars in the document path: ' +
documentPath,
() => {
return withTestCollection(async coll => {
const subColl1 = collection(coll, documentPath);
await addDoc(subColl1, { foo: 'bar' });
await addDoc(subColl1, { foo: 'baz' });
const snapshot1 = await getCount(subColl1);
expect(snapshot1.data().count).to.equal(2);
});
}
);
}
);
it('run count query on empty collection', () => {
return withTestCollection(async coll => {
const snapshot = await getCount(coll);
expect(snapshot.data().count).to.equal(0);
});
});
it('run count query on collection with 3 docs', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot = await getCount(coll);
expect(snapshot.data().count).to.equal(3);
});
});
it('run count query fails on invalid collection reference', () => {
return withTestDb(async db => {
const queryForRejection = collection(db, '__badpath__');
await expect(getCount(queryForRejection)).to.eventually.be.rejected;
});
});
it('count query supports filter', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, where('author', '==', 'authorA'));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(2);
});
});
it('count query supports filter and a small limit size', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, where('author', '==', 'authorA'), limit(1));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(1);
});
});
it('count query supports filter and a large limit size', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, where('author', '==', 'authorA'), limit(3));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(2);
});
});
it('count query supports order by', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: null },
{ author: 'authorB' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('title'));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(3);
});
});
it('count query supports order by and startAt', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAt(2));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(2);
});
});
it('count query supports order by and startAfter', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAfter(2));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(1);
});
});
it('count query supports order by and endAt', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAt(1), endAt(2));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(2);
});
});
it('count query supports order by and endBefore', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAt(1), endBefore(2));
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(1);
});
});
it("count query doesn't use converter", () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
const throwingConverter = {
toFirestore(obj: never): DocumentData {
throw new Error('should never be called');
},
fromFirestore(snapshot: QueryDocumentSnapshot): never {
throw new Error('should never be called');
}
};
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(
coll,
where('author', '==', 'authorA')
).withConverter(throwingConverter);
const snapshot = await getCount(query_);
expect(snapshot.data().count).to.equal(2);
});
});
it('count query supports collection groups', () => {
return withTestDb(async db => {
const collectionGroupId = doc(collection(db, 'countTest')).id;
const docPaths = [
`${collectionGroupId}/cg-doc1`,
`abc/123/${collectionGroupId}/cg-doc2`,
`zzz${collectionGroupId}/cg-doc3`,
`abc/123/zzz${collectionGroupId}/cg-doc4`,
`abc/123/zzz/${collectionGroupId}`
];
const batch = writeBatch(db);
for (const docPath of docPaths) {
batch.set(doc(db, docPath), { x: 1 });
}
await batch.commit();
const snapshot = await getCount(collectionGroup(db, collectionGroupId));
expect(snapshot.data().count).to.equal(2);
});
});
it('aggregateQuerySnapshotEqual on same queries be truthy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query1 = query(coll, where('author', '==', 'authorA'));
const query2 = query(coll, where('author', '==', 'authorA'));
const snapshot1A = await getCount(query1);
const snapshot1B = await getCount(query1);
const snapshot2 = await getCount(query2);
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot1B)).to.be.true;
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot2)).to.be.true;
});
});
it('aggregateQuerySnapshotEqual on same queries with different documents size be falsy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query1 = query(coll, where('author', '==', 'authorA'));
const snapshot1A = await getCount(query1);
await addDoc(coll, { author: 'authorA', title: 'titleD' });
const query2 = query(coll, where('author', '==', 'authorA'));
const snapshot1B = await getCount(query1);
const snapshot2 = await getCount(query2);
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot1B)).to.be.false;
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot2)).to.be.false;
});
});
it('aggregateQuerySnapshotEqual on different queries be falsy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' },
{ author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query1 = query(coll, where('author', '==', 'authorA'));
const query2 = query(coll, where('author', '==', 'authorB'));
const snapshot1 = await getCount(query1);
const snapshot2 = await getCount(query2);
expect(aggregateQuerySnapshotEqual(snapshot1, snapshot2)).to.be.false;
});
});
it('count query fails on a terminated Firestore', () => {
return withTestCollection(async coll => {
await terminate(coll.firestore);
expect(() => getCount(coll)).to.throw(
'The client has already been terminated.'
);
});
});
it('terminate Firestore not effect count query in flight', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const promise = getCount(coll);
await terminate(coll.firestore);
const snapshot = await promise;
expect(snapshot.data().count).to.equal(3);
});
});
// Only verify the error message for missing indexes when running against
// production, since the Firestore Emulator does not require index creation
// and will, therefore, never fail in this situation.
// eslint-disable-next-line no-restricted-properties
it.skip('getCount error message contains console link if missing index', () => {
return withTestCollection(async coll => {
const query_ = query(
coll,
where('key1', '==', 42),
where('key2', '<', 42)
);
// TODO(b/316359394) Remove the special logic for non-default databases
// once cl/582465034 is rolled out to production.
if (coll.firestore._databaseId.isDefaultDatabase) {
await expect(getCount(query_)).to.be.eventually.rejectedWith(
/index.*https:\/\/console\.firebase\.google\.com/
);
} else {
await expect(getCount(query_)).to.be.eventually.rejected;
}
});
});
});
describe('Aggregate queries', () => {
it('AggregateQuerySnapshot inherits the original query', () => {
return withTestCollection(async coll => {
const query_ = query(coll);
const snapshot = await getAggregate(query_, { count: count() });
expect(snapshot.query).to.equal(query_);
});
});
it('run aggregate query on empty collection', () => {
return withTestCollection(async coll => {
const snapshot = await getAggregate(coll, { myCount: count() });
expect(snapshot.data().myCount).to.equal(0);
});
});
it('run aggregate query on collection with 3 docs', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot = await getAggregate(coll, { myCount: count() });
expect(snapshot.data().myCount).to.equal(3);
});
});
it('run aggregate query with duplicate aggregates', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot = await getAggregate(coll, {
myCount: count(),
yourCount: count()
});
expect(snapshot.data().myCount).to.equal(3);
expect(snapshot.data().yourCount).to.equal(3);
});
});
it('run aggregate query fails on invalid collection reference', () => {
return withTestDb(async db => {
const queryForRejection = collection(db, '__badpath__');
await expect(getAggregate(queryForRejection, { myCount: count() })).to
.eventually.be.rejected;
});
});
it('aggregate query supports filter', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, where('author', '==', 'authorA'));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(2);
});
});
it('aggregate query supports filter and a small limit size', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, where('author', '==', 'authorA'), limit(1));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(1);
});
});
it('aggregate query supports filter and a large limit size', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, where('author', '==', 'authorA'), limit(3));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(2);
});
});
it('aggregate query supports order by', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: null },
{ author: 'authorB' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('title'));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(3);
});
});
it('aggregate query supports order by and startAt', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAt(2));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(2);
});
});
it('aggregate query supports order by and startAfter', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAfter(2));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(1);
});
});
it('aggregate query supports order by and endAt', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAt(1), endAt(2));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(2);
});
});
it('aggregate query supports order by and endBefore', () => {
const testDocs = [
{ id: 3, author: 'authorA', title: 'titleA' },
{ id: 1, author: 'authorA', title: 'titleB' },
{ id: 2, author: 'authorB', title: 'titleC' },
{ id: null, author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(coll, orderBy('id'), startAt(1), endBefore(2));
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(1);
});
});
it("aggregate query doesn't use converter", () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
const throwingConverter = {
toFirestore(obj: never): DocumentData {
throw new Error('should never be called');
},
fromFirestore(snapshot: QueryDocumentSnapshot): never {
throw new Error('should never be called');
}
};
return withTestCollectionAndInitialData(testDocs, async coll => {
const query_ = query(
coll,
where('author', '==', 'authorA')
).withConverter(throwingConverter);
const snapshot = await getAggregate(query_, { myCount: count() });
expect(snapshot.data().myCount).to.equal(2);
});
});
it('aggregate query supports collection groups', () => {
return withTestDb(async db => {
const collectionGroupId = doc(collection(db, 'countTest')).id;
const docPaths = [
`${collectionGroupId}/cg-doc1`,
`abc/123/${collectionGroupId}/cg-doc2`,
`zzz${collectionGroupId}/cg-doc3`,
`abc/123/zzz${collectionGroupId}/cg-doc4`,
`abc/123/zzz/${collectionGroupId}`
];
const batch = writeBatch(db);
for (const docPath of docPaths) {
batch.set(doc(db, docPath), { x: 1 });
}
await batch.commit();
const snapshot = await getAggregate(
collectionGroup(db, collectionGroupId),
{ myCount: count() }
);
expect(snapshot.data().myCount).to.equal(2);
});
});
it('aggregateQuerySnapshotEqual on same queries be truthy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query1 = query(coll, where('author', '==', 'authorA'));
const query2 = query(coll, where('author', '==', 'authorA'));
const snapshot1A = await getAggregate(query1, { myCount: count() });
const snapshot1B = await getAggregate(query1, { myCount: count() });
const snapshot2 = await getAggregate(query2, { myCount: count() });
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot1B)).to.be.true;
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot2)).to.be.true;
});
});
it('aggregateQuerySnapshotEqual on same queries with different documents size be falsy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query1 = query(coll, where('author', '==', 'authorA'));
const snapshot1A = await getAggregate(query1, { myCount: count() });
await addDoc(coll, { author: 'authorA', title: 'titleD' });
const query2 = query(coll, where('author', '==', 'authorA'));
const snapshot1B = await getAggregate(query1, { myCount: count() });
const snapshot2 = await getAggregate(query2, { myCount: count() });
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot1B)).to.be.false;
expect(aggregateQuerySnapshotEqual(snapshot1A, snapshot2)).to.be.false;
});
});
it('aggregateQuerySnapshotEqual on different queries be falsy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' },
{ author: 'authorB', title: 'titleD' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const query1 = query(coll, where('author', '==', 'authorA'));
const query2 = query(coll, where('author', '==', 'authorB'));
const snapshot1 = await getAggregate(query1, { myCount: count() });
const snapshot2 = await getAggregate(query2, { myCount: count() });
expect(aggregateQuerySnapshotEqual(snapshot1, snapshot2)).to.be.false;
});
});
it('aggregate query fails on a terminated Firestore', () => {
return withTestCollection(async coll => {
await terminate(coll.firestore);
expect(() => getAggregate(coll, { myCount: count() })).to.throw(
'The client has already been terminated.'
);
});
});
it('terminate Firestore not effect aggregate query in flight', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA' },
{ author: 'authorA', title: 'titleB' },
{ author: 'authorB', title: 'titleC' }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const promise = getAggregate(coll, { myCount: count() });
await terminate(coll.firestore);
const snapshot = await promise;
expect(snapshot.data().myCount).to.equal(3);
});
});
// Only verify the error message for missing indexes when running against
// production, since the Firestore Emulator does not require index creation
// and will, therefore, never fail in this situation.
// eslint-disable-next-line no-restricted-properties
it.skipEmulator.skipEnterprise(
'getAggregate error message contains console link if missing index',
() => {
return withTestCollection(async coll => {
const query_ = query(
coll,
where('key1', '==', 42),
where('key2', '<', 42)
);
// TODO(b/316359394) Remove the special logic for non-default databases
// once cl/582465034 is rolled out to production.
if (coll.firestore._databaseId.isDefaultDatabase) {
await expect(
getAggregate(query_, {
myCount: count()
})
).to.be.eventually.rejectedWith(
/index.*https:\/\/console\.firebase\.google\.com/
);
} else {
await expect(
getAggregate(query_, {
myCount: count()
})
).to.be.eventually.rejected;
}
});
}
);
});
describe('Aggregate queries - sum / average', () => {
it('aggregateQuerySnapshotEqual on different aggregations to be falsy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA', rating: 1 },
{ author: 'authorA', title: 'titleB', rating: 5 },
{ author: 'authorB', title: 'titleC', rating: 4 },
{ author: 'authorB', title: 'titleD', rating: 3 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot1 = await getAggregate(coll, { sum: sum('rating') });
const snapshot2 = await getAggregate(coll, { avg: average('rating') });
// `snapshot1` and `snapshot2` have different types and therefore the
// following use of `aggregateQuerySnapshotEqual(...)` will cause a
// TS error. To test the method for JS users, we ignore the TS error.
// @ts-expect-error
expect(aggregateQuerySnapshotEqual(snapshot1, snapshot2)).to.be.false;
});
});
it('aggregateQuerySnapshotEqual on same aggregations with different aliases to be falsy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA', rating: 1 },
{ author: 'authorA', title: 'titleB', rating: 5 },
{ author: 'authorB', title: 'titleC', rating: 4 },
{ author: 'authorB', title: 'titleD', rating: 3 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot1 = await getAggregate(coll, { foo: average('rating') });
const snapshot2 = await getAggregate(coll, { bar: average('rating') });
// `snapshot1` and `snapshot2` have different types and therefore the
// following use of `aggregateQuerySnapshotEqual(...)` will cause a
// TS error. To test the method for JS users, we ignore the TS error.
// @ts-expect-error
expect(aggregateQuerySnapshotEqual(snapshot1, snapshot2)).to.be.false;
});
});
it('aggregateQuerySnapshotEqual on same aggregations with same aliases to be truthy', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA', rating: 1 },
{ author: 'authorA', title: 'titleB', rating: 5 },
{ author: 'authorB', title: 'titleC', rating: 4 },
{ author: 'authorB', title: 'titleD', rating: 3 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot1 = await getAggregate(coll, {
foo: average('rating'),
bar: sum('rating')
});
const snapshot2 = await getAggregate(coll, {
bar: sum('rating'),
foo: average('rating')
});
expect(aggregateQuerySnapshotEqual(snapshot1, snapshot2)).to.be.true;
});
});
it('can run sum query getAggregationFromServer', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA', pages: 100 },
{ author: 'authorB', title: 'titleB', pages: 50 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot = await getAggregate(coll, {
totalPages: sum('pages')
});
expect(snapshot.data().totalPages).to.equal(150);
});
});
it('can run average query getAggregationFromServer', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA', pages: 100 },
{ author: 'authorB', title: 'titleB', pages: 50 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot = await getAggregate(coll, {
averagePages: average('pages')
});
expect(snapshot.data().averagePages).to.equal(75);
});
});
it('can get multiple aggregations using getAggregationFromServer', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA', pages: 100 },
{ author: 'authorB', title: 'titleB', pages: 50 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot = await getAggregate(coll, {
totalPages: sum('pages'),
averagePages: average('pages'),
count: count()
});
expect(snapshot.data().totalPages).to.equal(150);
expect(snapshot.data().averagePages).to.equal(75);
expect(snapshot.data().count).to.equal(2);
});
});
it.skipEmulator.skipEnterprise(
'fails when exceeding the max (5) aggregations using getAggregationFromServer',
() => {
const testDocs = [
{ author: 'authorA', title: 'titleA', pages: 100 },
{ author: 'authorB', title: 'titleB', pages: 50 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const promise = getAggregate(coll, {
totalPages: sum('pages'),
averagePages: average('pages'),
count: count(),
totalPagesX: sum('pages'),
averagePagesY: average('pages'),
countZ: count()
});
await expect(promise).to.eventually.be.rejectedWith(
/maximum number of aggregations/
);
});
}
);
// Only run tests that require indexes against the emulator, because we don't
// have a way to dynamically create the indexes when running the tests.
(USE_EMULATOR ? apiDescribe : apiDescribe.skip)(
'queries requiring indexes',
() => {
it('performs aggregations on documents with all aggregated fields using getAggregationFromServer', () => {
const testDocs = [
{ author: 'authorA', title: 'titleA', pages: 100, year: 1980 },
{ author: 'authorB', title: 'titleB', pages: 50, year: 2020 },
{ author: 'authorC', title: 'titleC', pages: 150, year: 2021 },
{ author: 'authorD', title: 'titleD', pages: 50 }
];
return withTestCollectionAndInitialData(testDocs, async coll => {
const snapshot = await getAggregate(coll, {
totalPages: sum('pages'),
averagePages: average('pages'),
averageYear: average('year'),
count: count()
});
expect(snapshot.data().totalPages).to.equal(300);
expect(snapshot.data().averagePages).to.equal(100);
expect(snapshot.data().averageYear).to.equal(2007);
expect(snapshot.data().count).to.equal(3);
});
});
}
);
});
describe('Vectors', () => {
it('can be read and written using the lite SDK', async () => {
return withTestCollection(async coll => {
const ref = await addDoc(coll, {
vector0: vector([0.0]),
vector1: vector([1, 2, 3.99])
});
await setDoc(ref, {
vector0: vector([0.0]),
vector1: vector([1, 2, 3.99]),
vector2: vector([0, 0, 0])
});
await updateDoc(ref, {
vector3: vector([-1, -200, -999])
});
const snap1 = await getDoc(ref);
expect(snap1.get('vector0').isEqual(vector([0.0]))).to.be.true;
expect(snap1.get('vector1').isEqual(vector([1, 2, 3.99]))).to.be.true;
expect(snap1.get('vector2').isEqual(vector([0, 0, 0]))).to.be.true;
expect(snap1.get('vector3').isEqual(vector([-1, -200, -999]))).to.be.true;
});
});
});