From 47e8f9b62edc0dad41eca3a9d54a422ea51edb9c Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 27 Mar 2024 17:42:17 -0600 Subject: [PATCH 1/6] chore: run api-report for main --- api-report/firestore.api.md | 196 +++++++++++++++++++++++++----------- 1 file changed, 139 insertions(+), 57 deletions(-) diff --git a/api-report/firestore.api.md b/api-report/firestore.api.md index faa939d84..483c28c6a 100644 --- a/api-report/firestore.api.md +++ b/api-report/firestore.api.md @@ -56,27 +56,30 @@ export class AggregateQuery, _aggregates: AggregateSpecType); // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration + explain(options?: firestore.ExplainOptions): Promise>>; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration get(): Promise>; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // // @internal - _get(transactionId?: Uint8Array): Promise>; + _get(transactionIdOrReadTime?: Uint8Array | Timestamp): Promise>; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration isEqual(other: firestore.AggregateQuery): boolean; get query(): Query; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // // @internal - _stream(transactionId?: Uint8Array): Readable; + _stream(transactionIdOrReadTime?: Uint8Array | Timestamp, explainOptions?: firestore.ExplainOptions): Readable; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // Warning: (ae-forgotten-export) The symbol "google" needs to be exported by the entry point index.d.ts // // @internal - toProto(transactionId?: Uint8Array): api.IRunAggregationQueryRequest; + toProto(transactionIdOrReadTime?: Uint8Array | Timestamp, explainOptions?: firestore.ExplainOptions): api.IRunAggregationQueryRequest; } // @public @@ -568,6 +571,63 @@ export class DocumentSnapshot); + // (undocumented) + readonly debugStats: Record; + // (undocumented) + readonly executionDuration: firestore.Duration; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // Warning: (ae-forgotten-export) The symbol "google" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "Serializer" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + static _fromProto(stats: IExecutionStats | null | undefined, serializer: Serializer): ExecutionStats | null; + // (undocumented) + readonly readOperations: number; + // (undocumented) + readonly resultsReturned: number; +} + +// Warning: (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// +// @public +export class ExplainMetrics implements firestore.ExplainMetrics { + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // + // @internal + constructor(planSummary: PlanSummary, executionStats: ExecutionStats | null); + // (undocumented) + readonly executionStats: ExecutionStats | null; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // Warning: (ae-forgotten-export) The symbol "google" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + static _fromProto(metrics: IExplainMetrics, serializer: Serializer): ExplainMetrics; + // (undocumented) + readonly planSummary: PlanSummary; +} + +// Warning: (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// +// @public +export class ExplainResults implements firestore.ExplainResults { + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // + // @internal + constructor(metrics: ExplainMetrics, snapshot: T | null); + // (undocumented) + readonly metrics: ExplainMetrics; + // (undocumented) + readonly snapshot: T | null; +} + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration // Warning: (ae-forgotten-export) The symbol "Path" needs to be exported by the entry point index.d.ts // @@ -818,7 +878,6 @@ class Firestore implements firestore.Firestore { // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" runTransaction(updateFunction: (transaction: Transaction) => Promise, transactionOptions?: firestore.ReadWriteTransactionOptions | firestore.ReadOnlyTransactionOptions): Promise; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration - // Warning: (ae-forgotten-export) The symbol "Serializer" needs to be exported by the entry point index.d.ts // // @internal _serializer: Serializer | null; @@ -901,6 +960,23 @@ export class GeoPoint implements Serializable, firestore.GeoPoint { // @public export const MAX_REQUEST_RETRIES = 5; +// Warning: (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// +// @public +export class PlanSummary implements firestore.PlanSummary { + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // + // @internal + constructor(indexesUsed: Record[]); + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // Warning: (ae-forgotten-export) The symbol "google" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + static _fromProto(plan: IPlanSummary | null | undefined, serializer: Serializer): PlanSummary; + // (undocumented) + readonly indexesUsed: Record[]; +} + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration // // @public @@ -937,6 +1013,11 @@ export class Query): Query; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration + explain(options?: firestore.ExplainOptions): Promise>>; + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + explainStream(explainOptions?: firestore.ExplainOptions): NodeJS.ReadableStream; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -961,10 +1042,9 @@ export class Query>; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // // @internal - _get(transactionId?: Uint8Array): Promise>; + _get(transactionIdOrReadTime?: Uint8Array | Timestamp): Promise>; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // // @internal (undocumented) @@ -1046,20 +1126,22 @@ export class Query; + begin(): Promise; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // // @internal @@ -1317,15 +1400,14 @@ export class Transaction implements firestore.Transaction { rollback(): Promise; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // + // @internal + runTransaction(updateFunction: (transaction: Transaction) => Promise): Promise; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // // @internal - runTransaction(updateFunction: (transaction: Transaction) => Promise, options: { - maxAttempts: number; - readOnly: boolean; - readTime?: Timestamp; - }): Promise; + runTransactionOnce(updateFunction: (transaction: Transaction) => Promise): Promise; // (undocumented) set(documentRef: firestore.DocumentReference, data: firestore.PartialWithFieldValue, options: firestore.SetOptions): Transaction; // (undocumented) @@ -1437,8 +1519,8 @@ export class WriteResult implements firestore.WriteResult { // Warnings were encountered during analysis: // +// build/src/aggregate.d.ts:49:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // build/src/aggregate.d.ts:50:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/aggregate.d.ts:51:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // build/src/bulk-writer.d.ts:50:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/bulk-writer.d.ts:84:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/bulk-writer.d.ts:147:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration @@ -1460,56 +1542,56 @@ export class WriteResult implements firestore.WriteResult { // build/src/bundle.d.ts:20:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/filter.d.ts:121:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/filter.d.ts:156:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:284:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:304:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:311:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:326:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:333:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:342:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:350:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:357:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:366:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:849:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:868:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:870:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:872:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:285:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:305:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:312:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:327:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:334:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:343:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:351:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:358:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:367:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:850:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:869:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:871:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // build/src/index.d.ts:873:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:883:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:885:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:874:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:884:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/index.d.ts:886:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:888:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:887:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // build/src/index.d.ts:889:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:890:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // build/src/path.d.ts:30:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/path.d.ts:32:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration // build/src/path.d.ts:120:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/path.d.ts:312:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/rate-limiter.d.ts:13:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:364:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:366:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration -// build/src/reference.d.ts:397:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:399:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration -// build/src/reference.d.ts:628:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:998:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration -// build/src/reference.d.ts:1004:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1006:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1014:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1016:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1016:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// build/src/reference.d.ts:1018:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1018:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// build/src/reference.d.ts:1020:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1022:24 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// build/src/reference.d.ts:1022:17 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// build/src/reference.d.ts:1031:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1033:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration -// build/src/reference.d.ts:1035:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1180:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1181:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1433:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:365:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:367:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// build/src/reference.d.ts:398:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:400:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// build/src/reference.d.ts:629:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:999:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/reference.d.ts:1005:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:1007:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference.d.ts:1015:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:1017:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference.d.ts:1017:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' +// build/src/reference.d.ts:1019:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference.d.ts:1019:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' +// build/src/reference.d.ts:1021:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference.d.ts:1023:24 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// build/src/reference.d.ts:1023:17 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// build/src/reference.d.ts:1032:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference.d.ts:1034:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/reference.d.ts:1036:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:1220:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference.d.ts:1221:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference.d.ts:1478:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/serializer.d.ts:26:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/serializer.d.ts:36:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/transaction.d.ts:233:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/transaction.d.ts:235:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/transaction.d.ts:239:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/transaction.d.ts:241:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration // build/src/write-batch.d.ts:86:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/write-batch.d.ts:109:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration From b834129e44ee81265e88c88d90a78cafc0efb265 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Mon, 29 Apr 2024 12:56:25 -0600 Subject: [PATCH 2/6] Refactor reference.ts to be one class per file. --- dev/src/bundle.ts | 2 +- dev/src/collection-group.ts | 3 +- dev/src/document-reader.ts | 2 +- dev/src/document.ts | 2 +- dev/src/index.ts | 23 +- dev/src/query-partition.ts | 4 +- dev/src/recursive-delete.ts | 2 +- dev/src/reference.ts | 4648 ----------------- dev/src/reference/aggregate-query-snapshot.ts | 113 + dev/src/reference/aggregate-query.ts | 379 ++ dev/src/reference/collection-reference.ts | 369 ++ .../reference/composite-filter-internal.ts | 86 + dev/src/reference/constants.ts | 56 + dev/src/reference/document-reference.ts | 608 +++ dev/src/reference/field-filter-internal.ts | 125 + dev/src/reference/field-order.ts | 54 + dev/src/reference/filter-internal.ts | 31 + dev/src/reference/helpers.ts | 138 + dev/src/reference/query-options.ts | 229 + dev/src/reference/query-snapshot.ts | 296 ++ dev/src/reference/query-util.ts | 330 ++ dev/src/reference/query.ts | 1645 ++++++ dev/src/reference/types.ts | 54 + dev/src/reference/vector-query-options.ts | 36 + dev/src/reference/vector-query-snapshot.ts | 290 + dev/src/reference/vector-query.ts | 219 + dev/src/transaction.ts | 15 +- dev/src/write-batch.ts | 2 +- 28 files changed, 5085 insertions(+), 4676 deletions(-) delete mode 100644 dev/src/reference.ts create mode 100644 dev/src/reference/aggregate-query-snapshot.ts create mode 100644 dev/src/reference/aggregate-query.ts create mode 100644 dev/src/reference/collection-reference.ts create mode 100644 dev/src/reference/composite-filter-internal.ts create mode 100644 dev/src/reference/constants.ts create mode 100644 dev/src/reference/document-reference.ts create mode 100644 dev/src/reference/field-filter-internal.ts create mode 100644 dev/src/reference/field-order.ts create mode 100644 dev/src/reference/filter-internal.ts create mode 100644 dev/src/reference/helpers.ts create mode 100644 dev/src/reference/query-options.ts create mode 100644 dev/src/reference/query-snapshot.ts create mode 100644 dev/src/reference/query-util.ts create mode 100644 dev/src/reference/query.ts create mode 100644 dev/src/reference/types.ts create mode 100644 dev/src/reference/vector-query-options.ts create mode 100644 dev/src/reference/vector-query-snapshot.ts create mode 100644 dev/src/reference/vector-query.ts diff --git a/dev/src/bundle.ts b/dev/src/bundle.ts index ce6901c6f..8f164dcd4 100644 --- a/dev/src/bundle.ts +++ b/dev/src/bundle.ts @@ -15,7 +15,7 @@ import {firestore, google} from '../protos/firestore_v1_proto_api'; import {DocumentSnapshot} from './document'; -import {QuerySnapshot} from './reference'; +import {QuerySnapshot} from './reference/query-snapshot'; import {Timestamp} from './timestamp'; import { invalidArgumentMessage, diff --git a/dev/src/collection-group.ts b/dev/src/collection-group.ts index 2a21cae03..363cd6de6 100644 --- a/dev/src/collection-group.ts +++ b/dev/src/collection-group.ts @@ -20,7 +20,8 @@ import * as protos from '../protos/firestore_v1_proto_api'; import {QueryPartition} from './query-partition'; import {requestTag} from './util'; import {logger} from './logger'; -import {Query, QueryOptions} from './reference'; +import {Query} from './reference/query'; +import {QueryOptions} from './reference/query-options'; import {FieldPath} from './path'; import {Firestore} from './index'; import {validateInteger} from './validate'; diff --git a/dev/src/document-reader.ts b/dev/src/document-reader.ts index 209bb9a32..397a88e86 100644 --- a/dev/src/document-reader.ts +++ b/dev/src/document-reader.ts @@ -15,7 +15,7 @@ */ import {DocumentSnapshot, DocumentSnapshotBuilder} from './document'; -import {DocumentReference} from './reference'; +import {DocumentReference} from './reference/document-reference'; import {FieldPath} from './path'; import {isPermanentRpcError} from './util'; import {google} from '../protos/firestore_v1_proto_api'; diff --git a/dev/src/document.ts b/dev/src/document.ts index fbea6c748..366cd9731 100644 --- a/dev/src/document.ts +++ b/dev/src/document.ts @@ -23,7 +23,7 @@ import * as assert from 'assert'; import {google} from '../protos/firestore_v1_proto_api'; import {FieldTransform} from './field-value'; import {FieldPath, validateFieldPath} from './path'; -import {DocumentReference} from './reference'; +import {DocumentReference} from './reference/document-reference'; import {Serializer} from './serializer'; import {Timestamp} from './timestamp'; import {ApiMapValue, defaultConverter, UpdateMap} from './types'; diff --git a/dev/src/index.ts b/dev/src/index.ts index e7480d48d..0cef8680f 100644 --- a/dev/src/index.ts +++ b/dev/src/index.ts @@ -42,7 +42,8 @@ import { validateResourcePath, } from './path'; import {ClientPool} from './pool'; -import {CollectionReference, DocumentReference} from './reference'; +import {CollectionReference} from './reference/collection-reference'; +import {DocumentReference} from './reference/document-reference'; import {Serializer} from './serializer'; import {Timestamp} from './timestamp'; import {parseGetAllArguments, Transaction} from './transaction'; @@ -85,18 +86,14 @@ import { RecursiveDelete, } from './recursive-delete'; -export { - CollectionReference, - DocumentReference, - QuerySnapshot, - Query, -} from './reference'; -export type { - AggregateQuery, - AggregateQuerySnapshot, - VectorQuery, - VectorQuerySnapshot, -} from './reference'; +export {CollectionReference} from './reference/collection-reference'; +export {DocumentReference} from './reference/document-reference'; +export {QuerySnapshot} from './reference/query-snapshot'; +export {Query} from './reference/query'; +export type {AggregateQuery} from './reference/aggregate-query'; +export type {AggregateQuerySnapshot} from './reference/aggregate-query-snapshot'; +export type {VectorQuery} from './reference/vector-query'; +export type {VectorQuerySnapshot} from './reference/vector-query-snapshot'; export {BulkWriter} from './bulk-writer'; export type {BulkWriterError} from './bulk-writer'; export type {BundleBuilder} from './bundle'; diff --git a/dev/src/query-partition.ts b/dev/src/query-partition.ts index 159552640..9d793586c 100644 --- a/dev/src/query-partition.ts +++ b/dev/src/query-partition.ts @@ -17,7 +17,9 @@ import * as firestore from '@google-cloud/firestore'; import * as protos from '../protos/firestore_v1_proto_api'; -import {FieldOrder, Query, QueryOptions} from './reference'; +import {FieldOrder} from './reference/field-order'; +import {Query} from './reference/query'; +import {QueryOptions} from './reference/query-options'; import {FieldPath} from './path'; import {Serializer} from './serializer'; import {Firestore} from './index'; diff --git a/dev/src/recursive-delete.ts b/dev/src/recursive-delete.ts index addd8e9d8..676875087 100644 --- a/dev/src/recursive-delete.ts +++ b/dev/src/recursive-delete.ts @@ -28,7 +28,7 @@ import Firestore, { import {Deferred, wrapError} from './util'; import type {GoogleError} from 'google-gax'; import {BulkWriterError} from './bulk-writer'; -import {QueryOptions} from './reference'; +import {QueryOptions} from './reference/query-options'; import {StatusCode} from './status-code'; /*! diff --git a/dev/src/reference.ts b/dev/src/reference.ts deleted file mode 100644 index 6afb5ff73..000000000 --- a/dev/src/reference.ts +++ /dev/null @@ -1,4648 +0,0 @@ -/*! - * Copyright 2017 Google Inc. All Rights Reserved. - * - * 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 * as firestore from '@google-cloud/firestore'; -import * as assert from 'assert'; -import {Duplex, Readable, Transform} from 'stream'; -import * as deepEqual from 'fast-deep-equal'; -import {GoogleError} from 'google-gax'; - -import * as protos from '../protos/firestore_v1_proto_api'; - -import {Aggregate, AggregateField, AggregateSpec} from './aggregate'; -import { - DocumentSnapshot, - DocumentSnapshotBuilder, - QueryDocumentSnapshot, -} from './document'; -import {DocumentChange} from './document-change'; -import {VectorValue} from './field-value'; -import {CompositeFilter, Filter, UnaryFilter} from './filter'; -import {Firestore} from './index'; -import {logger} from './logger'; -import {compare} from './order'; -import { - FieldPath, - QualifiedResourcePath, - ResourcePath, - validateFieldPath, - validateResourcePath, -} from './path'; -import {Serializable, Serializer, validateUserInput} from './serializer'; -import {Timestamp} from './timestamp'; -import {defaultConverter} from './types'; -import { - autoId, - Deferred, - getTotalTimeout, - isArrayEqual, - isPermanentRpcError, - isPrimitiveArrayEqual, - mapToArray, - requestTag, - wrapError, -} from './util'; -import { - invalidArgumentMessage, - validateEnumValue, - validateFunction, - validateInteger, - validateMinNumberOfArguments, -} from './validate'; -import {DocumentWatch, QueryWatch} from './watch'; -import {validateDocumentData, WriteBatch, WriteResult} from './write-batch'; -import api = protos.google.firestore.v1; -import {ExplainMetrics, ExplainResults} from './query-profile'; - -/** - * The direction of a `Query.orderBy()` clause is specified as 'desc' or 'asc' - * (descending or ascending). - * - * @private - * @internal - */ -const directionOperators: {[k: string]: api.StructuredQuery.Direction} = { - asc: 'ASCENDING', - desc: 'DESCENDING', -}; - -/** - * Filter conditions in a `Query.where()` clause are specified using the - * strings '<', '<=', '==', '!=', '>=', '>', 'array-contains', 'in', 'not-in', - * and 'array-contains-any'. - * - * @private - * @internal - */ -const comparisonOperators: { - [k: string]: api.StructuredQuery.FieldFilter.Operator; -} = { - '<': 'LESS_THAN', - '<=': 'LESS_THAN_OR_EQUAL', - '==': 'EQUAL', - '!=': 'NOT_EQUAL', - '>': 'GREATER_THAN', - '>=': 'GREATER_THAN_OR_EQUAL', - 'array-contains': 'ARRAY_CONTAINS', - in: 'IN', - 'not-in': 'NOT_IN', - 'array-contains-any': 'ARRAY_CONTAINS_ANY', -}; - -const NOOP_MESSAGE = Symbol('a noop message'); - -/** - * onSnapshot() callback that receives a QuerySnapshot. - * - * @callback querySnapshotCallback - * @param {QuerySnapshot} snapshot A query snapshot. - */ - -/** - * onSnapshot() callback that receives a DocumentSnapshot. - * - * @callback documentSnapshotCallback - * @param {DocumentSnapshot} snapshot A document snapshot. - */ - -/** - * onSnapshot() callback that receives an error. - * - * @callback errorCallback - * @param {Error} err An error from a listen. - */ - -/** - * A DocumentReference refers to a document location in a Firestore database - * and can be used to write, read, or listen to the location. The document at - * the referenced location may or may not exist. A DocumentReference can - * also be used to create a - * [CollectionReference]{@link CollectionReference} to a - * subcollection. - * - * @class DocumentReference - */ -export class DocumentReference< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, - > - implements - Serializable, - firestore.DocumentReference -{ - /** - * @private - * @internal - * @param _firestore The Firestore Database client. - * @param _path The Path of this reference. - * @param _converter The converter to use when serializing data. - */ - constructor( - private readonly _firestore: Firestore, - /** - * @private - * @internal - **/ - readonly _path: ResourcePath, - /** - * @internal - * @private - **/ - readonly _converter = defaultConverter() - ) {} - - /** - * The string representation of the DocumentReference's location. - * @private - * @internal - * @type {string} - * @name DocumentReference#formattedName - */ - get formattedName(): string { - const projectId = this.firestore.projectId; - const databaseId = this.firestore.databaseId; - return this._path.toQualifiedResourcePath(projectId, databaseId) - .formattedName; - } - - /** - * The [Firestore]{@link Firestore} instance for the Firestore - * database (useful for performing transactions, etc.). - * - * @type {Firestore} - * @name DocumentReference#firestore - * @readonly - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * - * collectionRef.add({foo: 'bar'}).then(documentReference => { - * let firestore = documentReference.firestore; - * console.log(`Root location for document is ${firestore.formattedName}`); - * }); - * ``` - */ - get firestore(): Firestore { - return this._firestore; - } - - /** - * A string representing the path of the referenced document (relative - * to the root of the database). - * - * @type {string} - * @name DocumentReference#path - * @readonly - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * - * collectionRef.add({foo: 'bar'}).then(documentReference => { - * console.log(`Added document at '${documentReference.path}'`); - * }); - * ``` - */ - get path(): string { - return this._path.relativeName; - } - - /** - * The last path element of the referenced document. - * - * @type {string} - * @name DocumentReference#id - * @readonly - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * - * collectionRef.add({foo: 'bar'}).then(documentReference => { - * console.log(`Added document with name '${documentReference.id}'`); - * }); - * ``` - */ - get id(): string { - return this._path.id!; - } - - /** - * Returns a resource path for this document. - * @private - * @internal - */ - get _resourcePath(): ResourcePath { - return this._path; - } - - /** - * A reference to the collection to which this DocumentReference belongs. - * - * @name DocumentReference#parent - * @type {CollectionReference} - * @readonly - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * let collectionRef = documentRef.parent; - * - * collectionRef.where('foo', '==', 'bar').get().then(results => { - * console.log(`Found ${results.size} matches in parent collection`); - * }): - * ``` - */ - get parent(): CollectionReference { - return new CollectionReference( - this._firestore, - this._path.parent()!, - this._converter - ); - } - - /** - * Reads the document referred to by this DocumentReference. - * - * @returns {Promise.} A Promise resolved with a - * DocumentSnapshot for the retrieved document on success. For missing - * documents, DocumentSnapshot.exists will be false. If the get() fails for - * other reasons, the Promise will be rejected. - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * - * documentRef.get().then(documentSnapshot => { - * if (documentSnapshot.exists) { - * console.log('Document retrieved successfully.'); - * } - * }); - * ``` - */ - get(): Promise> { - return this._firestore.getAll(this).then(([result]) => result); - } - - /** - * Gets a [CollectionReference]{@link CollectionReference} instance - * that refers to the collection at the specified path. - * - * @param {string} collectionPath A slash-separated path to a collection. - * @returns {CollectionReference} A reference to the new - * subcollection. - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * let subcollection = documentRef.collection('subcollection'); - * console.log(`Path to subcollection: ${subcollection.path}`); - * ``` - */ - collection(collectionPath: string): CollectionReference { - validateResourcePath('collectionPath', collectionPath); - - const path = this._path.append(collectionPath); - if (!path.isCollection) { - throw new Error( - `Value for argument "collectionPath" must point to a collection, but was "${collectionPath}". Your path does not contain an odd number of components.` - ); - } - - return new CollectionReference(this._firestore, path); - } - - /** - * Fetches the subcollections that are direct children of this document. - * - * @returns {Promise.>} A Promise that resolves - * with an array of CollectionReferences. - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * - * documentRef.listCollections().then(collections => { - * for (let collection of collections) { - * console.log(`Found subcollection with id: ${collection.id}`); - * } - * }); - * ``` - */ - listCollections(): Promise> { - const tag = requestTag(); - return this.firestore.initializeIfNeeded(tag).then(() => { - const request: api.IListCollectionIdsRequest = { - parent: this.formattedName, - // Setting `pageSize` to an arbitrarily large value lets the backend cap - // the page size (currently to 300). Note that the backend rejects - // MAX_INT32 (b/146883794). - pageSize: Math.pow(2, 16) - 1, - }; - return this._firestore - .request( - 'listCollectionIds', - request, - tag - ) - .then(collectionIds => { - const collections: Array = []; - - // We can just sort this list using the default comparator since it - // will only contain collection ids. - collectionIds.sort(); - - for (const collectionId of collectionIds) { - collections.push(this.collection(collectionId)); - } - - return collections; - }); - }); - } - - /** - * Create a document with the provided object values. This will fail the write - * if a document exists at its location. - * - * @param {DocumentData} data An object that contains the fields and data to - * serialize as the document. - * @throws {Error} If the provided input is not a valid Firestore document or if the document already exists. - * @returns {Promise.} A Promise that resolves with the - * write time of this create. - * - * @example - * ``` - * let documentRef = firestore.collection('col').doc(); - * - * documentRef.create({foo: 'bar'}).then((res) => { - * console.log(`Document created at ${res.updateTime}`); - * }).catch((err) => { - * console.log(`Failed to create document: ${err}`); - * }); - * ``` - */ - create(data: firestore.WithFieldValue): Promise { - const writeBatch = new WriteBatch(this._firestore); - return writeBatch - .create(this, data) - .commit() - .then(([writeResult]) => writeResult); - } - - /** - * Deletes the document referred to by this `DocumentReference`. - * - * A delete for a non-existing document is treated as a success (unless - * lastUptimeTime is provided). - * - * @param {Precondition=} precondition A precondition to enforce for this - * delete. - * @param {Timestamp=} precondition.lastUpdateTime If set, enforces that the - * document was last updated at lastUpdateTime. Fails the delete if the - * document was last updated at a different time. - * @param {boolean=} precondition.exists If set, enforces that the target - * document must or must not exist. - * @returns {Promise.} A Promise that resolves with the - * delete time. - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * - * documentRef.delete().then(() => { - * console.log('Document successfully deleted.'); - * }); - * ``` - */ - delete(precondition?: firestore.Precondition): Promise { - const writeBatch = new WriteBatch(this._firestore); - return writeBatch - .delete(this, precondition) - .commit() - .then(([writeResult]) => writeResult); - } - - set( - data: firestore.PartialWithFieldValue, - options: firestore.SetOptions - ): Promise; - set(data: firestore.WithFieldValue): Promise; - /** - * Writes to the document referred to by this DocumentReference. If the - * document does not yet exist, it will be created. If you pass - * [SetOptions]{@link SetOptions}, the provided data can be merged into an - * existing document. - * - * @param {T|Partial} data A map of the fields and values for - * the document. - * @param {SetOptions=} options An object to configure the set behavior. - * @param {boolean=} options.merge If true, set() merges the values specified - * in its data argument. Fields omitted from this set() call remain untouched. - * If your input sets any field to an empty map, all nested fields are - * overwritten. - * @param {Array.=} options.mergeFields If provided, - * set() only replaces the specified field paths. Any field path that is not - * specified is ignored and remains untouched. If your input sets any field to - * an empty map, all nested fields are overwritten. - * @throws {Error} If the provided input is not a valid Firestore document. - * @returns {Promise.} A Promise that resolves with the - * write time of this set. - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * - * documentRef.set({foo: 'bar'}).then(res => { - * console.log(`Document written at ${res.updateTime}`); - * }); - * ``` - */ - set( - data: firestore.PartialWithFieldValue, - options?: firestore.SetOptions - ): Promise { - let writeBatch = new WriteBatch(this._firestore); - if (options) { - writeBatch = writeBatch.set(this, data, options); - } else { - writeBatch = writeBatch.set( - this, - data as firestore.WithFieldValue - ); - } - return writeBatch.commit().then(([writeResult]) => writeResult); - } - - /** - * Updates fields in the document referred to by this DocumentReference. - * If the document doesn't yet exist, the update fails and the returned - * Promise will be rejected. - * - * The update() method accepts either an object with field paths encoded as - * keys and field values encoded as values, or a variable number of arguments - * that alternate between field paths and field values. - * - * A Precondition restricting this update can be specified as the last - * argument. - * - * @param {UpdateData|string|FieldPath} dataOrField An object containing the - * fields and values with which to update the document or the path of the - * first field to update. - * @param { - * ...(*|string|FieldPath|Precondition)} preconditionOrValues An alternating - * list of field paths and values to update or a Precondition to restrict - * this update. - * @throws {Error} If the provided input is not valid Firestore data. - * @returns {Promise.} A Promise that resolves once the - * data has been successfully written to the backend. - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * - * documentRef.update({foo: 'bar'}).then(res => { - * console.log(`Document updated at ${res.updateTime}`); - * }); - * ``` - */ - update( - dataOrField: - | firestore.UpdateData - | string - | firestore.FieldPath, - ...preconditionOrValues: Array< - unknown | string | firestore.FieldPath | firestore.Precondition - > - ): Promise { - // eslint-disable-next-line prefer-rest-params - validateMinNumberOfArguments('DocumentReference.update', arguments, 1); - - const writeBatch = new WriteBatch(this._firestore); - return writeBatch - .update(this, dataOrField, ...preconditionOrValues) - .commit() - .then(([writeResult]) => writeResult); - } - - /** - * Attaches a listener for DocumentSnapshot events. - * - * @param {documentSnapshotCallback} onNext A callback to be called every - * time a new `DocumentSnapshot` is available. - * @param {errorCallback=} onError A callback to be called if the listen fails - * or is cancelled. No further callbacks will occur. If unset, errors will be - * logged to the console. - * - * @returns {function()} An unsubscribe function that can be called to cancel - * the snapshot listener. - * - * @example - * ``` - * let documentRef = firestore.doc('col/doc'); - * - * let unsubscribe = documentRef.onSnapshot(documentSnapshot => { - * if (documentSnapshot.exists) { - * console.log(documentSnapshot.data()); - * } - * }, err => { - * console.log(`Encountered error: ${err}`); - * }); - * - * // Remove this listener. - * unsubscribe(); - * ``` - */ - onSnapshot( - onNext: ( - snapshot: firestore.DocumentSnapshot - ) => void, - onError?: (error: Error) => void - ): () => void { - validateFunction('onNext', onNext); - validateFunction('onError', onError, {optional: true}); - - const watch: DocumentWatch = - new (require('./watch').DocumentWatch)(this.firestore, this); - return watch.onSnapshot((readTime, size, docs) => { - for (const document of docs()) { - if (document.ref.path === this.path) { - onNext(document); - return; - } - } - - // The document is missing. - const ref = new DocumentReference( - this._firestore, - this._path, - this._converter - ); - const document = new DocumentSnapshotBuilder( - ref - ); - document.readTime = readTime; - onNext(document.build()); - }, onError || console.error); - } - - /** - * Returns true if this `DocumentReference` is equal to the provided value. - * - * @param {*} other The value to compare against. - * @return {boolean} true if this `DocumentReference` is equal to the provided - * value. - */ - isEqual( - other: firestore.DocumentReference - ): boolean { - return ( - this === other || - (other instanceof DocumentReference && - this._firestore === other._firestore && - this._path.isEqual(other._path) && - this._converter === other._converter) - ); - } - - /** - * Converts this DocumentReference to the Firestore Proto representation. - * - * @private - * @internal - */ - toProto(): api.IValue { - return {referenceValue: this.formattedName}; - } - - withConverter(converter: null): DocumentReference; - withConverter< - NewAppModelType, - NewDbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - converter: firestore.FirestoreDataConverter - ): DocumentReference; - /** - * Applies a custom data converter to this DocumentReference, allowing you to - * use your own custom model objects with Firestore. When you call set(), - * get(), etc. on the returned DocumentReference instance, the provided - * converter will convert between Firestore data of type `NewDbModelType` and - * your custom type `NewAppModelType`. - * - * Using the converter allows you to specify generic type arguments when - * storing and retrieving objects from Firestore. - * - * Passing in `null` as the converter parameter removes the current - * converter. - * - * @example - * ``` - * class Post { - * constructor(readonly title: string, readonly author: string) {} - * - * toString(): string { - * return this.title + ', by ' + this.author; - * } - * } - * - * const postConverter = { - * toFirestore(post: Post): FirebaseFirestore.DocumentData { - * return {title: post.title, author: post.author}; - * }, - * fromFirestore( - * snapshot: FirebaseFirestore.QueryDocumentSnapshot - * ): Post { - * const data = snapshot.data(); - * return new Post(data.title, data.author); - * } - * }; - * - * const postSnap = await Firestore() - * .collection('posts') - * .withConverter(postConverter) - * .doc().get(); - * const post = postSnap.data(); - * if (post !== undefined) { - * post.title; // string - * post.toString(); // Should be defined - * post.someNonExistentProperty; // TS error - * } - * - * ``` - * @param {FirestoreDataConverter | null} converter Converts objects to and - * from Firestore. Passing in `null` removes the current converter. - * @return A DocumentReference that uses the provided converter. - */ - withConverter< - NewAppModelType, - NewDbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - converter: firestore.FirestoreDataConverter< - NewAppModelType, - NewDbModelType - > | null - ): DocumentReference { - return new DocumentReference( - this.firestore, - this._path, - converter ?? defaultConverter() - ); - } -} - -/** - * A Query order-by field. - * - * @private - * @internal - * @class - */ -export class FieldOrder { - /** - * @param field The name of a document field (member) on which to order query - * results. - * @param direction One of 'ASCENDING' (default) or 'DESCENDING' to - * set the ordering direction to ascending or descending, respectively. - */ - constructor( - readonly field: FieldPath, - readonly direction: api.StructuredQuery.Direction = 'ASCENDING' - ) {} - - /** - * Generates the proto representation for this field order. - * @private - * @internal - */ - toProto(): api.StructuredQuery.IOrder { - return { - field: { - fieldPath: this.field.formattedName, - }, - direction: this.direction, - }; - } -} - -abstract class FilterInternal { - /** Returns a list of all field filters that are contained within this filter */ - abstract getFlattenedFilters(): FieldFilterInternal[]; - - /** Returns a list of all filters that are contained within this filter */ - abstract getFilters(): FilterInternal[]; - - /** Returns the proto representation of this filter */ - abstract toProto(): Filter; - - abstract isEqual(other: FilterInternal): boolean; -} - -class CompositeFilterInternal extends FilterInternal { - constructor( - private filters: FilterInternal[], - private operator: api.StructuredQuery.CompositeFilter.Operator - ) { - super(); - } - - // Memoized list of all field filters that can be found by traversing the tree of filters - // contained in this composite filter. - private memoizedFlattenedFilters: FieldFilterInternal[] | null = null; - - public getFilters(): FilterInternal[] { - return this.filters; - } - - public isConjunction(): boolean { - return this.operator === 'AND'; - } - - public getFlattenedFilters(): FieldFilterInternal[] { - if (this.memoizedFlattenedFilters !== null) { - return this.memoizedFlattenedFilters; - } - - this.memoizedFlattenedFilters = this.filters.reduce( - (allFilters: FieldFilterInternal[], subfilter: FilterInternal) => - allFilters.concat(subfilter.getFlattenedFilters()), - [] - ); - - return this.memoizedFlattenedFilters; - } - - public toProto(): api.StructuredQuery.IFilter { - if (this.filters.length === 1) { - return this.filters[0].toProto(); - } - - const proto: api.StructuredQuery.IFilter = { - compositeFilter: { - op: this.operator, - filters: this.filters.map(filter => filter.toProto()), - }, - }; - - return proto; - } - - isEqual(other: FilterInternal): boolean { - if (other instanceof CompositeFilterInternal) { - const otherFilters = other.getFilters(); - return ( - this.operator === other.operator && - this.getFilters().length === other.getFilters().length && - this.getFilters().every((filter, index) => - filter.isEqual(otherFilters[index]) - ) - ); - } else { - return false; - } - } -} - -/** - * A field constraint for a Query where clause. - * - * @private - * @internal - * @class - */ -class FieldFilterInternal extends FilterInternal { - public getFlattenedFilters(): FieldFilterInternal[] { - return [this]; - } - - public getFilters(): FilterInternal[] { - return [this]; - } - - /** - * @param serializer The Firestore serializer - * @param field The path of the property value to compare. - * @param op A comparison operation. - * @param value The value to which to compare the field for inclusion in a - * query. - */ - constructor( - private readonly serializer: Serializer, - readonly field: FieldPath, - private readonly op: api.StructuredQuery.FieldFilter.Operator, - private readonly value: unknown - ) { - super(); - } - - /** - * Returns whether this FieldFilter uses an equals comparison. - * - * @private - * @internal - */ - isInequalityFilter(): boolean { - switch (this.op) { - case 'GREATER_THAN': - case 'GREATER_THAN_OR_EQUAL': - case 'LESS_THAN': - case 'LESS_THAN_OR_EQUAL': - case 'NOT_EQUAL': - case 'NOT_IN': - return true; - default: - return false; - } - } - - /** - * Generates the proto representation for this field filter. - * - * @private - * @internal - */ - toProto(): api.StructuredQuery.IFilter { - if (typeof this.value === 'number' && isNaN(this.value)) { - return { - unaryFilter: { - field: { - fieldPath: this.field.formattedName, - }, - op: this.op === 'EQUAL' ? 'IS_NAN' : 'IS_NOT_NAN', - }, - }; - } - - if (this.value === null) { - return { - unaryFilter: { - field: { - fieldPath: this.field.formattedName, - }, - op: this.op === 'EQUAL' ? 'IS_NULL' : 'IS_NOT_NULL', - }, - }; - } - - return { - fieldFilter: { - field: { - fieldPath: this.field.formattedName, - }, - op: this.op, - value: this.serializer.encodeValue(this.value), - }, - }; - } - - isEqual(other: FilterInternal): boolean { - return ( - other instanceof FieldFilterInternal && - this.field.isEqual(other.field) && - this.op === other.op && - deepEqual(this.value, other.value) - ); - } -} - -/** - * A QuerySnapshot contains zero or more - * [QueryDocumentSnapshot]{@link QueryDocumentSnapshot} objects - * representing the results of a query. The documents can be accessed as an - * array via the [documents]{@link QuerySnapshot#documents} property - * or enumerated using the [forEach]{@link QuerySnapshot#forEach} - * method. The number of documents can be determined via the - * [empty]{@link QuerySnapshot#empty} and - * [size]{@link QuerySnapshot#size} properties. - * - * @class QuerySnapshot - */ -export class QuerySnapshot< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, -> implements firestore.QuerySnapshot -{ - private _materializedDocs: Array< - QueryDocumentSnapshot - > | null = null; - private _materializedChanges: Array< - DocumentChange - > | null = null; - private _docs: - | (() => Array>) - | null = null; - private _changes: - | (() => Array>) - | null = null; - - /** - * @private - * - * @param _query The originating query. - * @param _readTime The time when this query snapshot was obtained. - * @param _size The number of documents in the result set. - * @param docs A callback returning a sorted array of documents matching - * this query - * @param changes A callback returning a sorted array of document change - * events for this snapshot. - */ - constructor( - private readonly _query: Query, - private readonly _readTime: Timestamp, - private readonly _size: number, - docs: () => Array>, - changes: () => Array> - ) { - this._docs = docs; - this._changes = changes; - } - - /** - * The query on which you called get() or onSnapshot() in order to get this - * QuerySnapshot. - * - * @type {Query} - * @name QuerySnapshot#query - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.limit(10).get().then(querySnapshot => { - * console.log(`Returned first batch of results`); - * let query = querySnapshot.query; - * return query.offset(10).get(); - * }).then(() => { - * console.log(`Returned second batch of results`); - * }); - * ``` - */ - get query(): Query { - return this._query; - } - - /** - * An array of all the documents in this QuerySnapshot. - * - * @type {Array.} - * @name QuerySnapshot#docs - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.get().then(querySnapshot => { - * let docs = querySnapshot.docs; - * for (let doc of docs) { - * console.log(`Document found at path: ${doc.ref.path}`); - * } - * }); - * ``` - */ - get docs(): Array> { - if (this._materializedDocs) { - return this._materializedDocs!; - } - this._materializedDocs = this._docs!(); - this._docs = null; - return this._materializedDocs!; - } - - /** - * True if there are no documents in the QuerySnapshot. - * - * @type {boolean} - * @name QuerySnapshot#empty - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.get().then(querySnapshot => { - * if (querySnapshot.empty) { - * console.log('No documents found.'); - * } - * }); - * ``` - */ - get empty(): boolean { - return this._size === 0; - } - - /** - * The number of documents in the QuerySnapshot. - * - * @type {number} - * @name QuerySnapshot#size - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.get().then(querySnapshot => { - * console.log(`Found ${querySnapshot.size} documents.`); - * }); - * ``` - */ - get size(): number { - return this._size; - } - - /** - * The time this query snapshot was obtained. - * - * @type {Timestamp} - * @name QuerySnapshot#readTime - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.get().then((querySnapshot) => { - * let readTime = querySnapshot.readTime; - * console.log(`Query results returned at '${readTime.toDate()}'`); - * }); - * ``` - */ - get readTime(): Timestamp { - return this._readTime; - } - - /** - * Returns an array of the documents changes since the last snapshot. If - * this is the first snapshot, all documents will be in the list as added - * changes. - * - * @return {Array.} - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.onSnapshot(querySnapshot => { - * let changes = querySnapshot.docChanges(); - * for (let change of changes) { - * console.log(`A document was ${change.type}.`); - * } - * }); - * ``` - */ - docChanges(): Array> { - if (this._materializedChanges) { - return this._materializedChanges!; - } - this._materializedChanges = this._changes!(); - this._changes = null; - return this._materializedChanges!; - } - - /** - * Enumerates all of the documents in the QuerySnapshot. This is a convenience - * method for running the same callback on each {@link QueryDocumentSnapshot} - * that is returned. - * - * @param {function} callback A callback to be called with a - * [QueryDocumentSnapshot]{@link QueryDocumentSnapshot} for each document in - * the snapshot. - * @param {*=} thisArg The `this` binding for the callback.. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Document found at path: ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - forEach( - callback: ( - result: firestore.QueryDocumentSnapshot - ) => void, - thisArg?: unknown - ): void { - validateFunction('callback', callback); - - for (const doc of this.docs) { - callback.call(thisArg, doc); - } - } - - /** - * Returns true if the document data in this `QuerySnapshot` is equal to the - * provided value. - * - * @param {*} other The value to compare against. - * @return {boolean} true if this `QuerySnapshot` is equal to the provided - * value. - */ - isEqual(other: firestore.QuerySnapshot): boolean { - // Since the read time is different on every query read, we explicitly - // ignore all metadata in this comparison. - - if (this === other) { - return true; - } - - if (!(other instanceof QuerySnapshot)) { - return false; - } - - if (this._size !== other._size) { - return false; - } - - if (!this._query.isEqual(other._query)) { - return false; - } - - if (this._materializedDocs && !this._materializedChanges) { - // If we have only materialized the documents, we compare them first. - return ( - isArrayEqual(this.docs, other.docs) && - isArrayEqual(this.docChanges(), other.docChanges()) - ); - } - - // Otherwise, we compare the changes first as we expect there to be fewer. - return ( - isArrayEqual(this.docChanges(), other.docChanges()) && - isArrayEqual(this.docs, other.docs) - ); - } -} - -/** - * A `VectorQuerySnapshot` contains zero or more `QueryDocumentSnapshot` objects - * representing the results of a query. The documents can be accessed as an - * array via the `docs` property or enumerated using the `forEach` method. The - * number of documents can be determined via the `empty` and `size` - * properties. - */ -export class VectorQuerySnapshot< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, -> implements firestore.VectorQuerySnapshot -{ - private _materializedDocs: Array< - QueryDocumentSnapshot - > | null = null; - private _materializedChanges: Array< - DocumentChange - > | null = null; - private _docs: - | (() => Array>) - | null = null; - private _changes: - | (() => Array>) - | null = null; - - /** - * @private - * @internal - * - * @param _query - The originating query. - * @param _readTime - The time when this query snapshot was obtained. - * @param _size - The number of documents in the result set. - * @param docs - A callback returning a sorted array of documents matching - * this query - * @param changes - A callback returning a sorted array of document change - * events for this snapshot. - */ - constructor( - private readonly _query: VectorQuery, - private readonly _readTime: Timestamp, - private readonly _size: number, - docs: () => Array>, - changes: () => Array> - ) { - this._docs = docs; - this._changes = changes; - } - - /** - * The `VectorQuery` on which you called get() in order to get this - * `VectorQuerySnapshot`. - * - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}) - * .get().then(querySnapshot => { - * console.log(`Returned first batch of results`); - * let query = querySnapshot.query; - * return query.offset(10).get(); - * }).then(() => { - * console.log(`Returned second batch of results`); - * }); - * ``` - */ - get query(): VectorQuery { - return this._query; - } - - /** - * An array of all the documents in this `VectorQuerySnapshot`. - * - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col') - * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); - * - * query.get().then(querySnapshot => { - * let docs = querySnapshot.docs; - * for (let doc of docs) { - * console.log(`Document found at path: ${doc.ref.path}`); - * } - * }); - * ``` - */ - get docs(): Array> { - if (this._materializedDocs) { - return this._materializedDocs!; - } - this._materializedDocs = this._docs!(); - this._docs = null; - return this._materializedDocs!; - } - - /** - * `true` if there are no documents in the `VectorQuerySnapshot`. - * - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col') - * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); - * - * query.get().then(querySnapshot => { - * if (querySnapshot.empty) { - * console.log('No documents found.'); - * } - * }); - * ``` - */ - get empty(): boolean { - return this._size === 0; - } - - /** - * The number of documents in the `VectorQuerySnapshot`. - * - * @readonly - * - * @example - * ``` - * let query = firestore.collection('col') - * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); - * - * query.get().then(querySnapshot => { - * console.log(`Found ${querySnapshot.size} documents.`); - * }); - * ``` - */ - get size(): number { - return this._size; - } - - /** - * The time this `VectorQuerySnapshot` was obtained. - * - * @example - * ``` - * let query = firestore.collection('col') - * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); - * - * query.get().then((querySnapshot) => { - * let readTime = querySnapshot.readTime; - * console.log(`Query results returned at '${readTime.toDate()}'`); - * }); - * ``` - */ - get readTime(): Timestamp { - return this._readTime; - } - - /** - * Returns an array of the documents changes since the last snapshot. If - * this is the first snapshot, all documents will be in the list as added - * changes. - * - * @returns An array of the documents changes since the last snapshot. - * - * @example - * ``` - * let query = firestore.collection('col') - * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); - * - * query.get().then(querySnapshot => { - * let changes = querySnapshot.docChanges(); - * for (let change of changes) { - * console.log(`A document was ${change.type}.`); - * } - * }); - * ``` - */ - docChanges(): Array> { - if (this._materializedChanges) { - return this._materializedChanges!; - } - this._materializedChanges = this._changes!(); - this._changes = null; - return this._materializedChanges!; - } - - /** - * Enumerates all of the documents in the `VectorQuerySnapshot`. This is a convenience - * method for running the same callback on each {@link QueryDocumentSnapshot} - * that is returned. - * - * @param callback - A callback to be called with a - * {@link QueryDocumentSnapshot} for each document in - * the snapshot. - * @param thisArg - The `this` binding for the callback.. - * - * @example - * ``` - * let query = firestore.collection('col') - * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); - * - * query.get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Document found at path: ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - forEach( - callback: ( - result: firestore.QueryDocumentSnapshot - ) => void, - thisArg?: unknown - ): void { - validateFunction('callback', callback); - - for (const doc of this.docs) { - callback.call(thisArg, doc); - } - } - - /** - * Returns true if the document data in this `VectorQuerySnapshot` is equal to the - * provided value. - * - * @param other - The value to compare against. - * @returns true if this `VectorQuerySnapshot` is equal to the provided - * value. - */ - isEqual( - other: firestore.VectorQuerySnapshot - ): boolean { - // Since the read time is different on every query read, we explicitly - // ignore all metadata in this comparison. - - if (this === other) { - return true; - } - - if (!(other instanceof VectorQuerySnapshot)) { - return false; - } - - if (this._size !== other._size) { - return false; - } - - if (!this._query.isEqual(other._query)) { - return false; - } - - if (this._materializedDocs && !this._materializedChanges) { - // If we have only materialized the documents, we compare them first. - return ( - isArrayEqual(this.docs, other.docs) && - isArrayEqual(this.docChanges(), other.docChanges()) - ); - } - - // Otherwise, we compare the changes first as we expect there to be fewer. - return ( - isArrayEqual(this.docChanges(), other.docChanges()) && - isArrayEqual(this.docs, other.docs) - ); - } -} - -/** Internal representation of a query cursor before serialization. */ -interface QueryCursor { - before: boolean; - values: api.IValue[]; -} - -/*! - * Denotes whether a provided limit is applied to the beginning or the end of - * the result set. - */ -enum LimitType { - First, - Last, -} - -/** - * Internal class representing custom Query options. - * - * These options are immutable. Modified options can be created using `with()`. - * @private - * @internal - */ -export class QueryOptions< - AppModelType, - DbModelType extends firestore.DocumentData, -> { - constructor( - readonly parentPath: ResourcePath, - readonly collectionId: string, - readonly converter: firestore.FirestoreDataConverter< - AppModelType, - DbModelType - >, - readonly allDescendants: boolean, - readonly filters: FilterInternal[], - readonly fieldOrders: FieldOrder[], - readonly startAt?: QueryCursor, - readonly endAt?: QueryCursor, - readonly limit?: number, - readonly limitType?: LimitType, - readonly offset?: number, - readonly projection?: api.StructuredQuery.IProjection, - // Whether to select all documents under `parentPath`. By default, only - // collections that match `collectionId` are selected. - readonly kindless = false, - // Whether to require consistent documents when restarting the query. By - // default, restarting the query uses the readTime offset of the original - // query to provide consistent results. - readonly requireConsistency = true - ) {} - - /** - * Returns query options for a collection group query. - * @private - * @internal - */ - static forCollectionGroupQuery< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - collectionId: string, - converter = defaultConverter() - ): QueryOptions { - return new QueryOptions( - /*parentPath=*/ ResourcePath.EMPTY, - collectionId, - converter, - /*allDescendants=*/ true, - /*fieldFilters=*/ [], - /*fieldOrders=*/ [] - ); - } - - /** - * Returns query options for a single-collection query. - * @private - * @internal - */ - static forCollectionQuery< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - collectionRef: ResourcePath, - converter = defaultConverter() - ): QueryOptions { - return new QueryOptions( - collectionRef.parent()!, - collectionRef.id!, - converter, - /*allDescendants=*/ false, - /*fieldFilters=*/ [], - /*fieldOrders=*/ [] - ); - } - - /** - * Returns query options for a query that fetches all descendants under the - * specified reference. - * - * @private - * @internal - */ - static forKindlessAllDescendants( - parent: ResourcePath, - id: string, - requireConsistency = true - ): QueryOptions { - let options = new QueryOptions< - firestore.DocumentData, - firestore.DocumentData - >( - parent, - id, - defaultConverter(), - /*allDescendants=*/ true, - /*fieldFilters=*/ [], - /*fieldOrders=*/ [] - ); - - options = options.with({ - kindless: true, - requireConsistency, - }); - return options; - } - - /** - * Returns the union of the current and the provided options. - * @private - * @internal - */ - with( - settings: Partial< - Omit, 'converter'> - > - ): QueryOptions { - return new QueryOptions( - coalesce(settings.parentPath, this.parentPath)!, - coalesce(settings.collectionId, this.collectionId)!, - this.converter, - coalesce(settings.allDescendants, this.allDescendants)!, - coalesce(settings.filters, this.filters)!, - coalesce(settings.fieldOrders, this.fieldOrders)!, - coalesce(settings.startAt, this.startAt), - coalesce(settings.endAt, this.endAt), - coalesce(settings.limit, this.limit), - coalesce(settings.limitType, this.limitType), - coalesce(settings.offset, this.offset), - coalesce(settings.projection, this.projection), - coalesce(settings.kindless, this.kindless), - coalesce(settings.requireConsistency, this.requireConsistency) - ); - } - - withConverter< - NewAppModelType, - NewDbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - converter: firestore.FirestoreDataConverter - ): QueryOptions { - return new QueryOptions( - this.parentPath, - this.collectionId, - converter, - this.allDescendants, - this.filters, - this.fieldOrders, - this.startAt, - this.endAt, - this.limit, - this.limitType, - this.offset, - this.projection - ); - } - - hasFieldOrders(): boolean { - return this.fieldOrders.length > 0; - } - - isEqual(other: QueryOptions): boolean { - if (this === other) { - return true; - } - - return ( - other instanceof QueryOptions && - this.parentPath.isEqual(other.parentPath) && - this.filtersEqual(other.filters) && - this.collectionId === other.collectionId && - this.converter === other.converter && - this.allDescendants === other.allDescendants && - this.limit === other.limit && - this.offset === other.offset && - deepEqual(this.fieldOrders, other.fieldOrders) && - deepEqual(this.startAt, other.startAt) && - deepEqual(this.endAt, other.endAt) && - deepEqual(this.projection, other.projection) && - this.kindless === other.kindless && - this.requireConsistency === other.requireConsistency - ); - } - - private filtersEqual(other: FilterInternal[]): boolean { - if (this.filters.length !== other.length) { - return false; - } - - for (let i = 0; i < other.length; i++) { - if (!this.filters[i].isEqual(other[i])) { - return false; - } - } - return true; - } -} - -class QueryUtil< - AppModelType, - DbModelType extends firestore.DocumentData, - Template extends - | Query - | VectorQuery, -> { - constructor( - /** @private */ - readonly _firestore: Firestore, - /** @private */ - readonly _queryOptions: QueryOptions, - /** @private */ - readonly _serializer: Serializer - ) {} - - _get( - query: Template, - transactionIdOrReadTime?: Uint8Array | Timestamp, - retryWithCursor = true - ): Promise< - | QuerySnapshot - | VectorQuerySnapshot - > { - const docs: Array> = []; - - // Capture the error stack to preserve stack tracing across async calls. - const stack = Error().stack!; - - return new Promise((resolve, reject) => { - let readTime: Timestamp; - - this._stream(query, transactionIdOrReadTime, retryWithCursor) - .on('error', err => { - reject(wrapError(err, stack)); - }) - .on('data', result => { - readTime = result.readTime; - if (result.document) { - docs.push(result.document); - } - }) - .on('end', () => { - if (this._queryOptions.limitType === LimitType.Last) { - // The results for limitToLast queries need to be flipped since - // we reversed the ordering constraints before sending the query - // to the backend. - docs.reverse(); - } - - resolve( - query._createSnapshot( - readTime, - docs.length, - () => docs, - () => { - const changes: Array< - DocumentChange - > = []; - for (let i = 0; i < docs.length; ++i) { - changes.push(new DocumentChange('added', docs[i], -1, i)); - } - return changes; - } - ) - ); - }); - }); - } - - // This method exists solely to enable unit tests to mock it. - _isPermanentRpcError(err: GoogleError, methodName: string): boolean { - return isPermanentRpcError(err, methodName); - } - - _hasRetryTimedOut(methodName: string, startTime: number): boolean { - const totalTimeout = getTotalTimeout(methodName); - if (totalTimeout === 0) { - return false; - } - - return Date.now() - startTime >= totalTimeout; - } - - stream(query: Template): NodeJS.ReadableStream { - if (this._queryOptions.limitType === LimitType.Last) { - throw new Error( - 'Query results for queries that include limitToLast() ' + - 'constraints cannot be streamed. Use Query.get() instead.' - ); - } - - const responseStream = this._stream(query); - const transform = new Transform({ - objectMode: true, - transform(chunk, encoding, callback) { - callback(undefined, chunk.document); - }, - }); - - responseStream.pipe(transform); - responseStream.on('error', e => transform.destroy(e)); - return transform; - } - - _stream( - query: Template, - transactionIdOrReadTime?: Uint8Array | Timestamp, - retryWithCursor = true, - explainOptions?: firestore.ExplainOptions - ): NodeJS.ReadableStream { - const tag = requestTag(); - const startTime = Date.now(); - const isExplain = explainOptions !== undefined; - - let lastReceivedDocument: QueryDocumentSnapshot< - AppModelType, - DbModelType - > | null = null; - - let backendStream: Duplex; - const stream = new Transform({ - objectMode: true, - transform: (proto, enc, callback) => { - if (proto === NOOP_MESSAGE) { - callback(undefined); - return; - } - - const output: { - readTime?: Timestamp; - document?: QueryDocumentSnapshot; - explainMetrics?: ExplainMetrics; - } = {}; - - if (proto.readTime) { - output.readTime = Timestamp.fromProto(proto.readTime); - } - - if (proto.document) { - const document = this._firestore.snapshot_( - proto.document, - proto.readTime - ); - const finalDoc = new DocumentSnapshotBuilder< - AppModelType, - DbModelType - >(document.ref.withConverter(this._queryOptions.converter)); - // Recreate the QueryDocumentSnapshot with the DocumentReference - // containing the original converter. - finalDoc.fieldsProto = document._fieldsProto; - finalDoc.readTime = document.readTime; - finalDoc.createTime = document.createTime; - finalDoc.updateTime = document.updateTime; - lastReceivedDocument = finalDoc.build() as QueryDocumentSnapshot< - AppModelType, - DbModelType - >; - output.document = lastReceivedDocument; - } - - if (proto.explainMetrics) { - output.explainMetrics = ExplainMetrics._fromProto( - proto.explainMetrics, - this._serializer - ); - } - - callback(undefined, output); - - if (proto.done) { - logger('QueryUtil._stream', tag, 'Trigger Logical Termination.'); - backendStream.unpipe(stream); - backendStream.resume(); - backendStream.end(); - stream.end(); - } - }, - }); - - this._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. - let request = query.toProto(transactionIdOrReadTime, explainOptions); - - let streamActive: Deferred; - do { - streamActive = new Deferred(); - const methodName = 'runQuery'; - backendStream = await this._firestore.requestStream( - methodName, - /* bidirectional= */ false, - request, - tag - ); - backendStream.on('error', err => { - backendStream.unpipe(stream); - - // If a non-transactional query failed, attempt to restart. - // Transactional queries are retried via the transaction runner. - // Explain queries are not retried with a cursor. That would produce - // incorrect/partial profiling results. - if ( - !isExplain && - !transactionIdOrReadTime && - !this._isPermanentRpcError(err, 'runQuery') - ) { - logger( - 'QueryUtil._stream', - tag, - 'Query failed with retryable stream error:', - err - ); - - // Enqueue a "no-op" write into the stream and wait for it to be - // read by the downstream consumer. This ensures that all enqueued - // results in the stream are consumed, which will give us an accurate - // value for `lastReceivedDocument`. - stream.write(NOOP_MESSAGE, () => { - if (this._hasRetryTimedOut(methodName, startTime)) { - logger( - 'QueryUtil._stream', - tag, - 'Query failed with retryable stream error but the total retry timeout has exceeded.' - ); - stream.destroy(err); - streamActive.resolve(/* active= */ false); - } else if (lastReceivedDocument && retryWithCursor) { - logger( - 'Query._stream', - tag, - 'Query failed with retryable stream error and progress was made receiving ' + - 'documents, so the stream is being retried.' - ); - - // Restart the query but use the last document we received as - // the query cursor. Note that we do not use backoff here. The - // call to `requestStream()` will backoff should the restart - // fail before delivering any results. - if (this._queryOptions.requireConsistency) { - request = query - .startAfter(lastReceivedDocument) - .toProto(lastReceivedDocument.readTime); - } else { - request = query.startAfter(lastReceivedDocument).toProto(); - } - - // Set lastReceivedDocument to null before each retry attempt to ensure the retry makes progress - lastReceivedDocument = null; - - streamActive.resolve(/* active= */ true); - } else { - logger( - 'QueryUtil._stream', - tag, - `Query failed with retryable stream error however either retryWithCursor="${retryWithCursor}", or ` + - 'no progress was made receiving documents, so the stream is being closed.' - ); - stream.destroy(err); - streamActive.resolve(/* active= */ false); - } - }); - } else { - logger( - 'QueryUtil._stream', - tag, - 'Query failed with stream error:', - err - ); - stream.destroy(err); - streamActive.resolve(/* active= */ false); - } - }); - backendStream.on('end', () => { - streamActive.resolve(/* active= */ false); - }); - backendStream.resume(); - backendStream.pipe(stream); - } while (await streamActive.promise); - }) - .catch(e => stream.destroy(e)); - - return stream; - } -} - -/** - * A Query refers to a query which you can read or stream from. You can also - * construct refined Query objects by adding filters and ordering. - * - * @class Query - */ -export class Query< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, -> implements firestore.Query -{ - /** - * @internal - * @private - **/ - readonly _serializer: Serializer; - /** - * @internal - * @private - **/ - protected readonly _allowUndefined: boolean; - /** - * @internal - * @private - **/ - readonly _queryUtil: QueryUtil< - AppModelType, - DbModelType, - Query - >; - - /** - * @internal - * @private - * - * @param _firestore The Firestore Database client. - * @param _queryOptions Options that define the query. - */ - constructor( - /** - * @internal - * @private - **/ - readonly _firestore: Firestore, - /** - * @internal - * @private - **/ - readonly _queryOptions: QueryOptions - ) { - this._serializer = new Serializer(_firestore); - this._allowUndefined = - !!this._firestore._settings.ignoreUndefinedProperties; - this._queryUtil = new QueryUtil< - AppModelType, - DbModelType, - Query - >(_firestore, _queryOptions, this._serializer); - } - - /** - * Extracts field values from the DocumentSnapshot based on the provided - * field order. - * - * @private - * @internal - * @param documentSnapshot The document to extract the fields from. - * @param fieldOrders The field order that defines what fields we should - * extract. - * @return {Array.<*>} The field values to use. - */ - static _extractFieldValues( - documentSnapshot: DocumentSnapshot, - fieldOrders: FieldOrder[] - ): unknown[] { - const fieldValues: unknown[] = []; - - for (const fieldOrder of fieldOrders) { - if (FieldPath.documentId().isEqual(fieldOrder.field)) { - fieldValues.push(documentSnapshot.ref); - } else { - const fieldValue = documentSnapshot.get(fieldOrder.field); - if (fieldValue === undefined) { - throw new Error( - `Field "${fieldOrder.field}" is missing in the provided DocumentSnapshot. ` + - 'Please provide a document that contains values for all specified ' + - 'orderBy() and where() constraints.' - ); - } else { - fieldValues.push(fieldValue); - } - } - } - return fieldValues; - } - - /** - * The [Firestore]{@link Firestore} instance for the Firestore - * database (useful for performing transactions, etc.). - * - * @type {Firestore} - * @name Query#firestore - * @readonly - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * - * collectionRef.add({foo: 'bar'}).then(documentReference => { - * let firestore = documentReference.firestore; - * console.log(`Root location for document is ${firestore.formattedName}`); - * }); - * ``` - */ - get firestore(): Firestore { - return this._firestore; - } - - /** - * Creates and returns a new [Query]{@link Query} with the additional filter - * that documents must contain the specified field and that its value should - * satisfy the relation constraint provided. - * - * This function returns a new (immutable) instance of the Query (rather than - * modify the existing instance) to impose the filter. - * - * @param {string|FieldPath} fieldPath The name of a property value to compare. - * @param {string} opStr A comparison operation in the form of a string. - * Acceptable operator strings are "<", "<=", "==", "!=", ">=", ">", "array-contains", - * "in", "not-in", and "array-contains-any". - * @param {*} value The value to which to compare the field for inclusion in - * a query. - * @returns {Query} The created Query. - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * - * collectionRef.where('foo', '==', 'bar').get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - where( - fieldPath: string | FieldPath, - opStr: firestore.WhereFilterOp, - value: unknown - ): Query; - - /** - * Creates and returns a new [Query]{@link Query} with the additional filter - * that documents should satisfy the relation constraint(s) provided. - * - * This function returns a new (immutable) instance of the Query (rather than - * modify the existing instance) to impose the filter. - * - * @param {Filter} filter A unary or composite filter to apply to the Query. - * @returns {Query} The created Query. - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * - * collectionRef.where(Filter.and(Filter.where('foo', '==', 'bar'), Filter.where('foo', '!=', 'baz'))).get() - * .then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - where(filter: Filter): Query; - - where( - fieldPathOrFilter: string | firestore.FieldPath | Filter, - opStr?: firestore.WhereFilterOp, - value?: unknown - ): Query { - let filter: Filter; - - if (fieldPathOrFilter instanceof Filter) { - filter = fieldPathOrFilter; - } else { - filter = Filter.where(fieldPathOrFilter, opStr!, value); - } - - if (this._queryOptions.startAt || this._queryOptions.endAt) { - throw new Error( - 'Cannot specify a where() filter after calling startAt(), ' + - 'startAfter(), endBefore() or endAt().' - ); - } - - const parsedFilter = this._parseFilter(filter); - - if (parsedFilter.getFilters().length === 0) { - // Return the existing query if not adding any more filters (e.g. an empty composite filter). - return this; - } - - const options = this._queryOptions.with({ - filters: this._queryOptions.filters.concat(parsedFilter), - }); - return new Query(this._firestore, options); - } - - /** - * @internal - * @private - */ - _parseFilter(filter: Filter): FilterInternal { - if (filter instanceof UnaryFilter) { - return this._parseFieldFilter(filter); - } - return this._parseCompositeFilter(filter as CompositeFilter); - } - - /** - * @internal - * @private - */ - _parseFieldFilter(fieldFilterData: UnaryFilter): FieldFilterInternal { - let value = fieldFilterData._getValue(); - let operator = fieldFilterData._getOperator(); - const fieldPath = fieldFilterData._getField(); - - validateFieldPath('fieldPath', fieldPath); - - operator = validateQueryOperator('opStr', operator, value); - validateQueryValue('value', value, this._allowUndefined); - - const path = FieldPath.fromArgument(fieldPath); - - if (FieldPath.documentId().isEqual(path)) { - if (operator === 'array-contains' || operator === 'array-contains-any') { - throw new Error( - `Invalid Query. You can't perform '${operator}' ` + - 'queries on FieldPath.documentId().' - ); - } else if (operator === 'in' || operator === 'not-in') { - if (!Array.isArray(value) || value.length === 0) { - throw new Error( - `Invalid Query. A non-empty array is required for '${operator}' filters.` - ); - } - value = value.map(el => this.validateReference(el)); - } else { - value = this.validateReference(value); - } - } - - return new FieldFilterInternal( - this._serializer, - path, - comparisonOperators[operator], - value - ); - } - - /** - * @internal - * @private - */ - _parseCompositeFilter(compositeFilterData: CompositeFilter): FilterInternal { - const parsedFilters = compositeFilterData - ._getFilters() - .map(filter => this._parseFilter(filter)) - .filter(parsedFilter => parsedFilter.getFilters().length > 0); - - // For composite filters containing 1 filter, return the only filter. - // For example: AND(FieldFilter1) == FieldFilter1 - if (parsedFilters.length === 1) { - return parsedFilters[0]; - } - return new CompositeFilterInternal( - parsedFilters, - compositeFilterData._getOperator() === 'AND' ? 'AND' : 'OR' - ); - } - - /** - * Creates and returns a new [Query]{@link Query} instance that applies a - * field mask to the result and returns only the specified subset of fields. - * You can specify a list of field paths to return, or use an empty list to - * only return the references of matching documents. - * - * Queries that contain field masks cannot be listened to via `onSnapshot()` - * listeners. - * - * This function returns a new (immutable) instance of the Query (rather than - * modify the existing instance) to impose the field mask. - * - * @param {...(string|FieldPath)} fieldPaths The field paths to return. - * @returns {Query} The created Query. - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * let documentRef = collectionRef.doc('doc'); - * - * return documentRef.set({x:10, y:5}).then(() => { - * return collectionRef.where('x', '>', 5).select('y').get(); - * }).then((res) => { - * console.log(`y is ${res.docs[0].get('y')}.`); - * }); - * ``` - */ - select(...fieldPaths: Array): Query { - const fields: api.StructuredQuery.IFieldReference[] = []; - - if (fieldPaths.length === 0) { - fields.push({fieldPath: FieldPath.documentId().formattedName}); - } else { - for (let i = 0; i < fieldPaths.length; ++i) { - validateFieldPath(i, fieldPaths[i]); - fields.push({ - fieldPath: FieldPath.fromArgument(fieldPaths[i]).formattedName, - }); - } - } - - // By specifying a field mask, the query result no longer conforms to type - // `T`. We there return `Query`; - const options = this._queryOptions.with({ - projection: {fields}, - }) as QueryOptions; - return new Query(this._firestore, options); - } - - /** - * Creates and returns a new [Query]{@link Query} that's additionally sorted - * by the specified field, optionally in descending order instead of - * ascending. - * - * This function returns a new (immutable) instance of the Query (rather than - * modify the existing instance) to impose the field mask. - * - * @param {string|FieldPath} fieldPath The field to sort by. - * @param {string=} directionStr Optional direction to sort by ('asc' or - * 'desc'). If not specified, order will be ascending. - * @returns {Query} The created Query. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '>', 42); - * - * query.orderBy('foo', 'desc').get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - orderBy( - fieldPath: string | firestore.FieldPath, - directionStr?: firestore.OrderByDirection - ): Query { - validateFieldPath('fieldPath', fieldPath); - directionStr = validateQueryOrder('directionStr', directionStr); - - if (this._queryOptions.startAt || this._queryOptions.endAt) { - throw new Error( - 'Cannot specify an orderBy() constraint after calling ' + - 'startAt(), startAfter(), endBefore() or endAt().' - ); - } - - const newOrder = new FieldOrder( - FieldPath.fromArgument(fieldPath), - directionOperators[directionStr || 'asc'] - ); - - const options = this._queryOptions.with({ - fieldOrders: this._queryOptions.fieldOrders.concat(newOrder), - }); - return new Query(this._firestore, options); - } - - /** - * Creates and returns a new [Query]{@link Query} that only returns the - * first matching documents. - * - * This function returns a new (immutable) instance of the Query (rather than - * modify the existing instance) to impose the limit. - * - * @param {number} limit The maximum number of items to return. - * @returns {Query} The created Query. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '>', 42); - * - * query.limit(1).get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - limit(limit: number): Query { - validateInteger('limit', limit); - - const options = this._queryOptions.with({ - limit, - limitType: LimitType.First, - }); - return new Query(this._firestore, options); - } - - /** - * Creates and returns a new [Query]{@link Query} that only returns the - * last matching documents. - * - * You must specify at least one orderBy clause for limitToLast queries, - * otherwise an exception will be thrown during execution. - * - * Results for limitToLast queries cannot be streamed via the `stream()` API. - * - * @param limit The maximum number of items to return. - * @return The created Query. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '>', 42); - * - * query.limitToLast(1).get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Last matching document is ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - limitToLast(limit: number): Query { - validateInteger('limitToLast', limit); - - const options = this._queryOptions.with({limit, limitType: LimitType.Last}); - return new Query(this._firestore, options); - } - - /** - * Specifies the offset of the returned results. - * - * This function returns a new (immutable) instance of the - * [Query]{@link Query} (rather than modify the existing instance) - * to impose the offset. - * - * @param {number} offset The offset to apply to the Query results - * @returns {Query} The created Query. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '>', 42); - * - * query.limit(10).offset(20).get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - offset(offset: number): Query { - validateInteger('offset', offset); - - const options = this._queryOptions.with({offset}); - 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 count the documents in cases where the result set is - * prohibitively large to download entirely (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}, - AppModelType, - DbModelType - > { - return this.aggregate({ - count: AggregateField.count(), - }); - } - - /** - * Returns a query that can perform the given aggregations. - * - * The returned query, when executed, calculates the specified aggregations - * over the documents in the result set of this query without actually - * downloading the documents. - * - * Using the returned query to perform aggregations is efficient because only - * the final aggregation values, not the documents' data, is downloaded. The - * returned query can perform aggregations of the documents count the - * documents in cases where the result set is prohibitively large to download - * entirely (thousands of documents). - * - * @param aggregateSpec An `AggregateSpec` object that specifies the aggregates - * to perform over the result set. The AggregateSpec specifies aliases for each - * aggregate, which can be used to retrieve the aggregate result. - * @example - * ```typescript - * const aggregateQuery = col.aggregate(query, { - * countOfDocs: count(), - * totalHours: sum('hours'), - * averageScore: average('score') - * }); - * - * const aggregateSnapshot = await aggregateQuery.get(); - * const countOfDocs: number = aggregateSnapshot.data().countOfDocs; - * const totalHours: number = aggregateSnapshot.data().totalHours; - * const averageScore: number | null = aggregateSnapshot.data().averageScore; - * ``` - */ - aggregate( - aggregateSpec: T - ): AggregateQuery { - return new AggregateQuery( - this, - aggregateSpec - ); - } - - /** - * Returns a query that can perform vector distance (similarity) search with given parameters. - * - * The returned query, when executed, performs a distance (similarity) search on the specified - * `vectorField` against the given `queryVector` and returns the top documents that are closest - * to the `queryVector`. - * - * Only documents whose `vectorField` field is a {@link VectorValue} of the same dimension as `queryVector` - * participate in the query, all other documents are ignored. - * - * @example - * ``` - * // Returns the closest 10 documents whose Euclidean distance from their 'embedding' fields are closed to [41, 42]. - * const vectorQuery = col.findNearest('embedding', [41, 42], {limit: 10, distanceMeasure: 'EUCLIDEAN'}); - * - * const querySnapshot = await aggregateQuery.get(); - * querySnapshot.forEach(...); - * ``` - * - * @param vectorField - A string or {@link FieldPath} specifying the vector field to search on. - * @param queryVector - The {@link VectorValue} used to measure the distance from `vectorField` values in the documents. - * @param options - Options control the vector query. `limit` specifies the upper bound of documents to return, must - * be a positive integer with a maximum value of 1000. `distanceMeasure` specifies what type of distance is calculated - * when performing the query. - */ - findNearest( - vectorField: string | firestore.FieldPath, - queryVector: firestore.VectorValue | Array, - options: { - limit: number; - distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT'; - } - ): VectorQuery { - validateFieldPath('vectorField', vectorField); - - if (options.limit <= 0) { - throw invalidArgumentMessage('options.limit', 'positive limit number'); - } - - if ( - (Array.isArray(queryVector) - ? queryVector.length - : queryVector.toArray().length) === 0 - ) { - throw invalidArgumentMessage( - 'queryVector', - 'vector size must be larger than 0' - ); - } - - return new VectorQuery( - this, - vectorField, - queryVector, - new VectorQueryOptions(options.limit, options.distanceMeasure) - ); - } - - /** - * Returns true if this `Query` is equal to the provided value. - * - * @param {*} other The value to compare against. - * @return {boolean} true if this `Query` is equal to the provided value. - */ - isEqual(other: firestore.Query): boolean { - if (this === other) { - return true; - } - - return ( - other instanceof Query && this._queryOptions.isEqual(other._queryOptions) - ); - } - - /** - * Returns the sorted array of inequality filter fields used in this query. - * - * @return An array of inequality filter fields sorted lexicographically by FieldPath. - */ - private getInequalityFilterFields(): FieldPath[] { - const inequalityFields: FieldPath[] = []; - - for (const filter of this._queryOptions.filters) { - for (const subFilter of filter.getFlattenedFilters()) { - if (subFilter.isInequalityFilter()) { - inequalityFields.push(subFilter.field); - } - } - } - - return inequalityFields.sort((a, b) => a.compareTo(b)); - } - - /** - * Computes the backend ordering semantics for DocumentSnapshot cursors. - * - * @private - * @internal - * @param cursorValuesOrDocumentSnapshot The snapshot of the document or the - * set of field values to use as the boundary. - * @returns The implicit ordering semantics. - */ - private createImplicitOrderBy( - cursorValuesOrDocumentSnapshot: Array< - DocumentSnapshot | unknown - > - ): FieldOrder[] { - // Add an implicit orderBy if the only cursor value is a DocumentSnapshot. - if ( - cursorValuesOrDocumentSnapshot.length !== 1 || - !(cursorValuesOrDocumentSnapshot[0] instanceof DocumentSnapshot) - ) { - return this._queryOptions.fieldOrders; - } - - const fieldOrders = this._queryOptions.fieldOrders.slice(); - const fieldsNormalized = new Set([ - ...fieldOrders.map(item => item.field.toString()), - ]); - - /** The order of the implicit ordering always matches the last explicit order by. */ - const lastDirection = - fieldOrders.length === 0 - ? directionOperators.ASC - : fieldOrders[fieldOrders.length - 1].direction; - - /** - * Any inequality fields not explicitly ordered should be implicitly ordered in a - * lexicographical order. When there are multiple inequality filters on the same field, the - * field should be added only once. - * Note: getInequalityFilterFields function sorts the key field before - * other fields. However, we want the key field to be sorted last. - */ - const inequalityFields = this.getInequalityFilterFields(); - for (const field of inequalityFields) { - if ( - !fieldsNormalized.has(field.toString()) && - !field.isEqual(FieldPath.documentId()) - ) { - fieldOrders.push(new FieldOrder(field, lastDirection)); - fieldsNormalized.add(field.toString()); - } - } - - // Add the document key field to the last if it is not explicitly ordered. - if (!fieldsNormalized.has(FieldPath.documentId().toString())) { - fieldOrders.push(new FieldOrder(FieldPath.documentId(), lastDirection)); - } - - return fieldOrders; - } - - /** - * Builds a Firestore 'Position' proto message. - * - * @private - * @internal - * @param {Array.} fieldOrders The field orders to use for this - * cursor. - * @param {Array.} cursorValuesOrDocumentSnapshot The - * snapshot of the document or the set of field values to use as the boundary. - * @param before Whether the query boundary lies just before or after the - * provided data. - * @returns {Object} The proto message. - */ - private createCursor( - fieldOrders: FieldOrder[], - cursorValuesOrDocumentSnapshot: Array, - before: boolean - ): QueryCursor { - let fieldValues; - - if ( - cursorValuesOrDocumentSnapshot.length === 1 && - cursorValuesOrDocumentSnapshot[0] instanceof DocumentSnapshot - ) { - fieldValues = Query._extractFieldValues( - cursorValuesOrDocumentSnapshot[0] as DocumentSnapshot, - fieldOrders - ); - } else { - fieldValues = cursorValuesOrDocumentSnapshot; - } - - if (fieldValues.length > fieldOrders.length) { - throw new Error( - 'Too many cursor values specified. The specified ' + - 'values must match the orderBy() constraints of the query.' - ); - } - - const options: QueryCursor = {values: [], before}; - - for (let i = 0; i < fieldValues.length; ++i) { - let fieldValue = fieldValues[i]; - - if (FieldPath.documentId().isEqual(fieldOrders[i].field)) { - fieldValue = this.validateReference(fieldValue); - } - - validateQueryValue(i, fieldValue, this._allowUndefined); - options.values!.push(this._serializer.encodeValue(fieldValue)!); - } - - return options; - } - - /** - * Validates that a value used with FieldValue.documentId() is either a - * string or a DocumentReference that is part of the query`s result set. - * Throws a validation error or returns a DocumentReference that can - * directly be used in the Query. - * - * @param val The value to validate. - * @throws If the value cannot be used for this query. - * @return If valid, returns a DocumentReference that can be used with the - * query. - * @private - * @internal - */ - private validateReference( - val: unknown - ): DocumentReference { - const basePath = this._queryOptions.allDescendants - ? this._queryOptions.parentPath - : this._queryOptions.parentPath.append(this._queryOptions.collectionId); - let reference: DocumentReference; - - if (typeof val === 'string') { - const path = basePath.append(val); - - if (this._queryOptions.allDescendants) { - if (!path.isDocument) { - throw new Error( - 'When querying a collection group and ordering by ' + - 'FieldPath.documentId(), the corresponding value must result in ' + - `a valid document path, but '${val}' is not because it ` + - 'contains an odd number of segments.' - ); - } - } else if (val.indexOf('/') !== -1) { - throw new Error( - 'When querying a collection and ordering by FieldPath.documentId(), ' + - `the corresponding value must be a plain document ID, but '${val}' ` + - 'contains a slash.' - ); - } - - reference = new DocumentReference( - this._firestore, - basePath.append(val), - this._queryOptions.converter - ); - } else if (val instanceof DocumentReference) { - reference = val; - if (!basePath.isPrefixOf(reference._path)) { - throw new Error( - `"${reference.path}" is not part of the query result set and ` + - 'cannot be used as a query boundary.' - ); - } - } else { - throw new Error( - 'The corresponding value for FieldPath.documentId() must be a ' + - `string or a DocumentReference, but was "${val}".` - ); - } - - if ( - !this._queryOptions.allDescendants && - reference._path.parent()!.compareTo(basePath) !== 0 - ) { - throw new Error( - 'Only a direct child can be used as a query boundary. ' + - `Found: "${reference.path}".` - ); - } - return reference; - } - - /** - * Creates and returns a new [Query]{@link Query} that starts at the provided - * set of field values relative to the order of the query. The order of the - * provided values must match the order of the order by clauses of the query. - * - * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot - * of the document the query results should start at or the field values to - * start this query at, in order of the query's order by. - * @returns {Query} A query with the new starting point. - * - * @example - * ``` - * let query = firestore.collection('col'); - * - * query.orderBy('foo').startAt(42).get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - startAt( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { - validateMinNumberOfArguments( - 'Query.startAt', - fieldValuesOrDocumentSnapshot, - 1 - ); - - const fieldOrders = this.createImplicitOrderBy( - fieldValuesOrDocumentSnapshot - ); - const startAt = this.createCursor( - fieldOrders, - fieldValuesOrDocumentSnapshot, - true - ); - - const options = this._queryOptions.with({fieldOrders, startAt}); - return new Query(this._firestore, options); - } - - /** - * Creates and returns a new [Query]{@link Query} that starts after the - * provided set of field values relative to the order of the query. The order - * of the provided values must match the order of the order by clauses of the - * query. - * - * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot - * of the document the query results should start after or the field values to - * start this query after, in order of the query's order by. - * @returns {Query} A query with the new starting point. - * - * @example - * ``` - * let query = firestore.collection('col'); - * - * query.orderBy('foo').startAfter(42).get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - startAfter( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { - validateMinNumberOfArguments( - 'Query.startAfter', - fieldValuesOrDocumentSnapshot, - 1 - ); - - const fieldOrders = this.createImplicitOrderBy( - fieldValuesOrDocumentSnapshot - ); - const startAt = this.createCursor( - fieldOrders, - fieldValuesOrDocumentSnapshot, - false - ); - - const options = this._queryOptions.with({fieldOrders, startAt}); - return new Query(this._firestore, options); - } - - /** - * Creates and returns a new [Query]{@link Query} that ends before the set of - * field values relative to the order of the query. The order of the provided - * values must match the order of the order by clauses of the query. - * - * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot - * of the document the query results should end before or the field values to - * end this query before, in order of the query's order by. - * @returns {Query} A query with the new ending point. - * - * @example - * ``` - * let query = firestore.collection('col'); - * - * query.orderBy('foo').endBefore(42).get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - endBefore( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { - validateMinNumberOfArguments( - 'Query.endBefore', - fieldValuesOrDocumentSnapshot, - 1 - ); - - const fieldOrders = this.createImplicitOrderBy( - fieldValuesOrDocumentSnapshot - ); - const endAt = this.createCursor( - fieldOrders, - fieldValuesOrDocumentSnapshot, - true - ); - - const options = this._queryOptions.with({fieldOrders, endAt}); - return new Query(this._firestore, options); - } - - /** - * Creates and returns a new [Query]{@link Query} that ends at the provided - * set of field values relative to the order of the query. The order of the - * provided values must match the order of the order by clauses of the query. - * - * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot - * of the document the query results should end at or the field values to end - * this query at, in order of the query's order by. - * @returns {Query} A query with the new ending point. - * - * @example - * ``` - * let query = firestore.collection('col'); - * - * query.orderBy('foo').endAt(42).get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - endAt( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { - validateMinNumberOfArguments( - 'Query.endAt', - fieldValuesOrDocumentSnapshot, - 1 - ); - - const fieldOrders = this.createImplicitOrderBy( - fieldValuesOrDocumentSnapshot - ); - const endAt = this.createCursor( - fieldOrders, - fieldValuesOrDocumentSnapshot, - false - ); - - const options = this._queryOptions.with({fieldOrders, endAt}); - return new Query(this._firestore, options); - } - - /** - * Executes the query and returns the results as a - * [QuerySnapshot]{@link QuerySnapshot}. - * - * @returns {Promise.} A Promise that resolves with the results - * of the Query. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * query.get().then(querySnapshot => { - * querySnapshot.forEach(documentSnapshot => { - * console.log(`Found document at ${documentSnapshot.ref.path}`); - * }); - * }); - * ``` - */ - get(): Promise> { - return this._get(); - } - - /** - * Plans and optionally executes this query. Returns a Promise that will be - * resolved with the planner information, statistics from the query execution (if any), - * and the query results (if any). - * - * @return A Promise that will be resolved with the planner information, statistics - * from the query execution (if any), and the query results (if any). - */ - explain( - options?: firestore.ExplainOptions - ): Promise>> { - if (options === undefined) { - options = {}; - } - - // Capture the error stack to preserve stack tracing across async calls. - const stack = Error().stack!; - - return new Promise((resolve, reject) => { - let readTime: Timestamp; - let docs: Array> | null = - null; - let metrics: ExplainMetrics | null = null; - - this._stream(undefined, options) - .on('error', err => { - reject(wrapError(err, stack)); - }) - .on('data', data => { - if (data.readTime) { - readTime = data.readTime; - } - if (data.document) { - if (docs === null) { - docs = []; - } - docs.push(data.document); - } - if (data.explainMetrics) { - metrics = data.explainMetrics; - - if (docs === null && metrics?.executionStats !== null) { - // This indicates that the query was executed, but no documents - // had matched the query. - docs = []; - } - } - }) - .on('end', () => { - if (metrics === null) { - reject('No explain results.'); - } - - // Some explain queries will not have a snapshot associated with them. - let snapshot: QuerySnapshot | null = null; - if (docs !== null) { - if (this._queryOptions.limitType === LimitType.Last) { - // The results for limitToLast queries need to be flipped since - // we reversed the ordering constraints before sending the query - // to the backend. - docs.reverse(); - } - - snapshot = new QuerySnapshot( - this, - readTime, - docs.length, - () => docs!, - () => { - const changes: Array< - DocumentChange - > = []; - for (let i = 0; i < docs!.length; ++i) { - changes.push(new DocumentChange('added', docs![i], -1, i)); - } - return changes; - } - ); - } - - resolve(new ExplainResults(metrics!, snapshot)); - }); - }); - } - - /** - * Internal get() method that accepts an optional transaction id. - * - * @private - * @internal - * @param transactionIdOrReadTime A transaction ID or the read time at which - * to execute the query. - */ - _get( - transactionIdOrReadTime?: Uint8Array | Timestamp - ): Promise> { - return this._queryUtil._get(this, transactionIdOrReadTime) as Promise< - QuerySnapshot - >; - } - - /** - * Executes the query and streams the results as - * [QueryDocumentSnapshots]{@link QueryDocumentSnapshot}. - * - * @returns {Stream.} A stream of - * QueryDocumentSnapshots. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * let count = 0; - * - * query.stream().on('data', (documentSnapshot) => { - * console.log(`Found document with name '${documentSnapshot.id}'`); - * ++count; - * }).on('end', () => { - * console.log(`Total count is ${count}`); - * }); - * ``` - */ - stream(): NodeJS.ReadableStream { - return this._queryUtil.stream(this); - } - - /** - * Executes the query and streams the results as the following object: - * {document?: DocumentSnapshot, metrics?: ExplainMetrics} - * - * The stream surfaces documents one at a time as they are received from the - * server, and at the end, it will surface the metrics associated with - * executing the query. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * let count = 0; - * - * query.explainStream({analyze: true}).on('data', (data) => { - * if (data.document) { - * // Use data.document which is a DocumentSnapshot instance. - * console.log(`Found document with name '${data.document.id}'`); - * ++count; - * } - * if (data.metrics) { - * // Use data.metrics which is an ExplainMetrics instance. - * } - * }).on('end', () => { - * console.log(`Received ${count} documents.`); - * }); - * ``` - */ - explainStream( - explainOptions?: firestore.ExplainOptions - ): NodeJS.ReadableStream { - if (explainOptions === undefined) { - explainOptions = {}; - } - if (this._queryOptions.limitType === LimitType.Last) { - throw new Error( - 'Query results for queries that include limitToLast() ' + - 'constraints cannot be streamed. Use Query.explain() instead.' - ); - } - - const responseStream = this._stream(undefined, explainOptions); - const transform = new Transform({ - objectMode: true, - transform(chunk, encoding, callback) { - callback(undefined, { - document: chunk.document, - metrics: chunk.explainMetrics, - }); - }, - }); - responseStream.pipe(transform); - responseStream.on('error', e => transform.destroy(e)); - return transform; - } - - /** - * Converts a QueryCursor to its proto representation. - * - * @param cursor The original cursor value - * @private - * @internal - */ - private toCursor(cursor: QueryCursor | undefined): api.ICursor | undefined { - if (cursor) { - return cursor.before - ? {before: true, values: cursor.values} - : {values: cursor.values}; - } - - return undefined; - } - - /** - * Internal method for serializing a query to its RunQuery proto - * representation with an optional transaction id or read time. - * - * @param transactionIdOrReadTime A transaction ID or the read time at which - * to execute the query. - * @param explainOptions Options to use for explaining the query (if any). - * @private - * @internal - * @returns Serialized JSON for the query. - */ - toProto( - transactionIdOrReadTime?: Uint8Array | Timestamp, - explainOptions?: firestore.ExplainOptions - ): api.IRunQueryRequest { - const projectId = this.firestore.projectId; - const databaseId = this.firestore.databaseId; - const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( - projectId, - databaseId - ); - - const structuredQuery = this.toStructuredQuery(); - - // For limitToLast queries, the structured query has to be translated to a version with - // reversed ordered, and flipped startAt/endAt to work properly. - if (this._queryOptions.limitType === LimitType.Last) { - if (!this._queryOptions.hasFieldOrders()) { - throw new Error( - 'limitToLast() queries require specifying at least one orderBy() clause.' - ); - } - - structuredQuery.orderBy = this._queryOptions.fieldOrders!.map(order => { - // Flip the orderBy directions since we want the last results - const dir = - order.direction === 'DESCENDING' ? 'ASCENDING' : 'DESCENDING'; - return new FieldOrder(order.field, dir).toProto(); - }); - - // Swap the cursors to match the now-flipped query ordering. - structuredQuery.startAt = this._queryOptions.endAt - ? this.toCursor({ - values: this._queryOptions.endAt.values, - before: !this._queryOptions.endAt.before, - }) - : undefined; - structuredQuery.endAt = this._queryOptions.startAt - ? this.toCursor({ - values: this._queryOptions.startAt.values, - before: !this._queryOptions.startAt.before, - }) - : undefined; - } - - const runQueryRequest: api.IRunQueryRequest = { - parent: parentPath.formattedName, - structuredQuery, - }; - - if (transactionIdOrReadTime instanceof Uint8Array) { - runQueryRequest.transaction = transactionIdOrReadTime; - } else if (transactionIdOrReadTime instanceof Timestamp) { - runQueryRequest.readTime = - transactionIdOrReadTime.toProto().timestampValue; - } - - if (explainOptions) { - runQueryRequest.explainOptions = explainOptions; - } - - return runQueryRequest; - } - - /** - * Converts current Query to an IBundledQuery. - * - * @private - * @internal - */ - _toBundledQuery(): protos.firestore.IBundledQuery { - const projectId = this.firestore.projectId; - const databaseId = this.firestore.databaseId; - const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( - projectId, - databaseId - ); - const structuredQuery = this.toStructuredQuery(); - - const bundledQuery: protos.firestore.IBundledQuery = { - parent: parentPath.formattedName, - structuredQuery, - }; - if (this._queryOptions.limitType === LimitType.First) { - bundledQuery.limitType = 'FIRST'; - } else if (this._queryOptions.limitType === LimitType.Last) { - bundledQuery.limitType = 'LAST'; - } - - return bundledQuery; - } - - private toStructuredQuery(): api.IStructuredQuery { - const structuredQuery: api.IStructuredQuery = { - from: [{}], - }; - - if (this._queryOptions.allDescendants) { - structuredQuery.from![0].allDescendants = true; - } - - // Kindless queries select all descendant documents, so we remove the - // collectionId field. - if (!this._queryOptions.kindless) { - structuredQuery.from![0].collectionId = this._queryOptions.collectionId; - } - - if (this._queryOptions.filters.length >= 1) { - structuredQuery.where = new CompositeFilterInternal( - this._queryOptions.filters, - 'AND' - ).toProto(); - } - - if (this._queryOptions.hasFieldOrders()) { - structuredQuery.orderBy = this._queryOptions.fieldOrders.map(o => - o.toProto() - ); - } - - structuredQuery.startAt = this.toCursor(this._queryOptions.startAt); - structuredQuery.endAt = this.toCursor(this._queryOptions.endAt); - - if (this._queryOptions.limit) { - structuredQuery.limit = {value: this._queryOptions.limit}; - } - - structuredQuery.offset = this._queryOptions.offset; - structuredQuery.select = this._queryOptions.projection; - - return structuredQuery; - } - - /** - * @internal - * @private - * This method exists solely to maintain backward compatability. - */ - _isPermanentRpcError(err: GoogleError, methodName: string): boolean { - return this._queryUtil._isPermanentRpcError(err, methodName); - } - - /** - * @internal - * @private - * This method exists solely to maintain backward compatability. - */ - _hasRetryTimedOut(methodName: string, startTime: number): boolean { - return this._queryUtil._hasRetryTimedOut(methodName, startTime); - } - - /** - * Internal streaming method that accepts an optional transaction ID. - * - * @param transactionIdOrReadTime A transaction ID or the read time at which - * to execute the query. - * @param explainOptions Options to use for explaining the query (if any). - * @private - * @internal - * @returns A stream of document results. - */ - _stream( - transactionIdOrReadTime?: Uint8Array | Timestamp, - explainOptions?: firestore.ExplainOptions - ): NodeJS.ReadableStream { - return this._queryUtil._stream( - this, - transactionIdOrReadTime, - true, - explainOptions - ); - } - - /** - * Attaches a listener for QuerySnapshot events. - * - * @param {querySnapshotCallback} onNext A callback to be called every time - * a new [QuerySnapshot]{@link QuerySnapshot} is available. - * @param {errorCallback=} onError A callback to be called if the listen - * fails or is cancelled. No further callbacks will occur. - * - * @returns {function()} An unsubscribe function that can be called to cancel - * the snapshot listener. - * - * @example - * ``` - * let query = firestore.collection('col').where('foo', '==', 'bar'); - * - * let unsubscribe = query.onSnapshot(querySnapshot => { - * console.log(`Received query snapshot of size ${querySnapshot.size}`); - * }, err => { - * console.log(`Encountered error: ${err}`); - * }); - * - * // Remove this listener. - * unsubscribe(); - * ``` - */ - onSnapshot( - onNext: (snapshot: QuerySnapshot) => void, - onError?: (error: Error) => void - ): () => void { - validateFunction('onNext', onNext); - validateFunction('onError', onError, {optional: true}); - - const watch: QueryWatch = - new (require('./watch').QueryWatch)( - this.firestore, - this, - this._queryOptions.converter - ); - - return watch.onSnapshot((readTime, size, docs, changes) => { - onNext(new QuerySnapshot(this, readTime, size, docs, changes)); - }, onError || console.error); - } - - /** - * Returns a function that can be used to sort QueryDocumentSnapshots - * according to the sort criteria of this query. - * - * @private - * @internal - */ - comparator(): ( - s1: QueryDocumentSnapshot, - s2: QueryDocumentSnapshot - ) => number { - return (doc1, doc2) => { - // Add implicit sorting by name, using the last specified direction. - const lastDirection = this._queryOptions.hasFieldOrders() - ? this._queryOptions.fieldOrders[ - this._queryOptions.fieldOrders.length - 1 - ].direction - : 'ASCENDING'; - const orderBys = this._queryOptions.fieldOrders.concat( - new FieldOrder(FieldPath.documentId(), lastDirection) - ); - - for (const orderBy of orderBys) { - let comp; - if (FieldPath.documentId().isEqual(orderBy.field)) { - comp = doc1.ref._path.compareTo(doc2.ref._path); - } else { - const v1 = doc1.protoField(orderBy.field); - const v2 = doc2.protoField(orderBy.field); - if (v1 === undefined || v2 === undefined) { - throw new Error( - 'Trying to compare documents on fields that ' + - "don't exist. Please include the fields you are ordering on " + - 'in your select() call.' - ); - } - comp = compare(v1, v2); - } - - if (comp !== 0) { - const direction = orderBy.direction === 'ASCENDING' ? 1 : -1; - return direction * comp; - } - } - - return 0; - }; - } - - withConverter(converter: null): Query; - withConverter< - NewAppModelType, - NewDbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - converter: firestore.FirestoreDataConverter - ): Query; - /** - * Applies a custom data converter to this Query, allowing you to use your - * own custom model objects with Firestore. When you call get() on the - * returned Query, the provided converter will convert between Firestore - * data of type `NewDbModelType` and your custom type `NewAppModelType`. - * - * Using the converter allows you to specify generic type arguments when - * storing and retrieving objects from Firestore. - * - * Passing in `null` as the converter parameter removes the current - * converter. - * - * @example - * ``` - * class Post { - * constructor(readonly title: string, readonly author: string) {} - * - * toString(): string { - * return this.title + ', by ' + this.author; - * } - * } - * - * const postConverter = { - * toFirestore(post: Post): FirebaseFirestore.DocumentData { - * return {title: post.title, author: post.author}; - * }, - * fromFirestore( - * snapshot: FirebaseFirestore.QueryDocumentSnapshot - * ): Post { - * const data = snapshot.data(); - * return new Post(data.title, data.author); - * } - * }; - * - * const postSnap = await Firestore() - * .collection('posts') - * .withConverter(postConverter) - * .doc().get(); - * const post = postSnap.data(); - * if (post !== undefined) { - * post.title; // string - * post.toString(); // Should be defined - * post.someNonExistentProperty; // TS error - * } - * - * ``` - * @param {FirestoreDataConverter | null} converter Converts objects to and - * from Firestore. Passing in `null` removes the current converter. - * @return A Query that uses the provided converter. - */ - withConverter< - NewAppModelType, - NewDbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - converter: firestore.FirestoreDataConverter< - NewAppModelType, - NewDbModelType - > | null - ): Query { - return new Query( - this.firestore, - this._queryOptions.withConverter(converter ?? defaultConverter()) - ); - } - - /** - * Construct the resulting snapshot for this query with given documents. - * - * @private - * @internal - */ - _createSnapshot( - readTime: Timestamp, - size: number, - docs: () => Array>, - changes: () => Array> - ): QuerySnapshot { - return new QuerySnapshot( - this, - readTime, - size, - docs, - changes - ); - } -} - -/** - * A CollectionReference object can be used for adding documents, getting - * document references, and querying for documents (using the methods - * inherited from [Query]{@link Query}). - * - * @class CollectionReference - * @extends Query - */ -export class CollectionReference< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, - > - extends Query - implements firestore.CollectionReference -{ - /** - * @private - * - * @param firestore The Firestore Database client. - * @param path The Path of this collection. - */ - constructor( - firestore: Firestore, - path: ResourcePath, - converter?: firestore.FirestoreDataConverter - ) { - super(firestore, QueryOptions.forCollectionQuery(path, converter)); - } - - /** - * Returns a resource path for this collection. - * @private - * @internal - */ - get _resourcePath(): ResourcePath { - return this._queryOptions.parentPath.append( - this._queryOptions.collectionId - ); - } - - /** - * The last path element of the referenced collection. - * - * @type {string} - * @name CollectionReference#id - * @readonly - * - * @example - * ``` - * let collectionRef = firestore.collection('col/doc/subcollection'); - * console.log(`ID of the subcollection: ${collectionRef.id}`); - * ``` - */ - get id(): string { - return this._queryOptions.collectionId; - } - - /** - * A reference to the containing Document if this is a subcollection, else - * null. - * - * @type {DocumentReference|null} - * @name CollectionReference#parent - * @readonly - * - * @example - * ``` - * let collectionRef = firestore.collection('col/doc/subcollection'); - * let documentRef = collectionRef.parent; - * console.log(`Parent name: ${documentRef.path}`); - * ``` - */ - get parent(): DocumentReference | null { - if (this._queryOptions.parentPath.isDocument) { - return new DocumentReference( - this.firestore, - this._queryOptions.parentPath - ); - } - - return null; - } - - /** - * A string representing the path of the referenced collection (relative - * to the root of the database). - * - * @type {string} - * @name CollectionReference#path - * @readonly - * - * @example - * ``` - * let collectionRef = firestore.collection('col/doc/subcollection'); - * console.log(`Path of the subcollection: ${collectionRef.path}`); - * ``` - */ - get path(): string { - return this._resourcePath.relativeName; - } - - /** - * Retrieves the list of documents in this collection. - * - * The document references returned may include references to "missing - * documents", i.e. document locations that have no document present but - * which contain subcollections with documents. Attempting to read such a - * document reference (e.g. via `.get()` or `.onSnapshot()`) will return a - * `DocumentSnapshot` whose `.exists` property is false. - * - * @return {Promise} The list of documents in this - * collection. - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * - * return collectionRef.listDocuments().then(documentRefs => { - * return firestore.getAll(...documentRefs); - * }).then(documentSnapshots => { - * for (let documentSnapshot of documentSnapshots) { - * if (documentSnapshot.exists) { - * console.log(`Found document with data: ${documentSnapshot.id}`); - * } else { - * console.log(`Found missing document: ${documentSnapshot.id}`); - * } - * } - * }); - * ``` - */ - listDocuments(): Promise< - Array> - > { - const tag = requestTag(); - return this.firestore.initializeIfNeeded(tag).then(() => { - const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( - this.firestore.projectId, - this.firestore.databaseId - ); - - const request: api.IListDocumentsRequest = { - parent: parentPath.formattedName, - collectionId: this.id, - showMissing: true, - // Setting `pageSize` to an arbitrarily large value lets the backend cap - // the page size (currently to 300). Note that the backend rejects - // MAX_INT32 (b/146883794). - pageSize: Math.pow(2, 16) - 1, - mask: {fieldPaths: []}, - }; - - return this.firestore - .request( - 'listDocuments', - request, - tag - ) - .then(documents => { - // Note that the backend already orders these documents by name, - // so we do not need to manually sort them. - return documents.map(doc => { - const path = QualifiedResourcePath.fromSlashSeparatedString( - doc.name! - ); - return this.doc(path.id!); - }); - }); - }); - } - - doc(): DocumentReference; - doc(documentPath: string): DocumentReference; - /** - * Gets a [DocumentReference]{@link DocumentReference} instance that - * refers to the document at the specified path. If no path is specified, an - * automatically-generated unique ID will be used for the returned - * DocumentReference. - * - * @param {string=} documentPath A slash-separated path to a document. - * @returns {DocumentReference} The `DocumentReference` - * instance. - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * let documentRefWithName = collectionRef.doc('doc'); - * let documentRefWithAutoId = collectionRef.doc(); - * console.log(`Reference with name: ${documentRefWithName.path}`); - * console.log(`Reference with auto-id: ${documentRefWithAutoId.path}`); - * ``` - */ - doc(documentPath?: string): DocumentReference { - if (arguments.length === 0) { - documentPath = autoId(); - } else { - validateResourcePath('documentPath', documentPath!); - } - - const path = this._resourcePath.append(documentPath!); - if (!path.isDocument) { - throw new Error( - `Value for argument "documentPath" must point to a document, but was "${documentPath}". Your path does not contain an even number of components.` - ); - } - - return new DocumentReference( - this.firestore, - path, - this._queryOptions.converter - ); - } - - /** - * Add a new document to this collection with the specified data, assigning - * it a document ID automatically. - * - * @param {DocumentData} data An Object containing the data for the new - * document. - * @throws {Error} If the provided input is not a valid Firestore document. - * @returns {Promise.} A Promise resolved with a - * [DocumentReference]{@link DocumentReference} pointing to the - * newly created document. - * - * @example - * ``` - * let collectionRef = firestore.collection('col'); - * collectionRef.add({foo: 'bar'}).then(documentReference => { - * console.log(`Added document with name: ${documentReference.id}`); - * }); - * ``` - */ - add( - data: firestore.WithFieldValue - ): Promise> { - const firestoreData = this._queryOptions.converter.toFirestore(data); - validateDocumentData( - 'data', - firestoreData, - /*allowDeletes=*/ false, - this._allowUndefined - ); - - const documentRef = this.doc(); - return documentRef.create(data).then(() => documentRef); - } - - /** - * Returns true if this `CollectionReference` is equal to the provided value. - * - * @param {*} other The value to compare against. - * @return {boolean} true if this `CollectionReference` is equal to the - * provided value. - */ - isEqual( - other: firestore.CollectionReference - ): boolean { - return ( - this === other || - (other instanceof CollectionReference && super.isEqual(other)) - ); - } - - withConverter(converter: null): CollectionReference; - withConverter< - NewAppModelType, - NewDbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - converter: firestore.FirestoreDataConverter - ): CollectionReference; - /** - * Applies a custom data converter to this CollectionReference, allowing you - * to use your own custom model objects with Firestore. When you call add() on - * the returned CollectionReference instance, the provided converter will - * convert between Firestore data of type `NewDbModelType` and your custom - * type `NewAppModelType`. - * - * Using the converter allows you to specify generic type arguments when - * storing and retrieving objects from Firestore. - * - * Passing in `null` as the converter parameter removes the current - * converter. - * - * @example - * ``` - * class Post { - * constructor(readonly title: string, readonly author: string) {} - * - * toString(): string { - * return this.title + ', by ' + this.author; - * } - * } - * - * const postConverter = { - * toFirestore(post: Post): FirebaseFirestore.DocumentData { - * return {title: post.title, author: post.author}; - * }, - * fromFirestore( - * snapshot: FirebaseFirestore.QueryDocumentSnapshot - * ): Post { - * const data = snapshot.data(); - * return new Post(data.title, data.author); - * } - * }; - * - * const postSnap = await Firestore() - * .collection('posts') - * .withConverter(postConverter) - * .doc().get(); - * const post = postSnap.data(); - * if (post !== undefined) { - * post.title; // string - * post.toString(); // Should be defined - * post.someNonExistentProperty; // TS error - * } - * - * ``` - * @param {FirestoreDataConverter | null} converter Converts objects to and - * from Firestore. Passing in `null` removes the current converter. - * @return A CollectionReference that uses the provided converter. - */ - withConverter< - NewAppModelType, - NewDbModelType extends firestore.DocumentData = firestore.DocumentData, - >( - converter: firestore.FirestoreDataConverter< - NewAppModelType, - NewDbModelType - > | null - ): CollectionReference { - return new CollectionReference( - this.firestore, - this._resourcePath, - converter ?? defaultConverter() - ); - } -} - -/** - * A query that calculates aggregations over an underlying query. - */ -export class AggregateQuery< - AggregateSpecType extends AggregateSpec, - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, -> implements - firestore.AggregateQuery -{ - private readonly clientAliasToServerAliasMap: Record = {}; - private readonly serverAliasToClientAliasMap: Record = {}; - - /** - * @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: AggregateSpecType - ) { - // Client-side aliases may be too long and exceed the 1500-byte string size limit. - // Such long strings do not need to be transferred over the wire either. - // The client maps the user's alias to a short form alias and send that to the server. - let aggregationNum = 0; - for (const clientAlias in this._aggregates) { - if (Object.prototype.hasOwnProperty.call(this._aggregates, clientAlias)) { - const serverAlias = `aggregate_${aggregationNum++}`; - this.clientAliasToServerAliasMap[clientAlias] = serverAlias; - this.serverAliasToClientAliasMap[serverAlias] = clientAlias; - } - } - } - - /** The query whose aggregations will be calculated by this object. */ - get query(): Query { - return this._query; - } - - /** - * Executes this query. - * - * @return A promise that will be resolved with the results of the query. - */ - get(): Promise< - AggregateQuerySnapshot - > { - return this._get(); - } - - /** - * Internal get() method that accepts an optional transaction id. - * - * @private - * @internal - * @param {bytes=} transactionId A transaction ID. - */ - _get( - transactionIdOrReadTime?: Uint8Array | Timestamp - ): Promise< - AggregateQuerySnapshot - > { - // Capture the error stack to preserve stack tracing across async calls. - const stack = Error().stack!; - - let result: AggregateQuerySnapshot< - AggregateSpecType, - AppModelType, - DbModelType - > | null = null; - - return new Promise((resolve, reject) => { - const stream = this._stream(transactionIdOrReadTime); - stream.on('error', err => { - reject(wrapError(err, stack)); - }); - stream.on('data', data => { - if (data.aggregationResult) { - result = data.aggregationResult; - } - }); - stream.on('end', () => { - stream.destroy(); - if (result === null) { - reject(Error('RunAggregationQueryResponse is missing result')); - } - resolve(result!); - }); - }); - } - - /** - * Internal streaming method that accepts an optional transaction ID. - * - * @private - * @internal - * @param transactionIdOrReadTime A transaction ID or the read time at which - * to execute the query. - * @param explainOptions Options to use for explaining the query (if any). - * @returns A stream of document results. - */ - _stream( - transactionIdOrReadTime?: Uint8Array | Timestamp, - explainOptions?: firestore.ExplainOptions - ): Readable { - const tag = requestTag(); - const firestore = this._query.firestore; - - const stream: Transform = new Transform({ - objectMode: true, - transform: (proto: api.IRunAggregationQueryResponse, enc, callback) => { - const output: { - aggregationResult?: AggregateQuerySnapshot< - AggregateSpecType, - AppModelType, - DbModelType - >; - explainMetrics?: ExplainMetrics; - } = {}; - - if (proto.result) { - const readTime = Timestamp.fromProto(proto.readTime!); - const data = this.decodeResult(proto.result); - output.aggregationResult = new AggregateQuerySnapshot( - this, - readTime, - data - ); - } - - if (proto.explainMetrics) { - output.explainMetrics = ExplainMetrics._fromProto( - proto.explainMetrics, - firestore._serializer! - ); - } - - callback(undefined, output); - }, - }); - - 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(transactionIdOrReadTime, explainOptions); - - const backendStream = await firestore.requestStream( - 'runAggregationQuery', - /* bidirectional= */ false, - request, - tag - ); - stream.on('close', () => { - backendStream.resume(); - backendStream.end(); - }); - backendStream.on('error', err => { - // TODO(group-by) When group-by queries are supported for aggregates - // consider implementing retries if the stream is making progress - // receiving results for groups. See the use of lastReceivedDocument - // in the retry strategy for runQuery. - // Also note that explain queries should not be retried. - - backendStream.unpipe(stream); - logger( - 'AggregateQuery._stream', - tag, - 'AggregateQuery failed with stream error:', - err - ); - stream.destroy(err); - }); - backendStream.resume(); - backendStream.pipe(stream); - }) - .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)) { - const alias = this.serverAliasToClientAliasMap[prop]; - assert( - alias !== null && alias !== undefined, - `'${prop}' not present in server-client alias mapping.` - ); - if (this._aggregates[alias] === undefined) { - throw new Error( - `Unexpected alias [${prop}] in result aggregate result` - ); - } - data[alias] = 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( - transactionIdOrReadTime?: Uint8Array | Timestamp, - explainOptions?: firestore.ExplainOptions - ): api.IRunAggregationQueryRequest { - const queryProto = this._query.toProto(); - const runQueryRequest: api.IRunAggregationQueryRequest = { - parent: queryProto.parent, - structuredAggregationQuery: { - structuredQuery: queryProto.structuredQuery, - aggregations: mapToArray(this._aggregates, (aggregate, clientAlias) => { - const serverAlias = this.clientAliasToServerAliasMap[clientAlias]; - assert( - serverAlias !== null && serverAlias !== undefined, - `'${clientAlias}' not present in client-server alias mapping.` - ); - return new Aggregate( - serverAlias, - aggregate.aggregateType, - aggregate._field - ).toProto(); - }), - }, - }; - - if (transactionIdOrReadTime instanceof Uint8Array) { - runQueryRequest.transaction = transactionIdOrReadTime; - } else if (transactionIdOrReadTime instanceof Timestamp) { - runQueryRequest.readTime = transactionIdOrReadTime; - } - - if (explainOptions) { - runQueryRequest.explainOptions = explainOptions; - } - - 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< - AggregateSpecType, - AppModelType, - DbModelType - > - ): 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); - } - - /** - * Plans and optionally executes this query. Returns a Promise that will be - * resolved with the planner information, statistics from the query - * execution (if any), and the query results (if any). - * - * @return A Promise that will be resolved with the planner information, - * statistics from the query execution (if any), and the query results (if any). - */ - explain( - options?: firestore.ExplainOptions - ): Promise< - ExplainResults< - AggregateQuerySnapshot - > - > { - if (options === undefined) { - options = {}; - } - // Capture the error stack to preserve stack tracing across async calls. - const stack = Error().stack!; - - let metrics: ExplainMetrics | null = null; - let aggregationResult: AggregateQuerySnapshot< - AggregateSpecType, - AppModelType, - DbModelType - > | null = null; - - return new Promise((resolve, reject) => { - const stream = this._stream(undefined, options); - stream.on('error', err => { - reject(wrapError(err, stack)); - }); - stream.on('data', data => { - if (data.aggregationResult) { - aggregationResult = data.aggregationResult; - } - - if (data.explainMetrics) { - metrics = data.explainMetrics; - } - }); - stream.on('end', () => { - stream.destroy(); - if (metrics === null) { - reject('No explain results.'); - } - resolve( - new ExplainResults< - AggregateQuerySnapshot - >(metrics!, aggregationResult) - ); - }); - }); - } -} - -/** - * The results of executing an aggregation query. - */ -export class AggregateQuerySnapshot< - AggregateSpecType extends firestore.AggregateSpec, - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, -> implements - firestore.AggregateQuerySnapshot< - AggregateSpecType, - AppModelType, - DbModelType - > -{ - /** - * @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< - AggregateSpecType, - AppModelType, - DbModelType - >, - private readonly _readTime: Timestamp, - private readonly _data: firestore.AggregateSpecData - ) {} - - /** The query that was executed to produce this result. */ - get query(): AggregateQuery { - return this._query; - } - - /** The time this snapshot was read. */ - get readTime(): 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< - AggregateSpecType, - AppModelType, - DbModelType - > - ): 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); - } -} - -class VectorQueryOptions { - constructor( - readonly limit: number, - readonly distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT' - ) {} - - isEqual(other: VectorQueryOptions): boolean { - if (this === other) { - return true; - } - if (!(other instanceof VectorQueryOptions)) { - return false; - } - - return ( - this.limit === other.limit && - this.distanceMeasure === other.distanceMeasure - ); - } -} - -/** - * A query that finds the documents whose vector fields are closest to a certain query vector. - * Create an instance of `VectorQuery` with {@link Query.findNearest}. - */ -export class VectorQuery< - AppModelType = firestore.DocumentData, - DbModelType extends firestore.DocumentData = firestore.DocumentData, -> implements firestore.VectorQuery -{ - /** - * @internal - * @private - **/ - readonly _queryUtil: QueryUtil< - AppModelType, - DbModelType, - VectorQuery - >; - - /** - * @private - * @internal - */ - constructor( - private readonly _query: Query, - private readonly vectorField: string | firestore.FieldPath, - private readonly queryVector: firestore.VectorValue | Array, - private readonly options: VectorQueryOptions - ) { - this._queryUtil = new QueryUtil< - AppModelType, - DbModelType, - VectorQuery - >(_query._firestore, _query._queryOptions, _query._serializer); - } - - /** The query whose results participants in the vector search. Filtering - * performed by the query will apply before the vector search. - **/ - get query(): Query { - return this._query; - } - - /** - * @private - * @internal - */ - private get _rawVectorField(): string { - return typeof this.vectorField === 'string' - ? this.vectorField - : this.vectorField.toString(); - } - - /** - * @private - * @internal - */ - private get _rawQueryVector(): Array { - return Array.isArray(this.queryVector) - ? this.queryVector - : this.queryVector.toArray(); - } - - /** - * Executes this vector search query. - * - * @returns A promise that will be resolved with the results of the query. - */ - get(): Promise> { - return this._queryUtil._get( - this, - /*transactionId*/ undefined, - // VectorQuery cannot be retried with cursors as they do not support cursors yet. - /*retryWithCursor*/ false - ) as Promise>; - } - - /** - * Internal streaming method that accepts an optional transaction ID. - * - * @param transactionId - A transaction ID. - * @private - * @internal - * @returns A stream of document results. - */ - _stream(transactionId?: Uint8Array): NodeJS.ReadableStream { - return this._queryUtil._stream( - this, - transactionId, - /*retryWithCursor*/ false - ); - } - - /** - * 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( - transactionIdOrReadTime?: Uint8Array | Timestamp - ): api.IRunQueryRequest { - const queryProto = this._query.toProto(transactionIdOrReadTime); - - const queryVector = Array.isArray(this.queryVector) - ? new VectorValue(this.queryVector) - : (this.queryVector as VectorValue); - - queryProto.structuredQuery!.findNearest = { - limit: {value: this.options.limit}, - distanceMeasure: this.options.distanceMeasure, - vectorField: { - fieldPath: FieldPath.fromArgument(this.vectorField).formattedName, - }, - queryVector: queryVector._toProto(this._query._serializer), - }; - return queryProto; - } - - /** - * Construct the resulting vector snapshot for this query with given documents. - * - * @private - * @internal - */ - _createSnapshot( - readTime: Timestamp, - size: number, - docs: () => Array>, - changes: () => Array> - ): VectorQuerySnapshot { - return new VectorQuerySnapshot( - this, - readTime, - size, - docs, - changes - ); - } - - /** - * Construct a new vector query whose result will start after To support stream(). - * This now throws an exception because cursors are not supported from the backend for vector queries yet. - * - * @private - * @internal - * @returns Serialized JSON for the query. - */ - startAfter( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ...fieldValuesOrDocumentSnapshot: Array - ): VectorQuery { - throw new Error( - 'Unimplemented: Vector query does not support cursors yet.' - ); - } - - /** - * 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 vector distance search as this `VectorQuery` 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. - * @returns `true` if this object is "equal" to the given object, as - * defined above, or `false` otherwise. - */ - isEqual(other: firestore.VectorQuery): boolean { - if (this === other) { - return true; - } - if (!(other instanceof VectorQuery)) { - return false; - } - if (!this.query.isEqual(other.query)) { - return false; - } - return ( - this._rawVectorField === other._rawVectorField && - isPrimitiveArrayEqual(this._rawQueryVector, other._rawQueryVector) && - this.options.isEqual(other.options) - ); - } -} - -/** - * Validates the input string as a field order direction. - * - * @private - * @internal - * @param arg The argument name or argument index (for varargs methods). - * @param op Order direction to validate. - * @throws when the direction is invalid - * @return a validated input value, which may be different from the provided - * value. - */ -export function validateQueryOrder( - arg: string, - op: unknown -): firestore.OrderByDirection | undefined { - // For backwards compatibility, we support both lower and uppercase values. - op = typeof op === 'string' ? op.toLowerCase() : op; - validateEnumValue(arg, op, Object.keys(directionOperators), {optional: true}); - return op as firestore.OrderByDirection | undefined; -} - -/** - * Validates the input string as a field comparison operator. - * - * @private - * @internal - * @param arg The argument name or argument index (for varargs methods). - * @param op Field comparison operator to validate. - * @param fieldValue Value that is used in the filter. - * @throws when the comparison operation is invalid - * @return a validated input value, which may be different from the provided - * value. - */ -export function validateQueryOperator( - arg: string | number, - op: unknown, - fieldValue: unknown -): firestore.WhereFilterOp { - // For backwards compatibility, we support both `=` and `==` for "equals". - if (op === '=') { - op = '=='; - } - - validateEnumValue(arg, op, Object.keys(comparisonOperators)); - - if ( - typeof fieldValue === 'number' && - isNaN(fieldValue) && - op !== '==' && - op !== '!=' - ) { - throw new Error( - "Invalid query. You can only perform '==' and '!=' comparisons on NaN." - ); - } - - if (fieldValue === null && op !== '==' && op !== '!=') { - throw new Error( - "Invalid query. You can only perform '==' and '!=' comparisons on Null." - ); - } - - return op as firestore.WhereFilterOp; -} - -/** - * Validates that 'value' is a DocumentReference. - * - * @private - * @internal - * @param arg The argument name or argument index (for varargs methods). - * @param value The argument to validate. - * @return the DocumentReference if valid - */ -export function validateDocumentReference< - AppModelType, - DbModelType extends firestore.DocumentData, ->( - arg: string | number, - value: firestore.DocumentReference -): DocumentReference { - if (!(value instanceof DocumentReference)) { - throw new Error(invalidArgumentMessage(arg, 'DocumentReference')); - } - return value; -} - -/** - * Validates that 'value' can be used as a query value. - * - * @private - * @internal - * @param arg The argument name or argument index (for varargs methods). - * @param value The argument to validate. - * @param allowUndefined Whether to allow nested properties that are `undefined`. - */ -function validateQueryValue( - arg: string | number, - value: unknown, - allowUndefined: boolean -): void { - validateUserInput(arg, value, 'query constraint', { - allowDeletes: 'none', - allowTransforms: false, - allowUndefined, - }); -} - -/** - * Returns the first non-undefined value or `undefined` if no such value exists. - * @private - * @internal - */ -function coalesce(...values: Array): T | undefined { - return values.find(value => value !== undefined); -} diff --git a/dev/src/reference/aggregate-query-snapshot.ts b/dev/src/reference/aggregate-query-snapshot.ts new file mode 100644 index 000000000..637aed2de --- /dev/null +++ b/dev/src/reference/aggregate-query-snapshot.ts @@ -0,0 +1,113 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as deepEqual from 'fast-deep-equal'; + +import * as firestore from '@google-cloud/firestore'; +import {Timestamp} from '../timestamp'; +import {AggregateQuery} from './aggregate-query'; + +/** + * The results of executing an aggregation query. + */ +export class AggregateQuerySnapshot< + AggregateSpecType extends firestore.AggregateSpec, + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, +> implements + firestore.AggregateQuerySnapshot< + AggregateSpecType, + AppModelType, + DbModelType + > +{ + /** + * @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< + AggregateSpecType, + AppModelType, + DbModelType + >, + private readonly _readTime: Timestamp, + private readonly _data: firestore.AggregateSpecData + ) {} + + /** The query that was executed to produce this result. */ + get query(): AggregateQuery { + return this._query; + } + + /** The time this snapshot was read. */ + get readTime(): 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< + AggregateSpecType, + AppModelType, + DbModelType + > + ): 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); + } +} diff --git a/dev/src/reference/aggregate-query.ts b/dev/src/reference/aggregate-query.ts new file mode 100644 index 000000000..b1693136d --- /dev/null +++ b/dev/src/reference/aggregate-query.ts @@ -0,0 +1,379 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; + +import * as assert from 'assert'; +import * as deepEqual from 'fast-deep-equal'; + +import * as firestore from '@google-cloud/firestore'; +import {Aggregate, AggregateSpec} from '../aggregate'; +import {Timestamp} from '../timestamp'; +import {mapToArray, requestTag, wrapError} from '../util'; +import {ExplainMetrics, ExplainResults} from '../query-profile'; +import {logger} from '../logger'; +import {AggregateQuerySnapshot} from './aggregate-query-snapshot'; +import {Query} from './query'; +import {Readable, Transform} from 'stream'; + +/** + * A query that calculates aggregations over an underlying query. + */ +export class AggregateQuery< + AggregateSpecType extends AggregateSpec, + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, +> implements + firestore.AggregateQuery +{ + private readonly clientAliasToServerAliasMap: Record = {}; + private readonly serverAliasToClientAliasMap: Record = {}; + + /** + * @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: AggregateSpecType + ) { + // Client-side aliases may be too long and exceed the 1500-byte string size limit. + // Such long strings do not need to be transferred over the wire either. + // The client maps the user's alias to a short form alias and send that to the server. + let aggregationNum = 0; + for (const clientAlias in this._aggregates) { + if (Object.prototype.hasOwnProperty.call(this._aggregates, clientAlias)) { + const serverAlias = `aggregate_${aggregationNum++}`; + this.clientAliasToServerAliasMap[clientAlias] = serverAlias; + this.serverAliasToClientAliasMap[serverAlias] = clientAlias; + } + } + } + + /** The query whose aggregations will be calculated by this object. */ + get query(): Query { + return this._query; + } + + /** + * Executes this query. + * + * @return A promise that will be resolved with the results of the query. + */ + get(): Promise< + AggregateQuerySnapshot + > { + return this._get(); + } + + /** + * Internal get() method that accepts an optional transaction id. + * + * @private + * @internal + * @param {bytes=} transactionId A transaction ID. + */ + _get( + transactionIdOrReadTime?: Uint8Array | Timestamp + ): Promise< + AggregateQuerySnapshot + > { + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + let result: AggregateQuerySnapshot< + AggregateSpecType, + AppModelType, + DbModelType + > | null = null; + + return new Promise((resolve, reject) => { + const stream = this._stream(transactionIdOrReadTime); + stream.on('error', err => { + reject(wrapError(err, stack)); + }); + stream.on('data', data => { + if (data.aggregationResult) { + result = data.aggregationResult; + } + }); + stream.on('end', () => { + stream.destroy(); + if (result === null) { + reject(Error('RunAggregationQueryResponse is missing result')); + } + resolve(result!); + }); + }); + } + + /** + * Internal streaming method that accepts an optional transaction ID. + * + * @private + * @internal + * @param transactionIdOrReadTime A transaction ID or the read time at which + * to execute the query. + * @param explainOptions Options to use for explaining the query (if any). + * @returns A stream of document results. + */ + _stream( + transactionIdOrReadTime?: Uint8Array | Timestamp, + explainOptions?: firestore.ExplainOptions + ): Readable { + const tag = requestTag(); + const firestore = this._query.firestore; + + const stream: Transform = new Transform({ + objectMode: true, + transform: (proto: api.IRunAggregationQueryResponse, enc, callback) => { + const output: { + aggregationResult?: AggregateQuerySnapshot< + AggregateSpecType, + AppModelType, + DbModelType + >; + explainMetrics?: ExplainMetrics; + } = {}; + + if (proto.result) { + const readTime = Timestamp.fromProto(proto.readTime!); + const data = this.decodeResult(proto.result); + output.aggregationResult = new AggregateQuerySnapshot( + this, + readTime, + data + ); + } + + if (proto.explainMetrics) { + output.explainMetrics = ExplainMetrics._fromProto( + proto.explainMetrics, + firestore._serializer! + ); + } + + callback(undefined, output); + }, + }); + + 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(transactionIdOrReadTime, explainOptions); + + const backendStream = await firestore.requestStream( + 'runAggregationQuery', + /* bidirectional= */ false, + request, + tag + ); + stream.on('close', () => { + backendStream.resume(); + backendStream.end(); + }); + backendStream.on('error', err => { + // TODO(group-by) When group-by queries are supported for aggregates + // consider implementing retries if the stream is making progress + // receiving results for groups. See the use of lastReceivedDocument + // in the retry strategy for runQuery. + // Also note that explain queries should not be retried. + + backendStream.unpipe(stream); + logger( + 'AggregateQuery._stream', + tag, + 'AggregateQuery failed with stream error:', + err + ); + stream.destroy(err); + }); + backendStream.resume(); + backendStream.pipe(stream); + }) + .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)) { + const alias = this.serverAliasToClientAliasMap[prop]; + assert( + alias !== null && alias !== undefined, + `'${prop}' not present in server-client alias mapping.` + ); + if (this._aggregates[alias] === undefined) { + throw new Error( + `Unexpected alias [${prop}] in result aggregate result` + ); + } + data[alias] = 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( + transactionIdOrReadTime?: Uint8Array | Timestamp, + explainOptions?: firestore.ExplainOptions + ): api.IRunAggregationQueryRequest { + const queryProto = this._query.toProto(); + const runQueryRequest: api.IRunAggregationQueryRequest = { + parent: queryProto.parent, + structuredAggregationQuery: { + structuredQuery: queryProto.structuredQuery, + aggregations: mapToArray(this._aggregates, (aggregate, clientAlias) => { + const serverAlias = this.clientAliasToServerAliasMap[clientAlias]; + assert( + serverAlias !== null && serverAlias !== undefined, + `'${clientAlias}' not present in client-server alias mapping.` + ); + return new Aggregate( + serverAlias, + aggregate.aggregateType, + aggregate._field + ).toProto(); + }), + }, + }; + + if (transactionIdOrReadTime instanceof Uint8Array) { + runQueryRequest.transaction = transactionIdOrReadTime; + } else if (transactionIdOrReadTime instanceof Timestamp) { + runQueryRequest.readTime = transactionIdOrReadTime; + } + + if (explainOptions) { + runQueryRequest.explainOptions = explainOptions; + } + + 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< + AggregateSpecType, + AppModelType, + DbModelType + > + ): 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); + } + + /** + * Plans and optionally executes this query. Returns a Promise that will be + * resolved with the planner information, statistics from the query + * execution (if any), and the query results (if any). + * + * @return A Promise that will be resolved with the planner information, + * statistics from the query execution (if any), and the query results (if any). + */ + explain( + options?: firestore.ExplainOptions + ): Promise< + ExplainResults< + AggregateQuerySnapshot + > + > { + if (options === undefined) { + options = {}; + } + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + let metrics: ExplainMetrics | null = null; + let aggregationResult: AggregateQuerySnapshot< + AggregateSpecType, + AppModelType, + DbModelType + > | null = null; + + return new Promise((resolve, reject) => { + const stream = this._stream(undefined, options); + stream.on('error', err => { + reject(wrapError(err, stack)); + }); + stream.on('data', data => { + if (data.aggregationResult) { + aggregationResult = data.aggregationResult; + } + + if (data.explainMetrics) { + metrics = data.explainMetrics; + } + }); + stream.on('end', () => { + stream.destroy(); + if (metrics === null) { + reject('No explain results.'); + } + resolve( + new ExplainResults< + AggregateQuerySnapshot + >(metrics!, aggregationResult) + ); + }); + }); + } +} diff --git a/dev/src/reference/collection-reference.ts b/dev/src/reference/collection-reference.ts new file mode 100644 index 000000000..9a50a1709 --- /dev/null +++ b/dev/src/reference/collection-reference.ts @@ -0,0 +1,369 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; + +import * as firestore from '@google-cloud/firestore'; +import { + QualifiedResourcePath, + ResourcePath, + validateResourcePath, +} from '../path'; +import {autoId, requestTag} from '../util'; +import {validateDocumentData} from '../write-batch'; +import {defaultConverter} from '../types'; +import {Query} from './query'; +import Firestore from '../index'; +import {DocumentReference} from './document-reference'; +import {QueryOptions} from './query-options'; + +/** + * A CollectionReference object can be used for adding documents, getting + * document references, and querying for documents (using the methods + * inherited from [Query]{@link Query}). + * + * @class CollectionReference + * @extends Query + */ +export class CollectionReference< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, + > + extends Query + implements firestore.CollectionReference +{ + /** + * @private + * + * @param firestore The Firestore Database client. + * @param path The Path of this collection. + */ + constructor( + firestore: Firestore, + path: ResourcePath, + converter?: firestore.FirestoreDataConverter + ) { + super(firestore, QueryOptions.forCollectionQuery(path, converter)); + } + + /** + * Returns a resource path for this collection. + * @private + * @internal + */ + get _resourcePath(): ResourcePath { + return this._queryOptions.parentPath.append( + this._queryOptions.collectionId + ); + } + + /** + * The last path element of the referenced collection. + * + * @type {string} + * @name CollectionReference#id + * @readonly + * + * @example + * ``` + * let collectionRef = firestore.collection('col/doc/subcollection'); + * console.log(`ID of the subcollection: ${collectionRef.id}`); + * ``` + */ + get id(): string { + return this._queryOptions.collectionId; + } + + /** + * A reference to the containing Document if this is a subcollection, else + * null. + * + * @type {DocumentReference|null} + * @name CollectionReference#parent + * @readonly + * + * @example + * ``` + * let collectionRef = firestore.collection('col/doc/subcollection'); + * let documentRef = collectionRef.parent; + * console.log(`Parent name: ${documentRef.path}`); + * ``` + */ + get parent(): DocumentReference | null { + if (this._queryOptions.parentPath.isDocument) { + return new DocumentReference( + this.firestore, + this._queryOptions.parentPath + ); + } + + return null; + } + + /** + * A string representing the path of the referenced collection (relative + * to the root of the database). + * + * @type {string} + * @name CollectionReference#path + * @readonly + * + * @example + * ``` + * let collectionRef = firestore.collection('col/doc/subcollection'); + * console.log(`Path of the subcollection: ${collectionRef.path}`); + * ``` + */ + get path(): string { + return this._resourcePath.relativeName; + } + + /** + * Retrieves the list of documents in this collection. + * + * The document references returned may include references to "missing + * documents", i.e. document locations that have no document present but + * which contain subcollections with documents. Attempting to read such a + * document reference (e.g. via `.get()` or `.onSnapshot()`) will return a + * `DocumentSnapshot` whose `.exists` property is false. + * + * @return {Promise} The list of documents in this + * collection. + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * + * return collectionRef.listDocuments().then(documentRefs => { + * return firestore.getAll(...documentRefs); + * }).then(documentSnapshots => { + * for (let documentSnapshot of documentSnapshots) { + * if (documentSnapshot.exists) { + * console.log(`Found document with data: ${documentSnapshot.id}`); + * } else { + * console.log(`Found missing document: ${documentSnapshot.id}`); + * } + * } + * }); + * ``` + */ + listDocuments(): Promise< + Array> + > { + const tag = requestTag(); + return this.firestore.initializeIfNeeded(tag).then(() => { + const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( + this.firestore.projectId, + this.firestore.databaseId + ); + + const request: api.IListDocumentsRequest = { + parent: parentPath.formattedName, + collectionId: this.id, + showMissing: true, + // Setting `pageSize` to an arbitrarily large value lets the backend cap + // the page size (currently to 300). Note that the backend rejects + // MAX_INT32 (b/146883794). + pageSize: Math.pow(2, 16) - 1, + mask: {fieldPaths: []}, + }; + + return this.firestore + .request( + 'listDocuments', + request, + tag + ) + .then(documents => { + // Note that the backend already orders these documents by name, + // so we do not need to manually sort them. + return documents.map(doc => { + const path = QualifiedResourcePath.fromSlashSeparatedString( + doc.name! + ); + return this.doc(path.id!); + }); + }); + }); + } + + doc(): DocumentReference; + doc(documentPath: string): DocumentReference; + /** + * Gets a [DocumentReference]{@link DocumentReference} instance that + * refers to the document at the specified path. If no path is specified, an + * automatically-generated unique ID will be used for the returned + * DocumentReference. + * + * @param {string=} documentPath A slash-separated path to a document. + * @returns {DocumentReference} The `DocumentReference` + * instance. + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * let documentRefWithName = collectionRef.doc('doc'); + * let documentRefWithAutoId = collectionRef.doc(); + * console.log(`Reference with name: ${documentRefWithName.path}`); + * console.log(`Reference with auto-id: ${documentRefWithAutoId.path}`); + * ``` + */ + doc(documentPath?: string): DocumentReference { + if (arguments.length === 0) { + documentPath = autoId(); + } else { + validateResourcePath('documentPath', documentPath!); + } + + const path = this._resourcePath.append(documentPath!); + if (!path.isDocument) { + throw new Error( + `Value for argument "documentPath" must point to a document, but was "${documentPath}". Your path does not contain an even number of components.` + ); + } + + return new DocumentReference( + this.firestore, + path, + this._queryOptions.converter + ); + } + + /** + * Add a new document to this collection with the specified data, assigning + * it a document ID automatically. + * + * @param {DocumentData} data An Object containing the data for the new + * document. + * @throws {Error} If the provided input is not a valid Firestore document. + * @returns {Promise.} A Promise resolved with a + * [DocumentReference]{@link DocumentReference} pointing to the + * newly created document. + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * collectionRef.add({foo: 'bar'}).then(documentReference => { + * console.log(`Added document with name: ${documentReference.id}`); + * }); + * ``` + */ + add( + data: firestore.WithFieldValue + ): Promise> { + const firestoreData = this._queryOptions.converter.toFirestore(data); + validateDocumentData( + 'data', + firestoreData, + /*allowDeletes=*/ false, + this._allowUndefined + ); + + const documentRef = this.doc(); + return documentRef.create(data).then(() => documentRef); + } + + /** + * Returns true if this `CollectionReference` is equal to the provided value. + * + * @param {*} other The value to compare against. + * @return {boolean} true if this `CollectionReference` is equal to the + * provided value. + */ + isEqual( + other: firestore.CollectionReference + ): boolean { + return ( + this === other || + (other instanceof CollectionReference && super.isEqual(other)) + ); + } + + withConverter(converter: null): CollectionReference; + withConverter< + NewAppModelType, + NewDbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + converter: firestore.FirestoreDataConverter + ): CollectionReference; + /** + * Applies a custom data converter to this CollectionReference, allowing you + * to use your own custom model objects with Firestore. When you call add() on + * the returned CollectionReference instance, the provided converter will + * convert between Firestore data of type `NewDbModelType` and your custom + * type `NewAppModelType`. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @example + * ``` + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * snapshot: FirebaseFirestore.QueryDocumentSnapshot + * ): Post { + * const data = snapshot.data(); + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await Firestore() + * .collection('posts') + * .withConverter(postConverter) + * .doc().get(); + * const post = postSnap.data(); + * if (post !== undefined) { + * post.title; // string + * post.toString(); // Should be defined + * post.someNonExistentProperty; // TS error + * } + * + * ``` + * @param {FirestoreDataConverter | null} converter Converts objects to and + * from Firestore. Passing in `null` removes the current converter. + * @return A CollectionReference that uses the provided converter. + */ + withConverter< + NewAppModelType, + NewDbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + converter: firestore.FirestoreDataConverter< + NewAppModelType, + NewDbModelType + > | null + ): CollectionReference { + return new CollectionReference( + this.firestore, + this._resourcePath, + converter ?? defaultConverter() + ); + } +} diff --git a/dev/src/reference/composite-filter-internal.ts b/dev/src/reference/composite-filter-internal.ts new file mode 100644 index 000000000..aa41d8c8d --- /dev/null +++ b/dev/src/reference/composite-filter-internal.ts @@ -0,0 +1,86 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; + +import {FilterInternal} from './filter-internal'; +import {FieldFilterInternal} from './field-filter-internal'; + +export class CompositeFilterInternal extends FilterInternal { + constructor( + private filters: FilterInternal[], + private operator: api.StructuredQuery.CompositeFilter.Operator + ) { + super(); + } + + // Memoized list of all field filters that can be found by traversing the tree of filters + // contained in this composite filter. + private memoizedFlattenedFilters: FieldFilterInternal[] | null = null; + + public getFilters(): FilterInternal[] { + return this.filters; + } + + public isConjunction(): boolean { + return this.operator === 'AND'; + } + + public getFlattenedFilters(): FieldFilterInternal[] { + if (this.memoizedFlattenedFilters !== null) { + return this.memoizedFlattenedFilters; + } + + this.memoizedFlattenedFilters = this.filters.reduce( + (allFilters: FieldFilterInternal[], subfilter: FilterInternal) => + allFilters.concat(subfilter.getFlattenedFilters()), + [] + ); + + return this.memoizedFlattenedFilters; + } + + public toProto(): api.StructuredQuery.IFilter { + if (this.filters.length === 1) { + return this.filters[0].toProto(); + } + + const proto: api.StructuredQuery.IFilter = { + compositeFilter: { + op: this.operator, + filters: this.filters.map(filter => filter.toProto()), + }, + }; + + return proto; + } + + isEqual(other: FilterInternal): boolean { + if (other instanceof CompositeFilterInternal) { + const otherFilters = other.getFilters(); + return ( + this.operator === other.operator && + this.getFilters().length === other.getFilters().length && + this.getFilters().every((filter, index) => + filter.isEqual(otherFilters[index]) + ) + ); + } else { + return false; + } + } +} diff --git a/dev/src/reference/constants.ts b/dev/src/reference/constants.ts new file mode 100644 index 000000000..ad0f28f05 --- /dev/null +++ b/dev/src/reference/constants.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; + +/** + * The direction of a `Query.orderBy()` clause is specified as 'desc' or 'asc' + * (descending or ascending). + * + * @private + * @internal + */ +export const directionOperators: {[k: string]: api.StructuredQuery.Direction} = + { + asc: 'ASCENDING', + desc: 'DESCENDING', + }; + +/** + * Filter conditions in a `Query.where()` clause are specified using the + * strings '<', '<=', '==', '!=', '>=', '>', 'array-contains', 'in', 'not-in', + * and 'array-contains-any'. + * + * @private + * @internal + */ +export const comparisonOperators: { + [k: string]: api.StructuredQuery.FieldFilter.Operator; +} = { + '<': 'LESS_THAN', + '<=': 'LESS_THAN_OR_EQUAL', + '==': 'EQUAL', + '!=': 'NOT_EQUAL', + '>': 'GREATER_THAN', + '>=': 'GREATER_THAN_OR_EQUAL', + 'array-contains': 'ARRAY_CONTAINS', + in: 'IN', + 'not-in': 'NOT_IN', + 'array-contains-any': 'ARRAY_CONTAINS_ANY', +}; + +export const NOOP_MESSAGE = Symbol('a noop message'); diff --git a/dev/src/reference/document-reference.ts b/dev/src/reference/document-reference.ts new file mode 100644 index 000000000..73cb0cf50 --- /dev/null +++ b/dev/src/reference/document-reference.ts @@ -0,0 +1,608 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; + +import * as firestore from '@google-cloud/firestore'; +import Firestore, {DocumentSnapshot, WriteBatch, WriteResult} from '../index'; +import {ResourcePath, validateResourcePath} from '../path'; +import {defaultConverter} from '../types'; +import {Serializable} from '../serializer'; +import {CollectionReference} from './collection-reference'; +import {requestTag} from '../util'; +import {validateFunction, validateMinNumberOfArguments} from '../validate'; +import {DocumentWatch} from '../watch'; +import {DocumentSnapshotBuilder} from '../document'; + +/** + * A DocumentReference refers to a document location in a Firestore database + * and can be used to write, read, or listen to the location. The document at + * the referenced location may or may not exist. A DocumentReference can + * also be used to create a + * [CollectionReference]{@link CollectionReference} to a + * subcollection. + * + * @class DocumentReference + */ +export class DocumentReference< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, + > + implements + Serializable, + firestore.DocumentReference +{ + /** + * @private + * @internal + * @param _firestore The Firestore Database client. + * @param _path The Path of this reference. + * @param _converter The converter to use when serializing data. + */ + constructor( + private readonly _firestore: Firestore, + /** + * @private + * @internal + **/ + readonly _path: ResourcePath, + /** + * @internal + * @private + **/ + readonly _converter = defaultConverter() + ) {} + + /** + * The string representation of the DocumentReference's location. + * @private + * @internal + * @type {string} + * @name DocumentReference#formattedName + */ + get formattedName(): string { + const projectId = this.firestore.projectId; + const databaseId = this.firestore.databaseId; + return this._path.toQualifiedResourcePath(projectId, databaseId) + .formattedName; + } + + /** + * The [Firestore]{@link Firestore} instance for the Firestore + * database (useful for performing transactions, etc.). + * + * @type {Firestore} + * @name DocumentReference#firestore + * @readonly + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * + * collectionRef.add({foo: 'bar'}).then(documentReference => { + * let firestore = documentReference.firestore; + * console.log(`Root location for document is ${firestore.formattedName}`); + * }); + * ``` + */ + get firestore(): Firestore { + return this._firestore; + } + + /** + * A string representing the path of the referenced document (relative + * to the root of the database). + * + * @type {string} + * @name DocumentReference#path + * @readonly + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * + * collectionRef.add({foo: 'bar'}).then(documentReference => { + * console.log(`Added document at '${documentReference.path}'`); + * }); + * ``` + */ + get path(): string { + return this._path.relativeName; + } + + /** + * The last path element of the referenced document. + * + * @type {string} + * @name DocumentReference#id + * @readonly + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * + * collectionRef.add({foo: 'bar'}).then(documentReference => { + * console.log(`Added document with name '${documentReference.id}'`); + * }); + * ``` + */ + get id(): string { + return this._path.id!; + } + + /** + * Returns a resource path for this document. + * @private + * @internal + */ + get _resourcePath(): ResourcePath { + return this._path; + } + + /** + * A reference to the collection to which this DocumentReference belongs. + * + * @name DocumentReference#parent + * @type {CollectionReference} + * @readonly + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * let collectionRef = documentRef.parent; + * + * collectionRef.where('foo', '==', 'bar').get().then(results => { + * console.log(`Found ${results.size} matches in parent collection`); + * }): + * ``` + */ + get parent(): CollectionReference { + return new CollectionReference( + this._firestore, + this._path.parent()!, + this._converter + ); + } + + /** + * Reads the document referred to by this DocumentReference. + * + * @returns {Promise.} A Promise resolved with a + * DocumentSnapshot for the retrieved document on success. For missing + * documents, DocumentSnapshot.exists will be false. If the get() fails for + * other reasons, the Promise will be rejected. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.get().then(documentSnapshot => { + * if (documentSnapshot.exists) { + * console.log('Document retrieved successfully.'); + * } + * }); + * ``` + */ + get(): Promise> { + return this._firestore.getAll(this).then(([result]) => result); + } + + /** + * Gets a [CollectionReference]{@link CollectionReference} instance + * that refers to the collection at the specified path. + * + * @param {string} collectionPath A slash-separated path to a collection. + * @returns {CollectionReference} A reference to the new + * subcollection. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * let subcollection = documentRef.collection('subcollection'); + * console.log(`Path to subcollection: ${subcollection.path}`); + * ``` + */ + collection(collectionPath: string): CollectionReference { + validateResourcePath('collectionPath', collectionPath); + + const path = this._path.append(collectionPath); + if (!path.isCollection) { + throw new Error( + `Value for argument "collectionPath" must point to a collection, but was "${collectionPath}". Your path does not contain an odd number of components.` + ); + } + + return new CollectionReference(this._firestore, path); + } + + /** + * Fetches the subcollections that are direct children of this document. + * + * @returns {Promise.>} A Promise that resolves + * with an array of CollectionReferences. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.listCollections().then(collections => { + * for (let collection of collections) { + * console.log(`Found subcollection with id: ${collection.id}`); + * } + * }); + * ``` + */ + listCollections(): Promise> { + const tag = requestTag(); + return this.firestore.initializeIfNeeded(tag).then(() => { + const request: api.IListCollectionIdsRequest = { + parent: this.formattedName, + // Setting `pageSize` to an arbitrarily large value lets the backend cap + // the page size (currently to 300). Note that the backend rejects + // MAX_INT32 (b/146883794). + pageSize: Math.pow(2, 16) - 1, + }; + return this._firestore + .request( + 'listCollectionIds', + request, + tag + ) + .then(collectionIds => { + const collections: Array = []; + + // We can just sort this list using the default comparator since it + // will only contain collection ids. + collectionIds.sort(); + + for (const collectionId of collectionIds) { + collections.push(this.collection(collectionId)); + } + + return collections; + }); + }); + } + + /** + * Create a document with the provided object values. This will fail the write + * if a document exists at its location. + * + * @param {DocumentData} data An object that contains the fields and data to + * serialize as the document. + * @throws {Error} If the provided input is not a valid Firestore document or if the document already exists. + * @returns {Promise.} A Promise that resolves with the + * write time of this create. + * + * @example + * ``` + * let documentRef = firestore.collection('col').doc(); + * + * documentRef.create({foo: 'bar'}).then((res) => { + * console.log(`Document created at ${res.updateTime}`); + * }).catch((err) => { + * console.log(`Failed to create document: ${err}`); + * }); + * ``` + */ + create(data: firestore.WithFieldValue): Promise { + const writeBatch = new WriteBatch(this._firestore); + return writeBatch + .create(this, data) + .commit() + .then(([writeResult]) => writeResult); + } + + /** + * Deletes the document referred to by this `DocumentReference`. + * + * A delete for a non-existing document is treated as a success (unless + * lastUptimeTime is provided). + * + * @param {Precondition=} precondition A precondition to enforce for this + * delete. + * @param {Timestamp=} precondition.lastUpdateTime If set, enforces that the + * document was last updated at lastUpdateTime. Fails the delete if the + * document was last updated at a different time. + * @param {boolean=} precondition.exists If set, enforces that the target + * document must or must not exist. + * @returns {Promise.} A Promise that resolves with the + * delete time. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.delete().then(() => { + * console.log('Document successfully deleted.'); + * }); + * ``` + */ + delete(precondition?: firestore.Precondition): Promise { + const writeBatch = new WriteBatch(this._firestore); + return writeBatch + .delete(this, precondition) + .commit() + .then(([writeResult]) => writeResult); + } + + set( + data: firestore.PartialWithFieldValue, + options: firestore.SetOptions + ): Promise; + set(data: firestore.WithFieldValue): Promise; + /** + * Writes to the document referred to by this DocumentReference. If the + * document does not yet exist, it will be created. If you pass + * [SetOptions]{@link SetOptions}, the provided data can be merged into an + * existing document. + * + * @param {T|Partial} data A map of the fields and values for + * the document. + * @param {SetOptions=} options An object to configure the set behavior. + * @param {boolean=} options.merge If true, set() merges the values specified + * in its data argument. Fields omitted from this set() call remain untouched. + * If your input sets any field to an empty map, all nested fields are + * overwritten. + * @param {Array.=} options.mergeFields If provided, + * set() only replaces the specified field paths. Any field path that is not + * specified is ignored and remains untouched. If your input sets any field to + * an empty map, all nested fields are overwritten. + * @throws {Error} If the provided input is not a valid Firestore document. + * @returns {Promise.} A Promise that resolves with the + * write time of this set. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.set({foo: 'bar'}).then(res => { + * console.log(`Document written at ${res.updateTime}`); + * }); + * ``` + */ + set( + data: firestore.PartialWithFieldValue, + options?: firestore.SetOptions + ): Promise { + let writeBatch = new WriteBatch(this._firestore); + if (options) { + writeBatch = writeBatch.set(this, data, options); + } else { + writeBatch = writeBatch.set( + this, + data as firestore.WithFieldValue + ); + } + return writeBatch.commit().then(([writeResult]) => writeResult); + } + + /** + * Updates fields in the document referred to by this DocumentReference. + * If the document doesn't yet exist, the update fails and the returned + * Promise will be rejected. + * + * The update() method accepts either an object with field paths encoded as + * keys and field values encoded as values, or a variable number of arguments + * that alternate between field paths and field values. + * + * A Precondition restricting this update can be specified as the last + * argument. + * + * @param {UpdateData|string|FieldPath} dataOrField An object containing the + * fields and values with which to update the document or the path of the + * first field to update. + * @param { + * ...(*|string|FieldPath|Precondition)} preconditionOrValues An alternating + * list of field paths and values to update or a Precondition to restrict + * this update. + * @throws {Error} If the provided input is not valid Firestore data. + * @returns {Promise.} A Promise that resolves once the + * data has been successfully written to the backend. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.update({foo: 'bar'}).then(res => { + * console.log(`Document updated at ${res.updateTime}`); + * }); + * ``` + */ + update( + dataOrField: + | firestore.UpdateData + | string + | firestore.FieldPath, + ...preconditionOrValues: Array< + unknown | string | firestore.FieldPath | firestore.Precondition + > + ): Promise { + // eslint-disable-next-line prefer-rest-params + validateMinNumberOfArguments('DocumentReference.update', arguments, 1); + + const writeBatch = new WriteBatch(this._firestore); + return writeBatch + .update(this, dataOrField, ...preconditionOrValues) + .commit() + .then(([writeResult]) => writeResult); + } + + /** + * Attaches a listener for DocumentSnapshot events. + * + * @param {documentSnapshotCallback} onNext A callback to be called every + * time a new `DocumentSnapshot` is available. + * @param {errorCallback=} onError A callback to be called if the listen fails + * or is cancelled. No further callbacks will occur. If unset, errors will be + * logged to the console. + * + * @returns {function()} An unsubscribe function that can be called to cancel + * the snapshot listener. + * + * @example + * ``` + * let documentRef = firestore.doc('col/doc'); + * + * let unsubscribe = documentRef.onSnapshot(documentSnapshot => { + * if (documentSnapshot.exists) { + * console.log(documentSnapshot.data()); + * } + * }, err => { + * console.log(`Encountered error: ${err}`); + * }); + * + * // Remove this listener. + * unsubscribe(); + * ``` + */ + onSnapshot( + onNext: ( + snapshot: firestore.DocumentSnapshot + ) => void, + onError?: (error: Error) => void + ): () => void { + validateFunction('onNext', onNext); + validateFunction('onError', onError, {optional: true}); + + const watch: DocumentWatch = + new (require('./watch').DocumentWatch)(this.firestore, this); + return watch.onSnapshot((readTime, size, docs) => { + for (const document of docs()) { + if (document.ref.path === this.path) { + onNext(document); + return; + } + } + + // The document is missing. + const ref = new DocumentReference( + this._firestore, + this._path, + this._converter + ); + const document = new DocumentSnapshotBuilder( + ref + ); + document.readTime = readTime; + onNext(document.build()); + }, onError || console.error); + } + + /** + * Returns true if this `DocumentReference` is equal to the provided value. + * + * @param {*} other The value to compare against. + * @return {boolean} true if this `DocumentReference` is equal to the provided + * value. + */ + isEqual( + other: firestore.DocumentReference + ): boolean { + return ( + this === other || + (other instanceof DocumentReference && + this._firestore === other._firestore && + this._path.isEqual(other._path) && + this._converter === other._converter) + ); + } + + /** + * Converts this DocumentReference to the Firestore Proto representation. + * + * @private + * @internal + */ + toProto(): api.IValue { + return {referenceValue: this.formattedName}; + } + + withConverter(converter: null): DocumentReference; + withConverter< + NewAppModelType, + NewDbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + converter: firestore.FirestoreDataConverter + ): DocumentReference; + /** + * Applies a custom data converter to this DocumentReference, allowing you to + * use your own custom model objects with Firestore. When you call set(), + * get(), etc. on the returned DocumentReference instance, the provided + * converter will convert between Firestore data of type `NewDbModelType` and + * your custom type `NewAppModelType`. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @example + * ``` + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * snapshot: FirebaseFirestore.QueryDocumentSnapshot + * ): Post { + * const data = snapshot.data(); + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await Firestore() + * .collection('posts') + * .withConverter(postConverter) + * .doc().get(); + * const post = postSnap.data(); + * if (post !== undefined) { + * post.title; // string + * post.toString(); // Should be defined + * post.someNonExistentProperty; // TS error + * } + * + * ``` + * @param {FirestoreDataConverter | null} converter Converts objects to and + * from Firestore. Passing in `null` removes the current converter. + * @return A DocumentReference that uses the provided converter. + */ + withConverter< + NewAppModelType, + NewDbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + converter: firestore.FirestoreDataConverter< + NewAppModelType, + NewDbModelType + > | null + ): DocumentReference { + return new DocumentReference( + this.firestore, + this._path, + converter ?? defaultConverter() + ); + } +} diff --git a/dev/src/reference/field-filter-internal.ts b/dev/src/reference/field-filter-internal.ts new file mode 100644 index 000000000..70ef12725 --- /dev/null +++ b/dev/src/reference/field-filter-internal.ts @@ -0,0 +1,125 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as deepEqual from 'fast-deep-equal'; + +import * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; +import {FilterInternal} from './filter-internal'; +import {Serializer} from '../serializer'; +import {FieldPath} from '../path'; + +/** + * A field constraint for a Query where clause. + * + * @private + * @internal + * @class + */ +export class FieldFilterInternal extends FilterInternal { + public getFlattenedFilters(): FieldFilterInternal[] { + return [this]; + } + + public getFilters(): FilterInternal[] { + return [this]; + } + + /** + * @param serializer The Firestore serializer + * @param field The path of the property value to compare. + * @param op A comparison operation. + * @param value The value to which to compare the field for inclusion in a + * query. + */ + constructor( + private readonly serializer: Serializer, + readonly field: FieldPath, + private readonly op: api.StructuredQuery.FieldFilter.Operator, + private readonly value: unknown + ) { + super(); + } + + /** + * Returns whether this FieldFilter uses an equals comparison. + * + * @private + * @internal + */ + isInequalityFilter(): boolean { + switch (this.op) { + case 'GREATER_THAN': + case 'GREATER_THAN_OR_EQUAL': + case 'LESS_THAN': + case 'LESS_THAN_OR_EQUAL': + case 'NOT_EQUAL': + case 'NOT_IN': + return true; + default: + return false; + } + } + + /** + * Generates the proto representation for this field filter. + * + * @private + * @internal + */ + toProto(): api.StructuredQuery.IFilter { + if (typeof this.value === 'number' && isNaN(this.value)) { + return { + unaryFilter: { + field: { + fieldPath: this.field.formattedName, + }, + op: this.op === 'EQUAL' ? 'IS_NAN' : 'IS_NOT_NAN', + }, + }; + } + + if (this.value === null) { + return { + unaryFilter: { + field: { + fieldPath: this.field.formattedName, + }, + op: this.op === 'EQUAL' ? 'IS_NULL' : 'IS_NOT_NULL', + }, + }; + } + + return { + fieldFilter: { + field: { + fieldPath: this.field.formattedName, + }, + op: this.op, + value: this.serializer.encodeValue(this.value), + }, + }; + } + + isEqual(other: FilterInternal): boolean { + return ( + other instanceof FieldFilterInternal && + this.field.isEqual(other.field) && + this.op === other.op && + deepEqual(this.value, other.value) + ); + } +} diff --git a/dev/src/reference/field-order.ts b/dev/src/reference/field-order.ts new file mode 100644 index 000000000..7d551f6d0 --- /dev/null +++ b/dev/src/reference/field-order.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; + +import {FieldPath} from '../path'; + +/** + * A Query order-by field. + * + * @private + * @internal + * @class + */ +export class FieldOrder { + /** + * @param field The name of a document field (member) on which to order query + * results. + * @param direction One of 'ASCENDING' (default) or 'DESCENDING' to + * set the ordering direction to ascending or descending, respectively. + */ + constructor( + readonly field: FieldPath, + readonly direction: api.StructuredQuery.Direction = 'ASCENDING' + ) {} + + /** + * Generates the proto representation for this field order. + * @private + * @internal + */ + toProto(): api.StructuredQuery.IOrder { + return { + field: { + fieldPath: this.field.formattedName, + }, + direction: this.direction, + }; + } +} diff --git a/dev/src/reference/filter-internal.ts b/dev/src/reference/filter-internal.ts new file mode 100644 index 000000000..50d28df05 --- /dev/null +++ b/dev/src/reference/filter-internal.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 {Filter} from '../filter'; +import {FieldFilterInternal} from './field-filter-internal'; + +export abstract class FilterInternal { + /** Returns a list of all field filters that are contained within this filter */ + abstract getFlattenedFilters(): FieldFilterInternal[]; + + /** Returns a list of all filters that are contained within this filter */ + abstract getFilters(): FilterInternal[]; + + /** Returns the proto representation of this filter */ + abstract toProto(): Filter; + + abstract isEqual(other: FilterInternal): boolean; +} diff --git a/dev/src/reference/helpers.ts b/dev/src/reference/helpers.ts new file mode 100644 index 000000000..8512eb201 --- /dev/null +++ b/dev/src/reference/helpers.ts @@ -0,0 +1,138 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as firestore from '@google-cloud/firestore'; +import {invalidArgumentMessage, validateEnumValue} from '../validate'; +import {validateUserInput} from '../serializer'; +import {DocumentReference} from './document-reference'; +import {comparisonOperators, directionOperators} from './constants'; + +/** + * Validates the input string as a field order direction. + * + * @private + * @internal + * @param arg The argument name or argument index (for varargs methods). + * @param op Order direction to validate. + * @throws when the direction is invalid + * @return a validated input value, which may be different from the provided + * value. + */ +export function validateQueryOrder( + arg: string, + op: unknown +): firestore.OrderByDirection | undefined { + // For backwards compatibility, we support both lower and uppercase values. + op = typeof op === 'string' ? op.toLowerCase() : op; + validateEnumValue(arg, op, Object.keys(directionOperators), {optional: true}); + return op as firestore.OrderByDirection | undefined; +} + +/** + * Validates the input string as a field comparison operator. + * + * @private + * @internal + * @param arg The argument name or argument index (for varargs methods). + * @param op Field comparison operator to validate. + * @param fieldValue Value that is used in the filter. + * @throws when the comparison operation is invalid + * @return a validated input value, which may be different from the provided + * value. + */ +export function validateQueryOperator( + arg: string | number, + op: unknown, + fieldValue: unknown +): firestore.WhereFilterOp { + // For backwards compatibility, we support both `=` and `==` for "equals". + if (op === '=') { + op = '=='; + } + + validateEnumValue(arg, op, Object.keys(comparisonOperators)); + + if ( + typeof fieldValue === 'number' && + isNaN(fieldValue) && + op !== '==' && + op !== '!=' + ) { + throw new Error( + "Invalid query. You can only perform '==' and '!=' comparisons on NaN." + ); + } + + if (fieldValue === null && op !== '==' && op !== '!=') { + throw new Error( + "Invalid query. You can only perform '==' and '!=' comparisons on Null." + ); + } + + return op as firestore.WhereFilterOp; +} + +/** + * Validates that 'value' is a DocumentReference. + * + * @private + * @internal + * @param arg The argument name or argument index (for varargs methods). + * @param value The argument to validate. + * @return the DocumentReference if valid + */ +export function validateDocumentReference< + AppModelType, + DbModelType extends firestore.DocumentData, +>( + arg: string | number, + value: firestore.DocumentReference +): DocumentReference { + if (!(value instanceof DocumentReference)) { + throw new Error(invalidArgumentMessage(arg, 'DocumentReference')); + } + return value; +} + +/** + * Validates that 'value' can be used as a query value. + * + * @private + * @internal + * @param arg The argument name or argument index (for varargs methods). + * @param value The argument to validate. + * @param allowUndefined Whether to allow nested properties that are `undefined`. + */ +export function validateQueryValue( + arg: string | number, + value: unknown, + allowUndefined: boolean +): void { + validateUserInput(arg, value, 'query constraint', { + allowDeletes: 'none', + allowTransforms: false, + allowUndefined, + }); +} + +/** + * Returns the first non-undefined value or `undefined` if no such value exists. + * @private + * @internal + */ +export function coalesce(...values: Array): T | undefined { + return values.find(value => value !== undefined); +} diff --git a/dev/src/reference/query-options.ts b/dev/src/reference/query-options.ts new file mode 100644 index 000000000..1bece78c9 --- /dev/null +++ b/dev/src/reference/query-options.ts @@ -0,0 +1,229 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; +import * as deepEqual from 'fast-deep-equal'; + +import * as firestore from '@google-cloud/firestore'; +import {ResourcePath} from '../path'; +import {defaultConverter} from '../types'; +import {FilterInternal} from './filter-internal'; +import {FieldOrder} from './field-order'; +import {LimitType, QueryCursor} from './types'; +import {coalesce} from './helpers'; + +/** + * Internal class representing custom Query options. + * + * These options are immutable. Modified options can be created using `with()`. + * @private + * @internal + */ +export class QueryOptions< + AppModelType, + DbModelType extends firestore.DocumentData, +> { + constructor( + readonly parentPath: ResourcePath, + readonly collectionId: string, + readonly converter: firestore.FirestoreDataConverter< + AppModelType, + DbModelType + >, + readonly allDescendants: boolean, + readonly filters: FilterInternal[], + readonly fieldOrders: FieldOrder[], + readonly startAt?: QueryCursor, + readonly endAt?: QueryCursor, + readonly limit?: number, + readonly limitType?: LimitType, + readonly offset?: number, + readonly projection?: api.StructuredQuery.IProjection, + // Whether to select all documents under `parentPath`. By default, only + // collections that match `collectionId` are selected. + readonly kindless = false, + // Whether to require consistent documents when restarting the query. By + // default, restarting the query uses the readTime offset of the original + // query to provide consistent results. + readonly requireConsistency = true + ) {} + + /** + * Returns query options for a collection group query. + * @private + * @internal + */ + static forCollectionGroupQuery< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + collectionId: string, + converter = defaultConverter() + ): QueryOptions { + return new QueryOptions( + /*parentPath=*/ ResourcePath.EMPTY, + collectionId, + converter, + /*allDescendants=*/ true, + /*fieldFilters=*/ [], + /*fieldOrders=*/ [] + ); + } + + /** + * Returns query options for a single-collection query. + * @private + * @internal + */ + static forCollectionQuery< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + collectionRef: ResourcePath, + converter = defaultConverter() + ): QueryOptions { + return new QueryOptions( + collectionRef.parent()!, + collectionRef.id!, + converter, + /*allDescendants=*/ false, + /*fieldFilters=*/ [], + /*fieldOrders=*/ [] + ); + } + + /** + * Returns query options for a query that fetches all descendants under the + * specified reference. + * + * @private + * @internal + */ + static forKindlessAllDescendants( + parent: ResourcePath, + id: string, + requireConsistency = true + ): QueryOptions { + let options = new QueryOptions< + firestore.DocumentData, + firestore.DocumentData + >( + parent, + id, + defaultConverter(), + /*allDescendants=*/ true, + /*fieldFilters=*/ [], + /*fieldOrders=*/ [] + ); + + options = options.with({ + kindless: true, + requireConsistency, + }); + return options; + } + + /** + * Returns the union of the current and the provided options. + * @private + * @internal + */ + with( + settings: Partial< + Omit, 'converter'> + > + ): QueryOptions { + return new QueryOptions( + coalesce(settings.parentPath, this.parentPath)!, + coalesce(settings.collectionId, this.collectionId)!, + this.converter, + coalesce(settings.allDescendants, this.allDescendants)!, + coalesce(settings.filters, this.filters)!, + coalesce(settings.fieldOrders, this.fieldOrders)!, + coalesce(settings.startAt, this.startAt), + coalesce(settings.endAt, this.endAt), + coalesce(settings.limit, this.limit), + coalesce(settings.limitType, this.limitType), + coalesce(settings.offset, this.offset), + coalesce(settings.projection, this.projection), + coalesce(settings.kindless, this.kindless), + coalesce(settings.requireConsistency, this.requireConsistency) + ); + } + + withConverter< + NewAppModelType, + NewDbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + converter: firestore.FirestoreDataConverter + ): QueryOptions { + return new QueryOptions( + this.parentPath, + this.collectionId, + converter, + this.allDescendants, + this.filters, + this.fieldOrders, + this.startAt, + this.endAt, + this.limit, + this.limitType, + this.offset, + this.projection + ); + } + + hasFieldOrders(): boolean { + return this.fieldOrders.length > 0; + } + + isEqual(other: QueryOptions): boolean { + if (this === other) { + return true; + } + + return ( + other instanceof QueryOptions && + this.parentPath.isEqual(other.parentPath) && + this.filtersEqual(other.filters) && + this.collectionId === other.collectionId && + this.converter === other.converter && + this.allDescendants === other.allDescendants && + this.limit === other.limit && + this.offset === other.offset && + deepEqual(this.fieldOrders, other.fieldOrders) && + deepEqual(this.startAt, other.startAt) && + deepEqual(this.endAt, other.endAt) && + deepEqual(this.projection, other.projection) && + this.kindless === other.kindless && + this.requireConsistency === other.requireConsistency + ); + } + + private filtersEqual(other: FilterInternal[]): boolean { + if (this.filters.length !== other.length) { + return false; + } + + for (let i = 0; i < other.length; i++) { + if (!this.filters[i].isEqual(other[i])) { + return false; + } + } + return true; + } +} diff --git a/dev/src/reference/query-snapshot.ts b/dev/src/reference/query-snapshot.ts new file mode 100644 index 000000000..1ad112464 --- /dev/null +++ b/dev/src/reference/query-snapshot.ts @@ -0,0 +1,296 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as firestore from '@google-cloud/firestore'; +import {QueryDocumentSnapshot} from '../document'; +import {DocumentChange} from '../document-change'; +import {Timestamp} from '../timestamp'; +import {validateFunction} from '../validate'; +import {isArrayEqual} from '../util'; +import {Query} from './query'; + +/** + * A QuerySnapshot contains zero or more + * [QueryDocumentSnapshot]{@link QueryDocumentSnapshot} objects + * representing the results of a query. The documents can be accessed as an + * array via the [documents]{@link QuerySnapshot#documents} property + * or enumerated using the [forEach]{@link QuerySnapshot#forEach} + * method. The number of documents can be determined via the + * [empty]{@link QuerySnapshot#empty} and + * [size]{@link QuerySnapshot#size} properties. + * + * @class QuerySnapshot + */ +export class QuerySnapshot< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, +> implements firestore.QuerySnapshot +{ + private _materializedDocs: Array< + QueryDocumentSnapshot + > | null = null; + private _materializedChanges: Array< + DocumentChange + > | null = null; + private _docs: + | (() => Array>) + | null = null; + private _changes: + | (() => Array>) + | null = null; + + /** + * @private + * + * @param _query The originating query. + * @param _readTime The time when this query snapshot was obtained. + * @param _size The number of documents in the result set. + * @param docs A callback returning a sorted array of documents matching + * this query + * @param changes A callback returning a sorted array of document change + * events for this snapshot. + */ + constructor( + private readonly _query: Query, + private readonly _readTime: Timestamp, + private readonly _size: number, + docs: () => Array>, + changes: () => Array> + ) { + this._docs = docs; + this._changes = changes; + } + + /** + * The query on which you called get() or onSnapshot() in order to get this + * QuerySnapshot. + * + * @type {Query} + * @name QuerySnapshot#query + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.limit(10).get().then(querySnapshot => { + * console.log(`Returned first batch of results`); + * let query = querySnapshot.query; + * return query.offset(10).get(); + * }).then(() => { + * console.log(`Returned second batch of results`); + * }); + * ``` + */ + get query(): Query { + return this._query; + } + + /** + * An array of all the documents in this QuerySnapshot. + * + * @type {Array.} + * @name QuerySnapshot#docs + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.get().then(querySnapshot => { + * let docs = querySnapshot.docs; + * for (let doc of docs) { + * console.log(`Document found at path: ${doc.ref.path}`); + * } + * }); + * ``` + */ + get docs(): Array> { + if (this._materializedDocs) { + return this._materializedDocs!; + } + this._materializedDocs = this._docs!(); + this._docs = null; + return this._materializedDocs!; + } + + /** + * True if there are no documents in the QuerySnapshot. + * + * @type {boolean} + * @name QuerySnapshot#empty + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.get().then(querySnapshot => { + * if (querySnapshot.empty) { + * console.log('No documents found.'); + * } + * }); + * ``` + */ + get empty(): boolean { + return this._size === 0; + } + + /** + * The number of documents in the QuerySnapshot. + * + * @type {number} + * @name QuerySnapshot#size + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.get().then(querySnapshot => { + * console.log(`Found ${querySnapshot.size} documents.`); + * }); + * ``` + */ + get size(): number { + return this._size; + } + + /** + * The time this query snapshot was obtained. + * + * @type {Timestamp} + * @name QuerySnapshot#readTime + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.get().then((querySnapshot) => { + * let readTime = querySnapshot.readTime; + * console.log(`Query results returned at '${readTime.toDate()}'`); + * }); + * ``` + */ + get readTime(): Timestamp { + return this._readTime; + } + + /** + * Returns an array of the documents changes since the last snapshot. If + * this is the first snapshot, all documents will be in the list as added + * changes. + * + * @return {Array.} + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.onSnapshot(querySnapshot => { + * let changes = querySnapshot.docChanges(); + * for (let change of changes) { + * console.log(`A document was ${change.type}.`); + * } + * }); + * ``` + */ + docChanges(): Array> { + if (this._materializedChanges) { + return this._materializedChanges!; + } + this._materializedChanges = this._changes!(); + this._changes = null; + return this._materializedChanges!; + } + + /** + * Enumerates all of the documents in the QuerySnapshot. This is a convenience + * method for running the same callback on each {@link QueryDocumentSnapshot} + * that is returned. + * + * @param {function} callback A callback to be called with a + * [QueryDocumentSnapshot]{@link QueryDocumentSnapshot} for each document in + * the snapshot. + * @param {*=} thisArg The `this` binding for the callback.. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Document found at path: ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + forEach( + callback: ( + result: firestore.QueryDocumentSnapshot + ) => void, + thisArg?: unknown + ): void { + validateFunction('callback', callback); + + for (const doc of this.docs) { + callback.call(thisArg, doc); + } + } + + /** + * Returns true if the document data in this `QuerySnapshot` is equal to the + * provided value. + * + * @param {*} other The value to compare against. + * @return {boolean} true if this `QuerySnapshot` is equal to the provided + * value. + */ + isEqual(other: firestore.QuerySnapshot): boolean { + // Since the read time is different on every query read, we explicitly + // ignore all metadata in this comparison. + + if (this === other) { + return true; + } + + if (!(other instanceof QuerySnapshot)) { + return false; + } + + if (this._size !== other._size) { + return false; + } + + if (!this._query.isEqual(other._query)) { + return false; + } + + if (this._materializedDocs && !this._materializedChanges) { + // If we have only materialized the documents, we compare them first. + return ( + isArrayEqual(this.docs, other.docs) && + isArrayEqual(this.docChanges(), other.docChanges()) + ); + } + + // Otherwise, we compare the changes first as we expect there to be fewer. + return ( + isArrayEqual(this.docChanges(), other.docChanges()) && + isArrayEqual(this.docs, other.docs) + ); + } +} diff --git a/dev/src/reference/query-util.ts b/dev/src/reference/query-util.ts new file mode 100644 index 000000000..ebc1617df --- /dev/null +++ b/dev/src/reference/query-util.ts @@ -0,0 +1,330 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as firestore from '@google-cloud/firestore'; +import {GoogleError} from 'google-gax'; +import {Duplex, Transform} from 'stream'; + +import {Serializer} from '../serializer'; +import {Timestamp} from '../timestamp'; +import {DocumentSnapshotBuilder, QueryDocumentSnapshot} from '../document'; +import { + Deferred, + getTotalTimeout, + isPermanentRpcError, + requestTag, + wrapError, +} from '../util'; +import {DocumentChange} from '../document-change'; +import {ExplainMetrics} from '../query-profile'; +import {logger} from '../logger'; +import {VectorQuery} from './vector-query'; +import {Query} from './query'; +import Firestore from '../index'; +import {QueryOptions} from './query-options'; +import {QuerySnapshot} from './query-snapshot'; +import {VectorQuerySnapshot} from './vector-query-snapshot'; +import {LimitType} from './types'; +import {NOOP_MESSAGE} from './constants'; + +export class QueryUtil< + AppModelType, + DbModelType extends firestore.DocumentData, + Template extends + | Query + | VectorQuery, +> { + constructor( + /** @private */ + readonly _firestore: Firestore, + /** @private */ + readonly _queryOptions: QueryOptions, + /** @private */ + readonly _serializer: Serializer + ) {} + + _get( + query: Template, + transactionIdOrReadTime?: Uint8Array | Timestamp, + retryWithCursor = true + ): Promise< + | QuerySnapshot + | VectorQuerySnapshot + > { + const docs: Array> = []; + + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + return new Promise((resolve, reject) => { + let readTime: Timestamp; + + this._stream(query, transactionIdOrReadTime, retryWithCursor) + .on('error', err => { + reject(wrapError(err, stack)); + }) + .on('data', result => { + readTime = result.readTime; + if (result.document) { + docs.push(result.document); + } + }) + .on('end', () => { + if (this._queryOptions.limitType === LimitType.Last) { + // The results for limitToLast queries need to be flipped since + // we reversed the ordering constraints before sending the query + // to the backend. + docs.reverse(); + } + + resolve( + query._createSnapshot( + readTime, + docs.length, + () => docs, + () => { + const changes: Array< + DocumentChange + > = []; + for (let i = 0; i < docs.length; ++i) { + changes.push(new DocumentChange('added', docs[i], -1, i)); + } + return changes; + } + ) + ); + }); + }); + } + + // This method exists solely to enable unit tests to mock it. + _isPermanentRpcError(err: GoogleError, methodName: string): boolean { + return isPermanentRpcError(err, methodName); + } + + _hasRetryTimedOut(methodName: string, startTime: number): boolean { + const totalTimeout = getTotalTimeout(methodName); + if (totalTimeout === 0) { + return false; + } + + return Date.now() - startTime >= totalTimeout; + } + + stream(query: Template): NodeJS.ReadableStream { + if (this._queryOptions.limitType === LimitType.Last) { + throw new Error( + 'Query results for queries that include limitToLast() ' + + 'constraints cannot be streamed. Use Query.get() instead.' + ); + } + + const responseStream = this._stream(query); + const transform = new Transform({ + objectMode: true, + transform(chunk, encoding, callback) { + callback(undefined, chunk.document); + }, + }); + + responseStream.pipe(transform); + responseStream.on('error', e => transform.destroy(e)); + return transform; + } + + _stream( + query: Template, + transactionIdOrReadTime?: Uint8Array | Timestamp, + retryWithCursor = true, + explainOptions?: firestore.ExplainOptions + ): NodeJS.ReadableStream { + const tag = requestTag(); + const startTime = Date.now(); + const isExplain = explainOptions !== undefined; + + let lastReceivedDocument: QueryDocumentSnapshot< + AppModelType, + DbModelType + > | null = null; + + let backendStream: Duplex; + const stream = new Transform({ + objectMode: true, + transform: (proto, enc, callback) => { + if (proto === NOOP_MESSAGE) { + callback(undefined); + return; + } + + const output: { + readTime?: Timestamp; + document?: QueryDocumentSnapshot; + explainMetrics?: ExplainMetrics; + } = {}; + + if (proto.readTime) { + output.readTime = Timestamp.fromProto(proto.readTime); + } + + if (proto.document) { + const document = this._firestore.snapshot_( + proto.document, + proto.readTime + ); + const finalDoc = new DocumentSnapshotBuilder< + AppModelType, + DbModelType + >(document.ref.withConverter(this._queryOptions.converter)); + // Recreate the QueryDocumentSnapshot with the DocumentReference + // containing the original converter. + finalDoc.fieldsProto = document._fieldsProto; + finalDoc.readTime = document.readTime; + finalDoc.createTime = document.createTime; + finalDoc.updateTime = document.updateTime; + lastReceivedDocument = finalDoc.build() as QueryDocumentSnapshot< + AppModelType, + DbModelType + >; + output.document = lastReceivedDocument; + } + + if (proto.explainMetrics) { + output.explainMetrics = ExplainMetrics._fromProto( + proto.explainMetrics, + this._serializer + ); + } + + callback(undefined, output); + + if (proto.done) { + logger('QueryUtil._stream', tag, 'Trigger Logical Termination.'); + backendStream.unpipe(stream); + backendStream.resume(); + backendStream.end(); + stream.end(); + } + }, + }); + + this._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. + let request = query.toProto(transactionIdOrReadTime, explainOptions); + + let streamActive: Deferred; + do { + streamActive = new Deferred(); + const methodName = 'runQuery'; + backendStream = await this._firestore.requestStream( + methodName, + /* bidirectional= */ false, + request, + tag + ); + backendStream.on('error', err => { + backendStream.unpipe(stream); + + // If a non-transactional query failed, attempt to restart. + // Transactional queries are retried via the transaction runner. + // Explain queries are not retried with a cursor. That would produce + // incorrect/partial profiling results. + if ( + !isExplain && + !transactionIdOrReadTime && + !this._isPermanentRpcError(err, 'runQuery') + ) { + logger( + 'QueryUtil._stream', + tag, + 'Query failed with retryable stream error:', + err + ); + + // Enqueue a "no-op" write into the stream and wait for it to be + // read by the downstream consumer. This ensures that all enqueued + // results in the stream are consumed, which will give us an accurate + // value for `lastReceivedDocument`. + stream.write(NOOP_MESSAGE, () => { + if (this._hasRetryTimedOut(methodName, startTime)) { + logger( + 'QueryUtil._stream', + tag, + 'Query failed with retryable stream error but the total retry timeout has exceeded.' + ); + stream.destroy(err); + streamActive.resolve(/* active= */ false); + } else if (lastReceivedDocument && retryWithCursor) { + logger( + 'Query._stream', + tag, + 'Query failed with retryable stream error and progress was made receiving ' + + 'documents, so the stream is being retried.' + ); + + // Restart the query but use the last document we received as + // the query cursor. Note that we do not use backoff here. The + // call to `requestStream()` will backoff should the restart + // fail before delivering any results. + if (this._queryOptions.requireConsistency) { + request = query + .startAfter(lastReceivedDocument) + .toProto(lastReceivedDocument.readTime); + } else { + request = query.startAfter(lastReceivedDocument).toProto(); + } + + // Set lastReceivedDocument to null before each retry attempt to ensure the retry makes progress + lastReceivedDocument = null; + + streamActive.resolve(/* active= */ true); + } else { + logger( + 'QueryUtil._stream', + tag, + `Query failed with retryable stream error however either retryWithCursor="${retryWithCursor}", or ` + + 'no progress was made receiving documents, so the stream is being closed.' + ); + stream.destroy(err); + streamActive.resolve(/* active= */ false); + } + }); + } else { + logger( + 'QueryUtil._stream', + tag, + 'Query failed with stream error:', + err + ); + stream.destroy(err); + streamActive.resolve(/* active= */ false); + } + }); + backendStream.on('end', () => { + streamActive.resolve(/* active= */ false); + }); + backendStream.resume(); + backendStream.pipe(stream); + } while (await streamActive.promise); + }) + .catch(e => stream.destroy(e)); + + return stream; + } +} diff --git a/dev/src/reference/query.ts b/dev/src/reference/query.ts new file mode 100644 index 000000000..fb91f0692 --- /dev/null +++ b/dev/src/reference/query.ts @@ -0,0 +1,1645 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; +import * as firestore from '@google-cloud/firestore'; +import {GoogleError} from 'google-gax'; +import {Transform} from 'stream'; + +import {QueryUtil} from './query-util'; +import { + Firestore, + AggregateField, + DocumentChange, + DocumentSnapshot, + ExplainMetrics, + FieldPath, + Filter, + QueryDocumentSnapshot, + Timestamp, +} from '../index'; +import {QueryOptions} from './query-options'; +import {FieldOrder} from './field-order'; +import {FilterInternal} from './filter-internal'; +import {FieldFilterInternal} from './field-filter-internal'; +import {CompositeFilterInternal} from './composite-filter-internal'; +import {comparisonOperators, directionOperators} from './constants'; +import {VectorQueryOptions} from './vector-query-options'; +import {DocumentReference} from './document-reference'; +import {QuerySnapshot} from './query-snapshot'; +import {Serializer} from '../serializer'; +import {ExplainResults} from '../query-profile'; + +import {CompositeFilter, UnaryFilter} from '../filter'; +import {validateFieldPath} from '../path'; +import { + validateQueryOperator, + validateQueryOrder, + validateQueryValue, +} from './helpers'; +import { + invalidArgumentMessage, + validateFunction, + validateInteger, + validateMinNumberOfArguments, +} from '../validate'; +import {LimitType, QueryCursor} from './types'; +import {AggregateQuery} from './aggregate-query'; +import {VectorQuery} from './vector-query'; +import {wrapError} from '../util'; +import {QueryWatch} from '../watch'; +import {compare} from '../order'; +import {defaultConverter} from '../types'; + +/** + * A Query refers to a query which you can read or stream from. You can also + * construct refined Query objects by adding filters and ordering. + * + * @class Query + */ +export class Query< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, +> implements firestore.Query +{ + /** + * @internal + * @private + **/ + readonly _serializer: Serializer; + /** + * @internal + * @private + **/ + protected readonly _allowUndefined: boolean; + /** + * @internal + * @private + **/ + readonly _queryUtil: QueryUtil< + AppModelType, + DbModelType, + Query + >; + + /** + * @internal + * @private + * + * @param _firestore The Firestore Database client. + * @param _queryOptions Options that define the query. + */ + constructor( + /** + * @internal + * @private + **/ + readonly _firestore: Firestore, + /** + * @internal + * @private + **/ + readonly _queryOptions: QueryOptions + ) { + this._serializer = new Serializer(_firestore); + this._allowUndefined = + !!this._firestore._settings.ignoreUndefinedProperties; + this._queryUtil = new QueryUtil< + AppModelType, + DbModelType, + Query + >(_firestore, _queryOptions, this._serializer); + } + + /** + * Extracts field values from the DocumentSnapshot based on the provided + * field order. + * + * @private + * @internal + * @param documentSnapshot The document to extract the fields from. + * @param fieldOrders The field order that defines what fields we should + * extract. + * @return {Array.<*>} The field values to use. + */ + static _extractFieldValues( + documentSnapshot: DocumentSnapshot, + fieldOrders: FieldOrder[] + ): unknown[] { + const fieldValues: unknown[] = []; + + for (const fieldOrder of fieldOrders) { + if (FieldPath.documentId().isEqual(fieldOrder.field)) { + fieldValues.push(documentSnapshot.ref); + } else { + const fieldValue = documentSnapshot.get(fieldOrder.field); + if (fieldValue === undefined) { + throw new Error( + `Field "${fieldOrder.field}" is missing in the provided DocumentSnapshot. ` + + 'Please provide a document that contains values for all specified ' + + 'orderBy() and where() constraints.' + ); + } else { + fieldValues.push(fieldValue); + } + } + } + return fieldValues; + } + + /** + * The [Firestore]{@link Firestore} instance for the Firestore + * database (useful for performing transactions, etc.). + * + * @type {Firestore} + * @name Query#firestore + * @readonly + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * + * collectionRef.add({foo: 'bar'}).then(documentReference => { + * let firestore = documentReference.firestore; + * console.log(`Root location for document is ${firestore.formattedName}`); + * }); + * ``` + */ + get firestore(): Firestore { + return this._firestore; + } + + /** + * Creates and returns a new [Query]{@link Query} with the additional filter + * that documents must contain the specified field and that its value should + * satisfy the relation constraint provided. + * + * This function returns a new (immutable) instance of the Query (rather than + * modify the existing instance) to impose the filter. + * + * @param {string|FieldPath} fieldPath The name of a property value to compare. + * @param {string} opStr A comparison operation in the form of a string. + * Acceptable operator strings are "<", "<=", "==", "!=", ">=", ">", "array-contains", + * "in", "not-in", and "array-contains-any". + * @param {*} value The value to which to compare the field for inclusion in + * a query. + * @returns {Query} The created Query. + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * + * collectionRef.where('foo', '==', 'bar').get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + where( + fieldPath: string | FieldPath, + opStr: firestore.WhereFilterOp, + value: unknown + ): Query; + + /** + * Creates and returns a new [Query]{@link Query} with the additional filter + * that documents should satisfy the relation constraint(s) provided. + * + * This function returns a new (immutable) instance of the Query (rather than + * modify the existing instance) to impose the filter. + * + * @param {Filter} filter A unary or composite filter to apply to the Query. + * @returns {Query} The created Query. + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * + * collectionRef.where(Filter.and(Filter.where('foo', '==', 'bar'), Filter.where('foo', '!=', 'baz'))).get() + * .then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + where(filter: Filter): Query; + + where( + fieldPathOrFilter: string | firestore.FieldPath | Filter, + opStr?: firestore.WhereFilterOp, + value?: unknown + ): Query { + let filter: Filter; + + if (fieldPathOrFilter instanceof Filter) { + filter = fieldPathOrFilter; + } else { + filter = Filter.where(fieldPathOrFilter, opStr!, value); + } + + if (this._queryOptions.startAt || this._queryOptions.endAt) { + throw new Error( + 'Cannot specify a where() filter after calling startAt(), ' + + 'startAfter(), endBefore() or endAt().' + ); + } + + const parsedFilter = this._parseFilter(filter); + + if (parsedFilter.getFilters().length === 0) { + // Return the existing query if not adding any more filters (e.g. an empty composite filter). + return this; + } + + const options = this._queryOptions.with({ + filters: this._queryOptions.filters.concat(parsedFilter), + }); + return new Query(this._firestore, options); + } + + /** + * @internal + * @private + */ + _parseFilter(filter: Filter): FilterInternal { + if (filter instanceof UnaryFilter) { + return this._parseFieldFilter(filter); + } + return this._parseCompositeFilter(filter as CompositeFilter); + } + + /** + * @internal + * @private + */ + _parseFieldFilter(fieldFilterData: UnaryFilter): FieldFilterInternal { + let value = fieldFilterData._getValue(); + let operator = fieldFilterData._getOperator(); + const fieldPath = fieldFilterData._getField(); + + validateFieldPath('fieldPath', fieldPath); + + operator = validateQueryOperator('opStr', operator, value); + validateQueryValue('value', value, this._allowUndefined); + + const path = FieldPath.fromArgument(fieldPath); + + if (FieldPath.documentId().isEqual(path)) { + if (operator === 'array-contains' || operator === 'array-contains-any') { + throw new Error( + `Invalid Query. You can't perform '${operator}' ` + + 'queries on FieldPath.documentId().' + ); + } else if (operator === 'in' || operator === 'not-in') { + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + `Invalid Query. A non-empty array is required for '${operator}' filters.` + ); + } + value = value.map(el => this.validateReference(el)); + } else { + value = this.validateReference(value); + } + } + + return new FieldFilterInternal( + this._serializer, + path, + comparisonOperators[operator], + value + ); + } + + /** + * @internal + * @private + */ + _parseCompositeFilter(compositeFilterData: CompositeFilter): FilterInternal { + const parsedFilters = compositeFilterData + ._getFilters() + .map(filter => this._parseFilter(filter)) + .filter(parsedFilter => parsedFilter.getFilters().length > 0); + + // For composite filters containing 1 filter, return the only filter. + // For example: AND(FieldFilter1) == FieldFilter1 + if (parsedFilters.length === 1) { + return parsedFilters[0]; + } + return new CompositeFilterInternal( + parsedFilters, + compositeFilterData._getOperator() === 'AND' ? 'AND' : 'OR' + ); + } + + /** + * Creates and returns a new [Query]{@link Query} instance that applies a + * field mask to the result and returns only the specified subset of fields. + * You can specify a list of field paths to return, or use an empty list to + * only return the references of matching documents. + * + * Queries that contain field masks cannot be listened to via `onSnapshot()` + * listeners. + * + * This function returns a new (immutable) instance of the Query (rather than + * modify the existing instance) to impose the field mask. + * + * @param {...(string|FieldPath)} fieldPaths The field paths to return. + * @returns {Query} The created Query. + * + * @example + * ``` + * let collectionRef = firestore.collection('col'); + * let documentRef = collectionRef.doc('doc'); + * + * return documentRef.set({x:10, y:5}).then(() => { + * return collectionRef.where('x', '>', 5).select('y').get(); + * }).then((res) => { + * console.log(`y is ${res.docs[0].get('y')}.`); + * }); + * ``` + */ + select(...fieldPaths: Array): Query { + const fields: api.StructuredQuery.IFieldReference[] = []; + + if (fieldPaths.length === 0) { + fields.push({fieldPath: FieldPath.documentId().formattedName}); + } else { + for (let i = 0; i < fieldPaths.length; ++i) { + validateFieldPath(i, fieldPaths[i]); + fields.push({ + fieldPath: FieldPath.fromArgument(fieldPaths[i]).formattedName, + }); + } + } + + // By specifying a field mask, the query result no longer conforms to type + // `T`. We there return `Query`; + const options = this._queryOptions.with({ + projection: {fields}, + }) as QueryOptions; + return new Query(this._firestore, options); + } + + /** + * Creates and returns a new [Query]{@link Query} that's additionally sorted + * by the specified field, optionally in descending order instead of + * ascending. + * + * This function returns a new (immutable) instance of the Query (rather than + * modify the existing instance) to impose the field mask. + * + * @param {string|FieldPath} fieldPath The field to sort by. + * @param {string=} directionStr Optional direction to sort by ('asc' or + * 'desc'). If not specified, order will be ascending. + * @returns {Query} The created Query. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '>', 42); + * + * query.orderBy('foo', 'desc').get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + orderBy( + fieldPath: string | firestore.FieldPath, + directionStr?: firestore.OrderByDirection + ): Query { + validateFieldPath('fieldPath', fieldPath); + directionStr = validateQueryOrder('directionStr', directionStr); + + if (this._queryOptions.startAt || this._queryOptions.endAt) { + throw new Error( + 'Cannot specify an orderBy() constraint after calling ' + + 'startAt(), startAfter(), endBefore() or endAt().' + ); + } + + const newOrder = new FieldOrder( + FieldPath.fromArgument(fieldPath), + directionOperators[directionStr || 'asc'] + ); + + const options = this._queryOptions.with({ + fieldOrders: this._queryOptions.fieldOrders.concat(newOrder), + }); + return new Query(this._firestore, options); + } + + /** + * Creates and returns a new [Query]{@link Query} that only returns the + * first matching documents. + * + * This function returns a new (immutable) instance of the Query (rather than + * modify the existing instance) to impose the limit. + * + * @param {number} limit The maximum number of items to return. + * @returns {Query} The created Query. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '>', 42); + * + * query.limit(1).get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + limit(limit: number): Query { + validateInteger('limit', limit); + + const options = this._queryOptions.with({ + limit, + limitType: LimitType.First, + }); + return new Query(this._firestore, options); + } + + /** + * Creates and returns a new [Query]{@link Query} that only returns the + * last matching documents. + * + * You must specify at least one orderBy clause for limitToLast queries, + * otherwise an exception will be thrown during execution. + * + * Results for limitToLast queries cannot be streamed via the `stream()` API. + * + * @param limit The maximum number of items to return. + * @return The created Query. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '>', 42); + * + * query.limitToLast(1).get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Last matching document is ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + limitToLast(limit: number): Query { + validateInteger('limitToLast', limit); + + const options = this._queryOptions.with({limit, limitType: LimitType.Last}); + return new Query(this._firestore, options); + } + + /** + * Specifies the offset of the returned results. + * + * This function returns a new (immutable) instance of the + * [Query]{@link Query} (rather than modify the existing instance) + * to impose the offset. + * + * @param {number} offset The offset to apply to the Query results + * @returns {Query} The created Query. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '>', 42); + * + * query.limit(10).offset(20).get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + offset(offset: number): Query { + validateInteger('offset', offset); + + const options = this._queryOptions.with({offset}); + 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 count the documents in cases where the result set is + * prohibitively large to download entirely (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}, + AppModelType, + DbModelType + > { + return this.aggregate({ + count: AggregateField.count(), + }); + } + + /** + * Returns a query that can perform the given aggregations. + * + * The returned query, when executed, calculates the specified aggregations + * over the documents in the result set of this query without actually + * downloading the documents. + * + * Using the returned query to perform aggregations is efficient because only + * the final aggregation values, not the documents' data, is downloaded. The + * returned query can perform aggregations of the documents count the + * documents in cases where the result set is prohibitively large to download + * entirely (thousands of documents). + * + * @param aggregateSpec An `AggregateSpec` object that specifies the aggregates + * to perform over the result set. The AggregateSpec specifies aliases for each + * aggregate, which can be used to retrieve the aggregate result. + * @example + * ```typescript + * const aggregateQuery = col.aggregate(query, { + * countOfDocs: count(), + * totalHours: sum('hours'), + * averageScore: average('score') + * }); + * + * const aggregateSnapshot = await aggregateQuery.get(); + * const countOfDocs: number = aggregateSnapshot.data().countOfDocs; + * const totalHours: number = aggregateSnapshot.data().totalHours; + * const averageScore: number | null = aggregateSnapshot.data().averageScore; + * ``` + */ + aggregate( + aggregateSpec: T + ): AggregateQuery { + return new AggregateQuery( + this, + aggregateSpec + ); + } + + /** + * Returns a query that can perform vector distance (similarity) search with given parameters. + * + * The returned query, when executed, performs a distance (similarity) search on the specified + * `vectorField` against the given `queryVector` and returns the top documents that are closest + * to the `queryVector`. + * + * Only documents whose `vectorField` field is a {@link VectorValue} of the same dimension as `queryVector` + * participate in the query, all other documents are ignored. + * + * @example + * ``` + * // Returns the closest 10 documents whose Euclidean distance from their 'embedding' fields are closed to [41, 42]. + * const vectorQuery = col.findNearest('embedding', [41, 42], {limit: 10, distanceMeasure: 'EUCLIDEAN'}); + * + * const querySnapshot = await aggregateQuery.get(); + * querySnapshot.forEach(...); + * ``` + * + * @param vectorField - A string or {@link FieldPath} specifying the vector field to search on. + * @param queryVector - The {@link VectorValue} used to measure the distance from `vectorField` values in the documents. + * @param options - Options control the vector query. `limit` specifies the upper bound of documents to return, must + * be a positive integer with a maximum value of 1000. `distanceMeasure` specifies what type of distance is calculated + * when performing the query. + */ + findNearest( + vectorField: string | firestore.FieldPath, + queryVector: firestore.VectorValue | Array, + options: { + limit: number; + distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT'; + } + ): VectorQuery { + validateFieldPath('vectorField', vectorField); + + if (options.limit <= 0) { + throw invalidArgumentMessage('options.limit', 'positive limit number'); + } + + if ( + (Array.isArray(queryVector) + ? queryVector.length + : queryVector.toArray().length) === 0 + ) { + throw invalidArgumentMessage( + 'queryVector', + 'vector size must be larger than 0' + ); + } + + return new VectorQuery( + this, + vectorField, + queryVector, + new VectorQueryOptions(options.limit, options.distanceMeasure) + ); + } + + /** + * Returns true if this `Query` is equal to the provided value. + * + * @param {*} other The value to compare against. + * @return {boolean} true if this `Query` is equal to the provided value. + */ + isEqual(other: firestore.Query): boolean { + if (this === other) { + return true; + } + + return ( + other instanceof Query && this._queryOptions.isEqual(other._queryOptions) + ); + } + + /** + * Returns the sorted array of inequality filter fields used in this query. + * + * @return An array of inequality filter fields sorted lexicographically by FieldPath. + */ + private getInequalityFilterFields(): FieldPath[] { + const inequalityFields: FieldPath[] = []; + + for (const filter of this._queryOptions.filters) { + for (const subFilter of filter.getFlattenedFilters()) { + if (subFilter.isInequalityFilter()) { + inequalityFields.push(subFilter.field); + } + } + } + + return inequalityFields.sort((a, b) => a.compareTo(b)); + } + + /** + * Computes the backend ordering semantics for DocumentSnapshot cursors. + * + * @private + * @internal + * @param cursorValuesOrDocumentSnapshot The snapshot of the document or the + * set of field values to use as the boundary. + * @returns The implicit ordering semantics. + */ + private createImplicitOrderBy( + cursorValuesOrDocumentSnapshot: Array< + DocumentSnapshot | unknown + > + ): FieldOrder[] { + // Add an implicit orderBy if the only cursor value is a DocumentSnapshot. + if ( + cursorValuesOrDocumentSnapshot.length !== 1 || + !(cursorValuesOrDocumentSnapshot[0] instanceof DocumentSnapshot) + ) { + return this._queryOptions.fieldOrders; + } + + const fieldOrders = this._queryOptions.fieldOrders.slice(); + const fieldsNormalized = new Set([ + ...fieldOrders.map(item => item.field.toString()), + ]); + + /** The order of the implicit ordering always matches the last explicit order by. */ + const lastDirection = + fieldOrders.length === 0 + ? directionOperators.ASC + : fieldOrders[fieldOrders.length - 1].direction; + + /** + * Any inequality fields not explicitly ordered should be implicitly ordered in a + * lexicographical order. When there are multiple inequality filters on the same field, the + * field should be added only once. + * Note: getInequalityFilterFields function sorts the key field before + * other fields. However, we want the key field to be sorted last. + */ + const inequalityFields = this.getInequalityFilterFields(); + for (const field of inequalityFields) { + if ( + !fieldsNormalized.has(field.toString()) && + !field.isEqual(FieldPath.documentId()) + ) { + fieldOrders.push(new FieldOrder(field, lastDirection)); + fieldsNormalized.add(field.toString()); + } + } + + // Add the document key field to the last if it is not explicitly ordered. + if (!fieldsNormalized.has(FieldPath.documentId().toString())) { + fieldOrders.push(new FieldOrder(FieldPath.documentId(), lastDirection)); + } + + return fieldOrders; + } + + /** + * Builds a Firestore 'Position' proto message. + * + * @private + * @internal + * @param {Array.} fieldOrders The field orders to use for this + * cursor. + * @param {Array.} cursorValuesOrDocumentSnapshot The + * snapshot of the document or the set of field values to use as the boundary. + * @param before Whether the query boundary lies just before or after the + * provided data. + * @returns {Object} The proto message. + */ + private createCursor( + fieldOrders: FieldOrder[], + cursorValuesOrDocumentSnapshot: Array, + before: boolean + ): QueryCursor { + let fieldValues; + + if ( + cursorValuesOrDocumentSnapshot.length === 1 && + cursorValuesOrDocumentSnapshot[0] instanceof DocumentSnapshot + ) { + fieldValues = Query._extractFieldValues( + cursorValuesOrDocumentSnapshot[0] as DocumentSnapshot, + fieldOrders + ); + } else { + fieldValues = cursorValuesOrDocumentSnapshot; + } + + if (fieldValues.length > fieldOrders.length) { + throw new Error( + 'Too many cursor values specified. The specified ' + + 'values must match the orderBy() constraints of the query.' + ); + } + + const options: QueryCursor = {values: [], before}; + + for (let i = 0; i < fieldValues.length; ++i) { + let fieldValue = fieldValues[i]; + + if (FieldPath.documentId().isEqual(fieldOrders[i].field)) { + fieldValue = this.validateReference(fieldValue); + } + + validateQueryValue(i, fieldValue, this._allowUndefined); + options.values!.push(this._serializer.encodeValue(fieldValue)!); + } + + return options; + } + + /** + * Validates that a value used with FieldValue.documentId() is either a + * string or a DocumentReference that is part of the query`s result set. + * Throws a validation error or returns a DocumentReference that can + * directly be used in the Query. + * + * @param val The value to validate. + * @throws If the value cannot be used for this query. + * @return If valid, returns a DocumentReference that can be used with the + * query. + * @private + * @internal + */ + private validateReference( + val: unknown + ): DocumentReference { + const basePath = this._queryOptions.allDescendants + ? this._queryOptions.parentPath + : this._queryOptions.parentPath.append(this._queryOptions.collectionId); + let reference: DocumentReference; + + if (typeof val === 'string') { + const path = basePath.append(val); + + if (this._queryOptions.allDescendants) { + if (!path.isDocument) { + throw new Error( + 'When querying a collection group and ordering by ' + + 'FieldPath.documentId(), the corresponding value must result in ' + + `a valid document path, but '${val}' is not because it ` + + 'contains an odd number of segments.' + ); + } + } else if (val.indexOf('/') !== -1) { + throw new Error( + 'When querying a collection and ordering by FieldPath.documentId(), ' + + `the corresponding value must be a plain document ID, but '${val}' ` + + 'contains a slash.' + ); + } + + reference = new DocumentReference( + this._firestore, + basePath.append(val), + this._queryOptions.converter + ); + } else if (val instanceof DocumentReference) { + reference = val; + if (!basePath.isPrefixOf(reference._path)) { + throw new Error( + `"${reference.path}" is not part of the query result set and ` + + 'cannot be used as a query boundary.' + ); + } + } else { + throw new Error( + 'The corresponding value for FieldPath.documentId() must be a ' + + `string or a DocumentReference, but was "${val}".` + ); + } + + if ( + !this._queryOptions.allDescendants && + reference._path.parent()!.compareTo(basePath) !== 0 + ) { + throw new Error( + 'Only a direct child can be used as a query boundary. ' + + `Found: "${reference.path}".` + ); + } + return reference; + } + + /** + * Creates and returns a new [Query]{@link Query} that starts at the provided + * set of field values relative to the order of the query. The order of the + * provided values must match the order of the order by clauses of the query. + * + * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot + * of the document the query results should start at or the field values to + * start this query at, in order of the query's order by. + * @returns {Query} A query with the new starting point. + * + * @example + * ``` + * let query = firestore.collection('col'); + * + * query.orderBy('foo').startAt(42).get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + startAt( + ...fieldValuesOrDocumentSnapshot: Array + ): Query { + validateMinNumberOfArguments( + 'Query.startAt', + fieldValuesOrDocumentSnapshot, + 1 + ); + + const fieldOrders = this.createImplicitOrderBy( + fieldValuesOrDocumentSnapshot + ); + const startAt = this.createCursor( + fieldOrders, + fieldValuesOrDocumentSnapshot, + true + ); + + const options = this._queryOptions.with({fieldOrders, startAt}); + return new Query(this._firestore, options); + } + + /** + * Creates and returns a new [Query]{@link Query} that starts after the + * provided set of field values relative to the order of the query. The order + * of the provided values must match the order of the order by clauses of the + * query. + * + * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot + * of the document the query results should start after or the field values to + * start this query after, in order of the query's order by. + * @returns {Query} A query with the new starting point. + * + * @example + * ``` + * let query = firestore.collection('col'); + * + * query.orderBy('foo').startAfter(42).get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + startAfter( + ...fieldValuesOrDocumentSnapshot: Array + ): Query { + validateMinNumberOfArguments( + 'Query.startAfter', + fieldValuesOrDocumentSnapshot, + 1 + ); + + const fieldOrders = this.createImplicitOrderBy( + fieldValuesOrDocumentSnapshot + ); + const startAt = this.createCursor( + fieldOrders, + fieldValuesOrDocumentSnapshot, + false + ); + + const options = this._queryOptions.with({fieldOrders, startAt}); + return new Query(this._firestore, options); + } + + /** + * Creates and returns a new [Query]{@link Query} that ends before the set of + * field values relative to the order of the query. The order of the provided + * values must match the order of the order by clauses of the query. + * + * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot + * of the document the query results should end before or the field values to + * end this query before, in order of the query's order by. + * @returns {Query} A query with the new ending point. + * + * @example + * ``` + * let query = firestore.collection('col'); + * + * query.orderBy('foo').endBefore(42).get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + endBefore( + ...fieldValuesOrDocumentSnapshot: Array + ): Query { + validateMinNumberOfArguments( + 'Query.endBefore', + fieldValuesOrDocumentSnapshot, + 1 + ); + + const fieldOrders = this.createImplicitOrderBy( + fieldValuesOrDocumentSnapshot + ); + const endAt = this.createCursor( + fieldOrders, + fieldValuesOrDocumentSnapshot, + true + ); + + const options = this._queryOptions.with({fieldOrders, endAt}); + return new Query(this._firestore, options); + } + + /** + * Creates and returns a new [Query]{@link Query} that ends at the provided + * set of field values relative to the order of the query. The order of the + * provided values must match the order of the order by clauses of the query. + * + * @param {...*|DocumentSnapshot} fieldValuesOrDocumentSnapshot The snapshot + * of the document the query results should end at or the field values to end + * this query at, in order of the query's order by. + * @returns {Query} A query with the new ending point. + * + * @example + * ``` + * let query = firestore.collection('col'); + * + * query.orderBy('foo').endAt(42).get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + endAt( + ...fieldValuesOrDocumentSnapshot: Array + ): Query { + validateMinNumberOfArguments( + 'Query.endAt', + fieldValuesOrDocumentSnapshot, + 1 + ); + + const fieldOrders = this.createImplicitOrderBy( + fieldValuesOrDocumentSnapshot + ); + const endAt = this.createCursor( + fieldOrders, + fieldValuesOrDocumentSnapshot, + false + ); + + const options = this._queryOptions.with({fieldOrders, endAt}); + return new Query(this._firestore, options); + } + + /** + * Executes the query and returns the results as a + * [QuerySnapshot]{@link QuerySnapshot}. + * + * @returns {Promise.} A Promise that resolves with the results + * of the Query. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Found document at ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + get(): Promise> { + return this._get(); + } + + /** + * Plans and optionally executes this query. Returns a Promise that will be + * resolved with the planner information, statistics from the query execution (if any), + * and the query results (if any). + * + * @return A Promise that will be resolved with the planner information, statistics + * from the query execution (if any), and the query results (if any). + */ + explain( + options?: firestore.ExplainOptions + ): Promise>> { + if (options === undefined) { + options = {}; + } + + // Capture the error stack to preserve stack tracing across async calls. + const stack = Error().stack!; + + return new Promise((resolve, reject) => { + let readTime: Timestamp; + let docs: Array> | null = + null; + let metrics: ExplainMetrics | null = null; + + this._stream(undefined, options) + .on('error', err => { + reject(wrapError(err, stack)); + }) + .on('data', data => { + if (data.readTime) { + readTime = data.readTime; + } + if (data.document) { + if (docs === null) { + docs = []; + } + docs.push(data.document); + } + if (data.explainMetrics) { + metrics = data.explainMetrics; + + if (docs === null && metrics?.executionStats !== null) { + // This indicates that the query was executed, but no documents + // had matched the query. + docs = []; + } + } + }) + .on('end', () => { + if (metrics === null) { + reject('No explain results.'); + } + + // Some explain queries will not have a snapshot associated with them. + let snapshot: QuerySnapshot | null = null; + if (docs !== null) { + if (this._queryOptions.limitType === LimitType.Last) { + // The results for limitToLast queries need to be flipped since + // we reversed the ordering constraints before sending the query + // to the backend. + docs.reverse(); + } + + snapshot = new QuerySnapshot( + this, + readTime, + docs.length, + () => docs!, + () => { + const changes: Array< + DocumentChange + > = []; + for (let i = 0; i < docs!.length; ++i) { + changes.push(new DocumentChange('added', docs![i], -1, i)); + } + return changes; + } + ); + } + + resolve(new ExplainResults(metrics!, snapshot)); + }); + }); + } + + /** + * Internal get() method that accepts an optional transaction id. + * + * @private + * @internal + * @param transactionIdOrReadTime A transaction ID or the read time at which + * to execute the query. + */ + _get( + transactionIdOrReadTime?: Uint8Array | Timestamp + ): Promise> { + return this._queryUtil._get(this, transactionIdOrReadTime) as Promise< + QuerySnapshot + >; + } + + /** + * Executes the query and streams the results as + * [QueryDocumentSnapshots]{@link QueryDocumentSnapshot}. + * + * @returns {Stream.} A stream of + * QueryDocumentSnapshots. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * let count = 0; + * + * query.stream().on('data', (documentSnapshot) => { + * console.log(`Found document with name '${documentSnapshot.id}'`); + * ++count; + * }).on('end', () => { + * console.log(`Total count is ${count}`); + * }); + * ``` + */ + stream(): NodeJS.ReadableStream { + return this._queryUtil.stream(this); + } + + /** + * Executes the query and streams the results as the following object: + * {document?: DocumentSnapshot, metrics?: ExplainMetrics} + * + * The stream surfaces documents one at a time as they are received from the + * server, and at the end, it will surface the metrics associated with + * executing the query. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * let count = 0; + * + * query.explainStream({analyze: true}).on('data', (data) => { + * if (data.document) { + * // Use data.document which is a DocumentSnapshot instance. + * console.log(`Found document with name '${data.document.id}'`); + * ++count; + * } + * if (data.metrics) { + * // Use data.metrics which is an ExplainMetrics instance. + * } + * }).on('end', () => { + * console.log(`Received ${count} documents.`); + * }); + * ``` + */ + explainStream( + explainOptions?: firestore.ExplainOptions + ): NodeJS.ReadableStream { + if (explainOptions === undefined) { + explainOptions = {}; + } + if (this._queryOptions.limitType === LimitType.Last) { + throw new Error( + 'Query results for queries that include limitToLast() ' + + 'constraints cannot be streamed. Use Query.explain() instead.' + ); + } + + const responseStream = this._stream(undefined, explainOptions); + const transform = new Transform({ + objectMode: true, + transform(chunk, encoding, callback) { + callback(undefined, { + document: chunk.document, + metrics: chunk.explainMetrics, + }); + }, + }); + responseStream.pipe(transform); + responseStream.on('error', e => transform.destroy(e)); + return transform; + } + + /** + * Converts a QueryCursor to its proto representation. + * + * @param cursor The original cursor value + * @private + * @internal + */ + private toCursor(cursor: QueryCursor | undefined): api.ICursor | undefined { + if (cursor) { + return cursor.before + ? {before: true, values: cursor.values} + : {values: cursor.values}; + } + + return undefined; + } + + /** + * Internal method for serializing a query to its RunQuery proto + * representation with an optional transaction id or read time. + * + * @param transactionIdOrReadTime A transaction ID or the read time at which + * to execute the query. + * @param explainOptions Options to use for explaining the query (if any). + * @private + * @internal + * @returns Serialized JSON for the query. + */ + toProto( + transactionIdOrReadTime?: Uint8Array | Timestamp, + explainOptions?: firestore.ExplainOptions + ): api.IRunQueryRequest { + const projectId = this.firestore.projectId; + const databaseId = this.firestore.databaseId; + const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( + projectId, + databaseId + ); + + const structuredQuery = this.toStructuredQuery(); + + // For limitToLast queries, the structured query has to be translated to a version with + // reversed ordered, and flipped startAt/endAt to work properly. + if (this._queryOptions.limitType === LimitType.Last) { + if (!this._queryOptions.hasFieldOrders()) { + throw new Error( + 'limitToLast() queries require specifying at least one orderBy() clause.' + ); + } + + structuredQuery.orderBy = this._queryOptions.fieldOrders!.map(order => { + // Flip the orderBy directions since we want the last results + const dir = + order.direction === 'DESCENDING' ? 'ASCENDING' : 'DESCENDING'; + return new FieldOrder(order.field, dir).toProto(); + }); + + // Swap the cursors to match the now-flipped query ordering. + structuredQuery.startAt = this._queryOptions.endAt + ? this.toCursor({ + values: this._queryOptions.endAt.values, + before: !this._queryOptions.endAt.before, + }) + : undefined; + structuredQuery.endAt = this._queryOptions.startAt + ? this.toCursor({ + values: this._queryOptions.startAt.values, + before: !this._queryOptions.startAt.before, + }) + : undefined; + } + + const runQueryRequest: api.IRunQueryRequest = { + parent: parentPath.formattedName, + structuredQuery, + }; + + if (transactionIdOrReadTime instanceof Uint8Array) { + runQueryRequest.transaction = transactionIdOrReadTime; + } else if (transactionIdOrReadTime instanceof Timestamp) { + runQueryRequest.readTime = + transactionIdOrReadTime.toProto().timestampValue; + } + + if (explainOptions) { + runQueryRequest.explainOptions = explainOptions; + } + + return runQueryRequest; + } + + /** + * Converts current Query to an IBundledQuery. + * + * @private + * @internal + */ + _toBundledQuery(): protos.firestore.IBundledQuery { + const projectId = this.firestore.projectId; + const databaseId = this.firestore.databaseId; + const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( + projectId, + databaseId + ); + const structuredQuery = this.toStructuredQuery(); + + const bundledQuery: protos.firestore.IBundledQuery = { + parent: parentPath.formattedName, + structuredQuery, + }; + if (this._queryOptions.limitType === LimitType.First) { + bundledQuery.limitType = 'FIRST'; + } else if (this._queryOptions.limitType === LimitType.Last) { + bundledQuery.limitType = 'LAST'; + } + + return bundledQuery; + } + + private toStructuredQuery(): api.IStructuredQuery { + const structuredQuery: api.IStructuredQuery = { + from: [{}], + }; + + if (this._queryOptions.allDescendants) { + structuredQuery.from![0].allDescendants = true; + } + + // Kindless queries select all descendant documents, so we remove the + // collectionId field. + if (!this._queryOptions.kindless) { + structuredQuery.from![0].collectionId = this._queryOptions.collectionId; + } + + if (this._queryOptions.filters.length >= 1) { + structuredQuery.where = new CompositeFilterInternal( + this._queryOptions.filters, + 'AND' + ).toProto(); + } + + if (this._queryOptions.hasFieldOrders()) { + structuredQuery.orderBy = this._queryOptions.fieldOrders.map(o => + o.toProto() + ); + } + + structuredQuery.startAt = this.toCursor(this._queryOptions.startAt); + structuredQuery.endAt = this.toCursor(this._queryOptions.endAt); + + if (this._queryOptions.limit) { + structuredQuery.limit = {value: this._queryOptions.limit}; + } + + structuredQuery.offset = this._queryOptions.offset; + structuredQuery.select = this._queryOptions.projection; + + return structuredQuery; + } + + /** + * @internal + * @private + * This method exists solely to maintain backward compatability. + */ + _isPermanentRpcError(err: GoogleError, methodName: string): boolean { + return this._queryUtil._isPermanentRpcError(err, methodName); + } + + /** + * @internal + * @private + * This method exists solely to maintain backward compatability. + */ + _hasRetryTimedOut(methodName: string, startTime: number): boolean { + return this._queryUtil._hasRetryTimedOut(methodName, startTime); + } + + /** + * Internal streaming method that accepts an optional transaction ID. + * + * @param transactionIdOrReadTime A transaction ID or the read time at which + * to execute the query. + * @param explainOptions Options to use for explaining the query (if any). + * @private + * @internal + * @returns A stream of document results. + */ + _stream( + transactionIdOrReadTime?: Uint8Array | Timestamp, + explainOptions?: firestore.ExplainOptions + ): NodeJS.ReadableStream { + return this._queryUtil._stream( + this, + transactionIdOrReadTime, + true, + explainOptions + ); + } + + /** + * Attaches a listener for QuerySnapshot events. + * + * @param {querySnapshotCallback} onNext A callback to be called every time + * a new [QuerySnapshot]{@link QuerySnapshot} is available. + * @param {errorCallback=} onError A callback to be called if the listen + * fails or is cancelled. No further callbacks will occur. + * + * @returns {function()} An unsubscribe function that can be called to cancel + * the snapshot listener. + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * let unsubscribe = query.onSnapshot(querySnapshot => { + * console.log(`Received query snapshot of size ${querySnapshot.size}`); + * }, err => { + * console.log(`Encountered error: ${err}`); + * }); + * + * // Remove this listener. + * unsubscribe(); + * ``` + */ + onSnapshot( + onNext: (snapshot: QuerySnapshot) => void, + onError?: (error: Error) => void + ): () => void { + validateFunction('onNext', onNext); + validateFunction('onError', onError, {optional: true}); + + const watch: QueryWatch = + new (require('./watch').QueryWatch)( + this.firestore, + this, + this._queryOptions.converter + ); + + return watch.onSnapshot((readTime, size, docs, changes) => { + onNext(new QuerySnapshot(this, readTime, size, docs, changes)); + }, onError || console.error); + } + + /** + * Returns a function that can be used to sort QueryDocumentSnapshots + * according to the sort criteria of this query. + * + * @private + * @internal + */ + comparator(): ( + s1: QueryDocumentSnapshot, + s2: QueryDocumentSnapshot + ) => number { + return (doc1, doc2) => { + // Add implicit sorting by name, using the last specified direction. + const lastDirection = this._queryOptions.hasFieldOrders() + ? this._queryOptions.fieldOrders[ + this._queryOptions.fieldOrders.length - 1 + ].direction + : 'ASCENDING'; + const orderBys = this._queryOptions.fieldOrders.concat( + new FieldOrder(FieldPath.documentId(), lastDirection) + ); + + for (const orderBy of orderBys) { + let comp; + if (FieldPath.documentId().isEqual(orderBy.field)) { + comp = doc1.ref._path.compareTo(doc2.ref._path); + } else { + const v1 = doc1.protoField(orderBy.field); + const v2 = doc2.protoField(orderBy.field); + if (v1 === undefined || v2 === undefined) { + throw new Error( + 'Trying to compare documents on fields that ' + + "don't exist. Please include the fields you are ordering on " + + 'in your select() call.' + ); + } + comp = compare(v1, v2); + } + + if (comp !== 0) { + const direction = orderBy.direction === 'ASCENDING' ? 1 : -1; + return direction * comp; + } + } + + return 0; + }; + } + + withConverter(converter: null): Query; + withConverter< + NewAppModelType, + NewDbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + converter: firestore.FirestoreDataConverter + ): Query; + /** + * Applies a custom data converter to this Query, allowing you to use your + * own custom model objects with Firestore. When you call get() on the + * returned Query, the provided converter will convert between Firestore + * data of type `NewDbModelType` and your custom type `NewAppModelType`. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @example + * ``` + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * snapshot: FirebaseFirestore.QueryDocumentSnapshot + * ): Post { + * const data = snapshot.data(); + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await Firestore() + * .collection('posts') + * .withConverter(postConverter) + * .doc().get(); + * const post = postSnap.data(); + * if (post !== undefined) { + * post.title; // string + * post.toString(); // Should be defined + * post.someNonExistentProperty; // TS error + * } + * + * ``` + * @param {FirestoreDataConverter | null} converter Converts objects to and + * from Firestore. Passing in `null` removes the current converter. + * @return A Query that uses the provided converter. + */ + withConverter< + NewAppModelType, + NewDbModelType extends firestore.DocumentData = firestore.DocumentData, + >( + converter: firestore.FirestoreDataConverter< + NewAppModelType, + NewDbModelType + > | null + ): Query { + return new Query( + this.firestore, + this._queryOptions.withConverter(converter ?? defaultConverter()) + ); + } + + /** + * Construct the resulting snapshot for this query with given documents. + * + * @private + * @internal + */ + _createSnapshot( + readTime: Timestamp, + size: number, + docs: () => Array>, + changes: () => Array> + ): QuerySnapshot { + return new QuerySnapshot( + this, + readTime, + size, + docs, + changes + ); + } +} diff --git a/dev/src/reference/types.ts b/dev/src/reference/types.ts new file mode 100644 index 000000000..e71fdfa57 --- /dev/null +++ b/dev/src/reference/types.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; + +/** Internal representation of a query cursor before serialization. */ +export interface QueryCursor { + before: boolean; + values: api.IValue[]; +} + +/*! + * Denotes whether a provided limit is applied to the beginning or the end of + * the result set. + */ +export enum LimitType { + First, + Last, +} + +/** + * onSnapshot() callback that receives a QuerySnapshot. + * + * @callback querySnapshotCallback + * @param {QuerySnapshot} snapshot A query snapshot. + */ + +/** + * onSnapshot() callback that receives a DocumentSnapshot. + * + * @callback documentSnapshotCallback + * @param {DocumentSnapshot} snapshot A document snapshot. + */ + +/** + * onSnapshot() callback that receives an error. + * + * @callback errorCallback + * @param {Error} err An error from a listen. + */ diff --git a/dev/src/reference/vector-query-options.ts b/dev/src/reference/vector-query-options.ts new file mode 100644 index 000000000..cc083aa62 --- /dev/null +++ b/dev/src/reference/vector-query-options.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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. + */ + +export class VectorQueryOptions { + constructor( + readonly limit: number, + readonly distanceMeasure: 'EUCLIDEAN' | 'COSINE' | 'DOT_PRODUCT' + ) {} + + isEqual(other: VectorQueryOptions): boolean { + if (this === other) { + return true; + } + if (!(other instanceof VectorQueryOptions)) { + return false; + } + + return ( + this.limit === other.limit && + this.distanceMeasure === other.distanceMeasure + ); + } +} diff --git a/dev/src/reference/vector-query-snapshot.ts b/dev/src/reference/vector-query-snapshot.ts new file mode 100644 index 000000000..36b5cb906 --- /dev/null +++ b/dev/src/reference/vector-query-snapshot.ts @@ -0,0 +1,290 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as firestore from '@google-cloud/firestore'; +import {QueryDocumentSnapshot} from '../document'; +import {DocumentChange} from '../document-change'; +import {Timestamp} from '../timestamp'; +import {validateFunction} from '../validate'; +import {isArrayEqual} from '../util'; +import {VectorQuery} from './vector-query'; + +/** + * A `VectorQuerySnapshot` contains zero or more `QueryDocumentSnapshot` objects + * representing the results of a query. The documents can be accessed as an + * array via the `docs` property or enumerated using the `forEach` method. The + * number of documents can be determined via the `empty` and `size` + * properties. + */ +export class VectorQuerySnapshot< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, +> implements firestore.VectorQuerySnapshot +{ + private _materializedDocs: Array< + QueryDocumentSnapshot + > | null = null; + private _materializedChanges: Array< + DocumentChange + > | null = null; + private _docs: + | (() => Array>) + | null = null; + private _changes: + | (() => Array>) + | null = null; + + /** + * @private + * @internal + * + * @param _query - The originating query. + * @param _readTime - The time when this query snapshot was obtained. + * @param _size - The number of documents in the result set. + * @param docs - A callback returning a sorted array of documents matching + * this query + * @param changes - A callback returning a sorted array of document change + * events for this snapshot. + */ + constructor( + private readonly _query: VectorQuery, + private readonly _readTime: Timestamp, + private readonly _size: number, + docs: () => Array>, + changes: () => Array> + ) { + this._docs = docs; + this._changes = changes; + } + + /** + * The `VectorQuery` on which you called get() in order to get this + * `VectorQuerySnapshot`. + * + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col').where('foo', '==', 'bar'); + * + * query.findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}) + * .get().then(querySnapshot => { + * console.log(`Returned first batch of results`); + * let query = querySnapshot.query; + * return query.offset(10).get(); + * }).then(() => { + * console.log(`Returned second batch of results`); + * }); + * ``` + */ + get query(): VectorQuery { + return this._query; + } + + /** + * An array of all the documents in this `VectorQuerySnapshot`. + * + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col') + * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); + * + * query.get().then(querySnapshot => { + * let docs = querySnapshot.docs; + * for (let doc of docs) { + * console.log(`Document found at path: ${doc.ref.path}`); + * } + * }); + * ``` + */ + get docs(): Array> { + if (this._materializedDocs) { + return this._materializedDocs!; + } + this._materializedDocs = this._docs!(); + this._docs = null; + return this._materializedDocs!; + } + + /** + * `true` if there are no documents in the `VectorQuerySnapshot`. + * + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col') + * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); + * + * query.get().then(querySnapshot => { + * if (querySnapshot.empty) { + * console.log('No documents found.'); + * } + * }); + * ``` + */ + get empty(): boolean { + return this._size === 0; + } + + /** + * The number of documents in the `VectorQuerySnapshot`. + * + * @readonly + * + * @example + * ``` + * let query = firestore.collection('col') + * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); + * + * query.get().then(querySnapshot => { + * console.log(`Found ${querySnapshot.size} documents.`); + * }); + * ``` + */ + get size(): number { + return this._size; + } + + /** + * The time this `VectorQuerySnapshot` was obtained. + * + * @example + * ``` + * let query = firestore.collection('col') + * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); + * + * query.get().then((querySnapshot) => { + * let readTime = querySnapshot.readTime; + * console.log(`Query results returned at '${readTime.toDate()}'`); + * }); + * ``` + */ + get readTime(): Timestamp { + return this._readTime; + } + + /** + * Returns an array of the documents changes since the last snapshot. If + * this is the first snapshot, all documents will be in the list as added + * changes. + * + * @returns An array of the documents changes since the last snapshot. + * + * @example + * ``` + * let query = firestore.collection('col') + * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); + * + * query.get().then(querySnapshot => { + * let changes = querySnapshot.docChanges(); + * for (let change of changes) { + * console.log(`A document was ${change.type}.`); + * } + * }); + * ``` + */ + docChanges(): Array> { + if (this._materializedChanges) { + return this._materializedChanges!; + } + this._materializedChanges = this._changes!(); + this._changes = null; + return this._materializedChanges!; + } + + /** + * Enumerates all of the documents in the `VectorQuerySnapshot`. This is a convenience + * method for running the same callback on each {@link QueryDocumentSnapshot} + * that is returned. + * + * @param callback - A callback to be called with a + * {@link QueryDocumentSnapshot} for each document in + * the snapshot. + * @param thisArg - The `this` binding for the callback.. + * + * @example + * ``` + * let query = firestore.collection('col') + * .findNearest("embedding", [0, 0], {limit: 10, distanceMeasure: "EUCLIDEAN"}); + * + * query.get().then(querySnapshot => { + * querySnapshot.forEach(documentSnapshot => { + * console.log(`Document found at path: ${documentSnapshot.ref.path}`); + * }); + * }); + * ``` + */ + forEach( + callback: ( + result: firestore.QueryDocumentSnapshot + ) => void, + thisArg?: unknown + ): void { + validateFunction('callback', callback); + + for (const doc of this.docs) { + callback.call(thisArg, doc); + } + } + + /** + * Returns true if the document data in this `VectorQuerySnapshot` is equal to the + * provided value. + * + * @param other - The value to compare against. + * @returns true if this `VectorQuerySnapshot` is equal to the provided + * value. + */ + isEqual( + other: firestore.VectorQuerySnapshot + ): boolean { + // Since the read time is different on every query read, we explicitly + // ignore all metadata in this comparison. + + if (this === other) { + return true; + } + + if (!(other instanceof VectorQuerySnapshot)) { + return false; + } + + if (this._size !== other._size) { + return false; + } + + if (!this._query.isEqual(other._query)) { + return false; + } + + if (this._materializedDocs && !this._materializedChanges) { + // If we have only materialized the documents, we compare them first. + return ( + isArrayEqual(this.docs, other.docs) && + isArrayEqual(this.docChanges(), other.docChanges()) + ); + } + + // Otherwise, we compare the changes first as we expect there to be fewer. + return ( + isArrayEqual(this.docChanges(), other.docChanges()) && + isArrayEqual(this.docs, other.docs) + ); + } +} diff --git a/dev/src/reference/vector-query.ts b/dev/src/reference/vector-query.ts new file mode 100644 index 000000000..869a9c84b --- /dev/null +++ b/dev/src/reference/vector-query.ts @@ -0,0 +1,219 @@ +/** + * Copyright 2024 Google LLC. All Rights Reserved. + * + * 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 * as protos from '../../protos/firestore_v1_proto_api'; +import api = protos.google.firestore.v1; +import * as firestore from '@google-cloud/firestore'; + +import {Timestamp} from '../timestamp'; +import {VectorValue} from '../field-value'; +import {FieldPath} from '../path'; +import {QueryDocumentSnapshot} from '../document'; +import {DocumentChange} from '../document-change'; +import {isPrimitiveArrayEqual} from '../util'; +import {QueryUtil} from './query-util'; +import {Query} from './query'; +import {VectorQueryOptions} from './vector-query-options'; +import {VectorQuerySnapshot} from './vector-query-snapshot'; + +/** + * A query that finds the documents whose vector fields are closest to a certain query vector. + * Create an instance of `VectorQuery` with {@link Query.findNearest}. + */ +export class VectorQuery< + AppModelType = firestore.DocumentData, + DbModelType extends firestore.DocumentData = firestore.DocumentData, +> implements firestore.VectorQuery +{ + /** + * @internal + * @private + **/ + readonly _queryUtil: QueryUtil< + AppModelType, + DbModelType, + VectorQuery + >; + + /** + * @private + * @internal + */ + constructor( + private readonly _query: Query, + private readonly vectorField: string | firestore.FieldPath, + private readonly queryVector: firestore.VectorValue | Array, + private readonly options: VectorQueryOptions + ) { + this._queryUtil = new QueryUtil< + AppModelType, + DbModelType, + VectorQuery + >(_query._firestore, _query._queryOptions, _query._serializer); + } + + /** The query whose results participants in the vector search. Filtering + * performed by the query will apply before the vector search. + **/ + get query(): Query { + return this._query; + } + + /** + * @private + * @internal + */ + private get _rawVectorField(): string { + return typeof this.vectorField === 'string' + ? this.vectorField + : this.vectorField.toString(); + } + + /** + * @private + * @internal + */ + private get _rawQueryVector(): Array { + return Array.isArray(this.queryVector) + ? this.queryVector + : this.queryVector.toArray(); + } + + /** + * Executes this vector search query. + * + * @returns A promise that will be resolved with the results of the query. + */ + get(): Promise> { + return this._queryUtil._get( + this, + /*transactionId*/ undefined, + // VectorQuery cannot be retried with cursors as they do not support cursors yet. + /*retryWithCursor*/ false + ) as Promise>; + } + + /** + * Internal streaming method that accepts an optional transaction ID. + * + * @param transactionId - A transaction ID. + * @private + * @internal + * @returns A stream of document results. + */ + _stream(transactionId?: Uint8Array): NodeJS.ReadableStream { + return this._queryUtil._stream( + this, + transactionId, + /*retryWithCursor*/ false + ); + } + + /** + * 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( + transactionIdOrReadTime?: Uint8Array | Timestamp + ): api.IRunQueryRequest { + const queryProto = this._query.toProto(transactionIdOrReadTime); + + const queryVector = Array.isArray(this.queryVector) + ? new VectorValue(this.queryVector) + : (this.queryVector as VectorValue); + + queryProto.structuredQuery!.findNearest = { + limit: {value: this.options.limit}, + distanceMeasure: this.options.distanceMeasure, + vectorField: { + fieldPath: FieldPath.fromArgument(this.vectorField).formattedName, + }, + queryVector: queryVector._toProto(this._query._serializer), + }; + return queryProto; + } + + /** + * Construct the resulting vector snapshot for this query with given documents. + * + * @private + * @internal + */ + _createSnapshot( + readTime: Timestamp, + size: number, + docs: () => Array>, + changes: () => Array> + ): VectorQuerySnapshot { + return new VectorQuerySnapshot( + this, + readTime, + size, + docs, + changes + ); + } + + /** + * Construct a new vector query whose result will start after To support stream(). + * This now throws an exception because cursors are not supported from the backend for vector queries yet. + * + * @private + * @internal + * @returns Serialized JSON for the query. + */ + startAfter( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ...fieldValuesOrDocumentSnapshot: Array + ): VectorQuery { + throw new Error( + 'Unimplemented: Vector query does not support cursors yet.' + ); + } + + /** + * 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 vector distance search as this `VectorQuery` 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. + * @returns `true` if this object is "equal" to the given object, as + * defined above, or `false` otherwise. + */ + isEqual(other: firestore.VectorQuery): boolean { + if (this === other) { + return true; + } + if (!(other instanceof VectorQuery)) { + return false; + } + if (!this.query.isEqual(other.query)) { + return false; + } + return ( + this._rawVectorField === other._rawVectorField && + isPrimitiveArrayEqual(this._rawQueryVector, other._rawQueryVector) && + this.options.isEqual(other.options) + ); + } +} diff --git a/dev/src/transaction.ts b/dev/src/transaction.ts index 0fec083f4..71f3f3d6e 100644 --- a/dev/src/transaction.ts +++ b/dev/src/transaction.ts @@ -26,14 +26,13 @@ import {Timestamp} from './timestamp'; import {logger} from './logger'; import {FieldPath, validateFieldPath} from './path'; import {StatusCode} from './status-code'; -import { - AggregateQuery, - AggregateQuerySnapshot, - DocumentReference, - Query, - QuerySnapshot, - validateDocumentReference, -} from './reference'; +import {AggregateQuery} from './reference/aggregate-query'; +import {AggregateQuerySnapshot} from './reference/aggregate-query-snapshot'; +import {DocumentReference} from './reference/document-reference'; +import {Query} from './reference/query'; +import {QuerySnapshot} from './reference/query-snapshot'; +import {validateDocumentReference} from './reference/helpers'; + import {isObject, isPlainObject} from './util'; import { invalidArgumentMessage, diff --git a/dev/src/write-batch.ts b/dev/src/write-batch.ts index 770b54f81..4a139fd26 100644 --- a/dev/src/write-batch.ts +++ b/dev/src/write-batch.ts @@ -26,7 +26,7 @@ import { import {Firestore} from './index'; import {logger} from './logger'; import {FieldPath, validateFieldPath} from './path'; -import {validateDocumentReference} from './reference'; +import {validateDocumentReference} from './reference/helpers'; import {Serializer, validateUserInput} from './serializer'; import {Timestamp} from './timestamp'; import {FirestoreUnaryMethod, UpdateMap} from './types'; From 4278d20db16f47195ec303c399069086a05341df Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Mon, 29 Apr 2024 12:59:50 -0600 Subject: [PATCH 3/6] Run yarn api-report to verify the public API did not change. --- api-report/firestore.api.md | 84 ++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/api-report/firestore.api.md b/api-report/firestore.api.md index 921ba16cf..97a736ae5 100644 --- a/api-report/firestore.api.md +++ b/api-report/firestore.api.md @@ -1628,58 +1628,58 @@ export class WriteResult implements firestore.WriteResult { // build/src/bundle.d.ts:20:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/filter.d.ts:121:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/filter.d.ts:156:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:285:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:305:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:292:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/index.d.ts:312:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:327:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:319:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/index.d.ts:334:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:343:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:351:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:341:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:350:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/index.d.ts:358:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:367:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:850:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:869:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:871:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:873:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:874:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:884:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/index.d.ts:886:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:887:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:889:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/index.d.ts:890:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:365:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:374:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:857:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:876:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:878:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:880:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:881:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:891:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/index.d.ts:893:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:894:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:896:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/index.d.ts:897:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // build/src/path.d.ts:30:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/path.d.ts:32:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration // build/src/path.d.ts:120:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/path.d.ts:312:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/rate-limiter.d.ts:13:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:365:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:367:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration -// build/src/reference.d.ts:398:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:400:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration -// build/src/reference.d.ts:800:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1228:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration -// build/src/reference.d.ts:1234:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1236:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1244:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1246:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1246:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// build/src/reference.d.ts:1248:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1248:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' -// build/src/reference.d.ts:1250:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1252:24 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// build/src/reference.d.ts:1252:17 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// build/src/reference.d.ts:1261:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1263:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration -// build/src/reference.d.ts:1265:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1449:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// build/src/reference.d.ts:1450:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1715:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1825:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/reference.d.ts:1830:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/aggregate-query.d.ts:71:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/field-filter-internal.d.ts:24:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/field-filter-internal.d.ts:26:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// build/src/reference/field-order.d.ts:22:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/field-order.d.ts:24:4 - (tsdoc-undefined-tag) The TSDoc tag "@class" is not defined in this configuration +// build/src/reference/query-options.d.ts:28:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:392:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/reference/query.d.ts:398:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:400:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:408:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:410:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:410:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' +// build/src/reference/query.d.ts:412:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:412:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' +// build/src/reference/query.d.ts:414:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:416:24 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// build/src/reference/query.d.ts:416:17 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// build/src/reference/query.d.ts:425:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:427:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/reference/query.d.ts:429:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/query.d.ts:613:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// build/src/reference/query.d.ts:614:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/vector-query.d.ts:52:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/reference/vector-query.d.ts:57:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/serializer.d.ts:26:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/serializer.d.ts:36:4 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/transaction.d.ts:239:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration -// build/src/transaction.d.ts:241:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration +// build/src/transaction.d.ts:241:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration +// build/src/transaction.d.ts:243:8 - (tsdoc-undefined-tag) The TSDoc tag "@return" is not defined in this configuration // build/src/write-batch.d.ts:86:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // build/src/write-batch.d.ts:109:8 - (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration From 1937778e823bd40da5a53178f1eca62a9c3df1e4 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Mon, 29 Apr 2024 13:10:18 -0600 Subject: [PATCH 4/6] Test fixes. --- dev/src/reference/document-reference.ts | 2 +- dev/src/reference/query.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/src/reference/document-reference.ts b/dev/src/reference/document-reference.ts index 73cb0cf50..48c2799aa 100644 --- a/dev/src/reference/document-reference.ts +++ b/dev/src/reference/document-reference.ts @@ -480,7 +480,7 @@ export class DocumentReference< validateFunction('onError', onError, {optional: true}); const watch: DocumentWatch = - new (require('./watch').DocumentWatch)(this.firestore, this); + new (require('../watch').DocumentWatch)(this.firestore, this); return watch.onSnapshot((readTime, size, docs) => { for (const document of docs()) { if (document.ref.path === this.path) { diff --git a/dev/src/reference/query.ts b/dev/src/reference/query.ts index fb91f0692..6a6d31abc 100644 --- a/dev/src/reference/query.ts +++ b/dev/src/reference/query.ts @@ -1490,7 +1490,7 @@ export class Query< validateFunction('onError', onError, {optional: true}); const watch: QueryWatch = - new (require('./watch').QueryWatch)( + new (require('../watch').QueryWatch)( this.firestore, this, this._queryOptions.converter From 025a9a21243a9ad0159175ce2da4daf19690f497 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Thu, 16 May 2024 15:24:12 -0700 Subject: [PATCH 5/6] Explain for VectorQuery. --- dev/src/reference/vector-query.ts | 50 +++++++++++++++++++--- dev/system-test/firestore.ts | 71 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/dev/src/reference/vector-query.ts b/dev/src/reference/vector-query.ts index b6b2dd0cc..7b4baa7a8 100644 --- a/dev/src/reference/vector-query.ts +++ b/dev/src/reference/vector-query.ts @@ -28,6 +28,8 @@ import {QueryUtil} from './query-util'; import {Query} from './query'; import {VectorQueryOptions} from './vector-query-options'; import {VectorQuerySnapshot} from './vector-query-snapshot'; +import {ExplainResults} from '../query-profile'; +import {QueryResponse} from './types'; /** * A query that finds the documents whose vector fields are closest to a certain query vector. @@ -92,24 +94,52 @@ export class VectorQuery< : this.queryVector.toArray(); } + /** + * Plans and optionally executes this vector search query. Returns a Promise that will be + * resolved with the planner information, statistics from the query execution (if any), + * and the query results (if any). + * + * @return A Promise that will be resolved with the planner information, statistics + * from the query execution (if any), and the query results (if any). + */ + async explain( + options?: firestore.ExplainOptions + ): Promise>> { + if (options === undefined) { + options = {}; + } + const {result, explainMetrics} = await this._getResponse(options); + if (!explainMetrics) { + throw new Error('No explain results'); + } + return new ExplainResults(explainMetrics, result || null); + } + /** * Executes this vector search query. * * @returns A promise that will be resolved with the results of the query. */ async get(): Promise> { - const {result} = await this._queryUtil._getResponse( - this, - /*transactionId*/ undefined, - // VectorQuery cannot be retried with cursors as they do not support cursors yet. - /*retryWithCursor*/ false - ); + const {result} = await this._getResponse(); if (!result) { throw new Error('No VectorQuerySnapshot result'); } return result; } + _getResponse( + explainOptions?: firestore.ExplainOptions + ): Promise>> { + return this._queryUtil._getResponse( + this, + undefined, + // VectorQuery cannot be retried with cursors as they do not support cursors yet. + /*retryWithCursor*/ false, + explainOptions + ); + } + /** * Internal streaming method that accepts an optional transaction ID. * @@ -135,7 +165,8 @@ export class VectorQuery< * @returns Serialized JSON for the query. */ toProto( - transactionOrReadTime?: Uint8Array | Timestamp | api.ITransactionOptions + transactionOrReadTime?: Uint8Array | Timestamp | api.ITransactionOptions, + explainOptions?: firestore.ExplainOptions ): api.IRunQueryRequest { const queryProto = this._query.toProto(transactionOrReadTime); @@ -151,6 +182,11 @@ export class VectorQuery< }, queryVector: queryVector._toProto(this._query._serializer), }; + + if (explainOptions) { + queryProto.explainOptions = explainOptions; + } + return queryProto; } diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index c21cfdbc6..5f7d683d7 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -429,6 +429,77 @@ describe('Firestore class', () => { expect(explainResults.snapshot!.data().count).to.equal(3); }); + it('can plan a vector query', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, + {foo: 'bar', embedding: FieldValue.vector([1, 1])}, + {foo: 'bar', embedding: FieldValue.vector([10, 0])}, + {foo: 'bar', embedding: FieldValue.vector([20, 0])}, + {foo: 'bar', embedding: FieldValue.vector([100, 100])}, + ]); + + const explainResults = await indexTestHelper + .query(collectionReference) + .findNearest('embedding', FieldValue.vector([1, 3]), { + limit: 10, + distanceMeasure: 'COSINE', + }) + .explain({analyze: false}); + + const metrics = explainResults.metrics; + + const plan = metrics.planSummary; + expect(plan).to.not.be.null; + expect(Object.keys(plan.indexesUsed).length).to.be.greaterThan(0); + + expect(metrics.executionStats).to.be.null; + expect(explainResults.snapshot).to.be.null; + }); + + it('can profile a vector query', async () => { + const indexTestHelper = new IndexTestHelper(firestore); + + const collectionReference = await indexTestHelper.createTestDocs([ + {foo: 'bar'}, + {foo: 'xxx', embedding: FieldValue.vector([10, 10])}, + {foo: 'bar', embedding: FieldValue.vector([1, 1])}, + {foo: 'bar', embedding: FieldValue.vector([10, 0])}, + {foo: 'bar', embedding: FieldValue.vector([20, 0])}, + {foo: 'bar', embedding: FieldValue.vector([100, 100])}, + ]); + + const explainResults = await indexTestHelper + .query(collectionReference) + .findNearest('embedding', FieldValue.vector([1, 3]), { + limit: 10, + distanceMeasure: 'COSINE', + }) + .explain({analyze: true}); + + const metrics = explainResults.metrics; + expect(metrics.planSummary).to.not.be.null; + expect( + Object.keys(metrics.planSummary.indexesUsed).length + ).to.be.greaterThan(0); + + expect(metrics.executionStats).to.not.be.null; + const stats = metrics.executionStats!; + + expect(stats.readOperations).to.be.greaterThan(0); + expect(stats.resultsReturned).to.be.equal(5); + expect( + stats.executionDuration.nanoseconds > 0 || + stats.executionDuration.seconds > 0 + ).to.be.true; + expect(Object.keys(stats.debugStats).length).to.be.greaterThan(0); + + expect(explainResults.snapshot).to.not.be.null; + expect(explainResults.snapshot!.docs.length).to.equal(5); + }); + it('getAll() supports array destructuring', () => { const ref1 = randomCol.doc('doc1'); const ref2 = randomCol.doc('doc2'); From 071692b3a7c1358bcf16d103c36bab1b70918d85 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 22 May 2024 12:45:40 -0600 Subject: [PATCH 6/6] Adding clarifying code comment. --- dev/src/reference/vector-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/src/reference/vector-query.ts b/dev/src/reference/vector-query.ts index 7b4baa7a8..4df93e60f 100644 --- a/dev/src/reference/vector-query.ts +++ b/dev/src/reference/vector-query.ts @@ -133,7 +133,7 @@ export class VectorQuery< ): Promise>> { return this._queryUtil._getResponse( this, - undefined, + /*transactionOrReadTime*/ undefined, // VectorQuery cannot be retried with cursors as they do not support cursors yet. /*retryWithCursor*/ false, explainOptions