diff --git a/dev/src/reference.ts b/dev/src/reference.ts index 78ab483c8..b5cac033a 100644 --- a/dev/src/reference.ts +++ b/dev/src/reference.ts @@ -15,7 +15,7 @@ */ import * as firestore from '@google-cloud/firestore'; -import {Duplex, Transform} from 'stream'; +import {Duplex, Readable, Transform} from 'stream'; import * as deepEqual from 'fast-deep-equal'; import * as protos from '../protos/firestore_v1_proto_api'; @@ -55,7 +55,6 @@ import { } from './validate'; import {DocumentWatch, QueryWatch} from './watch'; import {validateDocumentData, WriteBatch, WriteResult} from './write-batch'; - import api = protos.google.firestore.v1; /** @@ -1599,6 +1598,27 @@ export class Query implements firestore.Query { return new Query(this._firestore, options); } + /** + * Returns a query that counts the documents in the result set of this + * query. + * + * The returned query, when executed, counts the documents in the result set + * of this query without actually downloading the documents. + * + * Using the returned query to count the documents is efficient because only + * the final count, not the documents' data, is downloaded. The returned + * query can even count the documents if the result set would be + * prohibitively large to download entirely (e.g. thousands of documents). + * + * @return a query that counts the documents in the result set of this + * query. The count can be retrieved from `snapshot.data().count`, where + * `snapshot` is the `AggregateQuerySnapshot` resulting from running the + * returned query. + */ + count(): AggregateQuery<{count: firestore.AggregateField}> { + return new AggregateQuery(this, {count: {}}); + } + /** * Returns true if this `Query` is equal to the provided value. * @@ -2832,6 +2852,307 @@ export class CollectionReference } } +/** + * A query that calculates aggregations over an underlying query. + */ +export class AggregateQuery + implements firestore.AggregateQuery +{ + /** + * @private + * @internal + * + * @param _query The query whose aggregations will be calculated by this + * object. + * @param _aggregates The aggregations that will be performed by this query. + */ + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _query: Query, + private readonly _aggregates: T + ) {} + + /** The query whose aggregations will be calculated by this object. */ + get query(): firestore.Query { + return this._query; + } + + /** + * Executes this query. + * + * @return A promise that will be resolved with the results of the query. + */ + get(): Promise> { + return this._get(); + } + + /** + * Internal get() method that accepts an optional transaction id. + * + * @private + * @internal + * @param {bytes=} transactionId A transaction ID. + */ + _get(transactionId?: Uint8Array): Promise> { + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + return new Promise((resolve, reject) => { + const stream = this._stream(transactionId); + stream.on('error', err => { + reject(wrapError(err, stack)); + }); + stream.once('data', result => { + stream.destroy(); + resolve(result); + }); + stream.on('end', () => { + reject('No AggregateQuery results'); + }); + }); + } + + /** + * Internal streaming method that accepts an optional transaction ID. + * + * @private + * @internal + * @param transactionId A transaction ID. + * @returns A stream of document results. + */ + _stream(transactionId?: Uint8Array): Readable { + const tag = requestTag(); + const firestore = this._query.firestore; + + const stream: Transform = new Transform({ + objectMode: true, + transform: (proto: api.IRunAggregationQueryResponse, enc, callback) => { + if (proto.result) { + const readTime = Timestamp.fromProto(proto.readTime!); + const data = this.decodeResult(proto.result); + callback( + undefined, + new AggregateQuerySnapshot(this, readTime, data) + ); + } else { + callback(Error('RunAggregationQueryResponse is missing result')); + } + }, + }); + + firestore + .initializeIfNeeded(tag) + .then(async () => { + // `toProto()` might throw an exception. We rely on the behavior of an + // async function to convert this exception into the rejected Promise we + // catch below. + const request = this.toProto(transactionId); + + let streamActive: Deferred; + do { + streamActive = new Deferred(); + const backendStream = await firestore.requestStream( + 'runAggregationQuery', + /* bidirectional= */ false, + request, + tag + ); + stream.on('close', () => { + backendStream.resume(); + backendStream.end(); + }); + backendStream.on('error', err => { + backendStream.unpipe(stream); + // If a non-transactional query failed, attempt to restart. + // Transactional queries are retried via the transaction runner. + if ( + !transactionId && + !isPermanentRpcError(err, 'runAggregationQuery') + ) { + logger( + 'AggregateQuery._stream', + tag, + 'AggregateQuery failed with retryable stream error:', + err + ); + streamActive.resolve(/* active= */ true); + } else { + logger( + 'AggregateQuery._stream', + tag, + 'AggregateQuery failed with stream error:', + err + ); + stream.destroy(err); + streamActive.resolve(/* active= */ false); + } + }); + backendStream.resume(); + backendStream.pipe(stream); + } while (await streamActive.promise); + }) + .catch(e => stream.destroy(e)); + + return stream; + } + + /** + * Internal method to decode values within result. + * @private + */ + private decodeResult( + proto: api.IAggregationResult + ): firestore.AggregateSpecData { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = {}; + const fields = proto.aggregateFields; + if (fields) { + const serializer = this._query.firestore._serializer!; + for (const prop of Object.keys(fields)) { + if (this._aggregates[prop] === undefined) { + throw new Error( + `Unexpected alias [${prop}] in result aggregate result` + ); + } + data[prop] = serializer.decodeValue(fields[prop]); + } + } + return data; + } + + /** + * Internal method for serializing a query to its RunAggregationQuery proto + * representation with an optional transaction id. + * + * @private + * @internal + * @returns Serialized JSON for the query. + */ + toProto(transactionId?: Uint8Array): api.IRunAggregationQueryRequest { + const queryProto = this._query.toProto(); + //TODO(tomandersen) inspect _query to build request - this is just hard + // coded count right now. + const runQueryRequest: api.IRunAggregationQueryRequest = { + parent: queryProto.parent, + structuredAggregationQuery: { + structuredQuery: queryProto.structuredQuery, + aggregations: [ + { + alias: 'count', + count: {}, + }, + ], + }, + }; + + if (transactionId instanceof Uint8Array) { + runQueryRequest.transaction = transactionId; + } + + return runQueryRequest; + } + + /** + * Compares this object with the given object for equality. + * + * This object is considered "equal" to the other object if and only if + * `other` performs the same aggregations as this `AggregateQuery` and + * the underlying Query of `other` compares equal to that of this object + * using `Query.isEqual()`. + * + * @param other The object to compare to this object for equality. + * @return `true` if this object is "equal" to the given object, as + * defined above, or `false` otherwise. + */ + isEqual(other: firestore.AggregateQuery): boolean { + if (this === other) { + return true; + } + if (!(other instanceof AggregateQuery)) { + return false; + } + if (!this.query.isEqual(other.query)) { + return false; + } + return deepEqual(this._aggregates, other._aggregates); + } +} + +/** + * The results of executing an aggregation query. + */ +export class AggregateQuerySnapshot + implements firestore.AggregateQuerySnapshot +{ + /** + * @private + * @internal + * + * @param _query The query that was executed to produce this result. + * @param _readTime The time this snapshot was read. + * @param _data The results of the aggregations performed over the underlying + * query. + */ + constructor( + private readonly _query: AggregateQuery, + private readonly _readTime: Timestamp, + private readonly _data: firestore.AggregateSpecData + ) {} + + /** The query that was executed to produce this result. */ + get query(): firestore.AggregateQuery { + return this._query; + } + + /** The time this snapshot was read. */ + get readTime(): firestore.Timestamp { + return this._readTime; + } + + /** + * 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(): firestore.AggregateSpecData { + return this._data; + } + + /** + * Compares this object with the given object for equality. + * + * Two `AggregateQuerySnapshot` instances are considered "equal" if they + * have the same data and their underlying queries compare "equal" using + * `AggregateQuery.isEqual()`. + * + * @param other The object to compare to this object for equality. + * @return `true` if this object is "equal" to the given object, as + * defined above, or `false` otherwise. + */ + isEqual(other: firestore.AggregateQuerySnapshot): boolean { + if (this === other) { + return true; + } + if (!(other instanceof AggregateQuerySnapshot)) { + return false; + } + // Since the read time is different on every read, we explicitly ignore all + // document metadata in this comparison, just like + // `DocumentSnapshot.isEqual()` does. + if (!this.query.isEqual(other.query)) { + return false; + } + + return deepEqual(this._data, other._data); + } +} + /** * Validates the input string as a field order direction. * diff --git a/dev/src/transaction.ts b/dev/src/transaction.ts index bbb6bcd37..3f01d433a 100644 --- a/dev/src/transaction.ts +++ b/dev/src/transaction.ts @@ -27,6 +27,8 @@ import {logger} from './logger'; import {FieldPath, validateFieldPath} from './path'; import {StatusCode} from './status-code'; import { + AggregateQuery, + AggregateQuerySnapshot, DocumentReference, Query, QuerySnapshot, @@ -97,6 +99,17 @@ export class Transaction implements firestore.Transaction { */ get(documentRef: DocumentReference): Promise>; + /** + * Retrieves an aggregate query result. Holds a pessimistic lock on all + * documents that were matched by the underlying query. + * + * @param aggregateQuery An aggregate query to execute. + * @return An AggregateQuerySnapshot for the retrieved data. + */ + get( + aggregateQuery: firestore.AggregateQuery + ): Promise>; + /** * Retrieve a document or a query result from the database. Holds a * pessimistic lock on all returned documents. @@ -120,9 +133,14 @@ export class Transaction implements firestore.Transaction { * }); * ``` */ - get( - refOrQuery: DocumentReference | Query - ): Promise | QuerySnapshot> { + get( + refOrQuery: + | firestore.DocumentReference + | firestore.Query + | firestore.AggregateQuery + ): Promise< + DocumentSnapshot | QuerySnapshot | AggregateQuerySnapshot + > { if (!this._writeBatch.isEmpty) { throw new Error(READ_AFTER_WRITE_ERROR_MSG); } @@ -137,8 +155,12 @@ export class Transaction implements firestore.Transaction { return refOrQuery._get(this._transactionId); } + if (refOrQuery instanceof AggregateQuery) { + return refOrQuery._get(this._transactionId); + } + throw new Error( - 'Value for argument "refOrQuery" must be a DocumentReference or a Query.' + 'Value for argument "refOrQuery" must be a DocumentReference, Query, or AggregateQuery.' ); } diff --git a/dev/src/types.ts b/dev/src/types.ts index 33bf17e9b..07dd5733b 100644 --- a/dev/src/types.ts +++ b/dev/src/types.ts @@ -65,6 +65,10 @@ export interface GapicClient { options?: CallOptions ): Duplex; runQuery(request?: api.IRunQueryRequest, options?: CallOptions): Duplex; + runAggregationQuery( + request?: api.IRunAggregationQueryRequest, + options?: CallOptions + ): Duplex; listDocuments( request: api.IListDocumentsRequest, options?: CallOptions @@ -95,6 +99,7 @@ export type FirestoreStreamingMethod = | 'listen' | 'partitionQueryStream' | 'runQuery' + | 'runAggregationQuery' | 'batchGetDocuments'; /** Type signature for the unary methods in the GAPIC layer. */ diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index 5d8f9ca1d..deb9454f8 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -13,15 +13,15 @@ // limitations under the License. import { - QuerySnapshot, DocumentData, - WithFieldValue, PartialWithFieldValue, + QuerySnapshot, SetOptions, Settings, + WithFieldValue, } from '@google-cloud/firestore'; -import {describe, it, before, beforeEach, afterEach} from 'mocha'; +import {afterEach, before, beforeEach, describe, it} from 'mocha'; import {expect, use} from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; import * as extend from 'extend'; @@ -51,10 +51,9 @@ import { verifyInstance, } from '../test/util/helpers'; import {BulkWriter} from '../src/bulk-writer'; -import {Status} from 'google-gax/build/src/status'; +import {Status} from 'google-gax'; import {QueryPartition} from '../src/query-partition'; import {CollectionGroup} from '../src/collection-group'; - import IBundleElement = firestore.IBundleElement; use(chaiAsPromised); @@ -2140,6 +2139,134 @@ describe('Query class', () => { }); }); +describe('Aggregates', () => { + let firestore: Firestore; + let randomCol: CollectionReference; + + beforeEach(() => { + randomCol = getTestRoot(); + firestore = randomCol.firestore; + }); + + afterEach(() => verifyInstance(firestore)); + + describe('Run outside Transaction', () => { + countTests(async (q, n) => { + const res = await q.get(); + expect(res.data().count).to.equal(n); + }); + }); + + describe('Run within Transaction', () => { + countTests(async (q, n) => { + const res = await firestore.runTransaction(f => f.get(q)); + expect(res.data().count).to.equal(n); + }); + }); + + function countTests( + runQueryAndExpectCount: ( + query: FirebaseFirestore.AggregateQuery<{ + count: FirebaseFirestore.AggregateField; + }>, + expectedCount: number + ) => Promise + ) { + it('counts 0 document from non-existent collection', async () => { + const count = randomCol.count(); + await runQueryAndExpectCount(count, 0); + }); + + it('counts 0 document from filtered empty collection', async () => { + await randomCol.doc('doc').set({foo: 'bar'}); + const count = randomCol.where('foo', '==', 'notbar').count(); + await runQueryAndExpectCount(count, 0); + }); + + it('counts 1 document', async () => { + await randomCol.doc('doc').set({foo: 'bar'}); + const count = randomCol.count(); + await runQueryAndExpectCount(count, 1); + }); + + it('counts 1 document', async () => { + await randomCol.doc('doc').set({foo: 'bar'}); + const count = randomCol.count(); + await runQueryAndExpectCount(count, 1); + }); + + it('counts 1 document', async () => { + await randomCol.doc('doc').set({foo: 'bar'}); + const count = randomCol.count(); + await runQueryAndExpectCount(count, 1); + }); + + it('counts multiple documents with filter', async () => { + await randomCol.doc('doc1').set({foo: 'bar'}); + await randomCol.doc('doc2').set({foo: 'bar'}); + await randomCol.doc('doc3').set({foo: 'notbar'}); + await randomCol.doc('doc3').set({notfoo: 'bar'}); + const count = randomCol.where('foo', '==', 'bar').count(); + await runQueryAndExpectCount(count, 2); + }); + + it('counts up to limit', async () => { + await randomCol.doc('doc1').set({foo: 'bar'}); + await randomCol.doc('doc2').set({foo: 'bar'}); + await randomCol.doc('doc3').set({foo: 'bar'}); + await randomCol.doc('doc4').set({foo: 'bar'}); + await randomCol.doc('doc5').set({foo: 'bar'}); + await randomCol.doc('doc6').set({foo: 'bar'}); + await randomCol.doc('doc7').set({foo: 'bar'}); + await randomCol.doc('doc8').set({foo: 'bar'}); + const count = randomCol.limit(5).count(); + await runQueryAndExpectCount(count, 5); + }); + + it('counts with orderBy', async () => { + await randomCol.doc('doc1').set({foo1: 'bar1'}); + await randomCol.doc('doc2').set({foo1: 'bar2'}); + await randomCol.doc('doc3').set({foo1: 'bar3'}); + await randomCol.doc('doc4').set({foo1: 'bar4'}); + await randomCol.doc('doc5').set({foo1: 'bar5'}); + await randomCol.doc('doc6').set({foo2: 'bar6'}); + await randomCol.doc('doc7').set({foo2: 'bar7'}); + await randomCol.doc('doc8').set({foo2: 'bar8'}); + + const count1 = randomCol.orderBy('foo2').count(); + await runQueryAndExpectCount(count1, 3); + + const count2 = randomCol.orderBy('foo3').count(); + await runQueryAndExpectCount(count2, 0); + }); + + it('counts with startAt, endAt and offset', async () => { + await randomCol.doc('doc1').set({foo: 'bar'}); + await randomCol.doc('doc2').set({foo: 'bar'}); + await randomCol.doc('doc3').set({foo: 'bar'}); + await randomCol.doc('doc4').set({foo: 'bar'}); + await randomCol.doc('doc5').set({foo: 'bar'}); + await randomCol.doc('doc6').set({foo: 'bar'}); + await randomCol.doc('doc7').set({foo: 'bar'}); + + const count1 = randomCol.startAfter(randomCol.doc('doc3')).count(); + await runQueryAndExpectCount(count1, 4); + + const count2 = randomCol.startAt(randomCol.doc('doc3')).count(); + await runQueryAndExpectCount(count2, 5); + + const count3 = randomCol.endAt(randomCol.doc('doc3')).count(); + await runQueryAndExpectCount(count3, 3); + + const count4 = randomCol.endBefore(randomCol.doc('doc3')).count(); + await runQueryAndExpectCount(count4, 2); + + const count5 = randomCol.offset(6).count(); + await runQueryAndExpectCount(count5, 1); + }); + } +}); + describe('Transaction class', () => { let firestore: Firestore; let randomCol: CollectionReference; diff --git a/dev/test/aggregateQuery.ts b/dev/test/aggregateQuery.ts new file mode 100644 index 000000000..db0eb7c82 --- /dev/null +++ b/dev/test/aggregateQuery.ts @@ -0,0 +1,153 @@ +// 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 {afterEach, beforeEach, it} from 'mocha'; +import { + ApiOverride, + createInstance, + stream, + streamWithoutEnd, + verifyInstance, +} from './util/helpers'; +import {Firestore, Query, Timestamp} from '../src'; +import {expect, use} from 'chai'; +import {google} from '../protos/firestore_v1_proto_api'; +import api = google.firestore.v1; +import * as chaiAsPromised from 'chai-as-promised'; +import {setTimeoutHandler} from '../src/backoff'; +use(chaiAsPromised); + +describe('aggregate query interface', () => { + let firestore: Firestore; + + beforeEach(() => { + setTimeoutHandler(setImmediate); + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(async () => { + await verifyInstance(firestore); + setTimeoutHandler(setTimeout); + }); + + it('has isEqual() method', () => { + const queryA = firestore.collection('collectionId'); + const queryB = firestore.collection('collectionId'); + + const queryEquals = (equals: Query[], notEquals: Query[]) => { + for (const equal1 of equals) { + const equal1count = equal1.count(); + for (const equal2 of equals) { + const equal2count = equal2.count(); + expect(equal1count.isEqual(equal2count)).to.be.true; + expect(equal2count.isEqual(equal1count)).to.be.true; + } + + for (const notEqual of notEquals) { + const notEqual2count = notEqual.count(); + expect(equal1count.isEqual(notEqual2count)).to.be.false; + expect(notEqual2count.isEqual(equal1count)).to.be.false; + } + } + }; + + queryEquals( + [ + queryA.orderBy('foo').endBefore('a'), + queryB.orderBy('foo').endBefore('a'), + ], + [ + queryA.orderBy('foo').endBefore('b'), + queryB.orderBy('bar').endBefore('a'), + ] + ); + }); + + it('returns results', async () => { + const result: api.IRunAggregationQueryResponse = { + result: { + aggregateFields: { + count: {integerValue: '99'}, + }, + }, + readTime: {seconds: 5, nanos: 6}, + }; + const overrides: ApiOverride = { + runAggregationQuery: () => stream(result), + }; + + firestore = await createInstance(overrides); + + const query = firestore.collection('collectionId').count(); + return query.get().then(results => { + expect(results.data().count).to.be.equal(99); + expect(results.readTime.isEqual(new Timestamp(5, 6))).to.be.true; + expect(results.query).to.be.equal(query); + }); + }); + + it('successful return without ending the stream on get()', async () => { + const result: api.IRunAggregationQueryResponse = { + result: { + aggregateFields: { + count: {integerValue: '99'}, + }, + }, + readTime: {seconds: 5, nanos: 6}, + }; + const overrides: ApiOverride = { + runAggregationQuery: () => streamWithoutEnd(result), + }; + + firestore = await createInstance(overrides); + + const query = firestore.collection('collectionId').count(); + return query.get().then(results => { + expect(results.data().count).to.be.equal(99); + expect(results.readTime.isEqual(new Timestamp(5, 6))).to.be.true; + expect(results.query).to.be.equal(query); + }); + }); + + it('handles stream exception at initialization', () => { + const query = firestore.collection('collectionId').count(); + + query._stream = () => { + throw new Error('Expected error'); + }; + + return expect(query.get()).to.eventually.rejectedWith('Expected error'); + }); + + it('handles stream exception during initialization', async () => { + const overrides: ApiOverride = { + runAggregationQuery: () => { + return stream(new Error('Expected error')); + }, + }; + firestore = await createInstance(overrides); + + const query = firestore.collection('collectionId').count(); + await query + .get() + .then(() => { + throw new Error('Unexpected success in Promise'); + }) + .catch(err => { + expect(err.message).to.equal('Expected error'); + }); + }); +}); diff --git a/dev/test/transaction.ts b/dev/test/transaction.ts index 8854bc159..19088425d 100644 --- a/dev/test/transaction.ts +++ b/dev/test/transaction.ts @@ -738,11 +738,11 @@ describe('transaction operations', () => { /* transactionOptions= */ {}, (transaction: InvalidApiUsage) => { expect(() => transaction.get()).to.throw( - 'Value for argument "refOrQuery" must be a DocumentReference or a Query.' + 'Value for argument "refOrQuery" must be a DocumentReference, Query, or AggregateQuery.' ); expect(() => transaction.get('foo')).to.throw( - 'Value for argument "refOrQuery" must be a DocumentReference or a Query.' + 'Value for argument "refOrQuery" must be a DocumentReference, Query, or AggregateQuery.' ); return Promise.resolve(); diff --git a/dev/test/util/helpers.ts b/dev/test/util/helpers.ts index aa8b258eb..f2d0c226b 100644 --- a/dev/test/util/helpers.ts +++ b/dev/test/util/helpers.ts @@ -24,7 +24,7 @@ import * as extend from 'extend'; import {JSONStreamIterator} from 'length-prefixed-json-stream'; import {Duplex, PassThrough} from 'stream'; import * as through2 from 'through2'; -import {firestore, google} from '../../protos/firestore_v1_proto_api'; +import {firestore} from '../../protos/firestore_v1_proto_api'; import type {grpc} from 'google-gax'; import * as proto from '../../protos/firestore_v1_proto_api'; import * as v1 from '../../src/v1'; diff --git a/types/firestore.d.ts b/types/firestore.d.ts index d75f0972f..1e33ce92a 100644 --- a/types/firestore.d.ts +++ b/types/firestore.d.ts @@ -21,11 +21,14 @@ // Declare a global (ambient) namespace // (used when not using import statement, but just script include). declare namespace FirebaseFirestore { + /** Alias for `any` but used where a Firestore field value would be provided. */ + export type DocumentFieldValue = any; + /** * Document data (for use with `DocumentReference.set()`) consists of fields * mapped to values. */ - export type DocumentData = {[field: string]: any}; + export type DocumentData = {[field: string]: DocumentFieldValue}; /** * Similar to Typescript's `Partial`, but allows nested fields to be @@ -590,6 +593,17 @@ declare namespace FirebaseFirestore { */ get(documentRef: DocumentReference): Promise>; + /** + * Retrieves an aggregate query result. Holds a pessimistic lock on all + * documents that were matched by the underlying query. + * + * @param aggregateQuery An aggregate query to execute. + * @return An AggregateQuerySnapshot for the retrieved data. + */ + get( + aggregateQuery: AggregateQuery + ): Promise>; + /** * Retrieves multiple documents from Firestore. Holds a pessimistic lock on * all returned documents. @@ -1699,6 +1713,25 @@ declare namespace FirebaseFirestore { onError?: (error: Error) => void ): () => void; + /** + * Returns a query that counts the documents in the result set of this + * query. + * + * The returned query, when executed, counts the documents in the result set + * of this query without actually downloading the documents. + * + * Using the returned query to count the documents is efficient because only + * the final count, not the documents' data, is downloaded. The returned + * query can even count the documents if the result set would be + * prohibitively large to download entirely (e.g. thousands of documents). + * + * @return a query that counts the documents in the result set of this + * query. The count can be retrieved from `snapshot.data().count`, where + * `snapshot` is the `AggregateQuerySnapshot` resulting from running the + * returned query. + */ + count(): AggregateQuery<{count: AggregateField}>; + /** * Returns true if this `Query` is equal to the provided one. * @@ -2019,6 +2052,105 @@ declare namespace FirebaseFirestore { toQuery(): Query; } + /** + * Represents an aggregation that can be performed by Firestore. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export class AggregateField { + private constructor(); + } + + /** + * 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; + }; + + /** + * A query that calculates aggregations over an underlying query. + */ + export class AggregateQuery { + private constructor(); + + /** The query whose aggregations will be calculated by this object. */ + readonly query: Query; + + /** + * Executes this query. + * + * @return A promise that will be resolved with the results of the query. + */ + get(): Promise>; + + /** + * Compares this object with the given object for equality. + * + * This object is considered "equal" to the other object if and only if + * `other` performs the same aggregations as this `AggregateQuery` and + * the underlying Query of `other` compares equal to that of this object + * using `Query.isEqual()`. + * + * @param other The object to compare to this object for equality. + * @return `true` if this object is "equal" to the given object, as + * defined above, or `false` otherwise. + */ + isEqual(other: AggregateQuery): boolean; + } + + /** + * The results of executing an aggregation query. + */ + export class AggregateQuerySnapshot { + private constructor(); + + /** The query that was executed to produce this result. */ + readonly query: AggregateQuery; + + /** The time this snapshot was read. */ + readonly readTime: Timestamp; + + /** + * 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; + + /** + * Compares this object with the given object for equality. + * + * Two `AggregateQuerySnapshot` instances are considered "equal" if they + * have the same data and their underlying queries compare "equal" using + * `AggregateQuery.isEqual()`. + * + * @param other The object to compare to this object for equality. + * @return `true` if this object is "equal" to the given object, as + * defined above, or `false` otherwise. + */ + isEqual(other: AggregateQuerySnapshot): boolean; + } + /** * Sentinel values that can be used when writing document fields with set(), * create() or update().