From 44023392efc49328837619d4a69bb5ffd386e511 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Tue, 17 Mar 2026 21:59:32 -0400 Subject: [PATCH 01/10] rough changes --- .../dev/src/pipelines/pipeline-util.ts | 27 +++- .../firestore/dev/src/pipelines/pipelines.ts | 115 +++++++++++++++--- .../firestore/dev/src/pipelines/stage.ts | 6 + .../dev/src/pipelines/structured-pipeline.ts | 5 + .../firestore/dev/system-test/pipeline.ts | 5 +- 5 files changed, 139 insertions(+), 19 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index 5c12257c23f..a263ef5350d 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -37,7 +37,7 @@ import { PipelineStreamElement, QueryCursor, } from '../reference/types'; -import {Serializer} from '../serializer'; +import {hasUserData, HasUserData, Serializer} from '../serializer'; import { Deferred, getTotalTimeout, @@ -763,3 +763,28 @@ export function aliasedAggregateToMap( new Map() as Map, ); } + +/** + * @internal + * + * Helper to read user data across a number of different formats. + * @param name - Name of the calling function. Used for error messages when invalid user data is encountered. + * @param expressionMap The expressions to validate. + * @returns the expressionMap argument. + */ +export function validateUserDataHelper< + T extends Map | HasUserData[] | HasUserData, +>(expressionMap: T, ignoreUndefinedProperties: boolean): T { + if (hasUserData(expressionMap)) { + expressionMap._validateUserData(ignoreUndefinedProperties); + } else if (Array.isArray(expressionMap)) { + expressionMap.forEach(readableData => { + readableData._validateUserData(ignoreUndefinedProperties); + }); + } else { + expressionMap.forEach(expr => + expr._validateUserData(ignoreUndefinedProperties), + ); + } + return expressionMap; +} diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 2844c29ca57..06e2c003526 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -349,7 +349,7 @@ export class PipelineSource implements firestore.Pipelines.PipelineSource { */ export class Pipeline implements firestore.Pipelines.Pipeline { constructor( - private db: Firestore, + private db: Firestore | undefined, private stages: Stage[], ) {} @@ -433,7 +433,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { : fieldOrOptions.fields; const normalizedFields: Map = selectablesToMap(fields); - this._validateUserData('select', normalizedFields); + this._deprecatedValidateUserData('select', normalizedFields); const internalOptions = { ...options, @@ -503,7 +503,8 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const convertedFields: Array = fields.map(f => isString(f) ? field(f) : (f as Field), ); - this._validateUserData('removeFields', convertedFields); + // REMOVED: This is now done in RemoveFields._validateUserData + // this._deprecatedValidateUserData('removeFields', convertedFields); const innerOptions = { ...options, @@ -602,7 +603,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const normalizedSelections: Map = selectablesToMap(selections); - this._validateUserData('select', normalizedSelections); + this._deprecatedValidateUserData('select', normalizedSelections); const internalOptions = { ...options, @@ -689,7 +690,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { : conditionOrOptions.condition; const convertedCondition: BooleanExpression = condition as BooleanExpression; - this._validateUserData('where', convertedCondition); + this._deprecatedValidateUserData('where', convertedCondition); const internalOptions: InternalWhereStageOptions = { ...options, @@ -906,7 +907,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? [groupOrOptions, ...additionalGroups] : groupOrOptions.groups; const convertedGroups: Map = selectablesToMap(groups); - this._validateUserData('distinct', convertedGroups); + this._deprecatedValidateUserData('distinct', convertedGroups); const internalOptions: InternalDistinctStageOptions = { ...options, @@ -996,7 +997,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const groups: Array = isAliasedAggregate(targetOrOptions) ? [] : (targetOrOptions.groups ?? []); const convertedGroups: Map = selectablesToMap(groups); - this._validateUserData('aggregate', convertedGroups); + this._deprecatedValidateUserData('aggregate', convertedGroups); const internalOptions: InternalAggregateStageOptions = { ...options, @@ -1040,9 +1041,9 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? toField(options.distanceField) : undefined; - this._validateUserData('findNearest', field); + this._deprecatedValidateUserData('findNearest', field); - this._validateUserData('findNearest', vectorValue); + this._deprecatedValidateUserData('findNearest', vectorValue); const internalOptions: InternalFindNearestStageOptions = { ...options, @@ -1177,7 +1178,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? valueOrOptions : valueOrOptions.map; const mapExpr = fieldOrExpression(fieldNameOrExpr); - this._validateUserData('replaceWith', mapExpr); + this._deprecatedValidateUserData('replaceWith', mapExpr); const internalOptions: InternalReplaceWithStageOptions = { ...options, @@ -1490,7 +1491,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? [orderingOrOptions, ...additionalOrderings] : orderingOrOptions.orderings; const normalizedOrderings = orderings as Array; - this._validateUserData('sort', normalizedOrderings); + this._deprecatedValidateUserData('sort', normalizedOrderings); const internalOptions: InternalSortStageOptions = { ...options, @@ -1545,9 +1546,10 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } }); + // TODO(dlarocque): Defer this validation to RawStage._validateUserData expressionParams.forEach(param => { if (hasUserData(param)) { - param._validateUserData(!!this.db._settings.ignoreUndefinedProperties); + param._validateUserData(!!this.db!._settings.ignoreUndefinedProperties); // TODO(dlarocque): Non-null assertion is a placeholder. } }); return this._addStage(new RawStage(name, expressionParams, options ?? {})); @@ -1602,10 +1604,22 @@ export class Pipeline implements firestore.Pipelines.Pipeline { transactionOrReadTime?: Uint8Array | Timestamp | api.ITransactionOptions, pipelineExecuteOptions?: firestore.Pipelines.PipelineExecuteOptions, ): Promise { + if (!this.db) { + throw new Error( + 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', + ); + } + + // Validates user data in the entire pipeline + this._validateUserData('execute'); + const util = new ExecutionUtil(this.db, this.db._serializer!); + const structuredPipeline = this._toStructuredPipeline( pipelineExecuteOptions, ); + structuredPipeline._validateUserData('execute'); + // this._deprecatedValidateUserData('execute', structuredPipeline); return util ._getResponse(structuredPipeline, transactionOrReadTime) .then(result => result!); @@ -1641,6 +1655,11 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * ``` */ stream(): NodeJS.ReadableStream { + if (!this.db) { + throw new Error( + 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', + ); + } const util = new ExecutionUtil(this.db, this.db._serializer!); // TODO(pipelines) support options const structuredPipeline = this._toStructuredPipeline(); @@ -1648,12 +1667,36 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } _toProto(): api.IPipeline { - const stages: IStage[] = this.stages.map(stage => - stage._toProto(this.db._serializer!), + if (!this.db) { + throw new Error( + 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', + ); + } + + const stages: IStage[] = this.stages.map( + // We use a non-null assertion here because we've already checked that + // 'db' is not null at the start of this function, but TS does not + // recognize that 'db' can no longer be undefined. + stage => stage._toProto(this.db!._serializer!), ); return {stages}; } + _validateUserData(name: string): void { + if (!this.db) { + throw new Error( + 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', + ); + } + + const ignoreUndefinedProperties = + !!this.db._settings.ignoreUndefinedProperties; + + this.stages.forEach(stage => { + stage._validateUserData(stage.name, ignoreUndefinedProperties); + }); + } + /** * @beta * Validates user data for each expression in the expressionMap. @@ -1662,9 +1705,14 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * @returns the expressionMap argument. * @private */ - _validateUserData< + _deprecatedValidateUserData< T extends Map | HasUserData[] | HasUserData, >(_: string, val: T): T { + if (!this.db) { + throw new Error( + 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', + ); + } const ignoreUndefinedProperties = !!this.db._settings.ignoreUndefinedProperties; if (hasUserData(val)) { @@ -2070,3 +2118,40 @@ export class PipelineResult implements firestore.Pipelines.PipelineResult { ); } } + +// /** +// * @beta +// * Creates a new Pipeline targeted at a subcollection relative to the current document context. +// * This creates a pipeline without a database instance, suitable for embedding as a subquery. +// * If executed directly, this pipeline will fail. +// * +// * @param path - The relative path to the subcollection. +// */ +// export function subcollection(path: string): Pipeline; +// /** +// * @beta +// * Creates a new Pipeline targeted at a subcollection relative to the current document context. +// * This creates a pipeline without a database instance, suitable for embedding as a subquery. +// * If executed directly, this pipeline will fail. +// * +// * @param options - Options defining how this SubcollectionStage is evaluated. +// */ +// export function subcollection(options: SubcollectionStageOptions): Pipeline; +// export function subcollection( +// pathOrOptions: string | SubcollectionStageOptions, +// ): Pipeline { +// // Process argument union(s) from method overloads +// let path: string; +// let options: {}; +// if (isString(pathOrOptions)) { +// path = pathOrOptions; +// options = {}; +// } else { +// ({path, ...options} = pathOrOptions); +// } + +// // Create stage object +// const stage = new SubcollectionSource(path, options); + +// return new Pipeline(undefined, [stage]); // TODO: pass stages +// } diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 9695624fbdd..59143e37980 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -29,6 +29,7 @@ import { } from './expression'; import {OptionsUtil} from './options-util'; import {CollectionReference} from '../reference/collection-reference'; +import {isField, isString, validateUserDataHelper} from './pipeline-util'; /** * Interface for Stage classes. @@ -37,6 +38,7 @@ export interface Stage extends ProtoSerializable { name: string; _toProto(serializer: Serializer): api.Pipeline.IStage; + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void; } /** @@ -69,6 +71,10 @@ export class RemoveFields implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.fields, ignoreUndefinedProperties); + } } /** diff --git a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts index bd74d236997..9d5b0fdafbb 100644 --- a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts +++ b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts @@ -90,4 +90,9 @@ export class StructuredPipeline ), }; } + + // TODO (dlarocque): Should this accept ignoreUndefinedProperties? + _validateUserData(name: string): void { + throw new Error('IMPLEMENT ME'); + } } diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 7bdb4405b17..cfb28c04d30 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -812,7 +812,7 @@ describe.skipClassic('Pipeline class', () => { it('throws on undefined in a map', async () => { try { - await firestore + firestore .pipeline() .collection(randomCol.path) .limit(1) @@ -821,8 +821,7 @@ describe.skipClassic('Pipeline class', () => { number: 1, bad: undefined, }).as('foo'), - ) - .execute(); + ); expect.fail('The statement above was expected to throw.'); } catch (e: unknown) { const error = e as Error; From 8f08e78b0d04c7d07b51570da28e479c4badbb70 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Tue, 17 Mar 2026 21:59:56 -0400 Subject: [PATCH 02/10] validate user data on all stages --- .../firestore/dev/src/pipelines/pipelines.ts | 56 +------------- .../firestore/dev/src/pipelines/stage.ts | 75 ++++++++++++++++++- .../dev/src/pipelines/structured-pipeline.ts | 35 ++++----- .../firestore/dev/system-test/pipeline.ts | 5 +- 4 files changed, 96 insertions(+), 75 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 06e2c003526..8090d0d4dac 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -433,8 +433,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { : fieldOrOptions.fields; const normalizedFields: Map = selectablesToMap(fields); - this._deprecatedValidateUserData('select', normalizedFields); - const internalOptions = { ...options, fields: normalizedFields, @@ -503,8 +501,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const convertedFields: Array = fields.map(f => isString(f) ? field(f) : (f as Field), ); - // REMOVED: This is now done in RemoveFields._validateUserData - // this._deprecatedValidateUserData('removeFields', convertedFields); const innerOptions = { ...options, @@ -603,8 +599,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const normalizedSelections: Map = selectablesToMap(selections); - this._deprecatedValidateUserData('select', normalizedSelections); - const internalOptions = { ...options, selections: normalizedSelections, @@ -690,7 +684,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { : conditionOrOptions.condition; const convertedCondition: BooleanExpression = condition as BooleanExpression; - this._deprecatedValidateUserData('where', convertedCondition); const internalOptions: InternalWhereStageOptions = { ...options, @@ -907,7 +900,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? [groupOrOptions, ...additionalGroups] : groupOrOptions.groups; const convertedGroups: Map = selectablesToMap(groups); - this._deprecatedValidateUserData('distinct', convertedGroups); const internalOptions: InternalDistinctStageOptions = { ...options, @@ -997,7 +989,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const groups: Array = isAliasedAggregate(targetOrOptions) ? [] : (targetOrOptions.groups ?? []); const convertedGroups: Map = selectablesToMap(groups); - this._deprecatedValidateUserData('aggregate', convertedGroups); const internalOptions: InternalAggregateStageOptions = { ...options, @@ -1041,10 +1032,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? toField(options.distanceField) : undefined; - this._deprecatedValidateUserData('findNearest', field); - - this._deprecatedValidateUserData('findNearest', vectorValue); - const internalOptions: InternalFindNearestStageOptions = { ...options, field, @@ -1178,7 +1165,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? valueOrOptions : valueOrOptions.map; const mapExpr = fieldOrExpression(fieldNameOrExpr); - this._deprecatedValidateUserData('replaceWith', mapExpr); const internalOptions: InternalReplaceWithStageOptions = { ...options, @@ -1491,7 +1477,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ? [orderingOrOptions, ...additionalOrderings] : orderingOrOptions.orderings; const normalizedOrderings = orderings as Array; - this._deprecatedValidateUserData('sort', normalizedOrderings); const internalOptions: InternalSortStageOptions = { ...options, @@ -1546,12 +1531,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } }); - // TODO(dlarocque): Defer this validation to RawStage._validateUserData - expressionParams.forEach(param => { - if (hasUserData(param)) { - param._validateUserData(!!this.db!._settings.ignoreUndefinedProperties); // TODO(dlarocque): Non-null assertion is a placeholder. - } - }); return this._addStage(new RawStage(name, expressionParams, options ?? {})); } @@ -1618,8 +1597,9 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const structuredPipeline = this._toStructuredPipeline( pipelineExecuteOptions, ); - structuredPipeline._validateUserData('execute'); - // this._deprecatedValidateUserData('execute', structuredPipeline); + structuredPipeline._validateUserData( + !!this.db._settings.ignoreUndefinedProperties, + ); return util ._getResponse(structuredPipeline, transactionOrReadTime) .then(result => result!); @@ -1696,36 +1676,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { stage._validateUserData(stage.name, ignoreUndefinedProperties); }); } - - /** - * @beta - * Validates user data for each expression in the expressionMap. - * @param name Name of the calling function. Used for error messages when invalid user data is encountered. - * @param val - * @returns the expressionMap argument. - * @private - */ - _deprecatedValidateUserData< - T extends Map | HasUserData[] | HasUserData, - >(_: string, val: T): T { - if (!this.db) { - throw new Error( - 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', - ); - } - const ignoreUndefinedProperties = - !!this.db._settings.ignoreUndefinedProperties; - if (hasUserData(val)) { - val._validateUserData(ignoreUndefinedProperties); - } else if (Array.isArray(val)) { - val.forEach(readableData => { - readableData._validateUserData(ignoreUndefinedProperties); - }); - } else { - val.forEach(expr => expr._validateUserData(ignoreUndefinedProperties)); - } - return val; - } } /** diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 59143e37980..1babc3f3863 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -29,7 +29,7 @@ import { } from './expression'; import {OptionsUtil} from './options-util'; import {CollectionReference} from '../reference/collection-reference'; -import {isField, isString, validateUserDataHelper} from './pipeline-util'; +import {validateUserDataHelper} from './pipeline-util'; /** * Interface for Stage classes. @@ -111,6 +111,14 @@ export class Aggregate implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.groups, ignoreUndefinedProperties); + validateUserDataHelper( + this.options.accumulators, + ignoreUndefinedProperties, + ); + } } /** @@ -143,6 +151,10 @@ export class Distinct implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.groups, ignoreUndefinedProperties); + } } /** @@ -186,6 +198,8 @@ export class CollectionSource implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} } /** @@ -221,6 +235,8 @@ export class CollectionGroupSource implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} } /** @@ -248,6 +264,8 @@ export class DatabaseSource implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} } /** @@ -283,6 +301,8 @@ export class DocumentsSource implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} } /** @@ -315,6 +335,10 @@ export class Where implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.condition, ignoreUndefinedProperties); + } } /** @@ -360,6 +384,20 @@ export class FindNearest implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this._options.field, ignoreUndefinedProperties); + validateUserDataHelper( + this._options.vectorValue, + ignoreUndefinedProperties, + ); + if (this._options.distanceField) { + validateUserDataHelper( + this._options.distanceField, + ignoreUndefinedProperties, + ); + } + } } /** @@ -396,6 +434,8 @@ export class Sample implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} } /** @@ -423,6 +463,11 @@ export class Union implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + // A Union stage embeds a full pipeline, we trigger its validation here. + (this.options.other as any)._validateUserData(name); + } } /** @@ -464,6 +509,10 @@ export class Unnest implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.expr, ignoreUndefinedProperties); + } } /** @@ -491,6 +540,8 @@ export class Limit implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} } /** @@ -518,6 +569,8 @@ export class Offset implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} } /** @@ -553,6 +606,10 @@ export class ReplaceWith implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.map, ignoreUndefinedProperties); + } } /** @@ -585,6 +642,10 @@ export class Select implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.selections, ignoreUndefinedProperties); + } } /** @@ -617,6 +678,10 @@ export class AddFields implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.fields, ignoreUndefinedProperties); + } } /** @@ -649,6 +714,10 @@ export class Sort implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.orderings, ignoreUndefinedProperties); + } } /** @@ -682,4 +751,8 @@ export class RawStage implements Stage { ), }; } + + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.params, ignoreUndefinedProperties); + } } diff --git a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts index 9d5b0fdafbb..710bfaf906f 100644 --- a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts +++ b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts @@ -1,19 +1,16 @@ -/** - * @license - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. import {ProtoSerializable, Serializer} from '../serializer'; import {google} from '../../protos/firestore_v1_proto_api'; @@ -91,8 +88,8 @@ export class StructuredPipeline }; } - // TODO (dlarocque): Should this accept ignoreUndefinedProperties? - _validateUserData(name: string): void { - throw new Error('IMPLEMENT ME'); + _validateUserData(ignoreUndefinedProperties: boolean): void { + // Structured pipeline options are primitive configurations or raw overrides. + // They do not contain Abstract Syntax Tree expressions that require deferred user data validation. } } diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index cfb28c04d30..7bdb4405b17 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -812,7 +812,7 @@ describe.skipClassic('Pipeline class', () => { it('throws on undefined in a map', async () => { try { - firestore + await firestore .pipeline() .collection(randomCol.path) .limit(1) @@ -821,7 +821,8 @@ describe.skipClassic('Pipeline class', () => { number: 1, bad: undefined, }).as('foo'), - ); + ) + .execute(); expect.fail('The statement above was expected to throw.'); } catch (e: unknown) { const error = e as Error; From 74e87e32541d926b439de527f6b6b567ab5572d9 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 18 Mar 2026 10:56:46 -0400 Subject: [PATCH 03/10] cleanup --- .../dev/src/pipelines/pipeline-util.ts | 3 -- .../firestore/dev/src/pipelines/pipelines.ts | 44 +------------------ .../dev/src/pipelines/structured-pipeline.ts | 34 +++++++------- 3 files changed, 18 insertions(+), 63 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index a263ef5350d..08a6fd7ad65 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -768,9 +768,6 @@ export function aliasedAggregateToMap( * @internal * * Helper to read user data across a number of different formats. - * @param name - Name of the calling function. Used for error messages when invalid user data is encountered. - * @param expressionMap The expressions to validate. - * @returns the expressionMap argument. */ export function validateUserDataHelper< T extends Map | HasUserData[] | HasUserData, diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 8090d0d4dac..9a674df2120 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -42,7 +42,7 @@ import { } from './pipeline-util'; import {DocumentReference} from '../reference/document-reference'; import {PipelineResponse} from '../reference/types'; -import {HasUserData, hasUserData, Serializer} from '../serializer'; +import {Serializer} from '../serializer'; import {ApiMapValue} from '../types'; import * as protos from '../../protos/firestore_v1_proto_api'; import api = protos.google.firestore.v1; @@ -1597,9 +1597,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { const structuredPipeline = this._toStructuredPipeline( pipelineExecuteOptions, ); - structuredPipeline._validateUserData( - !!this.db._settings.ignoreUndefinedProperties, - ); return util ._getResponse(structuredPipeline, transactionOrReadTime) .then(result => result!); @@ -1662,7 +1659,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { return {stages}; } - _validateUserData(name: string): void { + _validateUserData(_: string): void { if (!this.db) { throw new Error( 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', @@ -2068,40 +2065,3 @@ export class PipelineResult implements firestore.Pipelines.PipelineResult { ); } } - -// /** -// * @beta -// * Creates a new Pipeline targeted at a subcollection relative to the current document context. -// * This creates a pipeline without a database instance, suitable for embedding as a subquery. -// * If executed directly, this pipeline will fail. -// * -// * @param path - The relative path to the subcollection. -// */ -// export function subcollection(path: string): Pipeline; -// /** -// * @beta -// * Creates a new Pipeline targeted at a subcollection relative to the current document context. -// * This creates a pipeline without a database instance, suitable for embedding as a subquery. -// * If executed directly, this pipeline will fail. -// * -// * @param options - Options defining how this SubcollectionStage is evaluated. -// */ -// export function subcollection(options: SubcollectionStageOptions): Pipeline; -// export function subcollection( -// pathOrOptions: string | SubcollectionStageOptions, -// ): Pipeline { -// // Process argument union(s) from method overloads -// let path: string; -// let options: {}; -// if (isString(pathOrOptions)) { -// path = pathOrOptions; -// options = {}; -// } else { -// ({path, ...options} = pathOrOptions); -// } - -// // Create stage object -// const stage = new SubcollectionSource(path, options); - -// return new Pipeline(undefined, [stage]); // TODO: pass stages -// } diff --git a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts index 710bfaf906f..bd74d236997 100644 --- a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts +++ b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts @@ -1,16 +1,19 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import {ProtoSerializable, Serializer} from '../serializer'; import {google} from '../../protos/firestore_v1_proto_api'; @@ -87,9 +90,4 @@ export class StructuredPipeline ), }; } - - _validateUserData(ignoreUndefinedProperties: boolean): void { - // Structured pipeline options are primitive configurations or raw overrides. - // They do not contain Abstract Syntax Tree expressions that require deferred user data validation. - } } From 3fa2d9e9de570e66866b1fbedcc67943723884be Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 18 Mar 2026 11:27:02 -0400 Subject: [PATCH 04/10] fix lint errors --- handwritten/firestore/.eslintrc.json | 22 ++++++------------- .../firestore/dev/src/pipelines/stage.ts | 16 +++++++------- handwritten/firestore/types/firestore.d.ts | 4 ++++ 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/handwritten/firestore/.eslintrc.json b/handwritten/firestore/.eslintrc.json index ed46c46a7ae..716ffebd494 100644 --- a/handwritten/firestore/.eslintrc.json +++ b/handwritten/firestore/.eslintrc.json @@ -2,13 +2,8 @@ "extends": "./node_modules/gts", "overrides": [ { - "files": [ - "dev/src/**/*.ts" - ], - "excludedFiles": [ - "dev/src/v1/*.ts", - "dev/src/v1beta1/*.ts" - ], + "files": ["dev/src/**/*.ts"], + "excludedFiles": ["dev/src/v1/*.ts", "dev/src/v1beta1/*.ts"], "parser": "@typescript-eslint/parser", "rules": { "@typescript-eslint/explicit-function-return-type": [ @@ -22,17 +17,14 @@ "@typescript-eslint/no-unused-vars": [ "warn", { - // Ignore args that are underscore only - "argsIgnorePattern": "^_$" + // Allow args to be unused if they start with an underscore + "argsIgnorePattern": "^_" } ] } }, { - "files": [ - "dev/test/*.ts", - "dev/system-test/*.ts" - ], + "files": ["dev/test/*.ts", "dev/system-test/*.ts"], "parser": "@typescript-eslint/parser", "rules": { "no-restricted-properties": [ @@ -49,8 +41,8 @@ "@typescript-eslint/no-unused-vars": [ "warn", { - // Ignore args that are underscore only - "argsIgnorePattern": "^_$" + // Allow args to be unused if they start with an underscore + "argsIgnorePattern": "^_" } ], "@typescript-eslint/no-floating-promises": "warn" diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 1babc3f3863..ba45f41887d 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -199,7 +199,7 @@ export class CollectionSource implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} + _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} } /** @@ -236,7 +236,7 @@ export class CollectionGroupSource implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} + _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} } /** @@ -265,7 +265,7 @@ export class DatabaseSource implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} + _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} } /** @@ -302,7 +302,7 @@ export class DocumentsSource implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} + _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} } /** @@ -435,7 +435,7 @@ export class Sample implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} + _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} } /** @@ -466,7 +466,7 @@ export class Union implements Stage { _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { // A Union stage embeds a full pipeline, we trigger its validation here. - (this.options.other as any)._validateUserData(name); + this.options.other._validateUserData(name, ignoreUndefinedProperties); } } @@ -541,7 +541,7 @@ export class Limit implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} + _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} } /** @@ -570,7 +570,7 @@ export class Offset implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void {} + _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} } /** diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index e58444a2053..edc666bec69 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -11588,6 +11588,10 @@ declare namespace FirebaseFirestore { * ``` */ export class Pipeline { + /** + * @internal + */ + _validateUserData(name: string, ignoreUndefinedProperties: boolean): void; /** * @beta * Adds new fields to outputs from previous stages. From 6150c34131488f9225d74955bc0afa1153482508 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Mon, 23 Mar 2026 11:35:10 -0400 Subject: [PATCH 05/10] remove _validateUserData from types --- handwritten/firestore/dev/src/pipelines/stage.ts | 7 ++++--- handwritten/firestore/types/firestore.d.ts | 4 ---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index ba45f41887d..7dbde9ed4fe 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -30,6 +30,7 @@ import { import {OptionsUtil} from './options-util'; import {CollectionReference} from '../reference/collection-reference'; import {validateUserDataHelper} from './pipeline-util'; +import {Pipeline} from './pipelines'; /** * Interface for Stage classes. @@ -464,9 +465,9 @@ export class Union implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(name: string): void { // A Union stage embeds a full pipeline, we trigger its validation here. - this.options.other._validateUserData(name, ignoreUndefinedProperties); + (this.options.other as Pipeline)._validateUserData(name); } } @@ -510,7 +511,7 @@ export class Unnest implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(_name: string, ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.expr, ignoreUndefinedProperties); } } diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index de2bd9d48a8..6b88278d4e4 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -11631,10 +11631,6 @@ declare namespace FirebaseFirestore { * ``` */ export class Pipeline { - /** - * @internal - */ - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void; /** * @beta * Adds new fields to outputs from previous stages. From 9edc6af86bff1ca7d1703eccdf5e640a9a041bba Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Mon, 23 Mar 2026 12:00:41 -0400 Subject: [PATCH 06/10] test(firestore): Update `RESOURCE_EXHAUSTED` retry count to 1 --- handwritten/firestore/dev/test/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handwritten/firestore/dev/test/index.ts b/handwritten/firestore/dev/test/index.ts index 73359e14f78..b0971528683 100644 --- a/handwritten/firestore/dev/test/index.ts +++ b/handwritten/firestore/dev/test/index.ts @@ -1264,7 +1264,7 @@ describe('getAll() method', () => { [Status.NOT_FOUND]: 1, [Status.ALREADY_EXISTS]: 1, [Status.PERMISSION_DENIED]: 1, - [Status.RESOURCE_EXHAUSTED]: 5, + [Status.RESOURCE_EXHAUSTED]: 1, [Status.FAILED_PRECONDITION]: 1, [Status.ABORTED]: 1, [Status.OUT_OF_RANGE]: 1, From 351bd959f65f4f5f0d73bc49cc5d208c320e9ef4 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Mon, 23 Mar 2026 12:18:44 -0400 Subject: [PATCH 07/10] Revert "test(firestore): Update `RESOURCE_EXHAUSTED` retry count to 1" This reverts commit 9edc6af86bff1ca7d1703eccdf5e640a9a041bba. --- handwritten/firestore/dev/test/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handwritten/firestore/dev/test/index.ts b/handwritten/firestore/dev/test/index.ts index b0971528683..73359e14f78 100644 --- a/handwritten/firestore/dev/test/index.ts +++ b/handwritten/firestore/dev/test/index.ts @@ -1264,7 +1264,7 @@ describe('getAll() method', () => { [Status.NOT_FOUND]: 1, [Status.ALREADY_EXISTS]: 1, [Status.PERMISSION_DENIED]: 1, - [Status.RESOURCE_EXHAUSTED]: 1, + [Status.RESOURCE_EXHAUSTED]: 5, [Status.FAILED_PRECONDITION]: 1, [Status.ABORTED]: 1, [Status.OUT_OF_RANGE]: 1, From 7868bbfdeef8b10479d39d3d5b07e520fa0698ea Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Mon, 23 Mar 2026 15:24:03 -0400 Subject: [PATCH 08/10] remove name from validateUserData method --- .../firestore/dev/src/pipelines/pipelines.ts | 17 +++---- .../firestore/dev/src/pipelines/stage.ts | 44 ++++++++++--------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 9a674df2120..e5e812d51c8 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -1590,7 +1590,9 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } // Validates user data in the entire pipeline - this._validateUserData('execute'); + this._validateUserData( + this.db._settings.ignoreUndefinedProperties ?? false, + ); const util = new ExecutionUtil(this.db, this.db._serializer!); @@ -1659,18 +1661,9 @@ export class Pipeline implements firestore.Pipelines.Pipeline { return {stages}; } - _validateUserData(_: string): void { - if (!this.db) { - throw new Error( - 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', - ); - } - - const ignoreUndefinedProperties = - !!this.db._settings.ignoreUndefinedProperties; - + _validateUserData(ignoreUndefinedProperties: boolean): void { this.stages.forEach(stage => { - stage._validateUserData(stage.name, ignoreUndefinedProperties); + stage._validateUserData(ignoreUndefinedProperties); }); } } diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 7dbde9ed4fe..0c7d5d40a0c 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -39,7 +39,7 @@ export interface Stage extends ProtoSerializable { name: string; _toProto(serializer: Serializer): api.Pipeline.IStage; - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void; + _validateUserData(ignoreUndefinedProperties: boolean): void; } /** @@ -73,7 +73,7 @@ export class RemoveFields implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.fields, ignoreUndefinedProperties); } } @@ -113,7 +113,7 @@ export class Aggregate implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.groups, ignoreUndefinedProperties); validateUserDataHelper( this.options.accumulators, @@ -153,7 +153,7 @@ export class Distinct implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.groups, ignoreUndefinedProperties); } } @@ -200,7 +200,7 @@ export class CollectionSource implements Stage { }; } - _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} + _validateUserData(_ignoreUndefinedProperties: boolean): void {} } /** @@ -237,7 +237,7 @@ export class CollectionGroupSource implements Stage { }; } - _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} + _validateUserData(_ignoreUndefinedProperties: boolean): void {} } /** @@ -266,7 +266,7 @@ export class DatabaseSource implements Stage { }; } - _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} + _validateUserData(_ignoreUndefinedProperties: boolean): void {} } /** @@ -303,7 +303,7 @@ export class DocumentsSource implements Stage { }; } - _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} + _validateUserData(_ignoreUndefinedProperties: boolean): void {} } /** @@ -337,7 +337,7 @@ export class Where implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.condition, ignoreUndefinedProperties); } } @@ -386,7 +386,7 @@ export class FindNearest implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this._options.field, ignoreUndefinedProperties); validateUserDataHelper( this._options.vectorValue, @@ -436,7 +436,7 @@ export class Sample implements Stage { }; } - _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} + _validateUserData(_ignoreUndefinedProperties: boolean): void {} } /** @@ -465,9 +465,11 @@ export class Union implements Stage { }; } - _validateUserData(name: string): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { // A Union stage embeds a full pipeline, we trigger its validation here. - (this.options.other as Pipeline)._validateUserData(name); + (this.options.other as Pipeline)._validateUserData( + ignoreUndefinedProperties, + ); } } @@ -511,7 +513,7 @@ export class Unnest implements Stage { }; } - _validateUserData(_name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.expr, ignoreUndefinedProperties); } } @@ -542,7 +544,7 @@ export class Limit implements Stage { }; } - _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} + _validateUserData(_ignoreUndefinedProperties: boolean): void {} } /** @@ -571,7 +573,7 @@ export class Offset implements Stage { }; } - _validateUserData(_name: string, _ignoreUndefinedProperties: boolean): void {} + _validateUserData(_ignoreUndefinedProperties: boolean): void {} } /** @@ -608,7 +610,7 @@ export class ReplaceWith implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.map, ignoreUndefinedProperties); } } @@ -644,7 +646,7 @@ export class Select implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.selections, ignoreUndefinedProperties); } } @@ -680,7 +682,7 @@ export class AddFields implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.fields, ignoreUndefinedProperties); } } @@ -716,7 +718,7 @@ export class Sort implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.options.orderings, ignoreUndefinedProperties); } } @@ -753,7 +755,7 @@ export class RawStage implements Stage { }; } - _validateUserData(name: string, ignoreUndefinedProperties: boolean): void { + _validateUserData(ignoreUndefinedProperties: boolean): void { validateUserDataHelper(this.params, ignoreUndefinedProperties); } } From f79113292716d1721b250a358dd41da27b1e063a Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Mon, 23 Mar 2026 14:43:30 -0400 Subject: [PATCH 09/10] feat(firestore): add support for subqueries --- .../firestore/dev/src/pipelines/expression.ts | 198 +++++ .../firestore/dev/src/pipelines/index.ts | 4 + .../dev/src/pipelines/pipeline-util.ts | 12 +- .../firestore/dev/src/pipelines/pipelines.ts | 259 +++++- .../firestore/dev/src/pipelines/stage.ts | 66 ++ .../firestore/dev/system-test/pipeline.ts | 771 ++++++++++++++++++ handwritten/firestore/types/firestore.d.ts | 347 +++++++- 7 files changed, 1648 insertions(+), 9 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index 8a32713c36b..037432a9fc2 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -28,6 +28,7 @@ import { } from './pipeline-util'; import {HasUserData, Serializer, validateUserInput} from '../serializer'; import {cast} from '../util'; +import {Pipeline} from './pipelines'; /** * @beta @@ -3071,6 +3072,23 @@ export abstract class Expression ]).asBoolean(); } + /** + * @beta + * Creates an expression that returns the value of a field from the document that results from the evaluation of this expression. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * field("address").getField("city") + * ``` + * + * @param key The field to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ + getField(key: string | Expression): Expression { + return new FunctionExpression('field', [this, valueToDefaultExpr(key)]); + } + // TODO(new-expression): Add new expression method definitions above this line /** @@ -10237,6 +10255,186 @@ export function isType( return fieldOrExpression(fieldNameOrExpression).isType(type); } +/** + * @beta + * Creates an expression that returns the value of a field from a document that results from the evaluation of the expression. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * getField(field("address"), "city") + * ``` + * + * @param key The field to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ +export function getField(expression: Expression, key: string): Expression; +/** + * @beta + * Creates an expression that returns the value of a field from a document that results from the evaluation of the expression. + * + * @example + * ```typescript + * // Get the value of the key resulting from the "addressField" variable in the "address" document. + * getField(field("address", variable("addressField")), + * ``` + * + * @param key The expression representing the key to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ +export function getField( + expression: Expression, + keyExpr: Expression, +): Expression; +/** + * @beta + * Creates an expression that returns the value of a field from the document with the given field name. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * getField("address", "city") + * ``` + * + * @param key The field to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ +export function getField(fieldName: string, key: string): Expression; +/** + * @beta + * Creates an expression that returns the value of a field from the document with the given field name. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * getField("address", variable("addressField")) + * ``` + * + * @param key The field to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ +export function getField(fieldName: string, keyExpr: Expression): Expression; +export function getField( + fieldOrExpr: string | Expression, + keyOrExpr: string | Expression, +): Expression { + return fieldOrExpression(fieldOrExpr).getField(keyOrExpr); +} + +/** + * @internal + * Expression representing a variable reference. This evaluates to the value of a variable + * defined in a pipeline. + */ +export class VariableExpression extends Expression { + expressionType: firestore.Pipelines.ExpressionType = 'Variable'; + + /** + * @hideconstructor + */ + constructor(private readonly name: string) { + super(); + } + + /** + * @internal + */ + _toProto(_serializer: Serializer): api.IValue { + return { + variableReferenceValue: this.name, + }; + } + + /** + * @internal + */ + _validateUserData(_ignoreUndefinedProperties: boolean): void {} +} + +/** + * @beta + * Creates an expression that retrieves the value of a variable bound via `define()`. + * + * @example + * ```typescript + * db.pipeline().collection("products") + * .define( + * field("price").multiply(0.9).as("discountedPrice"), + * field("stock").add(10).as("newStock") + * ) + * .where(variable("discountedPrice").lessThan(100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param name - The name of the variable to retrieve. + * @returns An `Expression` representing the variable's value. + */ +export function variable(name: string): Expression { + return new VariableExpression(name); +} + +/** + * @beta + * Creates an expression that represents the current document being processed. + * + * @example + * ```typescript + * // Define the current document as a variable "doc" + * firestore.pipeline().collection("books") + * .define(currentDocument().as("doc")) + * // Access a field from the defined document variable + * .select(variable("doc").mapGet("title")); + * ``` + * + * @returns An `Expression` representing the current document. + */ +export function currentDocument(): Expression { + return new FunctionExpression('current_document', []); +} + +/** + * @internal + */ +class PipelineValueExpression extends Expression { + expressionType: firestore.Pipelines.ExpressionType = 'PipelineValue'; + + /** + * @hideconstructor + */ + constructor(private readonly pipeline: firestore.Pipelines.Pipeline) { + super(); + } + + /** + * @internal + */ + _toProto(serializer: Serializer): api.IValue { + return { + // Casting to bypass type checking becuase _validateUserData does not exist in the public types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pipelineValue: (this.pipeline as Pipeline)._toProto(serializer), + }; + } + + /** + * @internal + */ + _validateUserData(_ignoreUndefinedProperties: boolean): void { + // Casting to bypass type checking becuase _validateUserData does not exist in the public types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.pipeline as any)._validateUserData('PipelineValueExpression'); + } +} + +/** + * @internal + */ +export function pipelineValue( + pipeline: firestore.Pipelines.Pipeline, +): Expression { + return new PipelineValueExpression(pipeline); +} + // TODO(new-expression): Add new top-level expression function definitions above this line /** diff --git a/handwritten/firestore/dev/src/pipelines/index.ts b/handwritten/firestore/dev/src/pipelines/index.ts index 4dcf2694b61..e31ea55cc70 100644 --- a/handwritten/firestore/dev/src/pipelines/index.ts +++ b/handwritten/firestore/dev/src/pipelines/index.ts @@ -17,6 +17,7 @@ export { PipelineResult, PipelineSnapshot, PipelineSource, + subcollection, } from './pipelines'; export { @@ -156,5 +157,8 @@ export { stringReplaceOne, nor, switchOn, + getField, + variable, + currentDocument, // TODO(new-expression): Add new expression exports above this line } from './expression'; diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index 08a6fd7ad65..214ef4385cb 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -65,6 +65,8 @@ import { lessThan, Field, AggregateFunction, + pipelineValue, + AliasedExpression, } from './expression'; import {Pipeline, PipelineResult, ExplainStats} from './pipelines'; import {StructuredPipeline} from './structured-pipeline'; @@ -603,6 +605,12 @@ export function isBooleanExpr( return val instanceof BooleanExpression; } +export function isAliasedExpr( + val: unknown, +): val is firestore.Pipelines.AliasedExpression { + return val instanceof AliasedExpression; +} + export function isField(val: unknown): val is firestore.Pipelines.Field { return val instanceof Field; } @@ -630,6 +638,9 @@ export function valueToDefaultExpr(value: unknown): Expression { if (isFirestoreValue(value)) { return constant(value); } + if (isPipeline(value)) { + return pipelineValue(value); + } if (value instanceof Expression) { return value; } else if (isPlainObject(value)) { @@ -640,7 +651,6 @@ export function valueToDefaultExpr(value: unknown): Expression { result = constant(value); } - // TODO(pipeline) is this still used? result._createdFromLiteral = true; return result; } diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index e5e812d51c8..3e713d61425 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -27,6 +27,7 @@ import { aliasedAggregateToMap, fieldOrExpression, isAliasedAggregate, + isAliasedExpr, isBooleanExpr, isCollectionReference, isExpr, @@ -59,6 +60,7 @@ import { constant, _mapValue, field, + FunctionExpression, } from './expression'; import { AddFields, @@ -95,6 +97,10 @@ import { InternalDocumentsStageOptions, InternalCollectionGroupStageOptions, InternalCollectionStageOptions, + Define, + SubcollectionSource, + InternalDefineStageOptions, + InternalSubcollectionStageOptions, } from './stage'; import {StructuredPipeline} from './structured-pipeline'; import Selectable = FirebaseFirestore.Pipelines.Selectable; @@ -510,6 +516,215 @@ export class Pipeline implements firestore.Pipelines.Pipeline { return this._addStage(new RemoveFields(innerOptions)); } + /** + * @beta + * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a + * variable for internal reuse within the pipeline body (accessed via the `variable()` function). + * + * This stage is useful for declaring reusable values or intermediate calculations that can be + * referenced multiple times in later parts of the pipeline. + * + * @example + * ```typescript + * db.pipeline().collection("products") + * .define( + * field("price").multiply(0.9).as("discountedPrice"), + * field("stock").add(10).as("newStock") + * ) + * .where(variable("discountedPrice").lessThan(100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param aliasedExpression - The first expression to bind to a variable. + * @param additionalExpressions - Optional additional expression to bind to a variable. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + define( + aliasedExpression: firestore.Pipelines.AliasedExpression, + ...additionalExpressions: firestore.Pipelines.AliasedExpression[] + ): Pipeline; + /** + * @beta + * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a + * variable for internal reuse within the pipeline body (accessed via the `variable()` function). + * + * This stage is useful for declaring reusable values or intermediate calculations that can be + * referenced multiple times in later parts of the pipeline. + * + * @example + * ```typescript + * db.pipeline().collection("products") + * .define( + * field("price").multiply(0.9).as("discountedPrice"), + * field("stock").add(10).as("newStock") + * ) + * .where(variable("discountedPrice").lessThan(100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param options - An object that specifies required and optional parameters for the stage. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + define(options: firestore.Pipelines.DefineStageOptions): Pipeline; + define( + aliasedExpressionOrOptions: + | firestore.Pipelines.AliasedExpression + | firestore.Pipelines.DefineStageOptions, + ...additionalExpressions: firestore.Pipelines.AliasedExpression[] + ): Pipeline { + const options = isAliasedExpr(aliasedExpressionOrOptions) + ? {} + : aliasedExpressionOrOptions; + + const aliasedExpressions: firestore.Pipelines.AliasedExpression[] = + isAliasedExpr(aliasedExpressionOrOptions) + ? [aliasedExpressionOrOptions, ...additionalExpressions] + : aliasedExpressionOrOptions.variables; + + const convertedExpressions: Map = + selectablesToMap(aliasedExpressions); + + const internalOptions: InternalDefineStageOptions = { + ...options, + variables: convertedExpressions, + }; + + return this._addStage(new Define(internalOptions)); + } + + /** + * @beta + * Converts this Pipeline into an expression that evaluates to an array of results. + * + *

Result Unwrapping:

+ *
    + *
  • If the items have a single field, their values are unwrapped and returned directly in the array.
  • + *
  • If the items have multiple fields, they are returned as objects in the array
  • + *
+ * + * @example + * ```typescript + * // Get a list of reviewers for each book + * db.pipeline().collection("books") + * .define(field("id").as("book_id")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("book_id").equal(variable("book_id"))) + * .select(field("reviewer")) + * .toArrayExpression() + * .as("reviewers") + * ) + * ``` + * + * Output: + * ```json + * [ + * { + * "id": "1", + * "title": "1984", + * "reviewers": ["Alice", "Bob"] + * } + * ] + * ``` + * + * Multiple Fields: + * ```typescript + * // Get a list of reviews (reviewer and rating) for each book + * db.pipeline().collection("books") + * .define(field("id").as("book_id")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("book_id").equal(variable("book_id"))) + * .select(field("reviewer"), field("rating")) + * .toArrayExpression() + * .as("reviews")) + * ``` + * + * Output: + * ```json + * [ + * { + * "id": "1", + * "title": "1984", + * "reviews": [ + * { "reviewer": "Alice", "rating": 5 }, + * { "reviewer": "Bob", "rating": 4 } + * ] + * } + * ] + * ``` + * + * @returns An `Expression` representing the execution of this pipeline. + */ + toArrayExpression(): firestore.Pipelines.Expression { + return new FunctionExpression('array', [fieldOrExpression(this)]); + } + + /** + * @beta + * Converts this Pipeline into an expression that evaluates to a single scalar result. + * + *

Runtime Validation: The runtime validates that the result set contains zero or one item. If + * zero items, it evaluates to `null`.

+ * + *

Result Unwrapping:

+ *
    + *
  • If the item has a single field, its value is unwrapped and returned directly.
  • + *
  • f the item has multiple fields, they are returned as an object.
  • + *
+ * + * @example + * ```typescript + * // Calculate average rating for a restaurant + * db.pipeline().collection("restaurants").addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate(average("rating").as("avg")) + * // Unwraps the single "avg" field to a scalar double + * .toScalarExpression().as("average_rating") + * ) + * ``` + * + * Output: + * ```json + * { + * "name": "The Burger Joint", + * "average_rating": 4.5 + * } + * ``` + * + * Multiple Fields: + * ```typescript + * // Calculate average rating AND count for a restaurant + * db.pipeline().collection("restaurants").addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate( + * average("rating").as("avg"), + * count().as("count") + * ) + * // Returns an object with "avg" and "count" fields + * .toScalarExpression().as("stats") + * ) + * ``` + * + * Output: + * ```json + * { + * "name": "The Burger Joint", + * "stats": { + * "avg": 4.5, + * "count": 100 + * } + * } + * ``` + * + * @returns An `Expression` representing the execution of this pipeline. + */ + toScalarExpression(): firestore.Pipelines.Expression { + return new FunctionExpression('scalar', [fieldOrExpression(this)]); + } + /** * @beta * Selects or creates a set of fields from the outputs of previous stages. @@ -1645,18 +1860,16 @@ export class Pipeline implements firestore.Pipelines.Pipeline { return util.stream(structuredPipeline, undefined); } - _toProto(): api.IPipeline { - if (!this.db) { + _toProto(serializer?: Serializer): api.IPipeline { + const resolvedSerializer = serializer || this.db?._serializer; + if (!resolvedSerializer) { throw new Error( 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', ); } - const stages: IStage[] = this.stages.map( - // We use a non-null assertion here because we've already checked that - // 'db' is not null at the start of this function, but TS does not - // recognize that 'db' can no longer be undefined. - stage => stage._toProto(this.db!._serializer!), + const stages: IStage[] = this.stages.map(stage => + stage._toProto(resolvedSerializer), ); return {stages}; } @@ -2058,3 +2271,35 @@ export class PipelineResult implements firestore.Pipelines.PipelineResult { ); } } + +/** + * @beta + * Creates a new Pipeline targeted at a subcollection relative to the current document context. + * This creates a pipeline without a database instance, suitable for embedding as a subquery. + * If executed directly, this pipeline will fail. + * + * @param path - The relative path to the subcollection. + */ +export function subcollection(path: string): Pipeline; +/** + * @beta + * Creates a new Pipeline targeted at a subcollection relative to the current document context. + * + * @param options - Options defining how this SubcollectionStage is evaluated. + */ +export function subcollection( + options: firestore.Pipelines.SubcollectionStageOptions, +): Pipeline; +export function subcollection( + pathOrOptions: string | firestore.Pipelines.SubcollectionStageOptions, +): Pipeline { + const options = isString(pathOrOptions) ? {} : pathOrOptions; + const path = isString(pathOrOptions) ? pathOrOptions : pathOrOptions.path; + + const internalOptions: InternalSubcollectionStageOptions = { + ...options, + path, + }; + + return new Pipeline(undefined, [new SubcollectionSource(internalOptions)]); +} diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 0c7d5d40a0c..aacb09b941d 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -723,6 +723,72 @@ export class Sort implements Stage { } } +/** + * Internal options for Define stage. + */ +export type InternalDefineStageOptions = Omit< + firestore.Pipelines.DefineStageOptions, + 'variables' +> & { + variables: Map; +}; + +/** + * Define stage. + */ +export class Define implements Stage { + name = 'let'; + readonly optionsUtil = new OptionsUtil({}); + + constructor(private options: InternalDefineStageOptions) {} + + _toProto(serializer: Serializer): api.Pipeline.IStage { + return { + name: this.name, + args: [serializer.encodeValue(this.options.variables)!], + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), + }; + } + + _validateUserData(ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.options.variables, ignoreUndefinedProperties); + } +} + +/** + * Internal options for Subcollection stage. + */ +export type InternalSubcollectionStageOptions = + firestore.Pipelines.SubcollectionStageOptions; + +/** + * Subcollection stage. + */ +export class SubcollectionSource implements Stage { + name = 'subcollection'; + readonly optionsUtil = new OptionsUtil({}); + + constructor(private options: InternalSubcollectionStageOptions) {} + + _toProto(serializer: Serializer): api.Pipeline.IStage { + return { + name: this.name, + args: [serializer.encodeValue(this.options.path)!], + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), + }; + } + + _validateUserData(_ignoreUndefinedProperties: boolean): void {} +} + /** * Raw stage. */ diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 8377424c8d2..0acb0b072dd 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -150,6 +150,9 @@ import { split, switchOn, nor, + variable, + currentDocument, + subcollection, // TODO(new-expression): add new expression imports above this line } from '../src/pipelines'; @@ -5625,6 +5628,774 @@ describe.skipClassic('Pipeline class', () => { }); }); }); + + describe('subquery', () => { + async function withSubqueryData( + data: {[path: string]: DocumentData}, + fn: () => Promise, + ): Promise { + const refs: DocumentReference[] = []; + try { + await Promise.all( + Object.entries(data).map(async ([path, docData]) => { + const ref = firestore.doc(path); + await ref.set(docData); + refs.push(ref); + }), + ); + return await fn(); + } finally { + await Promise.all(refs.map(r => r.delete())); + } + } + + it('zero result scalar returns null', async () => { + const testDocs = { + [`${randomCol.path}/book1`]: {title: 'A Book Title'}, + }; + + await withSubqueryData(testDocs, async () => { + const emptyScalar = firestore + .pipeline() + .collection(`${randomCol.path}/book1/reviews`) + .where(equal('reviewer', 'Alice')) + .select(currentDocument().as('data')); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .select(emptyScalar.toScalarExpression().as('firstReviewData')) + .limit(1) + .execute(); + + expectResults(results, {firstReviewData: null}); + }); + }); + + it('array subquery join and empty result', async () => { + const reviewsCollName = `book_reviews_${Date.now()}`; + const reviewsDocs = { + [`${reviewsCollName}/r1`]: { + bookTitle: "The Hitchhiker's Guide to the Galaxy", + reviewer: 'Alice', + }, + [`${reviewsCollName}/r2`]: { + bookTitle: "The Hitchhiker's Guide to the Galaxy", + reviewer: 'Bob', + }, + }; + + await withSubqueryData(reviewsDocs, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .select(field('reviewer').as('reviewer')) + .sort(field('reviewer').ascending()); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .where( + or( + equal('title', "The Hitchhiker's Guide to the Galaxy"), + equal('title', 'Pride and Prejudice'), + ), + ) + .define(field('title').as('bookTitle')) + .addFields(reviewsSub.toArrayExpression().as('reviewsData')) + .select('title', 'reviewsData') + .sort(field('title').descending()) + .execute(); + + expectResults( + results, + {title: 'Pride and Prejudice', reviewsData: []}, + { + title: "The Hitchhiker's Guide to the Galaxy", + reviewsData: ['Alice', 'Bob'], + }, + ); + }); + }); + + it('multiple array subqueries', async () => { + const reviewsCollectionName = `reviews_multi_${Date.now()}`; + const authorsCollectionName = `authors_multi_${Date.now()}`; + + const data = { + [`${reviewsCollectionName}/r1`]: { + bookTitle: '1984', + rating: 5, + }, + [`${authorsCollectionName}/a1`]: { + authorName: 'George Orwell', + nationality: 'British', + }, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollectionName) + .where(equal('bookTitle', variable('bookTitle'))) + .select(field('rating').as('rating')); + + const authorsSub = firestore + .pipeline() + .collection(authorsCollectionName) + .where(equal('authorName', variable('authorName'))) + .select(field('nationality').as('nationality')); + + const snapshot = await firestore + .pipeline() + .collection(randomCol.path) + .where(equal('title', '1984')) + .define( + field('title').as('bookTitle'), + field('author').as('authorName'), + ) + .addFields( + reviewsSub.toArrayExpression().as('reviewsData'), + authorsSub.toArrayExpression().as('authorsData'), + ) + .select('title', 'reviewsData', 'authorsData') + .execute(); + + expectResults(snapshot, { + title: '1984', + reviewsData: [5], + authorsData: ['British'], + }); + }); + }); + + it('array subquery join multiple fields preserves map', async () => { + const reviewsCollName = `reviews_map_${Date.now()}`; + + const data = { + [`${reviewsCollName}/r1`]: { + bookTitle: '1984', + reviewer: 'Alice', + rating: 5, + }, + [`${reviewsCollName}/r2`]: { + bookTitle: '1984', + reviewer: 'Bob', + rating: 4, + }, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .select( + field('reviewer').as('reviewer'), + field('rating').as('rating'), + ) + .sort(field('reviewer').ascending()); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .where(equal('title', '1984')) + .define(field('title').as('bookTitle')) + .addFields(reviewsSub.toArrayExpression().as('reviewsData')) + .select('title', 'reviewsData') + .execute(); + + expectResults(results, { + title: '1984', + reviewsData: [ + {reviewer: 'Alice', rating: 5}, + {reviewer: 'Bob', rating: 4}, + ], + }); + }); + }); + + it('array subquery in where stage on books', async () => { + const reviewsCollName = `reviews_where_${Date.now()}`; + + const data = { + [`${reviewsCollName}/r1`]: { + bookTitle: 'Dune', + reviewer: 'Paul', + }, + [`${reviewsCollName}/r2`]: { + bookTitle: 'Foundation', + reviewer: 'Hari', + }, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .select(field('reviewer').as('reviewer')); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .where(or(equal('title', 'Dune'), equal('title', 'The Great Gatsby'))) + .define(field('title').as('bookTitle')) + .where(reviewsSub.toArrayExpression().arrayContains('Paul')) + .select('title') + .execute(); + + expectResults(results, {title: 'Dune'}); + }); + }); + + it('scalar subquery single aggregation unwrapping', async () => { + const reviewsCollName = `reviews_agg_single_${Date.now()}`; + + const data = { + [`${reviewsCollName}/r1`]: {bookTitle: '1984', rating: 4}, + [`${reviewsCollName}/r2`]: {bookTitle: '1984', rating: 5}, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .aggregate(average('rating').as('val')); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .where(equal('title', '1984')) + .define(field('title').as('bookTitle')) + .addFields(reviewsSub.toScalarExpression().as('averageRating')) + .select('title', 'averageRating') + .execute(); + + expectResults(results, {title: '1984', averageRating: 4.5}); + }); + }); + + it('scalar subquery multiple aggregations map wrapping', async () => { + const reviewsCollName = `reviews_agg_multi_${Date.now()}`; + + const data = { + [`${reviewsCollName}/r1`]: {bookTitle: '1984', rating: 4}, + [`${reviewsCollName}/r2`]: {bookTitle: '1984', rating: 5}, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .aggregate(average('rating').as('avg'), countAll().as('count')); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .where(equal('title', '1984')) + .define(field('title').as('bookTitle')) + .addFields(reviewsSub.toScalarExpression().as('stats')) + .select('title', 'stats') + .execute(); + + expectResults(results, { + title: '1984', + stats: {avg: 4.5, count: 2}, + }); + }); + }); + + it('scalar subquery zero results', async () => { + const reviewsCollName = `reviews_zero_${Date.now()}`; + + // No reviews for "1984" + + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .aggregate(average('rating').as('avg')); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .where(equal('title', '1984')) + .define(field('title').as('bookTitle')) + .addFields(reviewsSub.toScalarExpression().as('averageRating')) + .select('title', 'averageRating') + .execute(); + + expectResults(results, {title: '1984', averageRating: null}); + }); + + it('scalar subquery multiple results runtime error', async () => { + const reviewsCollName = `reviews_multiple_${Date.now()}`; + + const data = { + [`${reviewsCollName}/r1`]: {bookTitle: '1984', rating: 4}, + [`${reviewsCollName}/r2`]: {bookTitle: '1984', rating: 5}, + }; + + await withSubqueryData(data, async () => { + // This subquery will return 2 documents, which is invalid for toScalarExpression() + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))); + + await expect( + firestore + .pipeline() + .collection(randomCol.path) + .where(equal('title', '1984')) + .define(field('title').as('bookTitle')) + .addFields(reviewsSub.toScalarExpression().as('reviewData')) + .execute(), + ).to.be.rejectedWith(/Subpipeline returned multiple results/); + }); + }); + + it('mixed scalar and array subqueries', async () => { + const reviewsCollName = `reviews_mixed_${Date.now()}`; + + const data = { + [`${reviewsCollName}/r1`]: { + bookTitle: '1984', + reviewer: 'Alice', + rating: 4, + }, + [`${reviewsCollName}/r2`]: { + bookTitle: '1984', + reviewer: 'Bob', + rating: 5, + }, + }; + + await withSubqueryData(data, async () => { + const arraySub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .select(field('reviewer').as('reviewer')) + .sort(field('reviewer').ascending()); + + const scalarSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookTitle', variable('bookTitle'))) + .aggregate(average('rating').as('val')); + + const results = await firestore + .pipeline() + .collection(randomCol.path) + .where(equal('title', '1984')) + .define(field('title').as('bookTitle')) + .addFields( + arraySub.toArrayExpression().as('allReviewers'), + scalarSub.toScalarExpression().as('averageRating'), + ) + .select('title', 'allReviewers', 'averageRating') + .execute(); + + expectResults(results, { + title: '1984', + allReviewers: ['Alice', 'Bob'], + averageRating: 4.5, + }); + }); + }); + + it('single scope variable usage', async () => { + const collName = `single_scope_${Date.now()}`; + + await withSubqueryData({[`${collName}/doc1`]: {price: 100}}, async () => { + let results = await firestore + .pipeline() + .collection(collName) + .define(field('price').multiply(0.8).as('discount')) + .where(variable('discount').lessThan(50.0)) + .select('price') + .execute(); + + expect(results.results).to.be.empty; + + const doc2Ref = firestore.doc(`${collName}/doc2`); + await doc2Ref.set({price: 50}); + + try { + results = await firestore + .pipeline() + .collection(collName) + .define(field('price').multiply(0.8).as('discount')) + .where(variable('discount').lessThan(50.0)) + .select('price') + .execute(); + + expectResults(results, {price: 50}); + } finally { + await doc2Ref.delete(); + } + }); + }); + + it('explicit field binding scope bridging', async () => { + const outerCollName = `outer_scope_${Date.now()}`; + const reviewsCollName = `reviews_scope_${Date.now()}`; + + const data = { + [`${outerCollName}/doc1`]: {title: '1984', id: '1'}, + [`${reviewsCollName}/r1`]: {bookId: '1', reviewer: 'Alice'}, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookId', variable('rid'))) + .select(field('reviewer').as('reviewer')); + + const results = await firestore + .pipeline() + .collection(outerCollName) + .where(equal('title', '1984')) + .define(field('id').as('rid')) + .addFields(reviewsSub.toArrayExpression().as('reviews')) + .select('title', 'reviews') + .execute(); + + expectResults(results, {title: '1984', reviews: ['Alice']}); + }); + }); + + it('multiple variable bindings', async () => { + const outerCollName = `outer_multi_${Date.now()}`; + const reviewsCollName = `reviews_multi_${Date.now()}`; + + const data = { + [`${outerCollName}/doc1`]: { + title: '1984', + id: '1', + category: 'sci-fi', + }, + [`${reviewsCollName}/r1`]: { + bookId: '1', + category: 'sci-fi', + reviewer: 'Alice', + }, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where( + and( + equal('bookId', variable('rid')), + equal('category', variable('rcat')), + ), + ) + .select(field('reviewer').as('reviewer')); + + const results = await firestore + .pipeline() + .collection(outerCollName) + .where(equal('title', '1984')) + .define(field('id').as('rid'), field('category').as('rcat')) + .addFields(reviewsSub.toArrayExpression().as('reviews')) + .select('title', 'reviews') + .execute(); + + expectResults(results, {title: '1984', reviews: ['Alice']}); + }); + }); + + it('current document binding', async () => { + const outerCollName = `outer_currentdoc_${Date.now()}`; + const reviewsCollName = `reviews_currentdoc_${Date.now()}`; + + const data = { + [`${outerCollName}/doc1`]: {title: '1984', author: 'George Orwell'}, + [`${reviewsCollName}/r1`]: { + authorName: 'George Orwell', + reviewer: 'Alice', + }, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('authorName', variable('doc').getField('author'))) + .select(field('reviewer').as('reviewer')); + + const results = await firestore + .pipeline() + .collection(outerCollName) + .where(equal('title', '1984')) + .define(currentDocument().as('doc')) + .addFields(reviewsSub.toArrayExpression().as('reviews')) + .select('title', 'reviews') + .execute(); + + expectResults(results, {title: '1984', reviews: ['Alice']}); + }); + }); + + it('unbound variable corner case', async () => { + const outerCollName = `outer_unbound_${Date.now()}`; + + try { + await firestore + .pipeline() + .collection(outerCollName) + .where(equal('title', variable('unknownVar'))) + .execute(); + expect.fail('Should have thrown an error'); + } catch (e: unknown) { + expect(e instanceof Error).to.be.true; + const err = e as Error; + expect(err.message).to.match(/unknown variable/i); + } + }); + + it('variable shadowing collision', async () => { + const outerCollName = `outer_shadow_${Date.now()}`; + const innerCollName = `inner_shadow_${Date.now()}`; + + const data = { + [`${outerCollName}/doc1`]: {title: '1984'}, + [`${innerCollName}/i1`]: {id: 'test'}, + }; + + await withSubqueryData(data, async () => { + const sub = firestore + .pipeline() + .collection(innerCollName) + .define(constant('inner_val').as('x')) + .select(variable('x').as('val')); + + const results = await firestore + .pipeline() + .collection(outerCollName) + .where(equal('title', '1984')) + .limit(1) + .define(constant('outer_val').as('x')) + .addFields(sub.toArrayExpression().as('shadowed')) + .select('shadowed') + .execute(); + + expectResults(results, {shadowed: ['inner_val']}); + }); + }); + + it('missing field on current document', async () => { + const outerCollName = `outer_missing_${Date.now()}`; + const reviewsCollName = `reviews_missing_${Date.now()}`; + + const data = { + [`${outerCollName}/doc1`]: {title: '1984'}, + [`${reviewsCollName}/r1`]: {bookId: '1', reviewer: 'Alice'}, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where(equal('bookId', variable('doc').getField('doesNotExist'))) + .select(field('reviewer').as('reviewer')); + + const results = await firestore + .pipeline() + .collection(outerCollName) + .where(equal('title', '1984')) + .define(currentDocument().as('doc')) + .addFields(reviewsSub.toArrayExpression().as('reviews')) + .select('title', 'reviews') + .execute(); + + expectResults(results, {title: '1984', reviews: []}); + }); + }); + + it('3 level deep join', async () => { + const publishersCollName = `publishers_${Date.now()}`; + const booksCollName = `books_${Date.now()}`; + const reviewsCollName = `reviews_${Date.now()}`; + + const data = { + [`${publishersCollName}/p1`]: {publisherId: 'pub1', name: 'Penguin'}, + [`${booksCollName}/b1`]: { + bookId: 'book1', + publisherId: 'pub1', + title: '1984', + }, + [`${reviewsCollName}/r1`]: {bookId: 'book1', reviewer: 'Alice'}, + }; + + await withSubqueryData(data, async () => { + const reviewsSub = firestore + .pipeline() + .collection(reviewsCollName) + .where( + and( + equal('bookId', variable('bookId')), + equal(variable('pubName'), 'Penguin'), + ), + ) + .select(field('reviewer').as('reviewer')); + + const booksSub = firestore + .pipeline() + .collection(booksCollName) + .where(equal('publisherId', variable('pubId'))) + .define(field('bookId').as('bookId')) + .addFields(reviewsSub.toArrayExpression().as('reviews')) + .select('title', 'reviews'); + + const results = await firestore + .pipeline() + .collection(publishersCollName) + .where(equal('publisherId', 'pub1')) + .define(field('publisherId').as('pubId'), field('name').as('pubName')) + .addFields(booksSub.toArrayExpression().as('books')) + .select('name', 'books') + .execute(); + + expectResults(results, { + name: 'Penguin', + books: [{title: '1984', reviews: ['Alice']}], + }); + }); + }); + + it('deep aggregation', async () => { + const outerColl = `outer_agg_${Date.now()}`; + const innerColl = `inner_agg_${Date.now()}`; + + const data = { + [`${outerColl}/doc1`]: {id: '1'}, + [`${outerColl}/doc2`]: {id: '2'}, + [`${innerColl}/i1`]: {outerId: '1', score: 10}, + [`${innerColl}/i2`]: {outerId: '2', score: 20}, + [`${innerColl}/i3`]: {outerId: '1', score: 30}, + }; + + await withSubqueryData(data, async () => { + const innerSub = firestore + .pipeline() + .collection(innerColl) + .where(equal('outerId', variable('oid'))) + .aggregate(average('score').as('s')); + + const results = await firestore + .pipeline() + .collection(outerColl) + .define(field('id').as('oid')) + .addFields(innerSub.toScalarExpression().as('docScore')) + .aggregate(sum('docScore').as('totalScore')) + .execute(); + + expectResults(results, {totalScore: 40.0}); + }); + }); + + it('pipeline stage support 9 layers', async () => { + const collName = `depth_${Date.now()}`; + + await withSubqueryData( + {[`${collName}/doc1`]: {val: 'hello'}}, + async () => { + let currentSubquery = firestore + .pipeline() + .collection(collName) + .limit(1) + .select(field('val').as('val')); + + for (let i = 0; i < 8; i++) { + currentSubquery = firestore + .pipeline() + .collection(collName) + .limit(1) + .addFields(currentSubquery.toArrayExpression().as(`nested_${i}`)) + .select(`nested_${i}`); + } + + const results = await currentSubquery.execute(); + expect(results.results.length).to.be.greaterThan(0); + }, + ); + }); + + it('standard subcollection query', async () => { + const collName = `subcoll_test_${Date.now()}`; + + const doc1Ref = firestore.doc(`${collName}/doc1`); + await doc1Ref.set({title: '1984'}); + + const r1Ref = firestore.doc(`${collName}/doc1/reviews/r1`); + await r1Ref.set({reviewer: 'Alice'}); + + const reviewsSub = subcollection('reviews').select( + field('reviewer').as('reviewer'), + ); + + const results = await firestore + .pipeline() + .collection(collName) + .where(equal('title', '1984')) + .addFields(reviewsSub.toArrayExpression().as('reviews')) + .select('title', 'reviews') + .execute(); + + expectResults(results, { + title: '1984', + reviews: ['Alice'], + }); + + await Promise.all([doc1Ref.delete(), r1Ref.delete()]); + }); + + it('missing subcollection', async () => { + const collName = `subcoll_missing_${Date.now()}`; + const doc1Ref = firestore.doc(`${collName}/doc1`); + + await doc1Ref.set({id: 'no_subcollection_here'}); + + const missingSub = subcollection('doesNotExist').select( + variable('p').as('subP'), + ); + + const results = await firestore + .pipeline() + .collection(collName) + .define(currentDocument().as('p')) + .select(missingSub.toArrayExpression().as('missingData')) + .limit(1) + .execute(); + + expectResults(results, {missingData: []}); + + await doc1Ref.delete(); + }); + + it('direct execution of subcollection pipeline', async () => { + const sub = subcollection('reviews'); + + try { + await sub.execute(); + } catch (e: unknown) { + expect(e instanceof Error); + const error: Error = e as Error; + expect(error.message).to.equal( + 'This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline.', + ); + } + }); + }); }); // This is the Query integration tests from the lite API (no cache support) diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 6b88278d4e4..78423e11cbc 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -3198,7 +3198,9 @@ declare namespace FirebaseFirestore { | 'Function' | 'AggregateFunction' | 'ListOfExprs' - | 'AliasedExpression'; + | 'AliasedExpression' + | 'Variable' + | 'PipelineValue'; /** * @beta * Represents an expression that can be evaluated to a value within the execution of a {@link @@ -4605,6 +4607,20 @@ declare namespace FirebaseFirestore { * @returns A new `Expression` representing the entries of the map. */ mapEntries(): FunctionExpression; + /** + * @beta + * Creates an expression that returns the value of a field from the document that results from the evaluation of this expression. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * field("address").getField("city") + * ``` + * + * @param key The field to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ + getField(key: string | Expression): Expression; /** * @beta * Creates an aggregation that counts the number of stage inputs with valid evaluations of the @@ -9695,6 +9711,110 @@ declare namespace FirebaseFirestore { */ export function mapEntries(mapExpression: Expression): FunctionExpression; + /** + * @beta + * Creates an expression that returns the value of a field from a document that results from the evaluation of the expression. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * getField(field("address"), "city") + * ``` + * + * @param expression The expression representing the document. + * @param key The field to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ + export function getField(expression: Expression, key: string): Expression; + /** + * @beta + * Creates an expression that returns the value of a field from a document that results from the evaluation of the expression. + * + * @example + * ```typescript + * // Get the value of the key resulting from the "addressField" variable in the "address" document. + * getField(field("address", variable("addressField")), + * ``` + * + * @param expression The expression representing the document. + * @param keyExpr The expression representing the key to access in the document. + * @returns A new `Expression` representing the value of the field in the document. + */ + export function getField( + expression: Expression, + keyExpr: Expression, + ): Expression; + /** + * @beta + * Creates an expression that returns the value of a field from the document with the given field name. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * getField("address", "city") + * ``` + * + * @param fieldName The field to access in the document. + * @param key The key to access. + * @returns A new `Expression` representing the value of the field in the document. + */ + export function getField(fieldName: string, key: string): Expression; + /** + * @beta + * Creates an expression that returns the value of a field from the document with the given field name. + * + * @example + * ```typescript + * // Get the value of the "city" field in the "address" document. + * getField("address", variable("addressField")) + * ``` + * + * @param fieldName The field to access in the document. + * @param keyExpr The key expression to access. + * @returns A new `Expression` representing the value of the field in the document. + */ + export function getField( + fieldName: string, + keyExpr: Expression, + ): Expression; + + /** + * @beta + * Creates an expression that retrieves the value of a variable bound via `define()`. + * + * @example + * ```typescript + * db.pipeline().collection("products") + * .define( + * field("price").multiply(0.9).as("discountedPrice"), + * field("stock").add(10).as("newStock") + * ) + * .where(variable("discountedPrice").lessThan(100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param name - The name of the variable to retrieve. + * @returns An `Expression` representing the variable's value. + */ + export function variable(name: string): Expression; + + /** + * @beta + * Creates an expression that represents the current document being processed. + * + * @example + * ```typescript + * // Define the current document as a variable "doc" + * firestore.pipeline().collection("books") + * .define(currentDocument().as("doc")) + * // Access a field from the defined document variable + * .select(variable("doc").mapGet("title")); + * ``` + * + * @returns An `Expression` representing the current document. + */ + export function currentDocument(): Expression; + /** * @beta * Creates an aggregation that counts the total number of stage inputs. @@ -11589,6 +11709,26 @@ declare namespace FirebaseFirestore { */ createFrom(query: Query): Pipeline; } + + /** + * @beta + * Creates a new Pipeline targeted at a subcollection relative to the current document context. + * This creates a pipeline without a database instance, suitable for embedding as a subquery. + * If executed directly, this pipeline will fail. + * + * @param path - The relative path to the subcollection. + * @returns A new `Pipeline` object configured to read from the specified subcollection. + */ + export function subcollection(path: string): Pipeline; + /** + * @beta + * Creates a new Pipeline targeted at a subcollection relative to the current document context. + * + * @param options - Options defining how this SubcollectionStage is evaluated. + * @returns A new `Pipeline` object configured to read from the specified subcollection. + */ + export function subcollection(options: SubcollectionStageOptions): Pipeline; + /** * @beta * The Pipeline class provides a flexible and expressive framework for building complex data @@ -11736,6 +11876,186 @@ declare namespace FirebaseFirestore { */ removeFields(options: RemoveFieldsStageOptions): Pipeline; + /** + * @beta + * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a + * variable for internal reuse within the pipeline body (accessed via the `variable()` function). + * + * This stage is useful for declaring reusable values or intermediate calculations that can be + * referenced multiple times in later parts of the pipeline. + * + * @example + * ```typescript + * db.pipeline().collection("products") + * .define( + * field("price").multiply(0.9).as("discountedPrice"), + * field("stock").add(10).as("newStock") + * ) + * .where(variable("discountedPrice").lessThan(100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param aliasedExpression - The first expression to bind to a variable. + * @param additionalExpressions - Optional additional expression to bind to a variable. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + define( + aliasedExpression: AliasedExpression, + ...additionalExpressions: AliasedExpression[] + ): Pipeline; + /** + * @beta + * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a + * variable for internal reuse within the pipeline body (accessed via the `variable()` function). + * + * This stage is useful for declaring reusable values or intermediate calculations that can be + * referenced multiple times in later parts of the pipeline. + * + * @example + * ```typescript + * db.pipeline().collection("products") + * .define( + * field("price").multiply(0.9).as("discountedPrice"), + * field("stock").add(10).as("newStock") + * ) + * .where(variable("discountedPrice").lessThan(100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param options - An object that specifies required and optional parameters for the stage. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + define(options: DefineStageOptions): Pipeline; + + /** + * @beta + * Converts this Pipeline into an expression that evaluates to an array of results. + * + *

Result Unwrapping:

+ *
    + *
  • If the items have a single field, their values are unwrapped and returned directly in the array.
  • + *
  • If the items have multiple fields, they are returned as objects in the array
  • + *
+ * + * @example + * ```typescript + * // Get a list of reviewers for each book + * db.pipeline().collection("books") + * .define(field("id").as("book_id")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("book_id").equal(variable("book_id"))) + * .select(field("reviewer")) + * .toArrayExpression() + * .as("reviewers") + * ) + * ``` + * + * Output: + * ```json + * [ + * { + * "id": "1", + * "title": "1984", + * "reviewers": ["Alice", "Bob"] + * } + * ] + * ``` + * + * Multiple Fields: + * ```typescript + * // Get a list of reviews (reviewer and rating) for each book + * db.pipeline().collection("books") + * .define(field("id").as("book_id")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("book_id").equal(variable("book_id"))) + * .select(field("reviewer"), field("rating")) + * .toArrayExpression() + * .as("reviews")) + * ``` + * + * Output: + * ```json + * [ + * { + * "id": "1", + * "title": "1984", + * "reviews": [ + * { "reviewer": "Alice", "rating": 5 }, + * { "reviewer": "Bob", "rating": 4 } + * ] + * } + * ] + * ``` + * + * @returns An `Expression` representing the execution of this pipeline. + */ + toArrayExpression(): Expression; + + /** + * @beta + * Converts this Pipeline into an expression that evaluates to a single scalar result. + * + *

Runtime Validation: The runtime validates that the result set contains zero or one item. If + * zero items, it evaluates to `null`.

+ * + *

Result Unwrapping:

+ *
    + *
  • If the item has a single field, its value is unwrapped and returned directly.
  • + *
  • f the item has multiple fields, they are returned as an object.
  • + *
+ * + * @example + * ```typescript + * // Calculate average rating for a restaurant + * db.pipeline().collection("restaurants").addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate(average("rating").as("avg")) + * // Unwraps the single "avg" field to a scalar double + * .toScalarExpression().as("average_rating") + * ) + * ``` + * + * Output: + * ```json + * { + * "name": "The Burger Joint", + * "average_rating": 4.5 + * } + * ``` + * + * Multiple Fields: + * ```typescript + * // Calculate average rating AND count for a restaurant + * db.pipeline().collection("restaurants").addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate( + * average("rating").as("avg"), + * count().as("count") + * ) + * // Returns an object with "avg" and "count" fields + * .toScalarExpression().as("stats") + * ) + * ``` + * + * Output: + * ```json + * { + * "name": "The Burger Joint", + * "stats": { + * "avg": 4.5, + * "count": 100 + * } + * } + * ``` + * + * @returns An `Expression` representing the execution of this pipeline. + */ + toScalarExpression(): Expression; + /** * @beta * Selects or creates a set of fields from the outputs of previous stages. @@ -12650,6 +12970,31 @@ declare namespace FirebaseFirestore { */ forceIndex?: string; }; + + /** + * @beta + * Options defining how a SubcollectionStage is evaluated. + */ + export type SubcollectionStageOptions = StageOptions & { + /** + * @beta + * The relative path to the subcollection. + */ + path: string; + }; + + /** + * @beta + * Options defining how a DefineStage is evaluated. See {@link Pipeline.define}. + */ + export type DefineStageOptions = StageOptions & { + /** + * @beta + * The variables to define. + */ + variables: AliasedExpression[]; + }; + /** * @beta * Options defining how a DatabaseStage is evaluated. See {@link PipelineSource.database}. From b0e829aeb968cd0c6ec9d51a07356c9e1f36947b Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Tue, 24 Mar 2026 10:47:15 -0400 Subject: [PATCH 10/10] fix docs --- .../firestore/dev/src/pipelines/expression.ts | 12 +++++++---- .../firestore/dev/src/pipelines/pipelines.ts | 15 +++++++------- handwritten/firestore/types/firestore.d.ts | 20 +++++++++---------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index 037432a9fc2..89333902c91 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -10265,6 +10265,7 @@ export function isType( * getField(field("address"), "city") * ``` * + * @param expression The expression representing the document. * @param key The field to access in the document. * @returns A new `Expression` representing the value of the field in the document. */ @@ -10276,10 +10277,11 @@ export function getField(expression: Expression, key: string): Expression; * @example * ```typescript * // Get the value of the key resulting from the "addressField" variable in the "address" document. - * getField(field("address", variable("addressField")), + * getField(field("address"), variable("addressField")) * ``` * - * @param key The expression representing the key to access in the document. + * @param expression The expression representing the document. + * @param keyExpr The expression representing the key to access in the document. * @returns A new `Expression` representing the value of the field in the document. */ export function getField( @@ -10296,7 +10298,8 @@ export function getField( * getField("address", "city") * ``` * - * @param key The field to access in the document. + * @param fieldName The name of the field containing the map/document. + * @param key The key to access. * @returns A new `Expression` representing the value of the field in the document. */ export function getField(fieldName: string, key: string): Expression; @@ -10310,7 +10313,8 @@ export function getField(fieldName: string, key: string): Expression; * getField("address", variable("addressField")) * ``` * - * @param key The field to access in the document. + * @param fieldName The name of the field containing the map/document. + * @param keyExpr The key expression to access. * @returns A new `Expression` representing the value of the field in the document. */ export function getField(fieldName: string, keyExpr: Expression): Expression; diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 3e713d61425..f3a2af010e4 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -536,7 +536,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * ``` * * @param aliasedExpression - The first expression to bind to a variable. - * @param additionalExpressions - Optional additional expression to bind to a variable. + * @param additionalExpressions - Optional additional expressions to bind to a variable. * @returns A new Pipeline object with this stage appended to the stage list. */ define( @@ -599,7 +599,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { *

Result Unwrapping:

*
    *
  • If the items have a single field, their values are unwrapped and returned directly in the array.
  • - *
  • If the items have multiple fields, they are returned as objects in the array
  • + *
  • If the items have multiple fields, they are returned as objects in the array.
  • *
* * @example @@ -613,7 +613,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * .select(field("reviewer")) * .toArrayExpression() * .as("reviewers") - * ) + * ); * ``` * * Output: @@ -637,7 +637,8 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * .where(field("book_id").equal(variable("book_id"))) * .select(field("reviewer"), field("rating")) * .toArrayExpression() - * .as("reviews")) + * .as("reviews") + * ); * ``` * * Output: @@ -670,7 +671,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { *

Result Unwrapping:

*
    *
  • If the item has a single field, its value is unwrapped and returned directly.
  • - *
  • f the item has multiple fields, they are returned as an object.
  • + *
  • If the item has multiple fields, they are returned as an object.
  • *
* * @example @@ -682,7 +683,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * .aggregate(average("rating").as("avg")) * // Unwraps the single "avg" field to a scalar double * .toScalarExpression().as("average_rating") - * ) + * ); * ``` * * Output: @@ -705,7 +706,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * ) * // Returns an object with "avg" and "count" fields * .toScalarExpression().as("stats") - * ) + * ); * ``` * * Output: diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 78423e11cbc..d885d6c1da8 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -9733,7 +9733,7 @@ declare namespace FirebaseFirestore { * @example * ```typescript * // Get the value of the key resulting from the "addressField" variable in the "address" document. - * getField(field("address", variable("addressField")), + * getField(field("address"), variable("addressField")) * ``` * * @param expression The expression representing the document. @@ -9754,7 +9754,7 @@ declare namespace FirebaseFirestore { * getField("address", "city") * ``` * - * @param fieldName The field to access in the document. + * @param fieldName The name of the field containing the map/document. * @param key The key to access. * @returns A new `Expression` representing the value of the field in the document. */ @@ -9769,7 +9769,7 @@ declare namespace FirebaseFirestore { * getField("address", variable("addressField")) * ``` * - * @param fieldName The field to access in the document. + * @param fieldName The name of the field containing the map/document. * @param keyExpr The key expression to access. * @returns A new `Expression` representing the value of the field in the document. */ @@ -11896,7 +11896,7 @@ declare namespace FirebaseFirestore { * ``` * * @param aliasedExpression - The first expression to bind to a variable. - * @param additionalExpressions - Optional additional expression to bind to a variable. + * @param additionalExpressions - Optional additional expressions to bind to a variable. * @returns A new Pipeline object with this stage appended to the stage list. */ define( @@ -11934,7 +11934,7 @@ declare namespace FirebaseFirestore { *

