diff --git a/.changeset/wicked-tomatoes-grow.md b/.changeset/wicked-tomatoes-grow.md new file mode 100644 index 00000000000..da2aeae0a17 --- /dev/null +++ b/.changeset/wicked-tomatoes-grow.md @@ -0,0 +1,6 @@ +--- +'@firebase/firestore': minor +'firebase': minor +--- + +Added `getCountFromServer()` (`getCount()` in the Lite SDK), which fetches the number of documents in the result set without actually downloading the documents. diff --git a/common/api-review/firestore-lite.api.md b/common/api-review/firestore-lite.api.md index 312db6e00ea..b336e0d0c8b 100644 --- a/common/api-review/firestore-lite.api.md +++ b/common/api-review/firestore-lite.api.md @@ -17,6 +17,35 @@ export type AddPrefixToKeys { + type: string; +} + +// @public +export type AggregateFieldType = AggregateField; + +// @public +export class AggregateQuerySnapshot { + data(): AggregateSpecData; + readonly query: Query; + readonly type = "AggregateQuerySnapshot"; +} + +// @public +export function aggregateQuerySnapshotEqual(left: AggregateQuerySnapshot, right: AggregateQuerySnapshot): boolean; + +// @public +export interface AggregateSpec { + // (undocumented) + [field: string]: AggregateFieldType; +} + +// @public +export type AggregateSpecData = { + [P in keyof T]: T[P] extends AggregateField ? U : never; +}; + // @public export function arrayRemove(...elements: unknown[]): FieldValue; @@ -169,6 +198,11 @@ export class GeoPoint { }; } +// @public +export function getCount(query: Query): Promise; +}>>; + // @public export function getDoc(reference: DocumentReference): Promise>; diff --git a/common/api-review/firestore.api.md b/common/api-review/firestore.api.md index 3b6538d867b..46ac63239c6 100644 --- a/common/api-review/firestore.api.md +++ b/common/api-review/firestore.api.md @@ -17,6 +17,35 @@ export type AddPrefixToKeys { + type: string; +} + +// @public +export type AggregateFieldType = AggregateField; + +// @public +export class AggregateQuerySnapshot { + data(): AggregateSpecData; + readonly query: Query; + readonly type = "AggregateQuerySnapshot"; +} + +// @public +export function aggregateQuerySnapshotEqual(left: AggregateQuerySnapshot, right: AggregateQuerySnapshot): boolean; + +// @public +export interface AggregateSpec { + // (undocumented) + [field: string]: AggregateFieldType; +} + +// @public +export type AggregateSpecData = { + [P in keyof T]: T[P] extends AggregateField ? U : never; +}; + // @public export function arrayRemove(...elements: unknown[]): FieldValue; @@ -209,6 +238,11 @@ export class GeoPoint { }; } +// @public +export function getCountFromServer(query: Query): Promise; +}>>; + // @public export function getDoc(reference: DocumentReference): Promise>; diff --git a/packages/firestore/lite/index.ts b/packages/firestore/lite/index.ts index ad7ec7d8294..5e3faa48e31 100644 --- a/packages/firestore/lite/index.ts +++ b/packages/firestore/lite/index.ts @@ -27,6 +27,19 @@ import { registerFirestore } from './register'; registerFirestore(); +export { + aggregateQuerySnapshotEqual, + getCount +} from '../src/lite-api/aggregate'; + +export { + AggregateField, + AggregateFieldType, + AggregateSpec, + AggregateSpecData, + AggregateQuerySnapshot +} from '../src/lite-api/aggregate_types'; + export { FirestoreSettings as Settings } from '../src/lite-api/settings'; export { diff --git a/packages/firestore/src/api.ts b/packages/firestore/src/api.ts index 544d275fc30..f05db09f568 100644 --- a/packages/firestore/src/api.ts +++ b/packages/firestore/src/api.ts @@ -15,6 +15,19 @@ * limitations under the License. */ +export { + aggregateQuerySnapshotEqual, + getCountFromServer +} from './api/aggregate'; + +export { + AggregateField, + AggregateFieldType, + AggregateSpec, + AggregateSpecData, + AggregateQuerySnapshot +} from './lite-api/aggregate_types'; + export { FieldPath, documentId } from './api/field_path'; export { diff --git a/packages/firestore/src/api/aggregate.ts b/packages/firestore/src/api/aggregate.ts index 501b5437573..a4713ca4149 100644 --- a/packages/firestore/src/api/aggregate.ts +++ b/packages/firestore/src/api/aggregate.ts @@ -17,23 +17,43 @@ import { Query } from '../api'; import { firestoreClientRunCountQuery } from '../core/firestore_client'; -import { AggregateField, AggregateQuerySnapshot } from '../lite-api/aggregate'; +import { + AggregateField, + AggregateQuerySnapshot +} from '../lite-api/aggregate_types'; import { cast } from '../util/input_validation'; import { ensureFirestoreConfigured, Firestore } from './database'; +import { ExpUserDataWriter } from './reference_impl'; + +export { aggregateQuerySnapshotEqual } from '../lite-api/aggregate'; /** - * Executes the query and returns the results as a `AggregateQuerySnapshot` from the - * server. Returns an error if the network is not available. + * Calculates the number of documents in the result set of the given query, + * without actually downloading the documents. + * + * Using this function to count the documents is efficient because only the + * final count, not the documents' data, is downloaded. This function can even + * count the documents if the result set would be prohibitively large to + * download entirely (e.g. thousands of documents). * - * @param query - The `Query` to execute. + * The result received from the server is presented, unaltered, without + * considering any local state. That is, documents in the local cache are not + * taken into consideration, neither are local modifications not yet + * synchronized with the server. Previously-downloaded results, if any, are not + * used: every request using this source necessarily involves a round trip to + * the server. * - * @returns A `Promise` that will be resolved with the results of the query. + * @param query - The query whose result set size to calculate. + * @returns A Promise that will be resolved with the count; the count can be + * retrieved from `snapshot.data().count`, where `snapshot` is the + * `AggregateQuerySnapshot` to which the returned Promise resolves. */ export function getCountFromServer( query: Query ): Promise }>> { const firestore = cast(query.firestore, Firestore); const client = ensureFirestoreConfigured(firestore); - return firestoreClientRunCountQuery(client, query); + const userDataWriter = new ExpUserDataWriter(firestore); + return firestoreClientRunCountQuery(client, query, userDataWriter); } diff --git a/packages/firestore/src/core/count_query_runner.ts b/packages/firestore/src/core/count_query_runner.ts new file mode 100644 index 00000000000..3161a270fc1 --- /dev/null +++ b/packages/firestore/src/core/count_query_runner.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AbstractUserDataWriter, Query } from '../api'; +import { + AggregateField, + AggregateQuerySnapshot +} from '../lite-api/aggregate_types'; +import { Value } from '../protos/firestore_proto_api'; +import { Datastore, invokeRunAggregationQueryRpc } from '../remote/datastore'; +import { hardAssert } from '../util/assert'; + +/** + * CountQueryRunner encapsulates the logic needed to run the count aggregation + * queries. + */ +export class CountQueryRunner { + constructor( + private readonly query: Query, + private readonly datastore: Datastore, + private readonly userDataWriter: AbstractUserDataWriter + ) {} + + run(): Promise }>> { + return invokeRunAggregationQueryRpc(this.datastore, this.query._query).then( + result => { + hardAssert( + result[0] !== undefined, + 'Aggregation fields are missing from result.' + ); + + const counts = Object.entries(result[0]) + .filter(([key, value]) => key === 'count_alias') + .map(([key, value]) => + this.userDataWriter.convertValue(value as Value) + ); + + const countValue = counts[0]; + + hardAssert( + typeof countValue === 'number', + 'Count aggregate field value is not a number: ' + countValue + ); + + return Promise.resolve( + new AggregateQuerySnapshot<{ count: AggregateField }>( + this.query, + { + count: countValue + } + ) + ); + } + ); + } +} diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index 6b9e410c53c..2c2c0af1771 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -17,17 +17,17 @@ import { GetOptions } from '@firebase/firestore-types'; +import { + AbstractUserDataWriter, + AggregateField, + AggregateQuerySnapshot +} from '../api'; import { LoadBundleTask } from '../api/bundle'; import { CredentialChangeListener, CredentialsProvider } from '../api/credentials'; import { User } from '../auth/user'; -import { - AggregateField, - AggregateQuerySnapshot, - getCount -} from '../lite-api/aggregate'; import { Query as LiteQuery } from '../lite-api/reference'; import { LocalStore } from '../local/local_store'; import { @@ -68,6 +68,7 @@ import { OfflineComponentProvider, OnlineComponentProvider } from './component_provider'; +import { CountQueryRunner } from './count_query_runner'; import { DatabaseId, DatabaseInfo } from './database_info'; import { addSnapshotsInSyncListener, @@ -510,7 +511,8 @@ export function firestoreClientTransaction( export function firestoreClientRunCountQuery( client: FirestoreClient, - query: LiteQuery + query: LiteQuery, + userDataWriter: AbstractUserDataWriter ): Promise }>> { const deferred = new Deferred< AggregateQuerySnapshot<{ count: AggregateField }> @@ -526,7 +528,12 @@ export function firestoreClientRunCountQuery( ) ); } else { - const result = await getCount(query); + const datastore = await getDatastore(client); + const result = new CountQueryRunner( + query, + datastore, + userDataWriter + ).run(); deferred.resolve(result); } } catch (e) { diff --git a/packages/firestore/src/lite-api/aggregate.ts b/packages/firestore/src/lite-api/aggregate.ts index 0fb0c15da94..390c6af5ef8 100644 --- a/packages/firestore/src/lite-api/aggregate.ts +++ b/packages/firestore/src/lite-api/aggregate.ts @@ -17,90 +17,32 @@ import { deepEqual } from '@firebase/util'; -import { Value } from '../protos/firestore_proto_api'; -import { invokeRunAggregationQueryRpc } from '../remote/datastore'; -import { hardAssert } from '../util/assert'; +import { CountQueryRunner } from '../core/count_query_runner'; import { cast } from '../util/input_validation'; +import { + AggregateField, + AggregateQuerySnapshot, + AggregateSpec +} from './aggregate_types'; import { getDatastore } from './components'; import { Firestore } from './database'; import { Query, queryEqual } from './reference'; import { LiteUserDataWriter } from './reference_impl'; /** - * An `AggregateField`that captures input type T. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export class AggregateField { - type = 'AggregateField'; -} - -/** - * Creates and returns an aggregation field that counts the documents in the result set. - * @returns An `AggregateField` object with number input type. - */ -export function count(): AggregateField { - return new AggregateField(); -} - -/** - * The union of all `AggregateField` types that are returned from the factory - * functions. - */ -export type AggregateFieldType = ReturnType; - -/** - * A type whose values are all `AggregateField` objects. - * This is used as an argument to the "getter" functions, and the snapshot will - * map the same names to the corresponding values. - */ -export interface AggregateSpec { - [field: string]: AggregateFieldType; -} - -/** - * A type whose keys are taken from an `AggregateSpec` type, and whose values - * are the result of the aggregation performed by the corresponding - * `AggregateField` from the input `AggregateSpec`. - */ -export type AggregateSpecData = { - [P in keyof T]: T[P] extends AggregateField ? U : never; -}; - -/** - * An `AggregateQuerySnapshot` contains the results of running an aggregate query. - */ -export class AggregateQuerySnapshot { - readonly type = 'AggregateQuerySnapshot'; - - /** @hideconstructor */ - constructor( - readonly query: Query, - private readonly _data: AggregateSpecData - ) {} - - /** - * The results of the requested aggregations. The keys of the returned object - * will be the same as those of the `AggregateSpec` object specified to the - * aggregation method, and the values will be the corresponding aggregation - * result. - * - * @returns The aggregation statistics result of running a query. - */ - data(): AggregateSpecData { - return this._data; - } -} - -/** - * Counts the number of documents in the result set of the given query, ignoring - * any locally-cached data and any locally-pending writes and simply surfacing - * whatever the server returns. If the server cannot be reached then the - * returned promise will be rejected. + * Calculates the number of documents in the result set of the given query, + * without actually downloading the documents. * - * @param query - The `Query` to execute. + * Using this function to count the documents is efficient because only the + * final count, not the documents' data, is downloaded. This function can even + * count the documents if the result set would be prohibitively large to + * download entirely (e.g. thousands of documents). * - * @returns An `AggregateQuerySnapshot` that contains the number of documents. + * @param query - The query whose result set size to calculate. + * @returns A Promise that will be resolved with the count; the count can be + * retrieved from `snapshot.data().count`, where `snapshot` is the + * `AggregateQuerySnapshot` to which the returned Promise resolves. */ export function getCount( query: Query @@ -108,40 +50,20 @@ export function getCount( const firestore = cast(query.firestore, Firestore); const datastore = getDatastore(firestore); const userDataWriter = new LiteUserDataWriter(firestore); - return invokeRunAggregationQueryRpc(datastore, query._query).then(result => { - hardAssert( - result[0] !== undefined, - 'Aggregation fields are missing from result.' - ); - - const counts = Object.entries(result[0]) - .filter(([key, value]) => key === 'count_alias') - .map(([key, value]) => userDataWriter.convertValue(value as Value)); - - const countValue = counts[0]; - - hardAssert( - typeof countValue === 'number', - 'Count aggregate field value is not a number: ' + countValue - ); - - return Promise.resolve( - new AggregateQuerySnapshot<{ count: AggregateField }>(query, { - count: countValue - }) - ); - }); + return new CountQueryRunner(query, datastore, userDataWriter).run(); } /** * Compares two `AggregateQuerySnapshot` instances for equality. + * * Two `AggregateQuerySnapshot` instances are considered "equal" if they have - * the same underlying query, and the same data. + * underlying queries that compare equal, and the same data. * - * @param left - The `AggregateQuerySnapshot` to compare. - * @param right - The `AggregateQuerySnapshot` to compare. + * @param left - The first `AggregateQuerySnapshot` to compare. + * @param right - The second `AggregateQuerySnapshot` to compare. * - * @returns true if the AggregateQuerySnapshots are equal. + * @returns `true` if the objects are "equal", as defined above, or `false` + * otherwise. */ export function aggregateQuerySnapshotEqual( left: AggregateQuerySnapshot, diff --git a/packages/firestore/src/lite-api/aggregate_types.ts b/packages/firestore/src/lite-api/aggregate_types.ts new file mode 100644 index 00000000000..d71a36e86eb --- /dev/null +++ b/packages/firestore/src/lite-api/aggregate_types.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Query } from './reference'; + +/** + * Represents an aggregation that can be performed by Firestore. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class AggregateField { + /** A type string to uniquely identify instances of this class. */ + type = 'AggregateField'; +} + +/** + * The union of all `AggregateField` types that are supported by Firestore. + */ +export type AggregateFieldType = AggregateField; + +/** + * A type whose property values are all `AggregateField` objects. + */ +export interface AggregateSpec { + [field: string]: AggregateFieldType; +} + +/** + * A type whose keys are taken from an `AggregateSpec`, and whose values are the + * result of the aggregation performed by the corresponding `AggregateField` + * from the input `AggregateSpec`. + */ +export type AggregateSpecData = { + [P in keyof T]: T[P] extends AggregateField ? U : never; +}; + +/** + * The results of executing an aggregation query. + */ +export class AggregateQuerySnapshot { + /** A type string to uniquely identify instances of this class. */ + readonly type = 'AggregateQuerySnapshot'; + + /** + * The underlying query over which the aggregations recorded in this + * `AggregateQuerySnapshot` were performed. + */ + readonly query: Query; + + /** @hideconstructor */ + constructor( + query: Query, + private readonly _data: AggregateSpecData + ) { + this.query = query; + } + + /** + * Returns the results of the aggregations performed over the underlying + * query. + * + * The keys of the returned object will be the same as those of the + * `AggregateSpec` object specified to the aggregation method, and the values + * will be the corresponding aggregation result. + * + * @returns The results of the aggregations performed over the underlying + * query. + */ + data(): AggregateSpecData { + return this._data; + } +} diff --git a/packages/firestore/test/integration/api/aggregation.test.ts b/packages/firestore/test/integration/api/aggregation.test.ts new file mode 100644 index 00000000000..0c0e9db0b15 --- /dev/null +++ b/packages/firestore/test/integration/api/aggregation.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; + +import { + collection, + collectionGroup, + disableNetwork, + doc, + DocumentData, + getCountFromServer, + query, + QueryDocumentSnapshot, + terminate, + where, + writeBatch +} from '../util/firebase_export'; +import { + apiDescribe, + withEmptyTestCollection, + withTestCollection, + withTestDb +} from '../util/helpers'; +import { USE_EMULATOR } from '../util/settings'; + +(USE_EMULATOR ? apiDescribe : apiDescribe.skip)( + 'Count quries', + (persistence: boolean) => { + it('can run count query getCountFromServer', () => { + const testDocs = { + a: { author: 'authorA', title: 'titleA' }, + b: { author: 'authorB', title: 'titleB' } + }; + return withTestCollection(persistence, testDocs, async coll => { + const snapshot = await getCountFromServer(coll); + expect(snapshot.data().count).to.equal(2); + }); + }); + + it("count query doesn't use converter", () => { + const testDocs = { + a: { author: 'authorA', title: 'titleA' }, + b: { author: 'authorB', title: 'titleB' } + }; + 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 withTestCollection(persistence, testDocs, async coll => { + const query_ = query( + coll, + where('author', '==', 'authorA') + ).withConverter(throwingConverter); + const snapshot = await getCountFromServer(query_); + expect(snapshot.data().count).to.equal(1); + }); + }); + + it('count query supports collection groups', () => { + return withTestDb(persistence, async db => { + const collectionGroupId = doc(collection(db, 'aggregateQueryTest')).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 getCountFromServer( + collectionGroup(db, collectionGroupId) + ); + expect(snapshot.data().count).to.equal(2); + }); + }); + + it('getCountFromServer fails if firestore is terminated', () => { + return withEmptyTestCollection(persistence, async (coll, firestore) => { + await terminate(firestore); + expect(() => getCountFromServer(coll)).to.throw( + 'The client has already been terminated.' + ); + }); + }); + + it("terminate doesn't crash when there is count query in flight", () => { + return withEmptyTestCollection(persistence, async (coll, firestore) => { + void getCountFromServer(coll); + await terminate(firestore); + }); + }); + + it('getCountFromServer fails if user is offline', () => { + return withEmptyTestCollection(persistence, async (coll, firestore) => { + await disableNetwork(firestore); + await expect(getCountFromServer(coll)).to.be.eventually.rejectedWith( + 'Failed to get count result because the client is offline' + ); + }); + }); + } +);