diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index 8a32713c36b..89333902c91 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,190 @@ 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 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 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; +/** + * @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 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; +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..f3a2af010e4 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,216 @@ 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 expressions 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:

+ * + * + * @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:

+ * + * + * @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 +1861,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 +2272,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..d885d6c1da8 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 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; + /** + * @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 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; + + /** + * @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 expressions 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.
  • + *
  • If 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}.