Result Unwrapping:

*
    *
  • If the items have a single field, their values are unwrapped and returned directly in the array.
  • - *
  • If the items have multiple fields, they are returned as objects in the array
  • + *
  • If the items have multiple fields, they are returned as objects in the array.
  • *
* * @example @@ -11947,7 +11947,7 @@ declare namespace FirebaseFirestore { * .where(field("book_id").equal(variable("book_id"))) * .select(field("reviewer")) * .toArrayExpression() - * .as("reviewers") + * .as("reviewers"); * ) * ``` * @@ -11972,7 +11972,7 @@ declare namespace FirebaseFirestore { * .where(field("book_id").equal(variable("book_id"))) * .select(field("reviewer"), field("rating")) * .toArrayExpression() - * .as("reviews")) + * .as("reviews")); * ``` * * Output: @@ -12003,7 +12003,7 @@ declare namespace FirebaseFirestore { *

Result Unwrapping:

*
    *
  • If the item has a single field, its value is unwrapped and returned directly.
  • - *
  • f the item has multiple fields, they are returned as an object.
  • + *
  • If the item has multiple fields, they are returned as an object.
  • *
* * @example @@ -12015,7 +12015,7 @@ declare namespace FirebaseFirestore { * .aggregate(average("rating").as("avg")) * // Unwraps the single "avg" field to a scalar double * .toScalarExpression().as("average_rating") - * ) + * ); * ``` * * Output: @@ -12038,7 +12038,7 @@ declare namespace FirebaseFirestore { * ) * // Returns an object with "avg" and "count" fields * .toScalarExpression().as("stats") - * ) + * ); * ``` * * Output: