From 57931d3988f6ac1a3f0c189fb9c5bc082371637c Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:31:49 +0000 Subject: [PATCH 1/8] feat(firestore): implement pipeline window functions API --- .../firestore/api-report/firestore.api.md | 68 +- .../firestore/dev/src/pipelines/expression.ts | 436 +++++ .../firestore/dev/src/pipelines/index.ts | 5 + .../dev/src/pipelines/pipeline-util.ts | 127 +- .../firestore/dev/src/pipelines/pipelines.ts | 152 ++ .../firestore/dev/src/pipelines/stage.ts | 51 + .../firestore/dev/system-test/pipeline.ts | 1489 +++++++++++++++++ .../dev/test/pipelines/add-window-fields.ts | 952 +++++++++++ .../firestore/dev/test/pipelines/stage.ts | 55 + handwritten/firestore/types/firestore.d.ts | 372 ++++ 10 files changed, 3677 insertions(+), 30 deletions(-) create mode 100644 handwritten/firestore/dev/test/pipelines/add-window-fields.ts diff --git a/handwritten/firestore/api-report/firestore.api.md b/handwritten/firestore/api-report/firestore.api.md index aea5fb00ba1a..418c26e9e425 100644 --- a/handwritten/firestore/api-report/firestore.api.md +++ b/handwritten/firestore/api-report/firestore.api.md @@ -68,6 +68,7 @@ class AggregateFunction implements AggregateFunction, HasUserData { _createdFromLiteral: boolean; // (undocumented) expressionType: firestore.Pipelines.ExpressionType; + over(window?: firestore.Pipelines.WindowSpec): WindowFunction; // (undocumented) _protoValueType: "ProtoValue"; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration @@ -170,6 +171,27 @@ class AliasedExpression implements firestore.Pipelines.Selectable, HasUserData { _validateUserData(ignoreUndefinedProperties: boolean): void; } +// @public +class AliasedWindowFunction implements firestore.Pipelines.AliasedWindowFunction, HasUserData { + constructor(windowFunction: WindowFunction, alias: string); + // (undocumented) + readonly alias: string; + // @internal (undocumented) + readonly _alias: string; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // + // @internal + _createdFromLiteral: boolean; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration + // + // @internal (undocumented) + _validateUserData(ignoreUndefinedProperties: boolean): void; + // (undocumented) + readonly windowFunction: WindowFunction; + // @internal (undocumented) + readonly _windowFunction: WindowFunction; +} + // @public function and(first: BooleanExpression, second: BooleanExpression, ...more: BooleanExpression[]): BooleanExpression; @@ -721,6 +743,9 @@ export const DEFAULT_MAX_IDLE_CHANNELS = 1; // @public export const DEFAULT_MAX_TRANSACTION_ATTEMPTS = 5; +// @public +function denseRank(): WindowFunction; + // @public function descending(expr: Expression): Ordering; @@ -1999,6 +2024,8 @@ class Pipeline implements firestore.Pipelines.Pipeline { addFields(field: firestore.Pipelines.Selectable, ...additionalFields: firestore.Pipelines.Selectable[]): Pipeline; // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag addFields(options: firestore.Pipelines.AddFieldsStageOptions): Pipeline; + addWindowFields(window: firestore.Pipelines.WindowSpec, field: firestore.Pipelines.AliasedAggregate | firestore.Pipelines.AliasedWindowFunction, ...additionalFields: Array): Pipeline; + addWindowFields(options: firestore.Pipelines.AddWindowFieldsStageOptions): Pipeline; aggregate(accumulator: firestore.Pipelines.AliasedAggregate, ...additionalAccumulators: firestore.Pipelines.AliasedAggregate[]): Pipeline; aggregate(options: firestore.Pipelines.AggregateStageOptions): Pipeline; define(aliasedExpression: firestore.Pipelines.AliasedExpression, ...additionalExpressions: firestore.Pipelines.AliasedExpression[]): Pipeline; @@ -2277,7 +2304,12 @@ declare namespace Pipelines { coalesce, documentMatches, score, - geoDistance + geoDistance, + WindowFunction, + AliasedWindowFunction, + rank, + denseRank, + rowNumber } } export { Pipelines } @@ -2619,6 +2651,9 @@ export class QuerySnapshot + isString(value) ? field(value) : (value as Expression), + ); + this.sort = + sort === undefined + ? [] + : Array.isArray(sort) + ? (sort as Ordering[]) + : [sort as Ordering]; + this.documents = window.documents; + this.range = window.range; + } + + /** + * @private + * @internal + */ + _toProto(serializer: Serializer): api.IValue { + const fields: Record = {}; + + // Empty `partition` and `sort` keys are omitted rather than sent as empty + // arrays, matching what the backend emits. + if (this.partition.length > 0) { + fields.partition = { + arrayValue: {values: this.partition.map(p => p._toProto(serializer))}, + }; + } + if (this.sort.length > 0) { + fields.sort = { + arrayValue: {values: this.sort.map(o => o._toProto(serializer))}, + }; + } + if (this.documents !== undefined) { + fields.documents = this.frameToProto(serializer, this.documents); + } + if (this.range !== undefined) { + fields.range = this.frameToProto(serializer, this.range); + } + + return {mapValue: {fields}}; + } + + /** + * @private + * @internal + */ + _validateUserData(ignoreUndefinedProperties: boolean): void { + this.partition.forEach(p => p._validateUserData(ignoreUndefinedProperties)); + this.sort.forEach(o => o._validateUserData(ignoreUndefinedProperties)); + + for (const frame of [this.documents, this.range]) { + if (frame === undefined) { + continue; + } + if (isExpr(frame.preceding)) { + (frame.preceding as Expression)._validateUserData( + ignoreUndefinedProperties, + ); + } + if (isExpr(frame.following)) { + (frame.following as Expression)._validateUserData( + ignoreUndefinedProperties, + ); + } + const unit = (frame as firestore.Pipelines.RangeWindowFrame).unit; + if (unit !== undefined && isExpr(unit)) { + (unit as Expression)._validateUserData(ignoreUndefinedProperties); + } + } + } + + /** + * Serializes a window frame, i.e. the value keyed by `documents` or `range` + * in a window specification. + */ + private frameToProto( + serializer: Serializer, + frame: + | firestore.Pipelines.DocumentWindowFrame + | firestore.Pipelines.RangeWindowFrame, + ): api.IValue { + const fields: Record = {}; + + // Bounds are only sent when supplied. The backend defaults an absent bound + // to the current document. + if (frame.preceding !== undefined) { + fields.preceding = this.frameBoundToProto( + serializer, + frame.preceding, + 'preceding', + ); + } + if (frame.following !== undefined) { + fields.following = this.frameBoundToProto( + serializer, + frame.following, + 'following', + ); + } + + // `unit` is only meaningful on a `range` frame, but it is encoded wherever + // it is supplied so that the backend reports the error. + const unit = (frame as firestore.Pipelines.RangeWindowFrame).unit; + if (unit !== undefined) { + fields.unit = isString(unit) + ? serializer.encodeValue(unit)! + : (unit as Expression)._toProto(serializer); + } + + return {mapValue: {fields}}; + } + + /** + * Serializes a single window frame bound. + */ + private frameBoundToProto( + serializer: Serializer, + bound: WindowFrameBound, + boundName: 'preceding' | 'following', + ): api.IValue { + if (typeof bound === 'number') { + return serializer.encodeValue(bound)!; + } + // Sentinel strings are passed through as-is rather than being checked + // against a known set, so that the backend can add new sentinels without an + // SDK update. Unknown values are rejected by the backend. + if (isString(bound)) { + return serializer.encodeValue(bound)!; + } + if (isExpr(bound)) { + return (bound as Expression)._toProto(serializer); + } + // Validated client side only because there is no way to encode a value of + // an unknown type into a proto `Value`. + throw new Error( + `Invalid window frame '${boundName}' bound. Expected a number, ` + + `'current', 'unbounded', or an Expression.`, + ); + } + + _protoValueType = 'ProtoValue' as const; +} + +/** + * A function that is evaluated over a window frame of documents, as part of an + * `Pipeline.addWindowFields()` stage. + * + * A `WindowFunction` is created either by one of the dedicated window function + * factories (such as {@link rank}), or by calling + * `over()` on an {@link AggregateFunction}. + */ +export class WindowFunction + implements firestore.Pipelines.WindowFunction, HasUserData +{ + expressionType: firestore.Pipelines.ExpressionType = 'WindowFunction'; + + /** + * @internal + * @private + * Indicates if this expression was created from a literal value passed + * by the caller. + */ + _createdFromLiteral = false; + + /** + * The accumulator level window this function is evaluated over. When + * `undefined`, the window of the enclosing stage is used. + */ + private window?: WindowSpecInternal; + + constructor( + private name: string, + private params: Expression[] = [], + ) {} + + /** + * @internal + * @private + */ + static _create( + name: string, + params: Expression[], + window?: WindowSpecInternal, + ): WindowFunction { + const wf = new WindowFunction(name, params); + wf.window = window; + + return wf; + } + + /** + * Evaluates this window function over a specific window frame, rather than + * over the window frame defined by the enclosing + * `Pipeline.addWindowFields()` stage. + * + * Today the backend only accepts a frame (`documents` or `range`) here, and + * rejects `partition` and `sort`, which must be specified on the enclosing + * `addWindowFields()` stage. + * + * @param window - A `WindowSpec` containing + * the `documents` or `range` frame to evaluate this function over. If + * omitted, the enclosing stage's frame is used. + * @returns A new `WindowFunction`. + */ + over(window?: firestore.Pipelines.WindowSpec): WindowFunction { + return WindowFunction._create( + this.name, + this.params, + window === undefined ? undefined : new WindowSpecInternal(window), + ); + } + + /** + * Assigns an alias to this `WindowFunction`. The alias specifies the name that + * the computed value will have in the output document. + * + * @param name - The alias to assign to this `WindowFunction`. + * @returns A new `AliasedWindowFunction`. + */ + as(name: string): AliasedWindowFunction { + return new AliasedWindowFunction(this, name); + } + + /** + * @private + * @internal + */ + _toProto(serializer: Serializer): api.IValue { + const functionValue: api.IValue = { + functionValue: { + name: this.name, + args: this.params.map(p => p._toProto(serializer)), + }, + }; + + if (this.window === undefined) { + return functionValue; + } + + return { + functionValue: { + name: 'over', + args: [functionValue, this.window._toProto(serializer)], + }, + }; + } + + _protoValueType = 'ProtoValue' as const; + + /** + * @private + * @internal + */ + _validateUserData(ignoreUndefinedProperties: boolean): void { + this.params.forEach(expr => { + return expr._validateUserData(ignoreUndefinedProperties); + }); + this.window?._validateUserData(ignoreUndefinedProperties); + } +} + +/** + * A `WindowFunction` with an alias. + */ +export class AliasedWindowFunction + implements firestore.Pipelines.AliasedWindowFunction, HasUserData +{ + /** + * @internal + */ + readonly _windowFunction: WindowFunction; + + /** + * @internal + */ + readonly _alias: string; + + constructor( + readonly windowFunction: WindowFunction, + readonly alias: string, + ) { + this._windowFunction = windowFunction; + this._alias = alias; + } + + /** + * @internal + * @private + * Indicates if this expression was created from a literal value passed + * by the caller. + */ + _createdFromLiteral = false; + + /** + * @private + * @internal + */ + _validateUserData(ignoreUndefinedProperties: boolean): void { + this.windowFunction._validateUserData(ignoreUndefinedProperties); + } +} + +/** + * Creates a window function that computes the rank of the current document + * within its window frame. Documents that compare equal in the window sort + * order receive the same rank, and the next rank is offset by the number of + * tied documents. + * + * @example + * ```typescript + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { partition: ['department'], sort: descending('salary') }, + * rank().as('salaryRank') + * ); + * ``` + * + * @returns A new `WindowFunction`. + */ +export function rank(): WindowFunction { + return WindowFunction._create('rank', []); +} + +/** + * Creates a window function that computes the rank of the current document + * within its window frame, without gaps in the ranking sequence. Documents that + * compare equal in the window sort order receive the same rank, and the next + * rank is always incremented by one. + * + * @example + * ```typescript + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { partition: ['department'], sort: descending('salary') }, + * denseRank().as('salaryRank') + * ); + * ``` + * + * @returns A new `WindowFunction`. + */ +export function denseRank(): WindowFunction { + return WindowFunction._create('dense_rank', []); +} + +/** + * Creates a window function that computes the sequential position of the + * current document within its window frame, starting at 1. Documents that + * compare equal in the window sort order receive distinct row numbers. + * + * @example + * ```typescript + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { partition: ['department'], sort: descending('salary') }, + * rowNumber().as('salaryRowNumber') + * ); + * ``` + * + * @returns A new `WindowFunction`. + */ +export function rowNumber(): WindowFunction { + return WindowFunction._create('row_number', []); +} diff --git a/handwritten/firestore/dev/src/pipelines/index.ts b/handwritten/firestore/dev/src/pipelines/index.ts index 2a0ab092bf73..7dfb6bab2996 100644 --- a/handwritten/firestore/dev/src/pipelines/index.ts +++ b/handwritten/firestore/dev/src/pipelines/index.ts @@ -172,5 +172,10 @@ export { documentMatches, score, geoDistance, + WindowFunction, + AliasedWindowFunction, + rank, + denseRank, + rowNumber, // 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 bac75f1d3460..5a5ef66559dc 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -18,7 +18,27 @@ import {Duplex, Transform} from 'stream'; import {google} from '../../protos/firestore_v1_proto_api'; import * as protos from '../../protos/firestore_v1_proto_api'; -import './expression'; +import { + Expression, + BooleanExpression, + and, + or, + field as createField, + constant, + map, + array, + Constant, + field, + Ordering, + greaterThan, + lessThan, + Field, + AggregateFunction, + AliasedWindowFunction, + WindowFunction, + pipelineValue, + AliasedExpression, +} from './expression'; import Firestore, { CollectionReference, DocumentReference, @@ -49,25 +69,6 @@ import { } from '../util'; import api = protos.google.firestore.v1; -import { - Expression, - BooleanExpression, - and, - or, - field as createField, - constant, - map, - array, - Constant, - field, - Ordering, - greaterThan, - lessThan, - Field, - AggregateFunction, - pipelineValue, - AliasedExpression, -} from './expression'; import {Pipeline, PipelineResult, ExplainStats} from './pipelines'; import {StructuredPipeline} from './structured-pipeline'; import Selectable = FirebaseFirestore.Pipelines.Selectable; @@ -598,6 +599,24 @@ export function isAliasedAggregate( ); } +/** + * Returns `true` if the given value is an `AliasedWindowFunction`. + * + * @internal + * @private + */ +export function isAliasedWindowFunction( + val: unknown, +): val is firestore.Pipelines.AliasedWindowFunction { + const candidate = val as firestore.Pipelines.AliasedWindowFunction; + return ( + candidate !== undefined && + candidate !== null && + isString(candidate.alias) && + candidate.windowFunction instanceof WindowFunction + ); +} + export function isExpr(val: unknown): val is firestore.Pipelines.Expression { return val instanceof Expression; } @@ -767,22 +786,72 @@ export function selectablesToObject( return result; } +/** + * Converts a list of aliased aggregate functions, and/or aliased window + * functions, into a map of output field name to the function that computes it. + */ +export function aliasedAggregateToMap( + aliasedAggregates: firestore.Pipelines.AliasedAggregate[], +): Map; export function aliasedAggregateToMap( - aliasedAggregatees: firestore.Pipelines.AliasedAggregate[], -): Map { - return aliasedAggregatees.reduce( + aliasedAggregates: Array< + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction + >, +): Map; +export function aliasedAggregateToMap( + aliasedAggregates: Array< + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction + >, +): Map { + return aliasedAggregates.reduce( ( - map: Map, - selectable: firestore.Pipelines.AliasedAggregate, + map: Map, + aliased: + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction, ) => { - if (map.get(selectable._alias) !== undefined) { - throw new Error(`Duplicate alias or field '${selectable._alias}'`); + const alias = isAliasedWindowFunction(aliased) + ? aliased.alias + : aliased._alias; + // Validated client side because a duplicate alias cannot be encoded: the + // second entry would silently overwrite the first in the map, and the + // backend would never see it. + if (map.get(alias) !== undefined) { + throw new Error(`Duplicate alias or field '${alias}'`); } - map.set(selectable._alias, selectable._aggregate as AggregateFunction); + map.set( + alias, + isAliasedWindowFunction(aliased) + ? (aliased.windowFunction as WindowFunction) + : (aliased._aggregate as AggregateFunction), + ); return map; }, - new Map() as Map, + new Map() as Map, + ); +} + +/** + * Discriminates between the two `Pipeline.addWindowFields()` overloads. + * + * @private + * @internal + */ +export function isAddWindowFieldsStageOptions( + value: + | firestore.Pipelines.WindowSpec + | firestore.Pipelines.AddWindowFieldsStageOptions, +): value is firestore.Pipelines.AddWindowFieldsStageOptions { + const candidate = value as firestore.Pipelines.AddWindowFieldsStageOptions; + return ( + typeof candidate === 'object' && + candidate !== null && + typeof candidate.window === 'object' && + candidate.window !== null && + Array.isArray(candidate.fields) ); } diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 924d597722f9..b93fdde3cb75 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -27,6 +27,7 @@ import { ExecutionUtil, aliasedAggregateToMap, fieldOrExpression, + isAddWindowFieldsStageOptions, isAliasedAggregate, isAliasedExpr, isBooleanExpr, @@ -60,6 +61,7 @@ import { Field, BooleanExpression, Ordering, + WindowSpecInternal, constant, _mapValue, field, @@ -68,6 +70,7 @@ import { } from './expression'; import { AddFields, + AddWindowFields, Aggregate, CollectionSource, CollectionGroupSource, @@ -88,6 +91,7 @@ import { Union, Unnest, DeleteStage, + InternalAddWindowFieldsStageOptions, InternalWhereStageOptions, InternalOffsetStageOptions, InternalLimitStageOptions, @@ -451,6 +455,154 @@ export class Pipeline implements firestore.Pipelines.Pipeline { return this._addStage(new AddFields(internalOptions)); } + /** + * Adds window function results to the output documents of the pipeline. + * + * Window functions evaluate expressions over a subset of documents (a "window frame") relative to the + * current document. This is similar to SQL window functions or MongoDB's `$setWindowFields` stage. + * + * @example + * ```typescript + * // 1. Unsorted partition/group aggregation (evaluates over the entire group) + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { + * partition: ['department'] + * }, + * average(field('salary')).as('departmentAverageSalary') + * ); + * + * // 2. Document-based moving average with explicit boundaries + * firestore.pipeline().collection("sales") + * .addWindowFields( + * { + * sort: ascending('date'), + * documents: { preceding: 1, following: 1 } + * }, + * average(field('amount')).as('movingAverageAmount') + * ); + * + * // 3. Document-based running total using default boundaries (unbounded preceding to current row). + * // Note: Offsets are physical document counts, so no time unit is required or used even when sorting on 'date'. + * firestore.pipeline().collection("sales") + * .addWindowFields( + * { + * sort: ascending('date'), + * documents: { preceding: 'unbounded', following: 'current' } + * }, + * sum(field('amount')).as('runningTotal') + * ); + * + * // 4. Range-based running average using default boundaries (unbounded preceding to current value) + * firestore.pipeline().collection("products") + * .addWindowFields( + * { + * sort: ascending('price'), + * range: { preceding: 'unbounded', following: 'current' } + * }, + * average(field('rating')).as('cumulativeAvgRating') + * ); + * + * // 5. Range-based date/time window with a time unit (cumulative sales over the last 30 days) + * firestore.pipeline().collection("sales") + * .addWindowFields( + * { + * sort: ascending('date'), + * range: { preceding: 30, following: 'current', unit: 'day' } + * }, + * sum(field('amount')).as('thirtyDayCumulativeSales') + * ); + * ``` + * + * @param window - The specification defining how documents are partitioned, ordered, and bounded in the window frame. + * @param field - The first window field to add, specified as an `AliasedAggregate` or `AliasedWindowFunction`. + * @param additionalFields - Optional additional window fields to add to the documents. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + addWindowFields( + window: firestore.Pipelines.WindowSpec, + field: + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction, + ...additionalFields: Array< + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction + > + ): Pipeline; + + /** + * Adds window function results to the output documents of the pipeline using options. + * + * @example + * ```typescript + * // 1. Unsorted partition/group aggregation using options + * firestore.pipeline().collection("employees") + * .addWindowFields({ + * window: { + * partition: ['department'] + * }, + * fields: [ + * average(field('salary')).as('departmentAverageSalary') + * ] + * }); + * + * // 2. Document-based moving average using options + * firestore.pipeline().collection("sales") + * .addWindowFields({ + * window: { + * sort: ascending('date'), + * documents: { preceding: 1, following: 1 } + * }, + * fields: [ + * average(field('amount')).as('movingAverageAmount') + * ] + * }); + * ``` + * + * @param options - An object specifying the window frame configuration and the fields to add. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + addWindowFields( + options: firestore.Pipelines.AddWindowFieldsStageOptions, + ): Pipeline; + addWindowFields( + windowOrOptions: + | firestore.Pipelines.WindowSpec + | firestore.Pipelines.AddWindowFieldsStageOptions, + field?: + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction, + ...additionalFields: Array< + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction + > + ): Pipeline { + // Process argument union(s) from method overloads + const usesOptions = isAddWindowFieldsStageOptions(windowOrOptions); + // The whole options object is forwarded, including `window` and `fields`. + // That is safe because `OptionsUtil` only serializes keys declared in the + // stage's option definitions, and this stage declares none. + const options: firestore.Pipelines.StageOptions = usesOptions + ? windowOrOptions + : {}; + const window: firestore.Pipelines.WindowSpec = usesOptions + ? windowOrOptions.window + : windowOrOptions; + const fields: Array< + | firestore.Pipelines.AliasedAggregate + | firestore.Pipelines.AliasedWindowFunction + > = usesOptions ? windowOrOptions.fields : [field!, ...additionalFields]; + + const internalOptions: InternalAddWindowFieldsStageOptions = { + ...options, + window: new WindowSpecInternal(window), + fields: aliasedAggregateToMap(fields), + }; + + // Add stage to the pipeline + return this._addStage(new AddWindowFields(internalOptions)); + } + /** * Remove fields from outputs of previous stages. * diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index c1153a03ad7e..5e42208a34d5 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -27,6 +27,8 @@ import { Field, field, Ordering, + WindowFunction, + WindowSpecInternal, } from './expression'; import {OptionsUtil} from './options-util'; import {CollectionReference} from '../reference/collection-reference'; @@ -688,6 +690,55 @@ export class AddFields implements Stage { } } +/** + * Internal options for AddWindowFields stage. + */ +export type InternalAddWindowFieldsStageOptions = Omit< + firestore.Pipelines.AddWindowFieldsStageOptions, + 'window' | 'fields' +> & { + window: WindowSpecInternal; + fields: Map; +}; + +/** + * Implementation of the `add_window_fields` stage. + * + * Serializes to a stage with exactly two arguments: + * 1. The `window_spec` map, containing the optional `partition` array, the + * optional `sort` array, and at most one of the `documents` or `range` + * frame maps. + * 2. The `fields` map, mapping each output field name to either a bare + * aggregate function value (stage level or default framing), or an + * `over(aggregate, frame)` function value (accumulator level framing). + */ +export class AddWindowFields implements Stage { + name = 'add_window_fields'; + readonly optionsUtil = new OptionsUtil({}); + + constructor(private options: InternalAddWindowFieldsStageOptions) {} + + _toProto(serializer: Serializer): api.Pipeline.IStage { + return { + name: this.name, + args: [ + this.options.window._toProto(serializer), + serializer.encodeValue(this.options.fields)!, + ], + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), + }; + } + + _validateUserData(ignoreUndefinedProperties: boolean): void { + this.options.window._validateUserData(ignoreUndefinedProperties); + validateUserDataHelper(this.options.fields, ignoreUndefinedProperties); + } +} + /** * Internal options for Sort stage. */ diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 9ae5bcd51596..091e50987cce 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -162,6 +162,9 @@ import { variable, currentDocument, subcollection, + denseRank, + rank, + rowNumber, // TODO(new-expression): add new expression imports above this line } from '../src/pipelines'; @@ -8458,3 +8461,1489 @@ describe.skipClassic('Query to Pipeline', () => { ); }); }); + +/** + * Test data for the window function suites below. + * + * The sales are deliberately constructed so that: + * - `product` and `region` give overlapping, non-nested partitions, + * - `salesPrice` has ties (30 and 60 both appear twice), which exercises + * `range` framing and ranking peers, and + * - `date` is strictly increasing and has an 11 day gap before the final sale, + * which exercises time based `range` framing. + */ +const windowTestDocs: {[id: string]: DocumentData} = { + sale1: { + product: 'phone', + region: 'east', + salesPrice: 12, + quantity: 1, + date: Timestamp.fromDate(new Date('2026-07-01T12:00:00Z')), + }, + sale2: { + product: 'phone', + region: 'west', + salesPrice: 30, + quantity: 2, + date: Timestamp.fromDate(new Date('2026-07-02T12:00:00Z')), + }, + sale3: { + product: 'tablet', + region: 'east', + salesPrice: 30, + quantity: 3, + date: Timestamp.fromDate(new Date('2026-07-02T18:00:00Z')), + }, + sale4: { + product: 'tablet', + region: 'west', + salesPrice: 60, + quantity: 4, + date: Timestamp.fromDate(new Date('2026-07-04T12:00:00Z')), + }, + sale5: { + product: 'tablet', + region: 'east', + salesPrice: 60, + quantity: 5, + date: Timestamp.fromDate(new Date('2026-07-15T12:00:00Z')), + }, +}; + +describe.skipClassic('Pipeline window functions (count)', () => { + let firestore: Firestore; + let productSales: CollectionReference; + + async function execute(ppl: Pipeline): Promise { + return ppl.execute(); + } + + before(async () => { + productSales = getTestRoot(); + await testCollectionWithDocs(productSales, windowTestDocs); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore as unknown as InternalFirestore)); + + describe('partitioning', () => { + it('treats an empty window spec as a single global window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields({}, count('quantity').as('windowCount')) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 5}, + {product: 'phone', windowCount: 5}, + {product: 'tablet', windowCount: 5}, + {product: 'tablet', windowCount: 5}, + {product: 'tablet', windowCount: 5}, + ); + }); + + it('partitions by a single field name', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + + it('partitions by multiple fields', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product', 'region']}, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'region', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', region: 'east', windowCount: 1}, + {product: 'phone', region: 'west', windowCount: 1}, + {product: 'tablet', region: 'east', windowCount: 2}, + {product: 'tablet', region: 'west', windowCount: 1}, + {product: 'tablet', region: 'east', windowCount: 2}, + ); + }); + + it('partitions by an expression', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: [toUpper('region')]}, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('region', 'windowCount'), + ); + + expectResults( + snapshot, + {region: 'east', windowCount: 3}, + {region: 'west', windowCount: 2}, + {region: 'east', windowCount: 3}, + {region: 'west', windowCount: 2}, + {region: 'east', windowCount: 3}, + ); + }); + }); + + describe('sorting and default framing', () => { + it('computes a running count with the default (sorted) frame', async () => { + // The implicit default frame is a `range` frame, so the sort key must be + // numeric. `quantity` is 1..5 in the same order as `date`, and has no + // ties, so the running count is unambiguous. + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('quantity')}, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1}, + {product: 'phone', runningCount: 2}, + {product: 'tablet', runningCount: 3}, + {product: 'tablet', runningCount: 4}, + {product: 'tablet', runningCount: 5}, + ); + }); + + it('computes a running count within each partition', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product'], sort: ascending('quantity')}, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1}, + {product: 'phone', runningCount: 2}, + {product: 'tablet', runningCount: 1}, + {product: 'tablet', runningCount: 2}, + {product: 'tablet', runningCount: 3}, + ); + }); + + it('honors a descending sort', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: descending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 5}, + {product: 'phone', runningCount: 4}, + {product: 'tablet', runningCount: 3}, + {product: 'tablet', runningCount: 2}, + {product: 'tablet', runningCount: 1}, + ); + }); + + it('honors multiple sort orderings', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: [ascending('product'), ascending('date')], + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1}, + {product: 'phone', runningCount: 2}, + {product: 'tablet', runningCount: 3}, + {product: 'tablet', runningCount: 4}, + {product: 'tablet', runningCount: 5}, + ); + }); + }); + + describe('documents framing', () => { + it("supports 'unbounded' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 4}, + {product: 'tablet', windowCount: 5}, + ); + }); + + it("supports 'unbounded' preceding to 'unbounded' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + + it("supports 'current' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'current', following: 'current'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); + }); + + describe('range framing', () => { + // In a `range` frame the `'current'` sentinel resolves to the current + // document only. A numeric `0` offset is what widens the frame to every + // document with an equal sort value (the SQL "peer group"). + it("counts only the current document with 'current' to 'current'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('salesPrice'), + range: {preceding: 'current', following: 'current'}, + }, + count('quantity').as('samePriceCount'), + ) + .sort(ascending('date')) + .select('product', 'salesPrice', 'samePriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', salesPrice: 12, samePriceCount: 1}, + {product: 'phone', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 1}, + ); + }); + + it('counts tied peers with a zero offset on both bounds', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('salesPrice'), + range: {preceding: 0, following: 0}, + }, + count('quantity').as('samePriceCount'), + ) + .sort(ascending('date')) + .select('product', 'salesPrice', 'samePriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', salesPrice: 12, samePriceCount: 1}, + {product: 'phone', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + ); + }); + + it("includes tied peers with 'unbounded' preceding to a zero offset", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: 0}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('salesPrice', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {salesPrice: 12, cumulativeCount: 1}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 60, cumulativeCount: 5}, + {salesPrice: 60, cumulativeCount: 5}, + ); + }); + + it("supports 'unbounded' to 'unbounded'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: 'unbounded'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + + it('supports a time unit on a date sorted range frame', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + range: { + preceding: 'unbounded', + following: 'current', + unit: 'day', + }, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('product', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {product: 'phone', cumulativeCount: 1}, + {product: 'phone', cumulativeCount: 2}, + {product: 'tablet', cumulativeCount: 3}, + {product: 'tablet', cumulativeCount: 4}, + {product: 'tablet', cumulativeCount: 5}, + ); + }); + }); + + describe('accumulator level framing', () => { + it('evaluates each accumulator over its own frame', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product'], sort: ascending('date')}, + count('quantity') + .over({ + documents: {preceding: 'unbounded', following: 'current'}, + }) + .as('runningCount'), + count('quantity') + .over({ + documents: {preceding: 'unbounded', following: 'unbounded'}, + }) + .as('partitionCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount', 'partitionCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1, partitionCount: 2}, + {product: 'phone', runningCount: 2, partitionCount: 2}, + {product: 'tablet', runningCount: 1, partitionCount: 3}, + {product: 'tablet', runningCount: 2, partitionCount: 3}, + {product: 'tablet', runningCount: 3, partitionCount: 3}, + ); + }); + + it('supports a range frame in over()', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product'], sort: ascending('salesPrice')}, + count('quantity') + .over({range: {preceding: 0, following: 0}}) + .as('samePriceCount'), + ) + .sort(ascending('date')) + .select('product', 'salesPrice', 'samePriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', salesPrice: 12, samePriceCount: 1}, + {product: 'phone', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + ); + }); + }); + + describe('output fields and composition', () => { + it('supports multiple output fields in a single stage', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + count('quantity').as('productCountCopy'), + ) + .sort(ascending('date')) + .select('product', 'productCount', 'productCountCopy'), + ); + + expectResults( + snapshot, + {product: 'phone', productCount: 2, productCountCopy: 2}, + {product: 'phone', productCount: 2, productCountCopy: 2}, + {product: 'tablet', productCount: 3, productCountCopy: 3}, + {product: 'tablet', productCount: 3, productCountCopy: 3}, + {product: 'tablet', productCount: 3, productCountCopy: 3}, + ); + }); + + it('supports nested output field paths', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('stats.productCount'), + ) + .sort(ascending('date')) + .select('product', 'stats'), + ); + + expectResults( + snapshot, + {product: 'phone', stats: {productCount: 2}}, + {product: 'phone', stats: {productCount: 2}}, + {product: 'tablet', stats: {productCount: 3}}, + {product: 'tablet', stats: {productCount: 3}}, + {product: 'tablet', stats: {productCount: 3}}, + ); + }); + + it('supports chaining multiple addWindowFields stages', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + ) + .addWindowFields( + {partition: ['region']}, + count('quantity').as('regionCount'), + ) + .sort(ascending('date')) + .select('product', 'region', 'productCount', 'regionCount'), + ); + + expectResults( + snapshot, + { + product: 'phone', + region: 'east', + productCount: 2, + regionCount: 3, + }, + { + product: 'phone', + region: 'west', + productCount: 2, + regionCount: 2, + }, + { + product: 'tablet', + region: 'east', + productCount: 3, + regionCount: 3, + }, + { + product: 'tablet', + region: 'west', + productCount: 3, + regionCount: 2, + }, + { + product: 'tablet', + region: 'east', + productCount: 3, + regionCount: 3, + }, + ); + }); + + it('supports filtering on a window field in a later stage', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + ) + .where(field('productCount').greaterThan(2)) + .sort(ascending('date')) + .select('product', 'productCount'), + ); + + expectResults( + snapshot, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + ); + }); + + it('supports sorting on a window field in a later stage', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + ) + .sort(ascending('productCount'), ascending('date')) + .select('product', 'productCount'), + ); + + expectResults( + snapshot, + {product: 'phone', productCount: 2}, + {product: 'phone', productCount: 2}, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + ); + }); + }); + + describe('options overload', () => { + it('supports the options object signature', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales.path) + .addWindowFields({ + window: { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + fields: [ + count('quantity').as('runningCount'), + count('salesPrice').as('runningPriceCount'), + ], + }) + .sort(ascending('date')) + .select('product', 'runningCount', 'runningPriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1, runningPriceCount: 1}, + {product: 'phone', runningCount: 2, runningPriceCount: 2}, + {product: 'tablet', runningCount: 1, runningPriceCount: 1}, + {product: 'tablet', runningCount: 2, runningPriceCount: 2}, + {product: 'tablet', runningCount: 3, runningPriceCount: 3}, + ); + }); + }); + + describe('error handling', () => { + it('rejects a range frame without a sort', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {range: {preceding: 'unbounded', following: 'current'}}, + count('quantity').as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error, /range/i); + }); + + it('rejects mixing stage level and accumulator level framing', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity') + .over({ + documents: {preceding: 'unbounded', following: 'unbounded'}, + }) + .as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error); + }); + + it('rejects partially specified accumulator level framing', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('date')}, + count('quantity') + .over({ + documents: {preceding: 'unbounded', following: 'current'}, + }) + .as('framed'), + count('quantity').as('unframed'), + ), + ), + ).to.be.rejectedWith(Error); + }); + + // The following combinations are deliberately not validated client side. + // These tests assert that the backend does reject them, so that removing + // the SDK checks does not silently let a malformed pipeline through. + it("rejects specifying both 'documents' and 'range'", async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + documents: {preceding: 'unbounded', following: 'current'}, + range: {preceding: 'unbounded', following: 'current'}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + count('quantity').as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error, /documents.*range|range.*documents/i); + }); + + it('rejects an unrecognized frame bound', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('quantity'), + documents: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + preceding: 'infinite' as any, + following: 'current', + }, + }, + count('quantity').as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error); + }); + + it('rejects a partition supplied to an accumulator level over()', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('quantity')}, + count('quantity') + .over({ + partition: ['product'], + documents: {preceding: 'unbounded', following: 'current'}, + }) + .as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error, /unexpected field/i); + }); + + it("rejects a 'unit' on a documents frame", async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: { + preceding: 'unbounded', + following: 'current', + unit: 'day', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + count('quantity').as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error); + }); + }); +}); + +// TODO(b/…): Un-skip this suite once cl/977837857 (physical execution of the +// non-count window aggregators, plus the `preceding` offset sign fix) has been +// submitted and rolled out to Nightly. Until then these pipelines fail with +// `UnsupportedOperationException: Unsupported window aggregator: `. +// Flip `WINDOW_AGGREGATORS_SUPPORTED` to `true` to run them. +const WINDOW_AGGREGATORS_SUPPORTED = false; + +(WINDOW_AGGREGATORS_SUPPORTED ? describe.skipClassic : describe.skip)( + 'Pipeline window functions (aggregators)', + () => { + let firestore: Firestore; + let productSales: CollectionReference; + + async function execute(ppl: Pipeline): Promise { + return ppl.execute(); + } + + before(async () => { + productSales = getTestRoot(); + await testCollectionWithDocs(productSales, windowTestDocs); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore as unknown as InternalFirestore)); + + describe('numeric aggregators over a partition', () => { + it('computes sum, average, minimum and maximum', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('total'), + average('salesPrice').as('averagePrice'), + minimum('salesPrice').as('minimumPrice'), + maximum('salesPrice').as('maximumPrice'), + ) + .sort(ascending('date')) + .select( + 'product', + 'total', + 'averagePrice', + 'minimumPrice', + 'maximumPrice', + ), + ); + + expectResults( + snapshot, + { + product: 'phone', + total: 42, + averagePrice: 21, + minimumPrice: 12, + maximumPrice: 30, + }, + { + product: 'phone', + total: 42, + averagePrice: 21, + minimumPrice: 12, + maximumPrice: 30, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + ); + }); + + it('computes countIf and countDistinct', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countIf(field('salesPrice').greaterThan(20)).as('expensiveCount'), + countDistinct('salesPrice').as('distinctPriceCount'), + ) + .sort(ascending('date')) + .select('product', 'expensiveCount', 'distinctPriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', expensiveCount: 1, distinctPriceCount: 2}, + {product: 'phone', expensiveCount: 1, distinctPriceCount: 2}, + {product: 'tablet', expensiveCount: 3, distinctPriceCount: 2}, + {product: 'tablet', expensiveCount: 3, distinctPriceCount: 2}, + {product: 'tablet', expensiveCount: 3, distinctPriceCount: 2}, + ); + }); + + it('aggregates over a computed expression', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum(multiply(field('salesPrice'), field('quantity'))).as( + 'totalRevenue', + ), + ) + .sort(ascending('date')) + .select('product', 'totalRevenue'), + ); + + expectResults( + snapshot, + {product: 'phone', totalRevenue: 72}, + {product: 'phone', totalRevenue: 72}, + {product: 'tablet', totalRevenue: 630}, + {product: 'tablet', totalRevenue: 630}, + {product: 'tablet', totalRevenue: 630}, + ); + }); + }); + + describe('ordered aggregators', () => { + it('computes first and last over the whole partition', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + first('salesPrice').as('firstPrice'), + last('salesPrice').as('lastPrice'), + ) + .sort(ascending('date')) + .select('product', 'firstPrice', 'lastPrice'), + ); + + expectResults( + snapshot, + {product: 'phone', firstPrice: 12, lastPrice: 30}, + {product: 'phone', firstPrice: 12, lastPrice: 30}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + ); + }); + + it('computes arrayAgg and arrayAggDistinct', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + arrayAgg('salesPrice').as('allPrices'), + arrayAggDistinct('salesPrice').as('distinctPrices'), + ) + .sort(ascending('date')) + .select('product', 'allPrices', 'distinctPrices'), + ); + + expectResults( + snapshot, + { + product: 'phone', + allPrices: [12, 30], + distinctPrices: [12, 30], + }, + { + product: 'phone', + allPrices: [12, 30], + distinctPrices: [12, 30], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + ); + }); + + it('computes a running total', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + sum('salesPrice').as('runningTotal'), + ) + .sort(ascending('date')) + .select('product', 'runningTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', runningTotal: 12}, + {product: 'phone', runningTotal: 42}, + {product: 'tablet', runningTotal: 72}, + {product: 'tablet', runningTotal: 132}, + {product: 'tablet', runningTotal: 192}, + ); + }); + }); + + describe('numeric documents offsets', () => { + it('computes a centered moving average', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 1, following: 1}, + }, + average('salesPrice').as('movingAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'movingAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', movingAverage: 21, windowCount: 2}, + {product: 'phone', movingAverage: 24, windowCount: 3}, + {product: 'tablet', movingAverage: 40, windowCount: 3}, + {product: 'tablet', movingAverage: 50, windowCount: 3}, + {product: 'tablet', movingAverage: 60, windowCount: 2}, + ); + }); + + it('computes a trailing sum', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 2, following: 0}, + }, + sum('salesPrice').as('trailingTotal'), + ) + .sort(ascending('date')) + .select('product', 'trailingTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', trailingTotal: 12}, + {product: 'phone', trailingTotal: 42}, + {product: 'tablet', trailingTotal: 72}, + {product: 'tablet', trailingTotal: 120}, + {product: 'tablet', trailingTotal: 150}, + ); + }); + + it('computes a look ahead average with a negative preceding bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to "documents between 1 following and 2 + // following": the window is [index - preceding, index + + // following]. + sort: ascending('date'), + documents: {preceding: -1, following: 2}, + }, + average('salesPrice').as('lookAheadAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 30, windowCount: 2}, + {product: 'phone', lookAheadAverage: 45, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 1}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + ); + }); + }); + + describe('numeric and time range offsets', () => { + it('computes a value based range window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 20, following: 0}, + }, + sum('salesPrice').as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('salesPrice', 'nearbyTotal'), + ); + + expectResults( + snapshot, + {salesPrice: 12, nearbyTotal: 12}, + {salesPrice: 30, nearbyTotal: 72}, + {salesPrice: 30, nearbyTotal: 72}, + {salesPrice: 60, nearbyTotal: 120}, + {salesPrice: 60, nearbyTotal: 120}, + ); + }); + + it('computes a trailing 3 day total', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + range: {preceding: 3, following: 'current', unit: 'day'}, + }, + sum('salesPrice').as('threeDayTotal'), + ) + .sort(ascending('date')) + .select('product', 'threeDayTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', threeDayTotal: 12}, + {product: 'phone', threeDayTotal: 42}, + {product: 'tablet', threeDayTotal: 72}, + {product: 'tablet', threeDayTotal: 132}, + {product: 'tablet', threeDayTotal: 60}, + ); + }); + + it('computes a cumulative total with an unbounded date range', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + range: { + preceding: 'unbounded', + following: 'current', + unit: 'day', + }, + }, + sum('salesPrice').as('cumulativeTotal'), + ) + .sort(ascending('date')) + .select('product', 'cumulativeTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', cumulativeTotal: 12}, + {product: 'phone', cumulativeTotal: 42}, + {product: 'tablet', cumulativeTotal: 72}, + {product: 'tablet', cumulativeTotal: 132}, + {product: 'tablet', cumulativeTotal: 192}, + ); + }); + }); + + describe('accumulator level framing', () => { + it('evaluates each aggregate over a different frame', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('date')}, + sum('salesPrice') + .over({ + documents: {preceding: 'unbounded', following: 'current'}, + }) + .as('runningTotal'), + average('salesPrice') + .over({documents: {preceding: 1, following: 1}}) + .as('movingAverage'), + ) + .sort(ascending('date')) + .select('product', 'runningTotal', 'movingAverage'), + ); + + expectResults( + snapshot, + {product: 'phone', runningTotal: 12, movingAverage: 21}, + {product: 'phone', runningTotal: 42, movingAverage: 24}, + {product: 'tablet', runningTotal: 72, movingAverage: 40}, + {product: 'tablet', runningTotal: 132, movingAverage: 50}, + {product: 'tablet', runningTotal: 192, movingAverage: 60}, + ); + }); + }); + + // `countAll()` serializes to a zero argument `count` function, which the + // backend maps to `FunctionKind.COUNT_ALL`. `WindowFunctionConverter` only + // handles `COUNT` today, so this currently fails with + // `UnsupportedOperationException: Unsupported window aggregator: COUNT_ALL`. + describe('countAll', () => { + it('counts every document in the window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + }); + + // Numeric `documents` offsets depend on the `toAlgebraBound()` sign fix in + // cl/977837857. Before that CL every numeric `documents` bound (including + // `0`) fails with an INTERNAL error, so these live in the gated suite even + // though they only use `count()`. + describe('documents framing with numeric offsets', () => { + it('supports zero preceding and zero following', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 0, following: 0}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); + + it("supports zero preceding to 'unbounded' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 0, following: 'unbounded'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 5}, + {product: 'phone', windowCount: 4}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 2}, + {product: 'tablet', windowCount: 1}, + ); + }); + + it('supports a symmetric moving window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 1, following: 1}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 2}, + ); + }); + }); + }, +); + +// TODO(b/…): Un-skip once the backend CL that adds `rank()`, `denseRank()` and +// `rowNumber()` support lands (it follows cl/977837857). +describe.skip('Pipeline window functions (ranking)', () => { + let firestore: Firestore; + let productSales: CollectionReference; + + async function execute(ppl: Pipeline): Promise { + return ppl.execute(); + } + + before(async () => { + productSales = getTestRoot(); + await testCollectionWithDocs(productSales, windowTestDocs); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore as unknown as InternalFirestore)); + + it('computes rank, denseRank and rowNumber', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: [ascending('salesPrice'), ascending('date')]}, + rank().as('priceRank'), + denseRank().as('priceDenseRank'), + rowNumber().as('priceRowNumber'), + ) + .sort(ascending('date')) + .select('salesPrice', 'priceRank', 'priceDenseRank', 'priceRowNumber'), + ); + + expectResults( + snapshot, + { + salesPrice: 12, + priceRank: 1, + priceDenseRank: 1, + priceRowNumber: 1, + }, + { + salesPrice: 30, + priceRank: 2, + priceDenseRank: 2, + priceRowNumber: 2, + }, + { + salesPrice: 30, + priceRank: 2, + priceDenseRank: 2, + priceRowNumber: 3, + }, + { + salesPrice: 60, + priceRank: 4, + priceDenseRank: 3, + priceRowNumber: 4, + }, + { + salesPrice: 60, + priceRank: 4, + priceDenseRank: 3, + priceRowNumber: 5, + }, + ); + }); +}); diff --git a/handwritten/firestore/dev/test/pipelines/add-window-fields.ts b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts new file mode 100644 index 000000000000..7902644e116f --- /dev/null +++ b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts @@ -0,0 +1,952 @@ +/** + * @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 {expect} from 'chai'; + +import {google} from '../../protos/firestore_v1_proto_api'; +import {Firestore, Pipelines} from '../../src'; +import {createInstance} from '../util/helpers'; + +import Pipeline = Pipelines.Pipeline; +import ProtoStage = google.firestore.v1.Pipeline.IStage; +import ProtoValue = google.firestore.v1.IValue; + +const { + arrayAgg, + arrayAggDistinct, + ascending, + average, + constant, + count, + countAll, + countDistinct, + countIf, + denseRank, + descending, + field, + first, + last, + maximum, + minimum, + rank, + rowNumber, + sum, + toLower, +} = Pipelines; + +let db: Firestore; + +/** + * Serializes `pipeline` the same way `execute()` does, and returns the proto + * for its (single) `add_window_fields` stage. + */ +function windowStage(pipeline: Pipeline): ProtoStage { + pipeline._validateUserData(db._settings.ignoreUndefinedProperties ?? false); + const proto = pipeline._toProto(); + const stages = (proto.stages ?? []).filter( + s => s.name === 'add_window_fields', + ); + expect(stages).to.have.lengthOf( + 1, + 'expected exactly one add_window_fields stage', + ); + return stages[0]; +} + +/** The `window_spec` argument (args[0]) of the `add_window_fields` stage. */ +function windowSpecArg(pipeline: Pipeline): ProtoValue { + const stage = windowStage(pipeline); + expect(stage.args).to.have.lengthOf( + 2, + 'add_window_fields must have exactly 2 args', + ); + return stage.args![0]; +} + +/** The `fields` argument (args[1]) of the `add_window_fields` stage. */ +function fieldsArg(pipeline: Pipeline): ProtoValue { + const stage = windowStage(pipeline); + expect(stage.args).to.have.lengthOf( + 2, + 'add_window_fields must have exactly 2 args', + ); + return stage.args![1]; +} + +function fieldRef(name: string): ProtoValue { + return {fieldReferenceValue: name}; +} + +function int(value: number): ProtoValue { + return {integerValue: value}; +} + +function str(value: string): ProtoValue { + return {stringValue: value}; +} + +function map(fields: Record): ProtoValue { + return {mapValue: {fields}}; +} + +function array(...values: ProtoValue[]): ProtoValue { + return {arrayValue: {values}}; +} + +function fn(name: string, ...args: ProtoValue[]): ProtoValue { + return {functionValue: {name, args}}; +} + +function ordering(expression: ProtoValue, direction: string): ProtoValue { + return map({direction: str(direction), expression}); +} + +function basePipeline(): Pipeline { + return db.pipeline().collection('sales'); +} + +describe('addWindowFields() serialization', () => { + beforeEach(async () => { + db = await createInstance(); + }); + + describe('stage shape', () => { + it('uses the add_window_fields stage name and 2 args', () => { + const stage = windowStage( + basePipeline().addWindowFields( + {partition: ['product']}, + countAll().as('c'), + ), + ); + + expect(stage.name).to.equal('add_window_fields'); + expect(stage.args).to.have.lengthOf(2); + }); + + it('does not send any stage options', () => { + const stage = windowStage( + basePipeline().addWindowFields( + {partition: ['product']}, + countAll().as('c'), + ), + ); + + expect(stage.options).to.deep.equal({}); + }); + }); + + describe('window_spec', () => { + it('serializes an empty window spec as an empty map', () => { + expect( + windowSpecArg(basePipeline().addWindowFields({}, countAll().as('c'))), + ).to.deep.equal(map({})); + }); + + it('serializes a partition of field name strings', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {partition: ['product', 'region']}, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({partition: array(fieldRef('product'), fieldRef('region'))}), + ); + }); + + it('serializes a partition of expressions', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {partition: [field('product'), toLower('region')]}, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + partition: array( + fieldRef('product'), + fn('to_lower', fieldRef('region')), + ), + }), + ); + }); + + it('serializes a nested field path partition', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {partition: ['metadata.region']}, + countAll().as('c'), + ), + ), + ).to.deep.equal(map({partition: array(fieldRef('metadata.region'))})); + }); + + it('serializes a single (non array) sort ordering', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({sort: array(ordering(fieldRef('date'), 'ascending'))}), + ); + }); + + it('serializes multiple sort orderings with mixed directions', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {sort: [ascending('date'), descending('salesPrice')]}, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array( + ordering(fieldRef('date'), 'ascending'), + ordering(fieldRef('salesPrice'), 'descending'), + ), + }), + ); + }); + + it('serializes partition and sort together', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {partition: ['product'], sort: ascending('date')}, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + partition: array(fieldRef('product')), + sort: array(ordering(fieldRef('date'), 'ascending')), + }), + ); + }); + + it('omits an empty partition array', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {partition: [], sort: ascending('date')}, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({sort: array(ordering(fieldRef('date'), 'ascending'))}), + ); + }); + + it('omits an empty sort array', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {partition: ['product'], sort: []}, + countAll().as('c'), + ), + ), + ).to.deep.equal(map({partition: array(fieldRef('product'))})); + }); + + it('omits the frame when using default framing', () => { + const spec = windowSpecArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + countAll().as('c'), + ), + ); + + expect(spec.mapValue!.fields).to.not.have.property('documents'); + expect(spec.mapValue!.fields).to.not.have.property('range'); + }); + }); + + describe('stage level documents framing', () => { + it('serializes numeric offsets as integers', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 2, following: 1}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({preceding: int(2), following: int(1)}), + }), + ); + }); + + it('serializes zero offsets', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 0, following: 0}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({preceding: int(0), following: int(0)}), + }), + ); + }); + + it('serializes negative offsets (look ahead windows)', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + documents: {preceding: -1, following: 2}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({preceding: int(-1), following: int(2)}), + }), + ); + }); + + it("serializes the 'unbounded' and 'current' sentinels", () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({ + preceding: str('unbounded'), + following: str('current'), + }), + }), + ); + }); + + it('serializes an unbounded to unbounded frame', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + {documents: {preceding: 'unbounded', following: 'unbounded'}}, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + documents: map({ + preceding: str('unbounded'), + following: str('unbounded'), + }), + }), + ); + }); + + it('serializes constant expression bounds', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + documents: {preceding: constant(3), following: constant(0)}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({preceding: int(3), following: int(0)}), + }), + ); + }); + }); + + describe('stage level range framing', () => { + it('serializes numeric range offsets', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 10, following: 10}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('salesPrice'), 'ascending')), + range: map({preceding: int(10), following: int(10)}), + }), + ); + }); + + it('serializes fractional range offsets as doubles', () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 2.5, following: 0}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('salesPrice'), 'ascending')), + range: map({preceding: {doubleValue: 2.5}, following: int(0)}), + }), + ); + }); + + it("serializes a range frame with a 'day' time unit", () => { + expect( + windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + range: {preceding: 30, following: 'current', unit: 'day'}, + }, + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + range: map({ + preceding: int(30), + following: str('current'), + unit: str('day'), + }), + }), + ); + }); + + for (const unit of [ + 'microsecond', + 'millisecond', + 'second', + 'minute', + 'hour', + 'day', + 'week', + 'month', + 'quarter', + 'year', + ] as const) { + it(`serializes the '${unit}' time unit`, () => { + const spec = windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + range: {preceding: 1, following: 'current', unit}, + }, + countAll().as('c'), + ), + ); + + expect(spec.mapValue!.fields!.range).to.deep.equal( + map({preceding: int(1), following: str('current'), unit: str(unit)}), + ); + }); + } + + it("omits 'unit' when it is not specified", () => { + const spec = windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: 'current'}, + }, + countAll().as('c'), + ), + ); + + expect(spec.mapValue!.fields!.range).to.deep.equal( + map({preceding: str('unbounded'), following: str('current')}), + ); + }); + }); + + describe('fields', () => { + it('maps each alias to its aggregate function', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('total'), + countAll().as('c'), + ), + ), + ).to.deep.equal( + map({ + total: fn('sum', fieldRef('salesPrice')), + c: fn('count'), + }), + ); + }); + + it('supports nested output field paths', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('stats.total'), + ), + ), + ).to.deep.equal(map({'stats.total': fn('sum', fieldRef('salesPrice'))})); + }); + + it('serializes all supported aggregate function names', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {partition: ['product']}, + countAll().as('countAll'), + count('salesPrice').as('count'), + countIf(field('salesPrice').greaterThan(10)).as('countIf'), + countDistinct('salesPrice').as('countDistinct'), + sum('salesPrice').as('sum'), + average('salesPrice').as('average'), + minimum('salesPrice').as('minimum'), + maximum('salesPrice').as('maximum'), + first('salesPrice').as('first'), + last('salesPrice').as('last'), + arrayAgg('salesPrice').as('arrayAgg'), + arrayAggDistinct('salesPrice').as('arrayAggDistinct'), + ), + ), + ).to.deep.equal( + map({ + countAll: fn('count'), + count: fn('count', fieldRef('salesPrice')), + countIf: fn( + 'count_if', + fn('greater_than', fieldRef('salesPrice'), int(10)), + ), + countDistinct: fn('count_distinct', fieldRef('salesPrice')), + sum: fn('sum', fieldRef('salesPrice')), + average: fn('average', fieldRef('salesPrice')), + minimum: fn('minimum', fieldRef('salesPrice')), + maximum: fn('maximum', fieldRef('salesPrice')), + first: fn('first', fieldRef('salesPrice')), + last: fn('last', fieldRef('salesPrice')), + arrayAgg: fn('array_agg', fieldRef('salesPrice')), + arrayAggDistinct: fn('array_agg_distinct', fieldRef('salesPrice')), + }), + ); + }); + + it('serializes aggregates over computed expressions', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {partition: ['product']}, + sum(field('salesPrice').multiply(2)).as('doubled'), + ), + ), + ).to.deep.equal( + map({ + doubled: fn('sum', fn('multiply', fieldRef('salesPrice'), int(2))), + }), + ); + }); + + it('serializes the ranking window functions', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {sort: descending('salesPrice')}, + rank().as('rank'), + denseRank().as('denseRank'), + rowNumber().as('rowNumber'), + ), + ), + ).to.deep.equal( + map({ + rank: fn('rank'), + denseRank: fn('dense_rank'), + rowNumber: fn('row_number'), + }), + ); + }); + + it('rejects duplicate aliases', () => { + expect(() => + basePipeline().addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('total'), + average('salesPrice').as('total'), + ), + ).to.throw(/Duplicate alias or field 'total'/); + }); + }); + + describe('accumulator level framing (over)', () => { + it("wraps an aggregate in over() with a 'documents' frame", () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + average('salesPrice') + .over({documents: {preceding: 1, following: 1}}) + .as('movingAverage'), + ), + ), + ).to.deep.equal( + map({ + movingAverage: fn( + 'over', + fn('average', fieldRef('salesPrice')), + map({ + documents: map({preceding: int(1), following: int(1)}), + }), + ), + }), + ); + }); + + it("wraps an aggregate in over() with a 'range' frame and time unit", () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + sum('salesPrice') + .over({ + range: {preceding: 10, following: 0, unit: 'day'}, + }) + .as('tenDayTotal'), + ), + ), + ).to.deep.equal( + map({ + tenDayTotal: fn( + 'over', + fn('sum', fieldRef('salesPrice')), + map({ + range: map({ + preceding: int(10), + following: int(0), + unit: str('day'), + }), + }), + ), + }), + ); + }); + + it('supports different frames for different accumulators', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {partition: ['product'], sort: ascending('date')}, + sum('salesPrice') + .over({ + documents: {preceding: 'unbounded', following: 'current'}, + }) + .as('runningTotal'), + average('salesPrice') + .over({documents: {preceding: 1, following: 1}}) + .as('movingAverage'), + ), + ), + ).to.deep.equal( + map({ + runningTotal: fn( + 'over', + fn('sum', fieldRef('salesPrice')), + map({ + documents: map({ + preceding: str('unbounded'), + following: str('current'), + }), + }), + ), + movingAverage: fn( + 'over', + fn('average', fieldRef('salesPrice')), + map({documents: map({preceding: int(1), following: int(1)})}), + ), + }), + ); + }); + + it('does not wrap in over() when no frame is provided', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + sum('salesPrice').over().as('total'), + ), + ), + ).to.deep.equal(map({total: fn('sum', fieldRef('salesPrice'))})); + }); + + it('supports over() on a ranking window function', () => { + expect( + fieldsArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + rank() + .over({ + documents: {preceding: 'unbounded', following: 'current'}, + }) + .as('r'), + ), + ), + ).to.deep.equal( + map({ + r: fn( + 'over', + fn('rank'), + map({ + documents: map({ + preceding: str('unbounded'), + following: str('current'), + }), + }), + ), + }), + ); + }); + + // The SDK does not reject `partition`/`sort` in `over()`. They are encoded + // into the same window spec map as the frame, and the backend responds with + // `Window frame has unexpected fields: [partition]`. Leaving this to the + // backend means accumulator level partitioning starts working without an + // SDK change if the backend ever supports it. + it('encodes a partition supplied to over()', () => { + const fields = fieldsArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + sum('salesPrice') + .over({ + partition: ['product'], + documents: {preceding: 1, following: 1}, + }) + .as('total'), + ), + ); + + expect(fields).to.deep.equal( + map({ + total: fn( + 'over', + fn('sum', fieldRef('salesPrice')), + map({ + partition: array(fieldRef('product')), + documents: map({preceding: int(1), following: int(1)}), + }), + ), + }), + ); + }); + + it('encodes a sort supplied to over()', () => { + const fields = fieldsArg( + basePipeline().addWindowFields( + {sort: ascending('date')}, + sum('salesPrice') + .over({ + sort: ascending('date'), + documents: {preceding: 1, following: 1}, + }) + .as('total'), + ), + ); + + expect(fields).to.deep.equal( + map({ + total: fn( + 'over', + fn('sum', fieldRef('salesPrice')), + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({preceding: int(1), following: int(1)}), + }), + ), + }), + ); + }); + }); + + describe('options overload', () => { + it('produces the same proto as the varargs overload', () => { + const varargs = windowStage( + basePipeline().addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 1, following: 1}, + }, + average('salesPrice').as('movingAverage'), + countAll().as('windowCount'), + ), + ); + + const options = windowStage( + basePipeline().addWindowFields({ + window: { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 1, following: 1}, + }, + fields: [ + average('salesPrice').as('movingAverage'), + countAll().as('windowCount'), + ], + }), + ); + + expect(options).to.deep.equal(varargs); + }); + + it('does not leak window/fields into the stage options', () => { + const stage = windowStage( + basePipeline().addWindowFields({ + window: {partition: ['product']}, + fields: [countAll().as('c')], + }), + ); + + expect(stage.options).to.deep.equal({}); + }); + }); + + // The SDK deliberately does not validate these. Everything below is + // encodable, so it is sent to the backend, which owns validation and returns + // a precise error. This keeps the SDK from having to change whenever the + // backend relaxes or extends a rule. + describe('backend validated combinations are still encoded', () => { + it("encodes both 'documents' and 'range' when both are supplied", () => { + const windowSpec = windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + // Deliberately bypassing the OneOf type. The backend replies + // "Cannot specify both 'documents' and 'range' window frames." + documents: {preceding: 1, following: 1}, + range: {preceding: 2, following: 2}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + countAll().as('c'), + ), + ); + + expect(windowSpec).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({preceding: int(1), following: int(1)}), + range: map({preceding: int(2), following: int(2)}), + }), + ); + }); + + it('passes through an unrecognized frame bound string', () => { + const windowSpec = windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + documents: {preceding: 'infinite' as any, following: 'current'}, + }, + countAll().as('c'), + ), + ); + + expect(windowSpec).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({ + preceding: str('infinite'), + following: str('current'), + }), + }), + ); + }); + + it("encodes 'unit' even on a 'documents' frame", () => { + const windowSpec = windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + documents: {preceding: 1, following: 1, unit: 'day'} as any, + }, + countAll().as('c'), + ), + ); + + expect(windowSpec).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({ + preceding: int(1), + following: int(1), + unit: str('day'), + }), + }), + ); + }); + + it('omits a bound that was not supplied', () => { + const windowSpec = windowSpecArg( + basePipeline().addWindowFields( + { + sort: ascending('date'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + documents: {preceding: 'unbounded'} as any, + }, + countAll().as('c'), + ), + ); + + expect(windowSpec).to.deep.equal( + map({ + sort: array(ordering(fieldRef('date'), 'ascending')), + documents: map({preceding: str('unbounded')}), + }), + ); + }); + }); +}); diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index e1fd454e7402..9eb091c63f63 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -156,6 +156,19 @@ describe('stage option serialization', () => { }), stageIndex: 1, }, + { + name: 'addWindowFields stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .addWindowFields({ + window: {partition: ['foo']}, + fields: [sum('foo').as('fooSum')], + rawOptions, + }), + stageIndex: 1, + }, { name: 'aggregate stage', pipeline: firestore => @@ -396,4 +409,46 @@ describe('stage _validateUserData', () => { }) .execute(); }); + + it('addWindowFields stage validation', async () => { + const firestore = await createInstance(); + + // Should throw when ignoreUndefinedProperties is false (default) + expect(() => { + void firestore + .pipeline() + .collection('foo') + .addWindowFields( + { + partition: [constant({foo: undefined})], + }, + sum('amount').as('total'), + ) + .execute(); + }).to.throw( + 'Value for argument "value" is not a valid constant value. Cannot use "undefined" as a Firestore value', + ); + + // Should not throw when ignoreUndefinedProperties is true + const spy = sinon.fake.returns(stream()); + const firestoreWithIgnore = await createInstance( + { + executePipeline: spy, + }, + { + ignoreUndefinedProperties: true, + }, + ); + + await firestoreWithIgnore + .pipeline() + .collection('foo') + .addWindowFields( + { + partition: [constant({foo: undefined})], + }, + sum('amount').as('total'), + ) + .execute(); + }); }); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 6cadd2e970fb..81f228284d6d 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -3430,6 +3430,7 @@ declare namespace FirebaseFirestore { | 'Constant' | 'Function' | 'AggregateFunction' + | 'WindowFunction' | 'ListOfExprs' | 'AliasedExpression' | 'Variable' @@ -6103,6 +6104,36 @@ declare namespace FirebaseFirestore { * AggregateFunction and associates it with the provided alias. */ as(name: string): AliasedAggregate; + + /** + * Evaluates this aggregate function over a specific window frame, rather than + * over the window frame defined by the enclosing + * {@link Pipeline.addWindowFields} stage. + * + * Today the backend only accepts a frame (`documents` or `range`) here, and + * rejects `partition` and `sort`, which must be specified on the enclosing + * `addWindowFields()` stage. + * + * @example + * ```typescript + * firestore.pipeline().collection("sales") + * .addWindowFields( + * { sort: ascending('date') }, + * // Uses the stage's default frame. + * sum('amount').as('runningTotal'), + * // Overrides the stage frame with a 3 document moving window. + * average('amount') + * .over({ documents: { preceding: 1, following: 1 } }) + * .as('movingAverage') + * ); + * ``` + * + * @param window - A {@link WindowSpec} containing the + * `documents` or `range` frame to evaluate this aggregate over. If omitted, the + * enclosing stage's frame is used. + * @returns A new {@link WindowFunction}. + */ + over(window?: WindowSpec): WindowFunction; } /** @@ -6123,6 +6154,65 @@ declare namespace FirebaseFirestore { readonly _alias: string; } + /** + * A function that is evaluated over a window frame of documents, as part of an + * {@link Pipeline.addWindowFields} stage. + * + * A `WindowFunction` is created either by one of the dedicated window function + * factories (such as {@link rank}), or by calling + * `over()` on an {@link AggregateFunction}. + */ + export class WindowFunction { + expressionType: ExpressionType; + + constructor(name: string, params?: Expression[]); + + /** + * Evaluates this window function over a specific window frame, rather than + * over the window frame defined by the enclosing + * {@link Pipeline.addWindowFields} stage. + * + * Today the backend only accepts a frame (`documents` or `range`) here, and + * rejects `partition` and `sort`, which must be specified on the enclosing + * `addWindowFields()` stage. + * + * @param window - A {@link WindowSpec} containing + * the `documents` or `range` frame to evaluate this function over. If + * omitted, the enclosing stage's frame is used. + * @returns A new {@link WindowFunction}. + */ + over(window?: WindowSpec): WindowFunction; + + /** + * Assigns an alias to this `WindowFunction`. The alias specifies the name that + * the computed value will have in the output document. + * + * @param name - The alias to assign to this `WindowFunction`. + * @returns A new {@link AliasedWindowFunction}. + */ + as(name: string): AliasedWindowFunction; + } + + /** + * A {@link WindowFunction} with an alias. + */ + export class AliasedWindowFunction { + constructor(windowFunction: WindowFunction, alias: string); + + readonly windowFunction: WindowFunction; + readonly alias: string; + + /** + * @internal + */ + readonly _windowFunction: WindowFunction; + + /** + * @internal + */ + readonly _alias: string; + } + /** * Represents an expression that has been assigned an alias using the `.as()` method. * @@ -12204,6 +12294,62 @@ declare namespace FirebaseFirestore { location: GeoPoint | Expression, ): Expression; + /** + * Creates a window function that computes the rank of the current document + * within its window frame. Documents that compare equal in the window sort + * order receive the same rank, and the next rank is offset by the number of + * tied documents. + * + * @example + * ```typescript + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { partition: ['department'], sort: descending('salary') }, + * rank().as('salaryRank') + * ); + * ``` + * + * @returns A new {@link WindowFunction}. + */ + export function rank(): WindowFunction; + + /** + * Creates a window function that computes the rank of the current document + * within its window frame, without gaps in the ranking sequence. Documents that + * compare equal in the window sort order receive the same rank, and the next + * rank is always incremented by one. + * + * @example + * ```typescript + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { partition: ['department'], sort: descending('salary') }, + * denseRank().as('salaryRank') + * ); + * ``` + * + * @returns A new {@link WindowFunction}. + */ + export function denseRank(): WindowFunction; + + /** + * Creates a window function that computes the sequential position of the + * current document within its window frame, starting at 1. Documents that + * compare equal in the window sort order receive distinct row numbers. + * + * @example + * ```typescript + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { partition: ['department'], sort: descending('salary') }, + * rowNumber().as('salaryRowNumber') + * ); + * ``` + * + * @returns A new {@link WindowFunction}. + */ + export function rowNumber(): WindowFunction; + // TODO(search) enable when supported by the backend // /** // * Evaluates if the value in the field specified by `fieldName` is between @@ -12568,6 +12714,110 @@ declare namespace FirebaseFirestore { * @returns A new Pipeline object with this stage appended to the stage list. */ addFields(options: AddFieldsStageOptions): Pipeline; + + /** + * Adds window function results to the output documents of the pipeline. + * + * Window functions evaluate expressions over a subset of documents (a "window frame") relative to the + * current document. This is similar to SQL window functions or MongoDB's `$setWindowFields` stage. + * + * @example + * ```typescript + * // 1. Unsorted partition/group aggregation (evaluates over the entire group) + * firestore.pipeline().collection("employees") + * .addWindowFields( + * { + * partition: ['department'] + * }, + * average(field('salary')).as('departmentAverageSalary') + * ); + * + * // 2. Document-based moving average with explicit boundaries + * firestore.pipeline().collection("sales") + * .addWindowFields( + * { + * sort: ascending('date'), + * documents: { preceding: 1, following: 1 } + * }, + * average(field('amount')).as('movingAverageAmount') + * ); + * + * // 3. Document-based running total using default boundaries (unbounded preceding to current row). + * // Note: Offsets are physical document counts, so no time unit is required or used even when sorting on 'date'. + * firestore.pipeline().collection("sales") + * .addWindowFields( + * { + * sort: ascending('date'), + * documents: { preceding: 'unbounded', following: 'current' } + * }, + * sum(field('amount')).as('runningTotal') + * ); + * + * // 4. Range-based running average using default boundaries (unbounded preceding to current value) + * firestore.pipeline().collection("products") + * .addWindowFields( + * { + * sort: ascending('price'), + * range: { preceding: 'unbounded', following: 'current' } + * }, + * average(field('rating')).as('cumulativeAvgRating') + * ); + * + * // 5. Range-based date/time window with a time unit (cumulative sales over the last 30 days) + * firestore.pipeline().collection("sales") + * .addWindowFields( + * { + * sort: ascending('date'), + * range: { preceding: 30, following: 'current', unit: 'day' } + * }, + * sum(field('amount')).as('thirtyDayCumulativeSales') + * ); + * ``` + * + * @param window - The specification defining how documents are partitioned, ordered, and bounded in the window frame. + * @param field - The first window field to add, specified as an {@link AliasedAggregate} or {@link AliasedWindowFunction}. + * @param additionalFields - Optional additional window fields to add to the documents. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + addWindowFields( + window: WindowSpec, + field: AliasedAggregate | AliasedWindowFunction, + ...additionalFields: Array + ): Pipeline; + + /** + * Adds window function results to the output documents of the pipeline using options. + * + * @example + * ```typescript + * // 1. Unsorted partition/group aggregation using options + * firestore.pipeline().collection("employees") + * .addWindowFields({ + * window: { + * partition: ['department'] + * }, + * fields: [ + * average(field('salary')).as('departmentAverageSalary') + * ] + * }); + * + * // 2. Document-based moving average using options + * firestore.pipeline().collection("sales") + * .addWindowFields({ + * window: { + * sort: ascending('date'), + * documents: { preceding: 1, following: 1 } + * }, + * fields: [ + * average(field('amount')).as('movingAverageAmount') + * ] + * }); + * ``` + * + * @param options - An object specifying the window frame configuration and the fields to add. + * @returns A new Pipeline object with this stage appended to the stage list. + */ + addWindowFields(options: AddWindowFieldsStageOptions): Pipeline; /** * Remove fields from outputs of previous stages. * @@ -13769,6 +14019,128 @@ declare namespace FirebaseFirestore { */ fields: Selectable[]; }; + + export interface DocumentWindowFrame { + /** + * The lower bound (inclusive) of the window frame, relative to the current document's position. + * + * Can be: + * - A number specifying the number of documents preceding the current document. + * - `'current'` to represent the current document itself. + * - `'unbounded'` to include all documents from the first document in the group. + */ + preceding: number | 'current' | 'unbounded' | Expression; + + /** + * The upper bound (inclusive) of the window frame, relative to the current document's position. + * + * Can be: + * - A number specifying the number of documents following the current document. + * - `'current'` to represent the current document itself. + * - `'unbounded'` to include all documents to the last document in the group. + */ + following: number | 'current' | 'unbounded' | Expression; + } + + export interface RangeWindowFrame { + /** + * The lower bound (inclusive) of the window frame, relative to the sort value of the current document. + * + * Can be: + * - A number specifying the value-based offset from the current document's sort value. + * - `'current'` to represent only documents with the same sort value as the current document. + * - `'unbounded'` to include all documents from the start of the group. + */ + preceding: number | 'current' | 'unbounded' | Expression; + + /** + * The upper bound (inclusive) of the window frame, relative to the sort value of the current document. + * + * Can be: + * - A number specifying the value-based offset from the current document's sort value. + * - `'current'` to represent only documents with the same sort value as the current document. + * - `'unbounded'` to include all documents to the end of the group. + */ + following: number | 'current' | 'unbounded' | Expression; + + /** + * The unit used to calculate range boundaries when the `sort` field contains date or time values. + */ + unit?: + | 'microsecond' + | 'millisecond' + | 'second' + | 'minute' + | 'hour' + | 'day' + | 'week' + | 'week(monday)' + | 'week(tuesday)' + | 'week(wednesday)' + | 'week(thursday)' + | 'week(friday)' + | 'week(saturday)' + | 'week(sunday)' + | 'isoweek' + | 'month' + | 'quarter' + | 'year' + | Expression; + } + + /** + * A type that specifies a window frame, over which a window function will be evaluated. + * + * Default frame behavior: + * - If `sort` is not specified, the default frame is `documents` from `'unbounded'` preceding to `'unbounded'` following (the entire partition/group). + * - If `sort` is specified, the default frame is `range` from `'unbounded'` preceding to `'current'` row value. + */ + export type WindowSpec = { + /** + * Evaluate the window function over documents in the same group as the current document. Documents are grouped by having the same value for all provided {@link Expression}s and fields as specified in this `partition` array. If a string value is provided, it is treated as the name of a field in the document. If this value is left unspecified, then a single group is used for all input documents to the stage. + */ + partition?: Array; + + /** + * The sort order of the documents in each group. + * + * Setting a value for `sort` changes the default window frame behavior. + * See {@link WindowSpec} for default frame specifications. + * + * See {@link Ordering}. + */ + sort?: Ordering | Ordering[]; + } & OneOf<{ + /** + * Defines a document-count based window frame relative to the position of the current document in the sorted group. + * + * See {@link WindowSpec} for default frame specifications if `documents` or `range` is not set. + */ + documents?: DocumentWindowFrame; + + /** + * Defines a range-value based window frame relative to the sort value of the current document. + * + * See {@link WindowSpec} for default frame specifications if `documents` or `range` is not set. + */ + range?: RangeWindowFrame; + }>; + + /** + * Options defining how an AddWindowFieldsStage is evaluated. See {@link Pipeline.addWindowFields}. + */ + export type AddWindowFieldsStageOptions = StageOptions & { + /** + * The window spec to evaluate the window function over. + */ + window: WindowSpec; + + /** + * The fields to add to each document, specified as an {@link AliasedAggregate} or {@link AliasedWindowFunction}. + * At least one field is required. + */ + fields: Array; + }; /** * Options defining how a RemoveFieldsStage is evaluated. See {@link Pipeline.removeFields}. */ From 346867f763b3e7528499bf335e9e1fb1c398747d Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:04:59 +0000 Subject: [PATCH 2/8] refactor(firestore): address review comments on window functions API - Simplify window partition mapping with fieldOrExpression helper - Replace WindowFunction._create factory with constructor - Use _alias and _windowFunction consistently - Remove references to SQL and MongoDB in documentation - Remove 'Today' from backend constraint documentation - Enable window aggregators system test suite against Nightly --- .../firestore/api-report/firestore.api.md | 5 --- .../firestore/dev/src/pipelines/expression.ts | 45 ++++++------------- .../dev/src/pipelines/pipeline-util.ts | 10 ++--- .../firestore/dev/src/pipelines/pipelines.ts | 2 +- .../firestore/dev/system-test/pipeline.ts | 21 +++------ handwritten/firestore/types/firestore.d.ts | 6 +-- 6 files changed, 29 insertions(+), 60 deletions(-) diff --git a/handwritten/firestore/api-report/firestore.api.md b/handwritten/firestore/api-report/firestore.api.md index 418c26e9e425..054f38ac8a61 100644 --- a/handwritten/firestore/api-report/firestore.api.md +++ b/handwritten/firestore/api-report/firestore.api.md @@ -3219,11 +3219,6 @@ class WindowFunction implements firestore.Pipelines.WindowFunction, HasUserData constructor(name: string, params?: Expression[]); as(name: string): AliasedWindowFunction; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration - // Warning: (ae-forgotten-export) The symbol "WindowSpecInternal" needs to be exported by the entry point index.d.ts - // - // @internal (undocumented) - static _create(name: string, params: Expression[], window?: WindowSpecInternal): WindowFunction; - // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // // @internal _createdFromLiteral: boolean; diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index a7db7496749a..ff143298fad2 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -3380,7 +3380,7 @@ export class AggregateFunction implements AggregateFunction, HasUserData { * over the window frame defined by the enclosing * `Pipeline.addWindowFields()` stage. * - * Today the backend only accepts a frame (`documents` or `range`) here, and + * The backend only accepts a frame (`documents` or `range`) here, and * rejects `partition` and `sort`, which must be specified on the enclosing * `addWindowFields()` stage. * @@ -3404,7 +3404,7 @@ export class AggregateFunction implements AggregateFunction, HasUserData { * @returns A new `WindowFunction`. */ over(window?: firestore.Pipelines.WindowSpec): WindowFunction { - return WindowFunction._create( + return new WindowFunction( this.name, this.params, window === undefined ? undefined : new WindowSpecInternal(window), @@ -11296,9 +11296,7 @@ export class WindowSpecInternal implements HasUserData { constructor(window: firestore.Pipelines.WindowSpec) { const sort = window.sort; - this.partition = (window.partition ?? []).map(value => - isString(value) ? field(value) : (value as Expression), - ); + this.partition = (window.partition ?? []).map(fieldOrExpression); this.sort = sort === undefined ? [] @@ -11460,38 +11458,23 @@ export class WindowFunction */ _createdFromLiteral = false; - /** - * The accumulator level window this function is evaluated over. When - * `undefined`, the window of the enclosing stage is used. - */ - private window?: WindowSpecInternal; - constructor( private name: string, private params: Expression[] = [], + /** + * The accumulator level window this function is evaluated over. When + * `undefined`, the window of the enclosing stage is used. + * @internal + */ + private window?: WindowSpecInternal, ) {} - /** - * @internal - * @private - */ - static _create( - name: string, - params: Expression[], - window?: WindowSpecInternal, - ): WindowFunction { - const wf = new WindowFunction(name, params); - wf.window = window; - - return wf; - } - /** * Evaluates this window function over a specific window frame, rather than * over the window frame defined by the enclosing * `Pipeline.addWindowFields()` stage. * - * Today the backend only accepts a frame (`documents` or `range`) here, and + * The backend only accepts a frame (`documents` or `range`) here, and * rejects `partition` and `sort`, which must be specified on the enclosing * `addWindowFields()` stage. * @@ -11501,7 +11484,7 @@ export class WindowFunction * @returns A new `WindowFunction`. */ over(window?: firestore.Pipelines.WindowSpec): WindowFunction { - return WindowFunction._create( + return new WindowFunction( this.name, this.params, window === undefined ? undefined : new WindowSpecInternal(window), @@ -11616,7 +11599,7 @@ export class AliasedWindowFunction * @returns A new `WindowFunction`. */ export function rank(): WindowFunction { - return WindowFunction._create('rank', []); + return new WindowFunction('rank', []); } /** @@ -11637,7 +11620,7 @@ export function rank(): WindowFunction { * @returns A new `WindowFunction`. */ export function denseRank(): WindowFunction { - return WindowFunction._create('dense_rank', []); + return new WindowFunction('dense_rank', []); } /** @@ -11657,5 +11640,5 @@ export function denseRank(): WindowFunction { * @returns A new `WindowFunction`. */ export function rowNumber(): WindowFunction { - return WindowFunction._create('row_number', []); + return new WindowFunction('row_number', []); } diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index 5a5ef66559dc..e983e4f293ff 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -612,8 +612,8 @@ export function isAliasedWindowFunction( return ( candidate !== undefined && candidate !== null && - isString(candidate.alias) && - candidate.windowFunction instanceof WindowFunction + isString(candidate._alias) && + candidate._windowFunction instanceof WindowFunction ); } @@ -812,9 +812,7 @@ export function aliasedAggregateToMap( | firestore.Pipelines.AliasedAggregate | firestore.Pipelines.AliasedWindowFunction, ) => { - const alias = isAliasedWindowFunction(aliased) - ? aliased.alias - : aliased._alias; + const alias = aliased._alias; // Validated client side because a duplicate alias cannot be encoded: the // second entry would silently overwrite the first in the map, and the // backend would never see it. @@ -825,7 +823,7 @@ export function aliasedAggregateToMap( map.set( alias, isAliasedWindowFunction(aliased) - ? (aliased.windowFunction as WindowFunction) + ? (aliased._windowFunction as WindowFunction) : (aliased._aggregate as AggregateFunction), ); return map; diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index b93fdde3cb75..ff93351afcd8 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -459,7 +459,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * Adds window function results to the output documents of the pipeline. * * Window functions evaluate expressions over a subset of documents (a "window frame") relative to the - * current document. This is similar to SQL window functions or MongoDB's `$setWindowFields` stage. + * current document. * * @example * ```typescript diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 091e50987cce..d7455f499a3a 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -9321,14 +9321,7 @@ describe.skipClassic('Pipeline window functions (count)', () => { }); }); -// TODO(b/…): Un-skip this suite once cl/977837857 (physical execution of the -// non-count window aggregators, plus the `preceding` offset sign fix) has been -// submitted and rolled out to Nightly. Until then these pipelines fail with -// `UnsupportedOperationException: Unsupported window aggregator: `. -// Flip `WINDOW_AGGREGATORS_SUPPORTED` to `true` to run them. -const WINDOW_AGGREGATORS_SUPPORTED = false; - -(WINDOW_AGGREGATORS_SUPPORTED ? describe.skipClassic : describe.skip)( +describe.skipClassic( 'Pipeline window functions (aggregators)', () => { let firestore: Firestore; @@ -9409,7 +9402,8 @@ const WINDOW_AGGREGATORS_SUPPORTED = false; ); }); - it('computes countIf and countDistinct', async () => { + // TODO: Un-skip once countIf and countDistinct window functions are supported by the backend. + it.skip('computes countIf and countDistinct', async () => { const snapshot = await execute( firestore .pipeline() @@ -9521,17 +9515,17 @@ const WINDOW_AGGREGATORS_SUPPORTED = false; { product: 'tablet', allPrices: [30, 60, 60], - distinctPrices: [30, 60], + distinctPrices: [60, 30], }, { product: 'tablet', allPrices: [30, 60, 60], - distinctPrices: [30, 60], + distinctPrices: [60, 30], }, { product: 'tablet', allPrices: [30, 60, 60], - distinctPrices: [30, 60], + distinctPrices: [60, 30], }, ); }); @@ -9879,8 +9873,7 @@ const WINDOW_AGGREGATORS_SUPPORTED = false; }, ); -// TODO(b/…): Un-skip once the backend CL that adds `rank()`, `denseRank()` and -// `rowNumber()` support lands (it follows cl/977837857). +// TODO: Un-skip once backend support for rank(), denseRank() and rowNumber() lands. describe.skip('Pipeline window functions (ranking)', () => { let firestore: Firestore; let productSales: CollectionReference; diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 81f228284d6d..42e618a94f5d 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -6110,7 +6110,7 @@ declare namespace FirebaseFirestore { * over the window frame defined by the enclosing * {@link Pipeline.addWindowFields} stage. * - * Today the backend only accepts a frame (`documents` or `range`) here, and + * The backend only accepts a frame (`documents` or `range`) here, and * rejects `partition` and `sort`, which must be specified on the enclosing * `addWindowFields()` stage. * @@ -6172,7 +6172,7 @@ declare namespace FirebaseFirestore { * over the window frame defined by the enclosing * {@link Pipeline.addWindowFields} stage. * - * Today the backend only accepts a frame (`documents` or `range`) here, and + * The backend only accepts a frame (`documents` or `range`) here, and * rejects `partition` and `sort`, which must be specified on the enclosing * `addWindowFields()` stage. * @@ -12719,7 +12719,7 @@ declare namespace FirebaseFirestore { * Adds window function results to the output documents of the pipeline. * * Window functions evaluate expressions over a subset of documents (a "window frame") relative to the - * current document. This is similar to SQL window functions or MongoDB's `$setWindowFields` stage. + * current document. * * @example * ```typescript From a18f03ab1a43e355ec9c9ae9f13a873e2618087b Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:31:19 +0000 Subject: [PATCH 3/8] refactor(firestore): address window functions API review feedback - Keep AliasedWindowFunction consistent with AliasedAggregate (internal _windowFunction/_alias). - Document that 'current' is peer-inclusive in range frames and positional in documents frames. - Add system tests for tied peers in range vs documents frames, string and multi-key range sorts, and fractional range offsets. - Clean up test comments and update the API report. --- .../firestore/api-report/firestore.api.md | 26 +-- .../firestore/dev/src/pipelines/expression.ts | 96 ++-------- .../firestore/dev/src/pipelines/index.ts | 2 - .../firestore/dev/src/pipelines/pipelines.ts | 4 +- .../firestore/dev/system-test/pipeline.ts | 179 ++++++++++++++---- .../dev/test/pipelines/add-window-fields.ts | 35 +--- handwritten/firestore/types/firestore.d.ts | 131 +++++-------- 7 files changed, 237 insertions(+), 236 deletions(-) diff --git a/handwritten/firestore/api-report/firestore.api.md b/handwritten/firestore/api-report/firestore.api.md index 054f38ac8a61..fd137b23a529 100644 --- a/handwritten/firestore/api-report/firestore.api.md +++ b/handwritten/firestore/api-report/firestore.api.md @@ -68,7 +68,10 @@ class AggregateFunction implements AggregateFunction, HasUserData { _createdFromLiteral: boolean; // (undocumented) expressionType: firestore.Pipelines.ExpressionType; - over(window?: firestore.Pipelines.WindowSpec): WindowFunction; + over(frame: firestore.OneOf<{ + documents: firestore.Pipelines.DocumentWindowFrame; + range: firestore.Pipelines.RangeWindowFrame; + }>): WindowFunction; // (undocumented) _protoValueType: "ProtoValue"; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration @@ -173,10 +176,8 @@ class AliasedExpression implements firestore.Pipelines.Selectable, HasUserData { // @public class AliasedWindowFunction implements firestore.Pipelines.AliasedWindowFunction, HasUserData { - constructor(windowFunction: WindowFunction, alias: string); + constructor(_windowFunction: WindowFunction, _alias: string); // (undocumented) - readonly alias: string; - // @internal (undocumented) readonly _alias: string; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration // @@ -187,8 +188,6 @@ class AliasedWindowFunction implements firestore.Pipelines.AliasedWindowFunction // @internal (undocumented) _validateUserData(ignoreUndefinedProperties: boolean): void; // (undocumented) - readonly windowFunction: WindowFunction; - // @internal (undocumented) readonly _windowFunction: WindowFunction; } @@ -743,9 +742,6 @@ export const DEFAULT_MAX_IDLE_CHANNELS = 1; // @public export const DEFAULT_MAX_TRANSACTION_ATTEMPTS = 5; -// @public -function denseRank(): WindowFunction; - // @public function descending(expr: Expression): Ordering; @@ -2307,9 +2303,7 @@ declare namespace Pipelines { geoDistance, WindowFunction, AliasedWindowFunction, - rank, - denseRank, - rowNumber + rank } } export { Pipelines } @@ -2720,9 +2714,6 @@ function round(fieldName: string, decimalPlaces: number | Expression): FunctionE // @public function round(expression: Expression, decimalPlaces: number | Expression): FunctionExpression; -// @public -function rowNumber(): WindowFunction; - // @public function rtrim(fieldName: string, valueToTrim?: string | Expression | Uint8Array | Buffer): FunctionExpression; @@ -3224,7 +3215,10 @@ class WindowFunction implements firestore.Pipelines.WindowFunction, HasUserData _createdFromLiteral: boolean; // (undocumented) expressionType: firestore.Pipelines.ExpressionType; - over(window?: firestore.Pipelines.WindowSpec): WindowFunction; + over(frame: firestore.OneOf<{ + documents: firestore.Pipelines.DocumentWindowFrame; + range: firestore.Pipelines.RangeWindowFrame; + }>): WindowFunction; // (undocumented) _protoValueType: "ProtoValue"; // Warning: (tsdoc-undefined-tag) The TSDoc tag "@private" is not defined in this configuration diff --git a/handwritten/firestore/dev/src/pipelines/expression.ts b/handwritten/firestore/dev/src/pipelines/expression.ts index ff143298fad2..28d845ab2a10 100644 --- a/handwritten/firestore/dev/src/pipelines/expression.ts +++ b/handwritten/firestore/dev/src/pipelines/expression.ts @@ -3380,10 +3380,6 @@ export class AggregateFunction implements AggregateFunction, HasUserData { * over the window frame defined by the enclosing * `Pipeline.addWindowFields()` stage. * - * The backend only accepts a frame (`documents` or `range`) here, and - * rejects `partition` and `sort`, which must be specified on the enclosing - * `addWindowFields()` stage. - * * @example * ```typescript * firestore.pipeline().collection("sales") @@ -3398,16 +3394,19 @@ export class AggregateFunction implements AggregateFunction, HasUserData { * ); * ``` * - * @param window - A `WindowSpec` containing the - * `documents` or `range` frame to evaluate this aggregate over. If omitted, the - * enclosing stage's frame is used. + * @param frame - The `documents` or `range` frame to evaluate this aggregate over. * @returns A new `WindowFunction`. */ - over(window?: firestore.Pipelines.WindowSpec): WindowFunction { + over( + frame: firestore.OneOf<{ + documents: firestore.Pipelines.DocumentWindowFrame; + range: firestore.Pipelines.RangeWindowFrame; + }>, + ): WindowFunction { return new WindowFunction( this.name, this.params, - window === undefined ? undefined : new WindowSpecInternal(window), + new WindowSpecInternal(frame), ); } @@ -11474,20 +11473,19 @@ export class WindowFunction * over the window frame defined by the enclosing * `Pipeline.addWindowFields()` stage. * - * The backend only accepts a frame (`documents` or `range`) here, and - * rejects `partition` and `sort`, which must be specified on the enclosing - * `addWindowFields()` stage. - * - * @param window - A `WindowSpec` containing - * the `documents` or `range` frame to evaluate this function over. If - * omitted, the enclosing stage's frame is used. + * @param frame - The `documents` or `range` frame to evaluate this function over. * @returns A new `WindowFunction`. */ - over(window?: firestore.Pipelines.WindowSpec): WindowFunction { + over( + frame: firestore.OneOf<{ + documents: firestore.Pipelines.DocumentWindowFrame; + range: firestore.Pipelines.RangeWindowFrame; + }>, + ): WindowFunction { return new WindowFunction( this.name, this.params, - window === undefined ? undefined : new WindowSpecInternal(window), + new WindowSpecInternal(frame), ); } @@ -11546,23 +11544,10 @@ export class WindowFunction export class AliasedWindowFunction implements firestore.Pipelines.AliasedWindowFunction, HasUserData { - /** - * @internal - */ - readonly _windowFunction: WindowFunction; - - /** - * @internal - */ - readonly _alias: string; - constructor( - readonly windowFunction: WindowFunction, - readonly alias: string, - ) { - this._windowFunction = windowFunction; - this._alias = alias; - } + readonly _windowFunction: WindowFunction, + readonly _alias: string, + ) {} /** * @internal @@ -11577,7 +11562,7 @@ export class AliasedWindowFunction * @internal */ _validateUserData(ignoreUndefinedProperties: boolean): void { - this.windowFunction._validateUserData(ignoreUndefinedProperties); + this._windowFunction._validateUserData(ignoreUndefinedProperties); } } @@ -11601,44 +11586,3 @@ export class AliasedWindowFunction export function rank(): WindowFunction { return new WindowFunction('rank', []); } - -/** - * Creates a window function that computes the rank of the current document - * within its window frame, without gaps in the ranking sequence. Documents that - * compare equal in the window sort order receive the same rank, and the next - * rank is always incremented by one. - * - * @example - * ```typescript - * firestore.pipeline().collection("employees") - * .addWindowFields( - * { partition: ['department'], sort: descending('salary') }, - * denseRank().as('salaryRank') - * ); - * ``` - * - * @returns A new `WindowFunction`. - */ -export function denseRank(): WindowFunction { - return new WindowFunction('dense_rank', []); -} - -/** - * Creates a window function that computes the sequential position of the - * current document within its window frame, starting at 1. Documents that - * compare equal in the window sort order receive distinct row numbers. - * - * @example - * ```typescript - * firestore.pipeline().collection("employees") - * .addWindowFields( - * { partition: ['department'], sort: descending('salary') }, - * rowNumber().as('salaryRowNumber') - * ); - * ``` - * - * @returns A new `WindowFunction`. - */ -export function rowNumber(): WindowFunction { - return new WindowFunction('row_number', []); -} diff --git a/handwritten/firestore/dev/src/pipelines/index.ts b/handwritten/firestore/dev/src/pipelines/index.ts index 7dfb6bab2996..dc22992a1224 100644 --- a/handwritten/firestore/dev/src/pipelines/index.ts +++ b/handwritten/firestore/dev/src/pipelines/index.ts @@ -175,7 +175,5 @@ export { WindowFunction, AliasedWindowFunction, rank, - denseRank, - rowNumber, // TODO(new-expression): Add new expression exports above this line } from './expression'; diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index ff93351afcd8..d008f48d928f 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -482,7 +482,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * average(field('amount')).as('movingAverageAmount') * ); * - * // 3. Document-based running total using default boundaries (unbounded preceding to current row). + * // 3. Document-based running total ('unbounded' preceding to 'current' document position, excluding later ties). * // Note: Offsets are physical document counts, so no time unit is required or used even when sorting on 'date'. * firestore.pipeline().collection("sales") * .addWindowFields( @@ -493,7 +493,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * sum(field('amount')).as('runningTotal') * ); * - * // 4. Range-based running average using default boundaries (unbounded preceding to current value) + * // 4. Range-based running average using default boundaries ('unbounded' preceding to 'current', including all peers with tied sort values) * firestore.pipeline().collection("products") * .addWindowFields( * { diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index d7455f499a3a..4594a89599ec 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -162,9 +162,7 @@ import { variable, currentDocument, subcollection, - denseRank, rank, - rowNumber, // TODO(new-expression): add new expression imports above this line } from '../src/pipelines'; @@ -8619,9 +8617,10 @@ describe.skipClassic('Pipeline window functions (count)', () => { describe('sorting and default framing', () => { it('computes a running count with the default (sorted) frame', async () => { - // The implicit default frame is a `range` frame, so the sort key must be - // numeric. `quantity` is 1..5 in the same order as `date`, and has no - // ties, so the running count is unambiguous. + // The implicit default frame when `sort` is specified is a `range` frame + // from `'unbounded'` preceding to `'current'` following. `quantity` is + // 1..5 in the same order as `date`, and has no ties, so the running + // count is unambiguous. const snapshot = await execute( firestore .pipeline() @@ -8747,6 +8746,32 @@ describe.skipClassic('Pipeline window functions (count)', () => { ); }); + it("does not include later tied peers with 'unbounded' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: [ascending('salesPrice'), ascending('date')], + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('salesPrice', 'runningCount'), + ); + + expectResults( + snapshot, + {salesPrice: 12, runningCount: 1}, + {salesPrice: 30, runningCount: 2}, + {salesPrice: 30, runningCount: 3}, + {salesPrice: 60, runningCount: 4}, + {salesPrice: 60, runningCount: 5}, + ); + }); + it("supports 'unbounded' preceding to 'unbounded' following", async () => { const snapshot = await execute( firestore @@ -8802,10 +8827,9 @@ describe.skipClassic('Pipeline window functions (count)', () => { }); describe('range framing', () => { - // In a `range` frame the `'current'` sentinel resolves to the current - // document only. A numeric `0` offset is what widens the frame to every - // document with an equal sort value (the SQL "peer group"). - it("counts only the current document with 'current' to 'current'", async () => { + // In a `range` frame the `'current'` sentinel is peer-inclusive (equivalent + // to a `0` offset) and includes every document with an equal sort value. + it("counts tied peers with 'current' to 'current'", async () => { const snapshot = await execute( firestore .pipeline() @@ -8827,8 +8851,8 @@ describe.skipClassic('Pipeline window functions (count)', () => { {product: 'phone', salesPrice: 12, samePriceCount: 1}, {product: 'phone', salesPrice: 30, samePriceCount: 1}, {product: 'tablet', salesPrice: 30, samePriceCount: 1}, - {product: 'tablet', salesPrice: 60, samePriceCount: 1}, - {product: 'tablet', salesPrice: 60, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, ); }); @@ -8859,6 +8883,32 @@ describe.skipClassic('Pipeline window functions (count)', () => { ); }); + it("includes tied peers with 'unbounded' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('salesPrice', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {salesPrice: 12, cumulativeCount: 1}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 60, cumulativeCount: 5}, + {salesPrice: 60, cumulativeCount: 5}, + ); + }); + it("includes tied peers with 'unbounded' preceding to a zero offset", async () => { const snapshot = await execute( firestore @@ -8885,6 +8935,58 @@ describe.skipClassic('Pipeline window functions (count)', () => { ); }); + it("supports a string sort key when bounds are 'unbounded' and 'current'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('product'), + range: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('product', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {product: 'phone', cumulativeCount: 2}, + {product: 'phone', cumulativeCount: 2}, + {product: 'tablet', cumulativeCount: 5}, + {product: 'tablet', cumulativeCount: 5}, + {product: 'tablet', cumulativeCount: 5}, + ); + }); + + it("supports multiple sort orderings when bounds are 'unbounded' and 'current'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: [ascending('product'), ascending('salesPrice')], + range: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('product', 'salesPrice', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {product: 'phone', salesPrice: 12, cumulativeCount: 1}, + {product: 'phone', salesPrice: 30, cumulativeCount: 2}, + {product: 'tablet', salesPrice: 30, cumulativeCount: 3}, + {product: 'tablet', salesPrice: 60, cumulativeCount: 5}, + {product: 'tablet', salesPrice: 60, cumulativeCount: 5}, + ); + }); + it("supports 'unbounded' to 'unbounded'", async () => { const snapshot = await execute( firestore @@ -9290,7 +9392,8 @@ describe.skipClassic('Pipeline window functions (count)', () => { .over({ partition: ['product'], documents: {preceding: 'unbounded', following: 'current'}, - }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) .as('windowCount'), ), ), @@ -9669,6 +9772,32 @@ describe.skipClassic( ); }); + it('computes a value based range window with fractional offsets', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 17.5, following: 0.5}, + }, + sum('salesPrice').as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('salesPrice', 'nearbyTotal'), + ); + + expectResults( + snapshot, + {salesPrice: 12, nearbyTotal: 12}, + {salesPrice: 30, nearbyTotal: 60}, + {salesPrice: 30, nearbyTotal: 60}, + {salesPrice: 60, nearbyTotal: 120}, + {salesPrice: 60, nearbyTotal: 120}, + ); + }); + it('computes a trailing 3 day total', async () => { const snapshot = await execute( firestore @@ -9758,10 +9887,6 @@ describe.skipClassic( }); }); - // `countAll()` serializes to a zero argument `count` function, which the - // backend maps to `FunctionKind.COUNT_ALL`. `WindowFunctionConverter` only - // handles `COUNT` today, so this currently fails with - // `UnsupportedOperationException: Unsupported window aggregator: COUNT_ALL`. describe('countAll', () => { it('counts every document in the window', async () => { const snapshot = await execute( @@ -9787,10 +9912,6 @@ describe.skipClassic( }); }); - // Numeric `documents` offsets depend on the `toAlgebraBound()` sign fix in - // cl/977837857. Before that CL every numeric `documents` bound (including - // `0`) fails with an INTERNAL error, so these live in the gated suite even - // though they only use `count()`. describe('documents framing with numeric offsets', () => { it('supports zero preceding and zero following', async () => { const snapshot = await execute( @@ -9873,7 +9994,7 @@ describe.skipClassic( }, ); -// TODO: Un-skip once backend support for rank(), denseRank() and rowNumber() lands. +// TODO: Un-skip once backend support for rank() lands. describe.skip('Pipeline window functions (ranking)', () => { let firestore: Firestore; let productSales: CollectionReference; @@ -9890,7 +10011,7 @@ describe.skip('Pipeline window functions (ranking)', () => { afterEach(() => verifyInstance(firestore as unknown as InternalFirestore)); - it('computes rank, denseRank and rowNumber', async () => { + it('computes rank', async () => { const snapshot = await execute( firestore .pipeline() @@ -9898,11 +10019,9 @@ describe.skip('Pipeline window functions (ranking)', () => { .addWindowFields( {sort: [ascending('salesPrice'), ascending('date')]}, rank().as('priceRank'), - denseRank().as('priceDenseRank'), - rowNumber().as('priceRowNumber'), ) .sort(ascending('date')) - .select('salesPrice', 'priceRank', 'priceDenseRank', 'priceRowNumber'), + .select('salesPrice', 'priceRank'), ); expectResults( @@ -9910,32 +10029,22 @@ describe.skip('Pipeline window functions (ranking)', () => { { salesPrice: 12, priceRank: 1, - priceDenseRank: 1, - priceRowNumber: 1, }, { salesPrice: 30, priceRank: 2, - priceDenseRank: 2, - priceRowNumber: 2, }, { salesPrice: 30, priceRank: 2, - priceDenseRank: 2, - priceRowNumber: 3, }, { salesPrice: 60, priceRank: 4, - priceDenseRank: 3, - priceRowNumber: 4, }, { salesPrice: 60, priceRank: 4, - priceDenseRank: 3, - priceRowNumber: 5, }, ); }); diff --git a/handwritten/firestore/dev/test/pipelines/add-window-fields.ts b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts index 7902644e116f..283cf44dad27 100644 --- a/handwritten/firestore/dev/test/pipelines/add-window-fields.ts +++ b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts @@ -35,7 +35,6 @@ const { countAll, countDistinct, countIf, - denseRank, descending, field, first, @@ -43,7 +42,6 @@ const { maximum, minimum, rank, - rowNumber, sum, toLower, } = Pipelines; @@ -592,21 +590,17 @@ describe('addWindowFields() serialization', () => { ); }); - it('serializes the ranking window functions', () => { + it('serializes the rank window function', () => { expect( fieldsArg( basePipeline().addWindowFields( {sort: descending('salesPrice')}, rank().as('rank'), - denseRank().as('denseRank'), - rowNumber().as('rowNumber'), ), ), ).to.deep.equal( map({ rank: fn('rank'), - denseRank: fn('dense_rank'), - rowNumber: fn('row_number'), }), ); }); @@ -711,17 +705,6 @@ describe('addWindowFields() serialization', () => { ); }); - it('does not wrap in over() when no frame is provided', () => { - expect( - fieldsArg( - basePipeline().addWindowFields( - {sort: ascending('date')}, - sum('salesPrice').over().as('total'), - ), - ), - ).to.deep.equal(map({total: fn('sum', fieldRef('salesPrice'))})); - }); - it('supports over() on a ranking window function', () => { expect( fieldsArg( @@ -750,11 +733,11 @@ describe('addWindowFields() serialization', () => { ); }); - // The SDK does not reject `partition`/`sort` in `over()`. They are encoded - // into the same window spec map as the frame, and the backend responds with - // `Window frame has unexpected fields: [partition]`. Leaving this to the - // backend means accumulator level partitioning starts working without an - // SDK change if the backend ever supports it. + // The SDK does not reject `partition`/`sort` in `over()` at runtime. They + // are encoded into the same window spec map as the frame, and the backend + // responds with `Window frame has unexpected fields: [partition]`. Leaving + // this to the backend means accumulator level partitioning starts working + // without a runtime SDK change if the backend ever supports it. it('encodes a partition supplied to over()', () => { const fields = fieldsArg( basePipeline().addWindowFields( @@ -763,7 +746,8 @@ describe('addWindowFields() serialization', () => { .over({ partition: ['product'], documents: {preceding: 1, following: 1}, - }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) .as('total'), ), ); @@ -790,7 +774,8 @@ describe('addWindowFields() serialization', () => { .over({ sort: ascending('date'), documents: {preceding: 1, following: 1}, - }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) .as('total'), ), ); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 42e618a94f5d..284f55ff66ff 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -6110,10 +6110,6 @@ declare namespace FirebaseFirestore { * over the window frame defined by the enclosing * {@link Pipeline.addWindowFields} stage. * - * The backend only accepts a frame (`documents` or `range`) here, and - * rejects `partition` and `sort`, which must be specified on the enclosing - * `addWindowFields()` stage. - * * @example * ```typescript * firestore.pipeline().collection("sales") @@ -6128,12 +6124,15 @@ declare namespace FirebaseFirestore { * ); * ``` * - * @param window - A {@link WindowSpec} containing the - * `documents` or `range` frame to evaluate this aggregate over. If omitted, the - * enclosing stage's frame is used. + * @param frame - The `documents` or `range` frame to evaluate this aggregate over. * @returns A new {@link WindowFunction}. */ - over(window?: WindowSpec): WindowFunction; + over( + frame: OneOf<{ + documents: DocumentWindowFrame; + range: RangeWindowFrame; + }>, + ): WindowFunction; } /** @@ -6172,16 +6171,15 @@ declare namespace FirebaseFirestore { * over the window frame defined by the enclosing * {@link Pipeline.addWindowFields} stage. * - * The backend only accepts a frame (`documents` or `range`) here, and - * rejects `partition` and `sort`, which must be specified on the enclosing - * `addWindowFields()` stage. - * - * @param window - A {@link WindowSpec} containing - * the `documents` or `range` frame to evaluate this function over. If - * omitted, the enclosing stage's frame is used. + * @param frame - The `documents` or `range` frame to evaluate this function over. * @returns A new {@link WindowFunction}. */ - over(window?: WindowSpec): WindowFunction; + over( + frame: OneOf<{ + documents: DocumentWindowFrame; + range: RangeWindowFrame; + }>, + ): WindowFunction; /** * Assigns an alias to this `WindowFunction`. The alias specifies the name that @@ -6197,17 +6195,14 @@ declare namespace FirebaseFirestore { * A {@link WindowFunction} with an alias. */ export class AliasedWindowFunction { - constructor(windowFunction: WindowFunction, alias: string); - - readonly windowFunction: WindowFunction; - readonly alias: string; - /** + * The underlying `WindowFunction` that this aliased window function wraps. * @internal */ readonly _windowFunction: WindowFunction; /** + * Specifies the name of the property that will contain the window function result in the output document. * @internal */ readonly _alias: string; @@ -12313,43 +12308,6 @@ declare namespace FirebaseFirestore { */ export function rank(): WindowFunction; - /** - * Creates a window function that computes the rank of the current document - * within its window frame, without gaps in the ranking sequence. Documents that - * compare equal in the window sort order receive the same rank, and the next - * rank is always incremented by one. - * - * @example - * ```typescript - * firestore.pipeline().collection("employees") - * .addWindowFields( - * { partition: ['department'], sort: descending('salary') }, - * denseRank().as('salaryRank') - * ); - * ``` - * - * @returns A new {@link WindowFunction}. - */ - export function denseRank(): WindowFunction; - - /** - * Creates a window function that computes the sequential position of the - * current document within its window frame, starting at 1. Documents that - * compare equal in the window sort order receive distinct row numbers. - * - * @example - * ```typescript - * firestore.pipeline().collection("employees") - * .addWindowFields( - * { partition: ['department'], sort: descending('salary') }, - * rowNumber().as('salaryRowNumber') - * ); - * ``` - * - * @returns A new {@link WindowFunction}. - */ - export function rowNumber(): WindowFunction; - // TODO(search) enable when supported by the backend // /** // * Evaluates if the value in the field specified by `fieldName` is between @@ -12742,7 +12700,7 @@ declare namespace FirebaseFirestore { * average(field('amount')).as('movingAverageAmount') * ); * - * // 3. Document-based running total using default boundaries (unbounded preceding to current row). + * // 3. Document-based running total ('unbounded' preceding to 'current' document position, excluding later ties). * // Note: Offsets are physical document counts, so no time unit is required or used even when sorting on 'date'. * firestore.pipeline().collection("sales") * .addWindowFields( @@ -12753,7 +12711,7 @@ declare namespace FirebaseFirestore { * sum(field('amount')).as('runningTotal') * ); * - * // 4. Range-based running average using default boundaries (unbounded preceding to current value) + * // 4. Range-based running average using default boundaries ('unbounded' preceding to 'current', including all peers with tied sort values) * firestore.pipeline().collection("products") * .addWindowFields( * { @@ -14020,13 +13978,18 @@ declare namespace FirebaseFirestore { fields: Selectable[]; }; + /** + * Defines a document-count based window frame relative to the position of the current document in the group. + * + * Specifying `sort` on the enclosing {@link WindowSpec} is optional for `documents` window frames; if no `sort` expressions are specified, documents are processed in incoming stream (fetch) order. + */ export interface DocumentWindowFrame { /** * The lower bound (inclusive) of the window frame, relative to the current document's position. * * Can be: * - A number specifying the number of documents preceding the current document. - * - `'current'` to represent the current document itself. + * - `'current'` to represent the current document's position (documents with tied sort values are not automatically included). * - `'unbounded'` to include all documents from the first document in the group. */ preceding: number | 'current' | 'unbounded' | Expression; @@ -14036,29 +13999,37 @@ declare namespace FirebaseFirestore { * * Can be: * - A number specifying the number of documents following the current document. - * - `'current'` to represent the current document itself. + * - `'current'` to represent the current document's position (documents with tied sort values are not automatically included). * - `'unbounded'` to include all documents to the last document in the group. */ following: number | 'current' | 'unbounded' | Expression; } + /** + * Defines a range-value based window frame relative to the sort value(s) of the current document. + * + * Sort requirements on the enclosing {@link WindowSpec}: + * - One or more `sort` expressions are required when specifying a `range` window frame. + * - If `preceding` and `following` use only `'current'` or `'unbounded'` (no numeric or time offsets), multiple `sort` expressions and non-numeric sort values (such as strings or booleans) are supported. + * - If `preceding` or `following` specifies a numeric offset (or a `unit` is specified for a time-based offset), exactly one `sort` expression is required and it must evaluate to a numeric or timestamp value. + */ export interface RangeWindowFrame { /** - * The lower bound (inclusive) of the window frame, relative to the sort value of the current document. + * The lower bound (inclusive) of the window frame, relative to the sort value(s) of the current document. * * Can be: - * - A number specifying the value-based offset from the current document's sort value. - * - `'current'` to represent only documents with the same sort value as the current document. + * - A number specifying the value-based offset from the current document's sort value (requires a single numeric or timestamp `sort` expression). + * - `'current'` to include all documents with the same sort value(s) as the current document (peers/ties, equivalent to an offset of `0`). * - `'unbounded'` to include all documents from the start of the group. */ preceding: number | 'current' | 'unbounded' | Expression; /** - * The upper bound (inclusive) of the window frame, relative to the sort value of the current document. + * The upper bound (inclusive) of the window frame, relative to the sort value(s) of the current document. * * Can be: - * - A number specifying the value-based offset from the current document's sort value. - * - `'current'` to represent only documents with the same sort value as the current document. + * - A number specifying the value-based offset from the current document's sort value (requires a single numeric or timestamp `sort` expression). + * - `'current'` to include all documents with the same sort value(s) as the current document (peers/ties, equivalent to an offset of `0`). * - `'unbounded'` to include all documents to the end of the group. */ following: number | 'current' | 'unbounded' | Expression; @@ -14074,14 +14045,6 @@ declare namespace FirebaseFirestore { | 'hour' | 'day' | 'week' - | 'week(monday)' - | 'week(tuesday)' - | 'week(wednesday)' - | 'week(thursday)' - | 'week(friday)' - | 'week(saturday)' - | 'week(sunday)' - | 'isoweek' | 'month' | 'quarter' | 'year' @@ -14093,7 +14056,7 @@ declare namespace FirebaseFirestore { * * Default frame behavior: * - If `sort` is not specified, the default frame is `documents` from `'unbounded'` preceding to `'unbounded'` following (the entire partition/group). - * - If `sort` is specified, the default frame is `range` from `'unbounded'` preceding to `'current'` row value. + * - If `sort` is specified, the default frame is `range` from `'unbounded'` preceding to `'current'` (which includes all documents with the same sort value(s) as the current document). */ export type WindowSpec = { /** @@ -14104,7 +14067,11 @@ declare namespace FirebaseFirestore { /** * The sort order of the documents in each group. * - * Setting a value for `sort` changes the default window frame behavior. + * - For `documents` window frames, `sort` is optional. If no `sort` expressions are specified, documents are processed in incoming stream (fetch) order. + * - For `range` window frames whose bounds use only `'current'` or `'unbounded'`, one or more `sort` expressions are required (and non-numeric sort values such as strings or booleans are supported). + * - For `range` window frames with numeric or time-based offsets, exactly one `sort` expression (numeric or timestamp) is required. + * + * Setting a value for `sort` also changes the default window frame behavior when neither `documents` nor `range` is specified. * See {@link WindowSpec} for default frame specifications. * * See {@link Ordering}. @@ -14112,14 +14079,18 @@ declare namespace FirebaseFirestore { sort?: Ordering | Ordering[]; } & OneOf<{ /** - * Defines a document-count based window frame relative to the position of the current document in the sorted group. + * Defines a document-count based window frame relative to the position of the current document in the group. + * + * Specifying `sort` is optional for `documents` window frames; if no `sort` expressions are specified, documents are processed in incoming stream (fetch) order. * * See {@link WindowSpec} for default frame specifications if `documents` or `range` is not set. */ documents?: DocumentWindowFrame; /** - * Defines a range-value based window frame relative to the sort value of the current document. + * Defines a range-value based window frame relative to the sort value(s) of the current document. + * + * One or more `sort` expressions are required when specifying a `range` window frame (and numeric or time-based offsets require a single numeric or timestamp `sort` expression). * * See {@link WindowSpec} for default frame specifications if `documents` or `range` is not set. */ From 50c3dfa22f09dacaaaaf357ae4968cfad4cbfbd1 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:07:34 +0000 Subject: [PATCH 4/8] docs(firestore): document and test negative window frame offsets --- .../firestore/dev/system-test/pipeline.ts | 88 +++++++++++++++++++ handwritten/firestore/types/firestore.d.ts | 8 +- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 4594a89599ec..9cb23cd166dd 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -9743,6 +9743,94 @@ describe.skipClassic( {product: 'tablet', lookAheadAverage: null, windowCount: 0}, ); }); + + it('computes a look behind average with a negative following bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to "documents between 2 preceding and 1 + // preceding": the window is [index - preceding, index + + // following]. + sort: ascending('date'), + documents: {preceding: 2, following: -1}, + }, + average('salesPrice').as('lookBehindAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookBehindAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookBehindAverage: null, windowCount: 0}, + {product: 'phone', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 21, windowCount: 2}, + {product: 'tablet', lookBehindAverage: 30, windowCount: 2}, + {product: 'tablet', lookBehindAverage: 45, windowCount: 2}, + ); + }); + + it('computes a range look behind average with a negative following bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to a range frame of [value - Infinity, value - 1], + // including only documents with a strictly lower salesPrice. + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: -1}, + }, + average('salesPrice').as('lookBehindAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookBehindAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookBehindAverage: null, windowCount: 0}, + {product: 'phone', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, + {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, + ); + }); + + it('computes a range look ahead average with a negative preceding bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to a range frame of [value + 1, value + Infinity], + // including only documents with a strictly higher salesPrice. + sort: ascending('salesPrice'), + range: {preceding: -1, following: 'unbounded'}, + }, + average('salesPrice').as('lookAheadAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 45, windowCount: 4}, + {product: 'phone', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + ); + }); }); describe('numeric and time range offsets', () => { diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 284f55ff66ff..a01b5de31491 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -13988,7 +13988,7 @@ declare namespace FirebaseFirestore { * The lower bound (inclusive) of the window frame, relative to the current document's position. * * Can be: - * - A number specifying the number of documents preceding the current document. + * - A number specifying a document offset relative to the current document. A positive integer (e.g. `2`) includes up to that many documents before the current document. A negative integer (e.g. `-1`) indicates a boundary following the current document, enabling frames that start after the current document. * - `'current'` to represent the current document's position (documents with tied sort values are not automatically included). * - `'unbounded'` to include all documents from the first document in the group. */ @@ -13998,7 +13998,7 @@ declare namespace FirebaseFirestore { * The upper bound (inclusive) of the window frame, relative to the current document's position. * * Can be: - * - A number specifying the number of documents following the current document. + * - A number specifying a document offset relative to the current document. A positive integer (e.g. `2`) includes up to that many documents after the current document. A negative integer (e.g. `-1`) indicates a boundary preceding the current document, enabling frames that end before the current document (e.g. excluding the current document). * - `'current'` to represent the current document's position (documents with tied sort values are not automatically included). * - `'unbounded'` to include all documents to the last document in the group. */ @@ -14018,7 +14018,7 @@ declare namespace FirebaseFirestore { * The lower bound (inclusive) of the window frame, relative to the sort value(s) of the current document. * * Can be: - * - A number specifying the value-based offset from the current document's sort value (requires a single numeric or timestamp `sort` expression). + * - A number specifying the value-based offset from the current document's sort value (requires a single numeric or timestamp `sort` expression). A positive offset subtracts from the current document's sort value (looking into the past). A negative offset adds to the current document's sort value (shifting the lower boundary past the current document). * - `'current'` to include all documents with the same sort value(s) as the current document (peers/ties, equivalent to an offset of `0`). * - `'unbounded'` to include all documents from the start of the group. */ @@ -14028,7 +14028,7 @@ declare namespace FirebaseFirestore { * The upper bound (inclusive) of the window frame, relative to the sort value(s) of the current document. * * Can be: - * - A number specifying the value-based offset from the current document's sort value (requires a single numeric or timestamp `sort` expression). + * - A number specifying the value-based offset from the current document's sort value (requires a single numeric or timestamp `sort` expression). A positive offset adds to the current document's sort value (looking into the future). A negative offset subtracts from the current document's sort value (shifting the upper boundary before the current document). * - `'current'` to include all documents with the same sort value(s) as the current document (peers/ties, equivalent to an offset of `0`). * - `'unbounded'` to include all documents to the end of the group. */ From fe6fe5dd516cf3f8b800debe21b269ce42910587 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:07:34 +0000 Subject: [PATCH 5/8] test(firestore): make window arrayAggDistinct assertions order-insensitive --- .../firestore/dev/system-test/pipeline.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 9cb23cd166dd..421a0f7b2250 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -9603,8 +9603,15 @@ describe.skipClassic( .select('product', 'allPrices', 'distinctPrices'), ); - expectResults( - snapshot, + const actual = snapshot.results.map(r => { + const data = r.data(); + data['distinctPrices'] = [...data['distinctPrices']].sort( + (a: number, b: number) => a - b, + ); + return data; + }); + + expect(actual).to.deep.equal([ { product: 'phone', allPrices: [12, 30], @@ -9618,19 +9625,19 @@ describe.skipClassic( { product: 'tablet', allPrices: [30, 60, 60], - distinctPrices: [60, 30], + distinctPrices: [30, 60], }, { product: 'tablet', allPrices: [30, 60, 60], - distinctPrices: [60, 30], + distinctPrices: [30, 60], }, { product: 'tablet', allPrices: [30, 60, 60], - distinctPrices: [60, 30], + distinctPrices: [30, 60], }, - ); + ]); }); it('computes a running total', async () => { From dcf5b047f02b026b3f87f302a658e2edc4ee71fa Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:55:02 +0000 Subject: [PATCH 6/8] test(firestore): remove window tests that bypass public types with casts --- .../firestore/dev/system-test/pipeline.ts | 85 ---------- .../dev/test/pipelines/add-window-fields.ts | 158 ------------------ 2 files changed, 243 deletions(-) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 421a0f7b2250..fdb1523f3c90 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -9336,91 +9336,6 @@ describe.skipClassic('Pipeline window functions (count)', () => { ), ).to.be.rejectedWith(Error); }); - - // The following combinations are deliberately not validated client side. - // These tests assert that the backend does reject them, so that removing - // the SDK checks does not silently let a malformed pipeline through. - it("rejects specifying both 'documents' and 'range'", async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - documents: {preceding: 'unbounded', following: 'current'}, - range: {preceding: 'unbounded', following: 'current'}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - count('quantity').as('windowCount'), - ), - ), - ).to.be.rejectedWith(Error, /documents.*range|range.*documents/i); - }); - - it('rejects an unrecognized frame bound', async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('quantity'), - documents: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - preceding: 'infinite' as any, - following: 'current', - }, - }, - count('quantity').as('windowCount'), - ), - ), - ).to.be.rejectedWith(Error); - }); - - it('rejects a partition supplied to an accumulator level over()', async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {sort: ascending('quantity')}, - count('quantity') - .over({ - partition: ['product'], - documents: {preceding: 'unbounded', following: 'current'}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) - .as('windowCount'), - ), - ), - ).to.be.rejectedWith(Error, /unexpected field/i); - }); - - it("rejects a 'unit' on a documents frame", async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: { - preceding: 'unbounded', - following: 'current', - unit: 'day', - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - count('quantity').as('windowCount'), - ), - ), - ).to.be.rejectedWith(Error); - }); }); }); diff --git a/handwritten/firestore/dev/test/pipelines/add-window-fields.ts b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts index 283cf44dad27..31d13b25ec58 100644 --- a/handwritten/firestore/dev/test/pipelines/add-window-fields.ts +++ b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts @@ -732,67 +732,6 @@ describe('addWindowFields() serialization', () => { }), ); }); - - // The SDK does not reject `partition`/`sort` in `over()` at runtime. They - // are encoded into the same window spec map as the frame, and the backend - // responds with `Window frame has unexpected fields: [partition]`. Leaving - // this to the backend means accumulator level partitioning starts working - // without a runtime SDK change if the backend ever supports it. - it('encodes a partition supplied to over()', () => { - const fields = fieldsArg( - basePipeline().addWindowFields( - {sort: ascending('date')}, - sum('salesPrice') - .over({ - partition: ['product'], - documents: {preceding: 1, following: 1}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) - .as('total'), - ), - ); - - expect(fields).to.deep.equal( - map({ - total: fn( - 'over', - fn('sum', fieldRef('salesPrice')), - map({ - partition: array(fieldRef('product')), - documents: map({preceding: int(1), following: int(1)}), - }), - ), - }), - ); - }); - - it('encodes a sort supplied to over()', () => { - const fields = fieldsArg( - basePipeline().addWindowFields( - {sort: ascending('date')}, - sum('salesPrice') - .over({ - sort: ascending('date'), - documents: {preceding: 1, following: 1}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) - .as('total'), - ), - ); - - expect(fields).to.deep.equal( - map({ - total: fn( - 'over', - fn('sum', fieldRef('salesPrice')), - map({ - sort: array(ordering(fieldRef('date'), 'ascending')), - documents: map({preceding: int(1), following: int(1)}), - }), - ), - }), - ); - }); }); describe('options overload', () => { @@ -837,101 +776,4 @@ describe('addWindowFields() serialization', () => { expect(stage.options).to.deep.equal({}); }); }); - - // The SDK deliberately does not validate these. Everything below is - // encodable, so it is sent to the backend, which owns validation and returns - // a precise error. This keeps the SDK from having to change whenever the - // backend relaxes or extends a rule. - describe('backend validated combinations are still encoded', () => { - it("encodes both 'documents' and 'range' when both are supplied", () => { - const windowSpec = windowSpecArg( - basePipeline().addWindowFields( - { - sort: ascending('date'), - // Deliberately bypassing the OneOf type. The backend replies - // "Cannot specify both 'documents' and 'range' window frames." - documents: {preceding: 1, following: 1}, - range: {preceding: 2, following: 2}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - countAll().as('c'), - ), - ); - - expect(windowSpec).to.deep.equal( - map({ - sort: array(ordering(fieldRef('date'), 'ascending')), - documents: map({preceding: int(1), following: int(1)}), - range: map({preceding: int(2), following: int(2)}), - }), - ); - }); - - it('passes through an unrecognized frame bound string', () => { - const windowSpec = windowSpecArg( - basePipeline().addWindowFields( - { - sort: ascending('date'), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - documents: {preceding: 'infinite' as any, following: 'current'}, - }, - countAll().as('c'), - ), - ); - - expect(windowSpec).to.deep.equal( - map({ - sort: array(ordering(fieldRef('date'), 'ascending')), - documents: map({ - preceding: str('infinite'), - following: str('current'), - }), - }), - ); - }); - - it("encodes 'unit' even on a 'documents' frame", () => { - const windowSpec = windowSpecArg( - basePipeline().addWindowFields( - { - sort: ascending('date'), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - documents: {preceding: 1, following: 1, unit: 'day'} as any, - }, - countAll().as('c'), - ), - ); - - expect(windowSpec).to.deep.equal( - map({ - sort: array(ordering(fieldRef('date'), 'ascending')), - documents: map({ - preceding: int(1), - following: int(1), - unit: str('day'), - }), - }), - ); - }); - - it('omits a bound that was not supplied', () => { - const windowSpec = windowSpecArg( - basePipeline().addWindowFields( - { - sort: ascending('date'), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - documents: {preceding: 'unbounded'} as any, - }, - countAll().as('c'), - ), - ); - - expect(windowSpec).to.deep.equal( - map({ - sort: array(ordering(fieldRef('date'), 'ascending')), - documents: map({preceding: str('unbounded')}), - }), - ); - }); - }); }); From 7c34535ceebbcc5c4e0c0fa630dba45b31e857ac Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:22:34 +0000 Subject: [PATCH 7/8] test(firestore): trim default window functions integration suite and gate comprehensive tests --- .../firestore/dev/system-test/pipeline.ts | 1490 +++++++++-------- .../dev/test/pipelines/add-window-fields.ts | 78 + 2 files changed, 878 insertions(+), 690 deletions(-) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 373f01833a32..cf2da82b62fa 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -8982,13 +8982,190 @@ const windowTestDocs: {[id: string]: DocumentData} = { }, }; -describe.skipClassic('Pipeline window functions (count)', () => { +function execute(ppl: Pipeline): Promise { + return ppl.execute(); +} + +describe.skipClassic('Pipeline window functions', () => { let firestore: Firestore; let productSales: CollectionReference; - async function execute(ppl: Pipeline): Promise { - return ppl.execute(); - } + before(async () => { + productSales = getTestRoot(); + await testCollectionWithDocs(productSales, windowTestDocs); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore)); + + it('computes running aggregates over a documents frame partitioned by a field and an expression and sorted by multiple keys', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product', toUpper('region')], + sort: [ascending('date'), descending('salesPrice')], + documents: {preceding: 'unbounded', following: constant(0)}, + }, + sum('salesPrice').as('stats.runningTotal'), + arrayAgg('quantity').as('quantities'), + countAll().as('count'), + ) + .sort(ascending('date')) + .select('product', 'stats', 'quantities', 'count'), + ); + + expectResults( + snapshot, + { + product: 'phone', + stats: {runningTotal: 12}, + quantities: [1], + count: 1, + }, + { + product: 'phone', + stats: {runningTotal: 30}, + quantities: [2], + count: 1, + }, + { + product: 'tablet', + stats: {runningTotal: 30}, + quantities: [3], + count: 1, + }, + { + product: 'tablet', + stats: {runningTotal: 60}, + quantities: [4], + count: 1, + }, + { + product: 'tablet', + stats: {runningTotal: 90}, + quantities: [3, 5], + count: 2, + }, + ); + }); + + it('counts documents in a time unit range frame using the options overload', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales.path) + .addWindowFields({ + window: { + sort: ascending('date'), + range: { + preceding: 12, + following: 'current', + unit: 'hour', + }, + }, + fields: [count('quantity').as('windowCount')], + }) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 2}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); + + it('computes look ahead and nearby aggregates with accumulator level frames', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('quantity')}, + average('salesPrice') + .over({documents: {preceding: -1, following: 2}}) + .as('lookAheadAverage'), + sum('salesPrice') + .over({range: {preceding: 1.5, following: 0.5}}) + .as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'nearbyTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 30, nearbyTotal: 12}, + {product: 'phone', lookAheadAverage: 45, nearbyTotal: 42}, + {product: 'tablet', lookAheadAverage: 60, nearbyTotal: 60}, + {product: 'tablet', lookAheadAverage: 60, nearbyTotal: 90}, + {product: 'tablet', lookAheadAverage: null, nearbyTotal: 120}, + ); + }); + + it('rejects a range frame without a sort', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {range: {preceding: 'unbounded', following: 'current'}}, + count('quantity').as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error, /range/i); + }); + + it('rejects duplicate aliases and unsupported field values', () => { + expect(() => + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('total'), + average('salesPrice').as('total'), + ), + ).to.throw(Error, /Duplicate alias or field 'total'/); + + expect(() => { + void execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: [constant(undefined as unknown as string)]}, + countAll().as('windowCount'), + ), + ); + }).to.throw(Error, /Cannot use "undefined" as a Firestore value/); + }); +}); + +// Comprehensive window function functional tests. Skipped by default on CI and +// local runs because the backend already tests window function semantics, and +// the active suite above exercises the SDK's window function code paths, server +// errors, and client-side validation. Set FIRESTORE_RUN_COMPREHENSIVE_TESTS=true +// to run the full matrix. +const runComprehensiveTests = + process.env.FIRESTORE_RUN_COMPREHENSIVE_TESTS === 'true' || + process.env.FIRESTORE_RUN_COMPREHENSIVE_TESTS === 'YES'; + +const comprehensiveDescribe = runComprehensiveTests + ? describe.skipClassic + : describe.skip; + +comprehensiveDescribe('Pipeline window functions (count)', () => { + let firestore: Firestore; + let productSales: CollectionReference; before(async () => { productSales = getTestRoot(); @@ -8996,7 +9173,7 @@ describe.skipClassic('Pipeline window functions (count)', () => { firestore = productSales.firestore; }); - afterEach(() => verifyInstance(firestore as unknown as InternalFirestore)); + afterEach(() => verifyInstance(firestore)); describe('partitioning', () => { it('treats an empty window spec as a single global window', async () => { @@ -9092,9 +9269,9 @@ describe.skipClassic('Pipeline window functions (count)', () => { describe('sorting and default framing', () => { it('computes a running count with the default (sorted) frame', async () => { // The implicit default frame when `sort` is specified is a `range` frame - // from `'unbounded'` preceding to `'current'` following. `quantity` is - // 1..5 in the same order as `date`, and has no ties, so the running - // count is unambiguous. + // from `'unbounded'` preceding to `'current'` following (peer-inclusive). + // `quantity` is 1..5 in the same order as `date` and has no ties, so the + // running count is 1..5. const snapshot = await execute( firestore .pipeline() @@ -9220,32 +9397,6 @@ describe.skipClassic('Pipeline window functions (count)', () => { ); }); - it("does not include later tied peers with 'unbounded' preceding to 'current' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: [ascending('salesPrice'), ascending('date')], - documents: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('runningCount'), - ) - .sort(ascending('date')) - .select('salesPrice', 'runningCount'), - ); - - expectResults( - snapshot, - {salesPrice: 12, runningCount: 1}, - {salesPrice: 30, runningCount: 2}, - {salesPrice: 30, runningCount: 3}, - {salesPrice: 60, runningCount: 4}, - {salesPrice: 60, runningCount: 5}, - ); - }); - it("supports 'unbounded' preceding to 'unbounded' following", async () => { const snapshot = await execute( firestore @@ -9298,11 +9449,39 @@ describe.skipClassic('Pipeline window functions (count)', () => { {product: 'tablet', windowCount: 1}, ); }); + + it("excludes tied peers with 'unbounded' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('runningCount'), + ) + .sort(ascending('runningCount')) + .select('salesPrice', 'runningCount'), + ); + + expectResults( + snapshot, + {salesPrice: 12, runningCount: 1}, + {salesPrice: 30, runningCount: 2}, + {salesPrice: 30, runningCount: 3}, + {salesPrice: 60, runningCount: 4}, + {salesPrice: 60, runningCount: 5}, + ); + }); }); describe('range framing', () => { - // In a `range` frame the `'current'` sentinel is peer-inclusive (equivalent - // to a `0` offset) and includes every document with an equal sort value. + // In a `range` frame the `'current'` sentinel is peer-inclusive (like SQL + // `CURRENT ROW` in RANGE mode), equivalent to a numeric `0` offset: it + // includes every document whose sort value(s) tie with the current + // document. it("counts tied peers with 'current' to 'current'", async () => { const snapshot = await execute( firestore @@ -9442,22 +9621,22 @@ describe.skipClassic('Pipeline window functions (count)', () => { .collection(productSales) .addWindowFields( { - sort: [ascending('product'), ascending('salesPrice')], + sort: [ascending('product'), ascending('region')], range: {preceding: 'unbounded', following: 'current'}, }, count('quantity').as('cumulativeCount'), ) .sort(ascending('date')) - .select('product', 'salesPrice', 'cumulativeCount'), + .select('product', 'region', 'cumulativeCount'), ); expectResults( snapshot, - {product: 'phone', salesPrice: 12, cumulativeCount: 1}, - {product: 'phone', salesPrice: 30, cumulativeCount: 2}, - {product: 'tablet', salesPrice: 30, cumulativeCount: 3}, - {product: 'tablet', salesPrice: 60, cumulativeCount: 5}, - {product: 'tablet', salesPrice: 60, cumulativeCount: 5}, + {product: 'phone', region: 'east', cumulativeCount: 1}, + {product: 'phone', region: 'west', cumulativeCount: 2}, + {product: 'tablet', region: 'east', cumulativeCount: 4}, + {product: 'tablet', region: 'west', cumulativeCount: 5}, + {product: 'tablet', region: 'east', cumulativeCount: 4}, ); }); @@ -9488,7 +9667,7 @@ describe.skipClassic('Pipeline window functions (count)', () => { ); }); - it('supports a time unit on a date sorted range frame', async () => { + it('applies the time unit to a range frame offset', async () => { const snapshot = await execute( firestore .pipeline() @@ -9497,24 +9676,24 @@ describe.skipClassic('Pipeline window functions (count)', () => { { sort: ascending('date'), range: { - preceding: 'unbounded', + preceding: 12, following: 'current', - unit: 'day', + unit: 'hour', }, }, - count('quantity').as('cumulativeCount'), + count('quantity').as('windowCount'), ) .sort(ascending('date')) - .select('product', 'cumulativeCount'), + .select('product', 'windowCount'), ); expectResults( snapshot, - {product: 'phone', cumulativeCount: 1}, - {product: 'phone', cumulativeCount: 2}, - {product: 'tablet', cumulativeCount: 3}, - {product: 'tablet', cumulativeCount: 4}, - {product: 'tablet', cumulativeCount: 5}, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 2}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, ); }); }); @@ -9792,708 +9971,639 @@ describe.skipClassic('Pipeline window functions (count)', () => { ).to.be.rejectedWith(Error); }); - it('rejects partially specified accumulator level framing', async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {sort: ascending('date')}, - count('quantity') - .over({ - documents: {preceding: 'unbounded', following: 'current'}, - }) - .as('framed'), - count('quantity').as('unframed'), - ), - ), - ).to.be.rejectedWith(Error); + it('evaluates accumulators without over() using the default frame when others use over()', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('date')}, + count('quantity') + .over({documents: {preceding: 0, following: 0}}) + .as('framed'), + count('quantity').as('unframed'), + ) + .sort(ascending('date')) + .select('product', 'framed', 'unframed'), + ); + + expectResults( + snapshot, + {product: 'phone', framed: 1, unframed: 1}, + {product: 'phone', framed: 1, unframed: 2}, + {product: 'tablet', framed: 1, unframed: 3}, + {product: 'tablet', framed: 1, unframed: 4}, + {product: 'tablet', framed: 1, unframed: 5}, + ); }); }); }); -describe.skipClassic( - 'Pipeline window functions (aggregators)', - () => { - let firestore: Firestore; - let productSales: CollectionReference; - - async function execute(ppl: Pipeline): Promise { - return ppl.execute(); - } +comprehensiveDescribe('Pipeline window functions (aggregators)', () => { + let firestore: Firestore; + let productSales: CollectionReference; - before(async () => { - productSales = getTestRoot(); - await testCollectionWithDocs(productSales, windowTestDocs); - firestore = productSales.firestore; - }); + before(async () => { + productSales = getTestRoot(); + await testCollectionWithDocs(productSales, windowTestDocs); + firestore = productSales.firestore; + }); - afterEach(() => verifyInstance(firestore as unknown as InternalFirestore)); + afterEach(() => verifyInstance(firestore)); - describe('numeric aggregators over a partition', () => { - it('computes sum, average, minimum and maximum', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - sum('salesPrice').as('total'), - average('salesPrice').as('averagePrice'), - minimum('salesPrice').as('minimumPrice'), - maximum('salesPrice').as('maximumPrice'), - ) - .sort(ascending('date')) - .select( - 'product', - 'total', - 'averagePrice', - 'minimumPrice', - 'maximumPrice', - ), - ); + describe('numeric aggregators over a partition', () => { + it('computes sum, average, minimum and maximum', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('total'), + average('salesPrice').as('averagePrice'), + minimum('salesPrice').as('minimumPrice'), + maximum('salesPrice').as('maximumPrice'), + ) + .sort(ascending('date')) + .select( + 'product', + 'total', + 'averagePrice', + 'minimumPrice', + 'maximumPrice', + ), + ); - expectResults( - snapshot, - { - product: 'phone', - total: 42, - averagePrice: 21, - minimumPrice: 12, - maximumPrice: 30, - }, - { - product: 'phone', - total: 42, - averagePrice: 21, - minimumPrice: 12, - maximumPrice: 30, - }, - { - product: 'tablet', - total: 150, - averagePrice: 50, - minimumPrice: 30, - maximumPrice: 60, - }, - { - product: 'tablet', - total: 150, - averagePrice: 50, - minimumPrice: 30, - maximumPrice: 60, - }, - { - product: 'tablet', - total: 150, - averagePrice: 50, - minimumPrice: 30, - maximumPrice: 60, - }, - ); - }); + expectResults( + snapshot, + { + product: 'phone', + total: 42, + averagePrice: 21, + minimumPrice: 12, + maximumPrice: 30, + }, + { + product: 'phone', + total: 42, + averagePrice: 21, + minimumPrice: 12, + maximumPrice: 30, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + ); + }); - // TODO: Un-skip once countIf and countDistinct window functions are supported by the backend. - it.skip('computes countIf and countDistinct', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - countIf(field('salesPrice').greaterThan(20)).as('expensiveCount'), - countDistinct('salesPrice').as('distinctPriceCount'), - ) - .sort(ascending('date')) - .select('product', 'expensiveCount', 'distinctPriceCount'), - ); + // Un-skip once the backend supports countIf and countDistinct in addWindowFields. + it.skip('computes countIf', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countIf(field('salesPrice').greaterThan(20)).as('expensiveCount'), + ) + .sort(ascending('date')) + .select('product', 'expensiveCount'), + ); - expectResults( - snapshot, - {product: 'phone', expensiveCount: 1, distinctPriceCount: 2}, - {product: 'phone', expensiveCount: 1, distinctPriceCount: 2}, - {product: 'tablet', expensiveCount: 3, distinctPriceCount: 2}, - {product: 'tablet', expensiveCount: 3, distinctPriceCount: 2}, - {product: 'tablet', expensiveCount: 3, distinctPriceCount: 2}, - ); - }); - - it('aggregates over a computed expression', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - sum(multiply(field('salesPrice'), field('quantity'))).as( - 'totalRevenue', - ), - ) - .sort(ascending('date')) - .select('product', 'totalRevenue'), - ); - - expectResults( - snapshot, - {product: 'phone', totalRevenue: 72}, - {product: 'phone', totalRevenue: 72}, - {product: 'tablet', totalRevenue: 630}, - {product: 'tablet', totalRevenue: 630}, - {product: 'tablet', totalRevenue: 630}, - ); - }); + expectResults( + snapshot, + {product: 'phone', expensiveCount: 1}, + {product: 'phone', expensiveCount: 1}, + {product: 'tablet', expensiveCount: 3}, + {product: 'tablet', expensiveCount: 3}, + {product: 'tablet', expensiveCount: 3}, + ); }); - describe('ordered aggregators', () => { - it('computes first and last over the whole partition', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'unbounded'}, - }, - first('salesPrice').as('firstPrice'), - last('salesPrice').as('lastPrice'), - ) - .sort(ascending('date')) - .select('product', 'firstPrice', 'lastPrice'), - ); - - expectResults( - snapshot, - {product: 'phone', firstPrice: 12, lastPrice: 30}, - {product: 'phone', firstPrice: 12, lastPrice: 30}, - {product: 'tablet', firstPrice: 30, lastPrice: 60}, - {product: 'tablet', firstPrice: 30, lastPrice: 60}, - {product: 'tablet', firstPrice: 30, lastPrice: 60}, - ); - }); + // Un-skip once the backend supports countIf and countDistinct in addWindowFields. + it.skip('computes countDistinct', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countDistinct('salesPrice').as('distinctPriceCount'), + ) + .sort(ascending('date')) + .select('product', 'distinctPriceCount'), + ); - it('computes arrayAgg and arrayAggDistinct', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'unbounded'}, - }, - arrayAgg('salesPrice').as('allPrices'), - arrayAggDistinct('salesPrice').as('distinctPrices'), - ) - .sort(ascending('date')) - .select('product', 'allPrices', 'distinctPrices'), - ); + expectResults( + snapshot, + {product: 'phone', distinctPriceCount: 2}, + {product: 'phone', distinctPriceCount: 2}, + {product: 'tablet', distinctPriceCount: 2}, + {product: 'tablet', distinctPriceCount: 2}, + {product: 'tablet', distinctPriceCount: 2}, + ); + }); - const actual = snapshot.results.map(r => { - const data = r.data(); - data['distinctPrices'] = [...data['distinctPrices']].sort( - (a: number, b: number) => a - b, - ); - return data; - }); + it('aggregates over a computed expression', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum(multiply(field('salesPrice'), field('quantity'))).as( + 'totalRevenue', + ), + ) + .sort(ascending('date')) + .select('product', 'totalRevenue'), + ); - expect(actual).to.deep.equal([ - { - product: 'phone', - allPrices: [12, 30], - distinctPrices: [12, 30], - }, - { - product: 'phone', - allPrices: [12, 30], - distinctPrices: [12, 30], - }, - { - product: 'tablet', - allPrices: [30, 60, 60], - distinctPrices: [30, 60], - }, - { - product: 'tablet', - allPrices: [30, 60, 60], - distinctPrices: [30, 60], - }, - { - product: 'tablet', - allPrices: [30, 60, 60], - distinctPrices: [30, 60], - }, - ]); - }); + expectResults( + snapshot, + {product: 'phone', totalRevenue: 72}, + {product: 'phone', totalRevenue: 72}, + {product: 'tablet', totalRevenue: 630}, + {product: 'tablet', totalRevenue: 630}, + {product: 'tablet', totalRevenue: 630}, + ); + }); + }); - it('computes a running total', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'current'}, - }, - sum('salesPrice').as('runningTotal'), - ) - .sort(ascending('date')) - .select('product', 'runningTotal'), - ); + describe('ordered aggregators', () => { + it('computes first and last over the whole partition', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + first('salesPrice').as('firstPrice'), + last('salesPrice').as('lastPrice'), + ) + .sort(ascending('date')) + .select('product', 'firstPrice', 'lastPrice'), + ); - expectResults( - snapshot, - {product: 'phone', runningTotal: 12}, - {product: 'phone', runningTotal: 42}, - {product: 'tablet', runningTotal: 72}, - {product: 'tablet', runningTotal: 132}, - {product: 'tablet', runningTotal: 192}, - ); - }); + expectResults( + snapshot, + {product: 'phone', firstPrice: 12, lastPrice: 30}, + {product: 'phone', firstPrice: 12, lastPrice: 30}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + ); }); - describe('numeric documents offsets', () => { - it('computes a centered moving average', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 1, following: 1}, - }, - average('salesPrice').as('movingAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'movingAverage', 'windowCount'), - ); + it('computes arrayAgg and arrayAggDistinct', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + arrayAgg('salesPrice').as('allPrices'), + arrayAggDistinct('salesPrice').as('distinctPrices'), + ) + .sort(ascending('date')) + .select('product', 'allPrices', 'distinctPrices'), + ); - expectResults( - snapshot, - {product: 'phone', movingAverage: 21, windowCount: 2}, - {product: 'phone', movingAverage: 24, windowCount: 3}, - {product: 'tablet', movingAverage: 40, windowCount: 3}, - {product: 'tablet', movingAverage: 50, windowCount: 3}, - {product: 'tablet', movingAverage: 60, windowCount: 2}, + const results = snapshot.results.map(r => { + const data = r.data(); + data['distinctPrices'] = [...data['distinctPrices']].sort( + (a: number, b: number) => a - b, ); + return data; }); - it('computes a trailing sum', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 2, following: 0}, - }, - sum('salesPrice').as('trailingTotal'), - ) - .sort(ascending('date')) - .select('product', 'trailingTotal'), - ); - - expectResults( - snapshot, - {product: 'phone', trailingTotal: 12}, - {product: 'phone', trailingTotal: 42}, - {product: 'tablet', trailingTotal: 72}, - {product: 'tablet', trailingTotal: 120}, - {product: 'tablet', trailingTotal: 150}, - ); - }); + expect(results).to.deep.equal([ + { + product: 'phone', + allPrices: [12, 30], + distinctPrices: [12, 30], + }, + { + product: 'phone', + allPrices: [12, 30], + distinctPrices: [12, 30], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + ]); + }); - it('computes a look ahead average with a negative preceding bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to "documents between 1 following and 2 - // following": the window is [index - preceding, index + - // following]. - sort: ascending('date'), - documents: {preceding: -1, following: 2}, - }, - average('salesPrice').as('lookAheadAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookAheadAverage', 'windowCount'), - ); + it('computes a running total', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + sum('salesPrice').as('runningTotal'), + ) + .sort(ascending('date')) + .select('product', 'runningTotal'), + ); - expectResults( - snapshot, - {product: 'phone', lookAheadAverage: 30, windowCount: 2}, - {product: 'phone', lookAheadAverage: 45, windowCount: 2}, - {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, - {product: 'tablet', lookAheadAverage: 60, windowCount: 1}, - {product: 'tablet', lookAheadAverage: null, windowCount: 0}, - ); - }); + expectResults( + snapshot, + {product: 'phone', runningTotal: 12}, + {product: 'phone', runningTotal: 42}, + {product: 'tablet', runningTotal: 72}, + {product: 'tablet', runningTotal: 132}, + {product: 'tablet', runningTotal: 192}, + ); + }); + }); - it('computes a look behind average with a negative following bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to "documents between 2 preceding and 1 - // preceding": the window is [index - preceding, index + - // following]. - sort: ascending('date'), - documents: {preceding: 2, following: -1}, - }, - average('salesPrice').as('lookBehindAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookBehindAverage', 'windowCount'), - ); + describe('numeric documents offsets', () => { + it('computes a centered moving average', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 1, following: 1}, + }, + average('salesPrice').as('movingAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'movingAverage', 'windowCount'), + ); - expectResults( - snapshot, - {product: 'phone', lookBehindAverage: null, windowCount: 0}, - {product: 'phone', lookBehindAverage: 12, windowCount: 1}, - {product: 'tablet', lookBehindAverage: 21, windowCount: 2}, - {product: 'tablet', lookBehindAverage: 30, windowCount: 2}, - {product: 'tablet', lookBehindAverage: 45, windowCount: 2}, - ); - }); + expectResults( + snapshot, + {product: 'phone', movingAverage: 21, windowCount: 2}, + {product: 'phone', movingAverage: 24, windowCount: 3}, + {product: 'tablet', movingAverage: 40, windowCount: 3}, + {product: 'tablet', movingAverage: 50, windowCount: 3}, + {product: 'tablet', movingAverage: 60, windowCount: 2}, + ); + }); - it('computes a range look behind average with a negative following bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to a range frame of [value - Infinity, value - 1], - // including only documents with a strictly lower salesPrice. - sort: ascending('salesPrice'), - range: {preceding: 'unbounded', following: -1}, - }, - average('salesPrice').as('lookBehindAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookBehindAverage', 'windowCount'), - ); + it('computes a trailing sum', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 2, following: 0}, + }, + sum('salesPrice').as('trailingTotal'), + ) + .sort(ascending('date')) + .select('product', 'trailingTotal'), + ); - expectResults( - snapshot, - {product: 'phone', lookBehindAverage: null, windowCount: 0}, - {product: 'phone', lookBehindAverage: 12, windowCount: 1}, - {product: 'tablet', lookBehindAverage: 12, windowCount: 1}, - {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, - {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, - ); - }); + expectResults( + snapshot, + {product: 'phone', trailingTotal: 12}, + {product: 'phone', trailingTotal: 42}, + {product: 'tablet', trailingTotal: 72}, + {product: 'tablet', trailingTotal: 120}, + {product: 'tablet', trailingTotal: 150}, + ); + }); - it('computes a range look ahead average with a negative preceding bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to a range frame of [value + 1, value + Infinity], - // including only documents with a strictly higher salesPrice. - sort: ascending('salesPrice'), - range: {preceding: -1, following: 'unbounded'}, - }, - average('salesPrice').as('lookAheadAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookAheadAverage', 'windowCount'), - ); + it('computes a look ahead average with a negative preceding bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to "documents between 1 following and 2 + // following": the window is [index - preceding, index + + // following]. + sort: ascending('date'), + documents: {preceding: -1, following: 2}, + }, + average('salesPrice').as('lookAheadAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'windowCount'), + ); - expectResults( - snapshot, - {product: 'phone', lookAheadAverage: 45, windowCount: 4}, - {product: 'phone', lookAheadAverage: 60, windowCount: 2}, - {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, - {product: 'tablet', lookAheadAverage: null, windowCount: 0}, - {product: 'tablet', lookAheadAverage: null, windowCount: 0}, - ); - }); + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 30, windowCount: 2}, + {product: 'phone', lookAheadAverage: 45, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 1}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + ); }); - describe('numeric and time range offsets', () => { - it('computes a value based range window', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - range: {preceding: 20, following: 0}, - }, - sum('salesPrice').as('nearbyTotal'), - ) - .sort(ascending('date')) - .select('salesPrice', 'nearbyTotal'), - ); + it('computes a look behind average with a negative following bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to "documents between 2 preceding and 1 + // preceding": the window is [index - preceding, index + + // following]. + sort: ascending('date'), + documents: {preceding: 2, following: -1}, + }, + average('salesPrice').as('lookBehindAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookBehindAverage', 'windowCount'), + ); - expectResults( - snapshot, - {salesPrice: 12, nearbyTotal: 12}, - {salesPrice: 30, nearbyTotal: 72}, - {salesPrice: 30, nearbyTotal: 72}, - {salesPrice: 60, nearbyTotal: 120}, - {salesPrice: 60, nearbyTotal: 120}, - ); - }); + expectResults( + snapshot, + {product: 'phone', lookBehindAverage: null, windowCount: 0}, + {product: 'phone', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 21, windowCount: 2}, + {product: 'tablet', lookBehindAverage: 30, windowCount: 2}, + {product: 'tablet', lookBehindAverage: 45, windowCount: 2}, + ); + }); - it('computes a value based range window with fractional offsets', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - range: {preceding: 17.5, following: 0.5}, - }, - sum('salesPrice').as('nearbyTotal'), - ) - .sort(ascending('date')) - .select('salesPrice', 'nearbyTotal'), - ); + it('computes a range look behind average with a negative following bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to a range frame of [value - Infinity, value - 1], + // including only documents with a strictly lower salesPrice. + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: -1}, + }, + average('salesPrice').as('lookBehindAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookBehindAverage', 'windowCount'), + ); - expectResults( - snapshot, - {salesPrice: 12, nearbyTotal: 12}, - {salesPrice: 30, nearbyTotal: 60}, - {salesPrice: 30, nearbyTotal: 60}, - {salesPrice: 60, nearbyTotal: 120}, - {salesPrice: 60, nearbyTotal: 120}, - ); - }); + expectResults( + snapshot, + {product: 'phone', lookBehindAverage: null, windowCount: 0}, + {product: 'phone', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, + {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, + ); + }); - it('computes a trailing 3 day total', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - range: {preceding: 3, following: 'current', unit: 'day'}, - }, - sum('salesPrice').as('threeDayTotal'), - ) - .sort(ascending('date')) - .select('product', 'threeDayTotal'), - ); + it('computes a range look ahead average with a negative preceding bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to a range frame of [value + 1, value + Infinity], + // including only documents with a strictly higher salesPrice. + sort: ascending('salesPrice'), + range: {preceding: -1, following: 'unbounded'}, + }, + average('salesPrice').as('lookAheadAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'windowCount'), + ); - expectResults( - snapshot, - {product: 'phone', threeDayTotal: 12}, - {product: 'phone', threeDayTotal: 42}, - {product: 'tablet', threeDayTotal: 72}, - {product: 'tablet', threeDayTotal: 132}, - {product: 'tablet', threeDayTotal: 60}, - ); - }); + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 45, windowCount: 4}, + {product: 'phone', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + ); + }); + }); - it('computes a cumulative total with an unbounded date range', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - range: { - preceding: 'unbounded', - following: 'current', - unit: 'day', - }, - }, - sum('salesPrice').as('cumulativeTotal'), - ) - .sort(ascending('date')) - .select('product', 'cumulativeTotal'), - ); + describe('numeric and time range offsets', () => { + it('computes a value based range window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 20, following: 0}, + }, + sum('salesPrice').as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('salesPrice', 'nearbyTotal'), + ); - expectResults( - snapshot, - {product: 'phone', cumulativeTotal: 12}, - {product: 'phone', cumulativeTotal: 42}, - {product: 'tablet', cumulativeTotal: 72}, - {product: 'tablet', cumulativeTotal: 132}, - {product: 'tablet', cumulativeTotal: 192}, - ); - }); + expectResults( + snapshot, + {salesPrice: 12, nearbyTotal: 12}, + {salesPrice: 30, nearbyTotal: 72}, + {salesPrice: 30, nearbyTotal: 72}, + {salesPrice: 60, nearbyTotal: 120}, + {salesPrice: 60, nearbyTotal: 120}, + ); }); - describe('accumulator level framing', () => { - it('evaluates each aggregate over a different frame', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {sort: ascending('date')}, - sum('salesPrice') - .over({ - documents: {preceding: 'unbounded', following: 'current'}, - }) - .as('runningTotal'), - average('salesPrice') - .over({documents: {preceding: 1, following: 1}}) - .as('movingAverage'), - ) - .sort(ascending('date')) - .select('product', 'runningTotal', 'movingAverage'), - ); + it('computes a value based range window with fractional offsets', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 17.5, following: 0.5}, + }, + sum('salesPrice').as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('salesPrice', 'nearbyTotal'), + ); - expectResults( - snapshot, - {product: 'phone', runningTotal: 12, movingAverage: 21}, - {product: 'phone', runningTotal: 42, movingAverage: 24}, - {product: 'tablet', runningTotal: 72, movingAverage: 40}, - {product: 'tablet', runningTotal: 132, movingAverage: 50}, - {product: 'tablet', runningTotal: 192, movingAverage: 60}, - ); - }); + expectResults( + snapshot, + {salesPrice: 12, nearbyTotal: 12}, + {salesPrice: 30, nearbyTotal: 60}, + {salesPrice: 30, nearbyTotal: 60}, + {salesPrice: 60, nearbyTotal: 120}, + {salesPrice: 60, nearbyTotal: 120}, + ); }); - describe('countAll', () => { - it('counts every document in the window', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); + it('computes a trailing 3 day total', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + range: {preceding: 3, following: 'current', unit: 'day'}, + }, + sum('salesPrice').as('threeDayTotal'), + ) + .sort(ascending('date')) + .select('product', 'threeDayTotal'), + ); - expectResults( - snapshot, - {product: 'phone', windowCount: 2}, - {product: 'phone', windowCount: 2}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - ); - }); + expectResults( + snapshot, + {product: 'phone', threeDayTotal: 12}, + {product: 'phone', threeDayTotal: 42}, + {product: 'tablet', threeDayTotal: 72}, + {product: 'tablet', threeDayTotal: 132}, + {product: 'tablet', threeDayTotal: 60}, + ); }); + }); - describe('documents framing with numeric offsets', () => { - it('supports zero preceding and zero following', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 0, following: 0}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); + describe('countAll', () => { + it('counts every document in the window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); - expectResults( - snapshot, - {product: 'phone', windowCount: 1}, - {product: 'phone', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - ); - }); + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + }); - it("supports zero preceding to 'unbounded' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 0, following: 'unbounded'}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); + describe('documents framing with numeric offsets', () => { + it('supports zero preceding and zero following', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 0, following: 0}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); - expectResults( - snapshot, - {product: 'phone', windowCount: 5}, - {product: 'phone', windowCount: 4}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 2}, - {product: 'tablet', windowCount: 1}, - ); - }); + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); - it('supports a symmetric moving window', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 1, following: 1}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); + it("supports zero preceding to 'unbounded' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 0, following: 'unbounded'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); - expectResults( - snapshot, - {product: 'phone', windowCount: 2}, - {product: 'phone', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 2}, - ); - }); + expectResults( + snapshot, + {product: 'phone', windowCount: 5}, + {product: 'phone', windowCount: 4}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 2}, + {product: 'tablet', windowCount: 1}, + ); }); - }, -); + }); +}); -// TODO: Un-skip once backend support for rank() lands. +// Un-skip once the backend supports `rank()`. describe.skip('Pipeline window functions (ranking)', () => { let firestore: Firestore; let productSales: CollectionReference; - async function execute(ppl: Pipeline): Promise { - return ppl.execute(); - } - before(async () => { productSales = getTestRoot(); await testCollectionWithDocs(productSales, windowTestDocs); firestore = productSales.firestore; }); - afterEach(() => verifyInstance(firestore as unknown as InternalFirestore)); + afterEach(() => verifyInstance(firestore)); it('computes rank', async () => { const snapshot = await execute( diff --git a/handwritten/firestore/dev/test/pipelines/add-window-fields.ts b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts index 31d13b25ec58..0457a8a2e5d6 100644 --- a/handwritten/firestore/dev/test/pipelines/add-window-fields.ts +++ b/handwritten/firestore/dev/test/pipelines/add-window-fields.ts @@ -776,4 +776,82 @@ describe('addWindowFields() serialization', () => { expect(stage.options).to.deep.equal({}); }); }); + + describe('user data validation', () => { + const invalidExpr = constant(undefined as unknown as number); + const expectedError = /Cannot use "undefined" as a Firestore value/; + + it('validates expressions in partition', () => { + expect(() => + windowStage( + basePipeline().addWindowFields( + {partition: [invalidExpr]}, + countAll().as('c'), + ), + ), + ).to.throw(expectedError); + }); + + it('validates expressions in sort', () => { + expect(() => + windowStage( + basePipeline().addWindowFields( + {sort: ascending(invalidExpr)}, + countAll().as('c'), + ), + ), + ).to.throw(expectedError); + }); + + it('validates expressions in documents and range frame bounds and unit', () => { + expect(() => + windowStage( + basePipeline().addWindowFields( + { + sort: ascending('date'), + documents: {preceding: invalidExpr, following: 'current'}, + }, + countAll().as('c'), + ), + ), + ).to.throw(expectedError); + + expect(() => + windowStage( + basePipeline().addWindowFields( + { + sort: ascending('date'), + range: { + preceding: 1, + following: 'current', + unit: invalidExpr, + }, + }, + countAll().as('c'), + ), + ), + ).to.throw(expectedError); + }); + + it('validates expressions in fields and over() frames', () => { + expect(() => + windowStage( + basePipeline().addWindowFields({}, sum(invalidExpr).as('total')), + ), + ).to.throw(expectedError); + + expect(() => + windowStage( + basePipeline().addWindowFields( + {sort: ascending('date')}, + countAll() + .over({ + documents: {preceding: 'unbounded', following: invalidExpr}, + }) + .as('c'), + ), + ), + ).to.throw(expectedError); + }); + }); }); From 7bd73e70aaad9ed3a5f04418adb24c3c0f98b758 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:27:56 +0000 Subject: [PATCH 8/8] test(firestore): move window function integration tests to pipeline_window.ts --- .../firestore/dev/system-test/pipeline.ts | 1732 +--------------- .../dev/system-test/pipeline_helpers.ts | 44 + .../dev/system-test/pipeline_window.ts | 1767 +++++++++++++++++ 3 files changed, 1812 insertions(+), 1731 deletions(-) create mode 100644 handwritten/firestore/dev/system-test/pipeline_helpers.ts create mode 100644 handwritten/firestore/dev/system-test/pipeline_window.ts diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index cf2da82b62fa..9e8e476504cb 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -162,7 +162,6 @@ import { variable, currentDocument, subcollection, - rank, // TODO(new-expression): add new expression imports above this line } from '../src/pipelines'; @@ -184,6 +183,7 @@ import {afterEach, describe, it} from 'mocha'; import '../test/util/mocha_extensions'; import {verifyInstance, isRest} from '../test/util/helpers'; import {getTestDb, getTestRoot} from './firestore'; +import {expectResults} from './pipeline_helpers'; import {Firestore as InternalFirestore} from '../src'; import {ServiceError} from 'google-gax'; @@ -210,26 +210,6 @@ async function testCollectionWithDocs( return targetCol; } -function expectResults(result: PipelineSnapshot, ...docs: string[]): void; -function expectResults(result: PipelineSnapshot, ...data: DocumentData[]): void; -function expectResults( - result: PipelineSnapshot, - ...data: DocumentData[] | string[] -): void { - if (data.length > 0) { - if (typeof data[0] === 'string') { - const actualIds = result.results.map(result => result.id); - expect(actualIds).to.deep.equal(data); - } else { - result.results.forEach(r => { - expect(r.data()).to.deep.equal(data.shift()); - }); - } - } else { - expect(result.results.length).to.equal(data.length); - } -} - describe.skipClassic('Pipeline class', () => { let firestore: Firestore; let randomCol: CollectionReference; @@ -8933,1713 +8913,3 @@ describe.skipClassic('Query to Pipeline', () => { ); }); }); - -/** - * Test data for the window function suites below. - * - * The sales are deliberately constructed so that: - * - `product` and `region` give overlapping, non-nested partitions, - * - `salesPrice` has ties (30 and 60 both appear twice), which exercises - * `range` framing and ranking peers, and - * - `date` is strictly increasing and has an 11 day gap before the final sale, - * which exercises time based `range` framing. - */ -const windowTestDocs: {[id: string]: DocumentData} = { - sale1: { - product: 'phone', - region: 'east', - salesPrice: 12, - quantity: 1, - date: Timestamp.fromDate(new Date('2026-07-01T12:00:00Z')), - }, - sale2: { - product: 'phone', - region: 'west', - salesPrice: 30, - quantity: 2, - date: Timestamp.fromDate(new Date('2026-07-02T12:00:00Z')), - }, - sale3: { - product: 'tablet', - region: 'east', - salesPrice: 30, - quantity: 3, - date: Timestamp.fromDate(new Date('2026-07-02T18:00:00Z')), - }, - sale4: { - product: 'tablet', - region: 'west', - salesPrice: 60, - quantity: 4, - date: Timestamp.fromDate(new Date('2026-07-04T12:00:00Z')), - }, - sale5: { - product: 'tablet', - region: 'east', - salesPrice: 60, - quantity: 5, - date: Timestamp.fromDate(new Date('2026-07-15T12:00:00Z')), - }, -}; - -function execute(ppl: Pipeline): Promise { - return ppl.execute(); -} - -describe.skipClassic('Pipeline window functions', () => { - let firestore: Firestore; - let productSales: CollectionReference; - - before(async () => { - productSales = getTestRoot(); - await testCollectionWithDocs(productSales, windowTestDocs); - firestore = productSales.firestore; - }); - - afterEach(() => verifyInstance(firestore)); - - it('computes running aggregates over a documents frame partitioned by a field and an expression and sorted by multiple keys', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product', toUpper('region')], - sort: [ascending('date'), descending('salesPrice')], - documents: {preceding: 'unbounded', following: constant(0)}, - }, - sum('salesPrice').as('stats.runningTotal'), - arrayAgg('quantity').as('quantities'), - countAll().as('count'), - ) - .sort(ascending('date')) - .select('product', 'stats', 'quantities', 'count'), - ); - - expectResults( - snapshot, - { - product: 'phone', - stats: {runningTotal: 12}, - quantities: [1], - count: 1, - }, - { - product: 'phone', - stats: {runningTotal: 30}, - quantities: [2], - count: 1, - }, - { - product: 'tablet', - stats: {runningTotal: 30}, - quantities: [3], - count: 1, - }, - { - product: 'tablet', - stats: {runningTotal: 60}, - quantities: [4], - count: 1, - }, - { - product: 'tablet', - stats: {runningTotal: 90}, - quantities: [3, 5], - count: 2, - }, - ); - }); - - it('counts documents in a time unit range frame using the options overload', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales.path) - .addWindowFields({ - window: { - sort: ascending('date'), - range: { - preceding: 12, - following: 'current', - unit: 'hour', - }, - }, - fields: [count('quantity').as('windowCount')], - }) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 1}, - {product: 'phone', windowCount: 1}, - {product: 'tablet', windowCount: 2}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - ); - }); - - it('computes look ahead and nearby aggregates with accumulator level frames', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {sort: ascending('quantity')}, - average('salesPrice') - .over({documents: {preceding: -1, following: 2}}) - .as('lookAheadAverage'), - sum('salesPrice') - .over({range: {preceding: 1.5, following: 0.5}}) - .as('nearbyTotal'), - ) - .sort(ascending('date')) - .select('product', 'lookAheadAverage', 'nearbyTotal'), - ); - - expectResults( - snapshot, - {product: 'phone', lookAheadAverage: 30, nearbyTotal: 12}, - {product: 'phone', lookAheadAverage: 45, nearbyTotal: 42}, - {product: 'tablet', lookAheadAverage: 60, nearbyTotal: 60}, - {product: 'tablet', lookAheadAverage: 60, nearbyTotal: 90}, - {product: 'tablet', lookAheadAverage: null, nearbyTotal: 120}, - ); - }); - - it('rejects a range frame without a sort', async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {range: {preceding: 'unbounded', following: 'current'}}, - count('quantity').as('windowCount'), - ), - ), - ).to.be.rejectedWith(Error, /range/i); - }); - - it('rejects duplicate aliases and unsupported field values', () => { - expect(() => - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - sum('salesPrice').as('total'), - average('salesPrice').as('total'), - ), - ).to.throw(Error, /Duplicate alias or field 'total'/); - - expect(() => { - void execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: [constant(undefined as unknown as string)]}, - countAll().as('windowCount'), - ), - ); - }).to.throw(Error, /Cannot use "undefined" as a Firestore value/); - }); -}); - -// Comprehensive window function functional tests. Skipped by default on CI and -// local runs because the backend already tests window function semantics, and -// the active suite above exercises the SDK's window function code paths, server -// errors, and client-side validation. Set FIRESTORE_RUN_COMPREHENSIVE_TESTS=true -// to run the full matrix. -const runComprehensiveTests = - process.env.FIRESTORE_RUN_COMPREHENSIVE_TESTS === 'true' || - process.env.FIRESTORE_RUN_COMPREHENSIVE_TESTS === 'YES'; - -const comprehensiveDescribe = runComprehensiveTests - ? describe.skipClassic - : describe.skip; - -comprehensiveDescribe('Pipeline window functions (count)', () => { - let firestore: Firestore; - let productSales: CollectionReference; - - before(async () => { - productSales = getTestRoot(); - await testCollectionWithDocs(productSales, windowTestDocs); - firestore = productSales.firestore; - }); - - afterEach(() => verifyInstance(firestore)); - - describe('partitioning', () => { - it('treats an empty window spec as a single global window', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields({}, count('quantity').as('windowCount')) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 5}, - {product: 'phone', windowCount: 5}, - {product: 'tablet', windowCount: 5}, - {product: 'tablet', windowCount: 5}, - {product: 'tablet', windowCount: 5}, - ); - }); - - it('partitions by a single field name', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 2}, - {product: 'phone', windowCount: 2}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - ); - }); - - it('partitions by multiple fields', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product', 'region']}, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'region', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', region: 'east', windowCount: 1}, - {product: 'phone', region: 'west', windowCount: 1}, - {product: 'tablet', region: 'east', windowCount: 2}, - {product: 'tablet', region: 'west', windowCount: 1}, - {product: 'tablet', region: 'east', windowCount: 2}, - ); - }); - - it('partitions by an expression', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: [toUpper('region')]}, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('region', 'windowCount'), - ); - - expectResults( - snapshot, - {region: 'east', windowCount: 3}, - {region: 'west', windowCount: 2}, - {region: 'east', windowCount: 3}, - {region: 'west', windowCount: 2}, - {region: 'east', windowCount: 3}, - ); - }); - }); - - describe('sorting and default framing', () => { - it('computes a running count with the default (sorted) frame', async () => { - // The implicit default frame when `sort` is specified is a `range` frame - // from `'unbounded'` preceding to `'current'` following (peer-inclusive). - // `quantity` is 1..5 in the same order as `date` and has no ties, so the - // running count is 1..5. - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {sort: ascending('quantity')}, - count('quantity').as('runningCount'), - ) - .sort(ascending('date')) - .select('product', 'runningCount'), - ); - - expectResults( - snapshot, - {product: 'phone', runningCount: 1}, - {product: 'phone', runningCount: 2}, - {product: 'tablet', runningCount: 3}, - {product: 'tablet', runningCount: 4}, - {product: 'tablet', runningCount: 5}, - ); - }); - - it('computes a running count within each partition', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product'], sort: ascending('quantity')}, - count('quantity').as('runningCount'), - ) - .sort(ascending('date')) - .select('product', 'runningCount'), - ); - - expectResults( - snapshot, - {product: 'phone', runningCount: 1}, - {product: 'phone', runningCount: 2}, - {product: 'tablet', runningCount: 1}, - {product: 'tablet', runningCount: 2}, - {product: 'tablet', runningCount: 3}, - ); - }); - - it('honors a descending sort', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: descending('date'), - documents: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('runningCount'), - ) - .sort(ascending('date')) - .select('product', 'runningCount'), - ); - - expectResults( - snapshot, - {product: 'phone', runningCount: 5}, - {product: 'phone', runningCount: 4}, - {product: 'tablet', runningCount: 3}, - {product: 'tablet', runningCount: 2}, - {product: 'tablet', runningCount: 1}, - ); - }); - - it('honors multiple sort orderings', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: [ascending('product'), ascending('date')], - documents: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('runningCount'), - ) - .sort(ascending('date')) - .select('product', 'runningCount'), - ); - - expectResults( - snapshot, - {product: 'phone', runningCount: 1}, - {product: 'phone', runningCount: 2}, - {product: 'tablet', runningCount: 3}, - {product: 'tablet', runningCount: 4}, - {product: 'tablet', runningCount: 5}, - ); - }); - }); - - describe('documents framing', () => { - it("supports 'unbounded' preceding to 'current' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 1}, - {product: 'phone', windowCount: 2}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 4}, - {product: 'tablet', windowCount: 5}, - ); - }); - - it("supports 'unbounded' preceding to 'unbounded' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'unbounded'}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 2}, - {product: 'phone', windowCount: 2}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - ); - }); - - it("supports 'current' preceding to 'current' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 'current', following: 'current'}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 1}, - {product: 'phone', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - ); - }); - - it("excludes tied peers with 'unbounded' preceding to 'current' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - documents: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('runningCount'), - ) - .sort(ascending('runningCount')) - .select('salesPrice', 'runningCount'), - ); - - expectResults( - snapshot, - {salesPrice: 12, runningCount: 1}, - {salesPrice: 30, runningCount: 2}, - {salesPrice: 30, runningCount: 3}, - {salesPrice: 60, runningCount: 4}, - {salesPrice: 60, runningCount: 5}, - ); - }); - }); - - describe('range framing', () => { - // In a `range` frame the `'current'` sentinel is peer-inclusive (like SQL - // `CURRENT ROW` in RANGE mode), equivalent to a numeric `0` offset: it - // includes every document whose sort value(s) tie with the current - // document. - it("counts tied peers with 'current' to 'current'", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('salesPrice'), - range: {preceding: 'current', following: 'current'}, - }, - count('quantity').as('samePriceCount'), - ) - .sort(ascending('date')) - .select('product', 'salesPrice', 'samePriceCount'), - ); - - expectResults( - snapshot, - {product: 'phone', salesPrice: 12, samePriceCount: 1}, - {product: 'phone', salesPrice: 30, samePriceCount: 1}, - {product: 'tablet', salesPrice: 30, samePriceCount: 1}, - {product: 'tablet', salesPrice: 60, samePriceCount: 2}, - {product: 'tablet', salesPrice: 60, samePriceCount: 2}, - ); - }); - - it('counts tied peers with a zero offset on both bounds', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('salesPrice'), - range: {preceding: 0, following: 0}, - }, - count('quantity').as('samePriceCount'), - ) - .sort(ascending('date')) - .select('product', 'salesPrice', 'samePriceCount'), - ); - - expectResults( - snapshot, - {product: 'phone', salesPrice: 12, samePriceCount: 1}, - {product: 'phone', salesPrice: 30, samePriceCount: 1}, - {product: 'tablet', salesPrice: 30, samePriceCount: 1}, - {product: 'tablet', salesPrice: 60, samePriceCount: 2}, - {product: 'tablet', salesPrice: 60, samePriceCount: 2}, - ); - }); - - it("includes tied peers with 'unbounded' preceding to 'current' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - range: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('cumulativeCount'), - ) - .sort(ascending('date')) - .select('salesPrice', 'cumulativeCount'), - ); - - expectResults( - snapshot, - {salesPrice: 12, cumulativeCount: 1}, - {salesPrice: 30, cumulativeCount: 3}, - {salesPrice: 30, cumulativeCount: 3}, - {salesPrice: 60, cumulativeCount: 5}, - {salesPrice: 60, cumulativeCount: 5}, - ); - }); - - it("includes tied peers with 'unbounded' preceding to a zero offset", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - range: {preceding: 'unbounded', following: 0}, - }, - count('quantity').as('cumulativeCount'), - ) - .sort(ascending('date')) - .select('salesPrice', 'cumulativeCount'), - ); - - expectResults( - snapshot, - {salesPrice: 12, cumulativeCount: 1}, - {salesPrice: 30, cumulativeCount: 3}, - {salesPrice: 30, cumulativeCount: 3}, - {salesPrice: 60, cumulativeCount: 5}, - {salesPrice: 60, cumulativeCount: 5}, - ); - }); - - it("supports a string sort key when bounds are 'unbounded' and 'current'", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('product'), - range: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('cumulativeCount'), - ) - .sort(ascending('date')) - .select('product', 'cumulativeCount'), - ); - - expectResults( - snapshot, - {product: 'phone', cumulativeCount: 2}, - {product: 'phone', cumulativeCount: 2}, - {product: 'tablet', cumulativeCount: 5}, - {product: 'tablet', cumulativeCount: 5}, - {product: 'tablet', cumulativeCount: 5}, - ); - }); - - it("supports multiple sort orderings when bounds are 'unbounded' and 'current'", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: [ascending('product'), ascending('region')], - range: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity').as('cumulativeCount'), - ) - .sort(ascending('date')) - .select('product', 'region', 'cumulativeCount'), - ); - - expectResults( - snapshot, - {product: 'phone', region: 'east', cumulativeCount: 1}, - {product: 'phone', region: 'west', cumulativeCount: 2}, - {product: 'tablet', region: 'east', cumulativeCount: 4}, - {product: 'tablet', region: 'west', cumulativeCount: 5}, - {product: 'tablet', region: 'east', cumulativeCount: 4}, - ); - }); - - it("supports 'unbounded' to 'unbounded'", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('salesPrice'), - range: {preceding: 'unbounded', following: 'unbounded'}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 2}, - {product: 'phone', windowCount: 2}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - ); - }); - - it('applies the time unit to a range frame offset', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - range: { - preceding: 12, - following: 'current', - unit: 'hour', - }, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 1}, - {product: 'phone', windowCount: 1}, - {product: 'tablet', windowCount: 2}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - ); - }); - }); - - describe('accumulator level framing', () => { - it('evaluates each accumulator over its own frame', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product'], sort: ascending('date')}, - count('quantity') - .over({ - documents: {preceding: 'unbounded', following: 'current'}, - }) - .as('runningCount'), - count('quantity') - .over({ - documents: {preceding: 'unbounded', following: 'unbounded'}, - }) - .as('partitionCount'), - ) - .sort(ascending('date')) - .select('product', 'runningCount', 'partitionCount'), - ); - - expectResults( - snapshot, - {product: 'phone', runningCount: 1, partitionCount: 2}, - {product: 'phone', runningCount: 2, partitionCount: 2}, - {product: 'tablet', runningCount: 1, partitionCount: 3}, - {product: 'tablet', runningCount: 2, partitionCount: 3}, - {product: 'tablet', runningCount: 3, partitionCount: 3}, - ); - }); - - it('supports a range frame in over()', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product'], sort: ascending('salesPrice')}, - count('quantity') - .over({range: {preceding: 0, following: 0}}) - .as('samePriceCount'), - ) - .sort(ascending('date')) - .select('product', 'salesPrice', 'samePriceCount'), - ); - - expectResults( - snapshot, - {product: 'phone', salesPrice: 12, samePriceCount: 1}, - {product: 'phone', salesPrice: 30, samePriceCount: 1}, - {product: 'tablet', salesPrice: 30, samePriceCount: 1}, - {product: 'tablet', salesPrice: 60, samePriceCount: 2}, - {product: 'tablet', salesPrice: 60, samePriceCount: 2}, - ); - }); - }); - - describe('output fields and composition', () => { - it('supports multiple output fields in a single stage', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - count('quantity').as('productCount'), - count('quantity').as('productCountCopy'), - ) - .sort(ascending('date')) - .select('product', 'productCount', 'productCountCopy'), - ); - - expectResults( - snapshot, - {product: 'phone', productCount: 2, productCountCopy: 2}, - {product: 'phone', productCount: 2, productCountCopy: 2}, - {product: 'tablet', productCount: 3, productCountCopy: 3}, - {product: 'tablet', productCount: 3, productCountCopy: 3}, - {product: 'tablet', productCount: 3, productCountCopy: 3}, - ); - }); - - it('supports nested output field paths', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - count('quantity').as('stats.productCount'), - ) - .sort(ascending('date')) - .select('product', 'stats'), - ); - - expectResults( - snapshot, - {product: 'phone', stats: {productCount: 2}}, - {product: 'phone', stats: {productCount: 2}}, - {product: 'tablet', stats: {productCount: 3}}, - {product: 'tablet', stats: {productCount: 3}}, - {product: 'tablet', stats: {productCount: 3}}, - ); - }); - - it('supports chaining multiple addWindowFields stages', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - count('quantity').as('productCount'), - ) - .addWindowFields( - {partition: ['region']}, - count('quantity').as('regionCount'), - ) - .sort(ascending('date')) - .select('product', 'region', 'productCount', 'regionCount'), - ); - - expectResults( - snapshot, - { - product: 'phone', - region: 'east', - productCount: 2, - regionCount: 3, - }, - { - product: 'phone', - region: 'west', - productCount: 2, - regionCount: 2, - }, - { - product: 'tablet', - region: 'east', - productCount: 3, - regionCount: 3, - }, - { - product: 'tablet', - region: 'west', - productCount: 3, - regionCount: 2, - }, - { - product: 'tablet', - region: 'east', - productCount: 3, - regionCount: 3, - }, - ); - }); - - it('supports filtering on a window field in a later stage', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - count('quantity').as('productCount'), - ) - .where(field('productCount').greaterThan(2)) - .sort(ascending('date')) - .select('product', 'productCount'), - ); - - expectResults( - snapshot, - {product: 'tablet', productCount: 3}, - {product: 'tablet', productCount: 3}, - {product: 'tablet', productCount: 3}, - ); - }); - - it('supports sorting on a window field in a later stage', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - count('quantity').as('productCount'), - ) - .sort(ascending('productCount'), ascending('date')) - .select('product', 'productCount'), - ); - - expectResults( - snapshot, - {product: 'phone', productCount: 2}, - {product: 'phone', productCount: 2}, - {product: 'tablet', productCount: 3}, - {product: 'tablet', productCount: 3}, - {product: 'tablet', productCount: 3}, - ); - }); - }); - - describe('options overload', () => { - it('supports the options object signature', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales.path) - .addWindowFields({ - window: { - partition: ['product'], - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'current'}, - }, - fields: [ - count('quantity').as('runningCount'), - count('salesPrice').as('runningPriceCount'), - ], - }) - .sort(ascending('date')) - .select('product', 'runningCount', 'runningPriceCount'), - ); - - expectResults( - snapshot, - {product: 'phone', runningCount: 1, runningPriceCount: 1}, - {product: 'phone', runningCount: 2, runningPriceCount: 2}, - {product: 'tablet', runningCount: 1, runningPriceCount: 1}, - {product: 'tablet', runningCount: 2, runningPriceCount: 2}, - {product: 'tablet', runningCount: 3, runningPriceCount: 3}, - ); - }); - }); - - describe('error handling', () => { - it('rejects a range frame without a sort', async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {range: {preceding: 'unbounded', following: 'current'}}, - count('quantity').as('windowCount'), - ), - ), - ).to.be.rejectedWith(Error, /range/i); - }); - - it('rejects mixing stage level and accumulator level framing', async () => { - await expect( - execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'current'}, - }, - count('quantity') - .over({ - documents: {preceding: 'unbounded', following: 'unbounded'}, - }) - .as('windowCount'), - ), - ), - ).to.be.rejectedWith(Error); - }); - - it('evaluates accumulators without over() using the default frame when others use over()', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {sort: ascending('date')}, - count('quantity') - .over({documents: {preceding: 0, following: 0}}) - .as('framed'), - count('quantity').as('unframed'), - ) - .sort(ascending('date')) - .select('product', 'framed', 'unframed'), - ); - - expectResults( - snapshot, - {product: 'phone', framed: 1, unframed: 1}, - {product: 'phone', framed: 1, unframed: 2}, - {product: 'tablet', framed: 1, unframed: 3}, - {product: 'tablet', framed: 1, unframed: 4}, - {product: 'tablet', framed: 1, unframed: 5}, - ); - }); - }); -}); - -comprehensiveDescribe('Pipeline window functions (aggregators)', () => { - let firestore: Firestore; - let productSales: CollectionReference; - - before(async () => { - productSales = getTestRoot(); - await testCollectionWithDocs(productSales, windowTestDocs); - firestore = productSales.firestore; - }); - - afterEach(() => verifyInstance(firestore)); - - describe('numeric aggregators over a partition', () => { - it('computes sum, average, minimum and maximum', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - sum('salesPrice').as('total'), - average('salesPrice').as('averagePrice'), - minimum('salesPrice').as('minimumPrice'), - maximum('salesPrice').as('maximumPrice'), - ) - .sort(ascending('date')) - .select( - 'product', - 'total', - 'averagePrice', - 'minimumPrice', - 'maximumPrice', - ), - ); - - expectResults( - snapshot, - { - product: 'phone', - total: 42, - averagePrice: 21, - minimumPrice: 12, - maximumPrice: 30, - }, - { - product: 'phone', - total: 42, - averagePrice: 21, - minimumPrice: 12, - maximumPrice: 30, - }, - { - product: 'tablet', - total: 150, - averagePrice: 50, - minimumPrice: 30, - maximumPrice: 60, - }, - { - product: 'tablet', - total: 150, - averagePrice: 50, - minimumPrice: 30, - maximumPrice: 60, - }, - { - product: 'tablet', - total: 150, - averagePrice: 50, - minimumPrice: 30, - maximumPrice: 60, - }, - ); - }); - - // Un-skip once the backend supports countIf and countDistinct in addWindowFields. - it.skip('computes countIf', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - countIf(field('salesPrice').greaterThan(20)).as('expensiveCount'), - ) - .sort(ascending('date')) - .select('product', 'expensiveCount'), - ); - - expectResults( - snapshot, - {product: 'phone', expensiveCount: 1}, - {product: 'phone', expensiveCount: 1}, - {product: 'tablet', expensiveCount: 3}, - {product: 'tablet', expensiveCount: 3}, - {product: 'tablet', expensiveCount: 3}, - ); - }); - - // Un-skip once the backend supports countIf and countDistinct in addWindowFields. - it.skip('computes countDistinct', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - countDistinct('salesPrice').as('distinctPriceCount'), - ) - .sort(ascending('date')) - .select('product', 'distinctPriceCount'), - ); - - expectResults( - snapshot, - {product: 'phone', distinctPriceCount: 2}, - {product: 'phone', distinctPriceCount: 2}, - {product: 'tablet', distinctPriceCount: 2}, - {product: 'tablet', distinctPriceCount: 2}, - {product: 'tablet', distinctPriceCount: 2}, - ); - }); - - it('aggregates over a computed expression', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - sum(multiply(field('salesPrice'), field('quantity'))).as( - 'totalRevenue', - ), - ) - .sort(ascending('date')) - .select('product', 'totalRevenue'), - ); - - expectResults( - snapshot, - {product: 'phone', totalRevenue: 72}, - {product: 'phone', totalRevenue: 72}, - {product: 'tablet', totalRevenue: 630}, - {product: 'tablet', totalRevenue: 630}, - {product: 'tablet', totalRevenue: 630}, - ); - }); - }); - - describe('ordered aggregators', () => { - it('computes first and last over the whole partition', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'unbounded'}, - }, - first('salesPrice').as('firstPrice'), - last('salesPrice').as('lastPrice'), - ) - .sort(ascending('date')) - .select('product', 'firstPrice', 'lastPrice'), - ); - - expectResults( - snapshot, - {product: 'phone', firstPrice: 12, lastPrice: 30}, - {product: 'phone', firstPrice: 12, lastPrice: 30}, - {product: 'tablet', firstPrice: 30, lastPrice: 60}, - {product: 'tablet', firstPrice: 30, lastPrice: 60}, - {product: 'tablet', firstPrice: 30, lastPrice: 60}, - ); - }); - - it('computes arrayAgg and arrayAggDistinct', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - partition: ['product'], - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'unbounded'}, - }, - arrayAgg('salesPrice').as('allPrices'), - arrayAggDistinct('salesPrice').as('distinctPrices'), - ) - .sort(ascending('date')) - .select('product', 'allPrices', 'distinctPrices'), - ); - - const results = snapshot.results.map(r => { - const data = r.data(); - data['distinctPrices'] = [...data['distinctPrices']].sort( - (a: number, b: number) => a - b, - ); - return data; - }); - - expect(results).to.deep.equal([ - { - product: 'phone', - allPrices: [12, 30], - distinctPrices: [12, 30], - }, - { - product: 'phone', - allPrices: [12, 30], - distinctPrices: [12, 30], - }, - { - product: 'tablet', - allPrices: [30, 60, 60], - distinctPrices: [30, 60], - }, - { - product: 'tablet', - allPrices: [30, 60, 60], - distinctPrices: [30, 60], - }, - { - product: 'tablet', - allPrices: [30, 60, 60], - distinctPrices: [30, 60], - }, - ]); - }); - - it('computes a running total', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 'unbounded', following: 'current'}, - }, - sum('salesPrice').as('runningTotal'), - ) - .sort(ascending('date')) - .select('product', 'runningTotal'), - ); - - expectResults( - snapshot, - {product: 'phone', runningTotal: 12}, - {product: 'phone', runningTotal: 42}, - {product: 'tablet', runningTotal: 72}, - {product: 'tablet', runningTotal: 132}, - {product: 'tablet', runningTotal: 192}, - ); - }); - }); - - describe('numeric documents offsets', () => { - it('computes a centered moving average', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 1, following: 1}, - }, - average('salesPrice').as('movingAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'movingAverage', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', movingAverage: 21, windowCount: 2}, - {product: 'phone', movingAverage: 24, windowCount: 3}, - {product: 'tablet', movingAverage: 40, windowCount: 3}, - {product: 'tablet', movingAverage: 50, windowCount: 3}, - {product: 'tablet', movingAverage: 60, windowCount: 2}, - ); - }); - - it('computes a trailing sum', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 2, following: 0}, - }, - sum('salesPrice').as('trailingTotal'), - ) - .sort(ascending('date')) - .select('product', 'trailingTotal'), - ); - - expectResults( - snapshot, - {product: 'phone', trailingTotal: 12}, - {product: 'phone', trailingTotal: 42}, - {product: 'tablet', trailingTotal: 72}, - {product: 'tablet', trailingTotal: 120}, - {product: 'tablet', trailingTotal: 150}, - ); - }); - - it('computes a look ahead average with a negative preceding bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to "documents between 1 following and 2 - // following": the window is [index - preceding, index + - // following]. - sort: ascending('date'), - documents: {preceding: -1, following: 2}, - }, - average('salesPrice').as('lookAheadAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookAheadAverage', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', lookAheadAverage: 30, windowCount: 2}, - {product: 'phone', lookAheadAverage: 45, windowCount: 2}, - {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, - {product: 'tablet', lookAheadAverage: 60, windowCount: 1}, - {product: 'tablet', lookAheadAverage: null, windowCount: 0}, - ); - }); - - it('computes a look behind average with a negative following bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to "documents between 2 preceding and 1 - // preceding": the window is [index - preceding, index + - // following]. - sort: ascending('date'), - documents: {preceding: 2, following: -1}, - }, - average('salesPrice').as('lookBehindAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookBehindAverage', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', lookBehindAverage: null, windowCount: 0}, - {product: 'phone', lookBehindAverage: 12, windowCount: 1}, - {product: 'tablet', lookBehindAverage: 21, windowCount: 2}, - {product: 'tablet', lookBehindAverage: 30, windowCount: 2}, - {product: 'tablet', lookBehindAverage: 45, windowCount: 2}, - ); - }); - - it('computes a range look behind average with a negative following bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to a range frame of [value - Infinity, value - 1], - // including only documents with a strictly lower salesPrice. - sort: ascending('salesPrice'), - range: {preceding: 'unbounded', following: -1}, - }, - average('salesPrice').as('lookBehindAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookBehindAverage', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', lookBehindAverage: null, windowCount: 0}, - {product: 'phone', lookBehindAverage: 12, windowCount: 1}, - {product: 'tablet', lookBehindAverage: 12, windowCount: 1}, - {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, - {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, - ); - }); - - it('computes a range look ahead average with a negative preceding bound', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - // Equivalent to a range frame of [value + 1, value + Infinity], - // including only documents with a strictly higher salesPrice. - sort: ascending('salesPrice'), - range: {preceding: -1, following: 'unbounded'}, - }, - average('salesPrice').as('lookAheadAverage'), - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'lookAheadAverage', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', lookAheadAverage: 45, windowCount: 4}, - {product: 'phone', lookAheadAverage: 60, windowCount: 2}, - {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, - {product: 'tablet', lookAheadAverage: null, windowCount: 0}, - {product: 'tablet', lookAheadAverage: null, windowCount: 0}, - ); - }); - }); - - describe('numeric and time range offsets', () => { - it('computes a value based range window', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - range: {preceding: 20, following: 0}, - }, - sum('salesPrice').as('nearbyTotal'), - ) - .sort(ascending('date')) - .select('salesPrice', 'nearbyTotal'), - ); - - expectResults( - snapshot, - {salesPrice: 12, nearbyTotal: 12}, - {salesPrice: 30, nearbyTotal: 72}, - {salesPrice: 30, nearbyTotal: 72}, - {salesPrice: 60, nearbyTotal: 120}, - {salesPrice: 60, nearbyTotal: 120}, - ); - }); - - it('computes a value based range window with fractional offsets', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('salesPrice'), - range: {preceding: 17.5, following: 0.5}, - }, - sum('salesPrice').as('nearbyTotal'), - ) - .sort(ascending('date')) - .select('salesPrice', 'nearbyTotal'), - ); - - expectResults( - snapshot, - {salesPrice: 12, nearbyTotal: 12}, - {salesPrice: 30, nearbyTotal: 60}, - {salesPrice: 30, nearbyTotal: 60}, - {salesPrice: 60, nearbyTotal: 120}, - {salesPrice: 60, nearbyTotal: 120}, - ); - }); - - it('computes a trailing 3 day total', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - range: {preceding: 3, following: 'current', unit: 'day'}, - }, - sum('salesPrice').as('threeDayTotal'), - ) - .sort(ascending('date')) - .select('product', 'threeDayTotal'), - ); - - expectResults( - snapshot, - {product: 'phone', threeDayTotal: 12}, - {product: 'phone', threeDayTotal: 42}, - {product: 'tablet', threeDayTotal: 72}, - {product: 'tablet', threeDayTotal: 132}, - {product: 'tablet', threeDayTotal: 60}, - ); - }); - }); - - describe('countAll', () => { - it('counts every document in the window', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {partition: ['product']}, - countAll().as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 2}, - {product: 'phone', windowCount: 2}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 3}, - ); - }); - }); - - describe('documents framing with numeric offsets', () => { - it('supports zero preceding and zero following', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 0, following: 0}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 1}, - {product: 'phone', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - {product: 'tablet', windowCount: 1}, - ); - }); - - it("supports zero preceding to 'unbounded' following", async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - { - sort: ascending('date'), - documents: {preceding: 0, following: 'unbounded'}, - }, - count('quantity').as('windowCount'), - ) - .sort(ascending('date')) - .select('product', 'windowCount'), - ); - - expectResults( - snapshot, - {product: 'phone', windowCount: 5}, - {product: 'phone', windowCount: 4}, - {product: 'tablet', windowCount: 3}, - {product: 'tablet', windowCount: 2}, - {product: 'tablet', windowCount: 1}, - ); - }); - }); -}); - -// Un-skip once the backend supports `rank()`. -describe.skip('Pipeline window functions (ranking)', () => { - let firestore: Firestore; - let productSales: CollectionReference; - - before(async () => { - productSales = getTestRoot(); - await testCollectionWithDocs(productSales, windowTestDocs); - firestore = productSales.firestore; - }); - - afterEach(() => verifyInstance(firestore)); - - it('computes rank', async () => { - const snapshot = await execute( - firestore - .pipeline() - .collection(productSales) - .addWindowFields( - {sort: [ascending('salesPrice'), ascending('date')]}, - rank().as('priceRank'), - ) - .sort(ascending('date')) - .select('salesPrice', 'priceRank'), - ); - - expectResults( - snapshot, - { - salesPrice: 12, - priceRank: 1, - }, - { - salesPrice: 30, - priceRank: 2, - }, - { - salesPrice: 30, - priceRank: 2, - }, - { - salesPrice: 60, - priceRank: 4, - }, - { - salesPrice: 60, - priceRank: 4, - }, - ); - }); -}); diff --git a/handwritten/firestore/dev/system-test/pipeline_helpers.ts b/handwritten/firestore/dev/system-test/pipeline_helpers.ts new file mode 100644 index 000000000000..d979405e74c9 --- /dev/null +++ b/handwritten/firestore/dev/system-test/pipeline_helpers.ts @@ -0,0 +1,44 @@ +// 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 {DocumentData} from '@google-cloud/firestore'; +import {expect} from 'chai'; + +import {PipelineSnapshot} from '../src/pipelines'; + +export function expectResults( + result: PipelineSnapshot, + ...docs: string[] +): void; +export function expectResults( + result: PipelineSnapshot, + ...data: DocumentData[] +): void; +export function expectResults( + result: PipelineSnapshot, + ...data: DocumentData[] | string[] +): void { + if (data.length > 0) { + if (typeof data[0] === 'string') { + const actualIds = result.results.map(r => r.id); + expect(actualIds).to.deep.equal(data); + } else { + result.results.forEach(r => { + expect(r.data()).to.deep.equal(data.shift()); + }); + } + } else { + expect(result.results.length).to.equal(data.length); + } +} diff --git a/handwritten/firestore/dev/system-test/pipeline_window.ts b/handwritten/firestore/dev/system-test/pipeline_window.ts new file mode 100644 index 000000000000..4b8b3e5823eb --- /dev/null +++ b/handwritten/firestore/dev/system-test/pipeline_window.ts @@ -0,0 +1,1767 @@ +// 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 {DocumentData} from '@google-cloud/firestore'; +import {expect, use} from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import {afterEach, before, describe, it} from 'mocha'; + +import {CollectionReference, Firestore, Timestamp} from '../src'; +import { + arrayAgg, + arrayAggDistinct, + ascending, + average, + constant, + count, + countAll, + countDistinct, + countIf, + descending, + field, + first, + last, + maximum, + minimum, + multiply, + Pipeline, + PipelineSnapshot, + rank, + sum, + toUpper, +} from '../src/pipelines'; +import {verifyInstance} from '../test/util/helpers'; +import '../test/util/mocha_extensions'; +import {getTestRoot} from './firestore'; +import {expectResults} from './pipeline_helpers'; + +use(chaiAsPromised); + +/** + * Test data for the window function suites below. + * + * The sales are deliberately constructed so that: + * - `product` and `region` give overlapping, non-nested partitions, + * - `salesPrice` has ties (30 and 60 both appear twice), which exercises + * `range` framing and ranking peers, and + * - `date` is strictly increasing and has an 11 day gap before the final sale, + * which exercises time based `range` framing. + */ +const windowTestDocs: {[id: string]: DocumentData} = { + sale1: { + product: 'phone', + region: 'east', + salesPrice: 12, + quantity: 1, + date: Timestamp.fromDate(new Date('2026-07-01T12:00:00Z')), + }, + sale2: { + product: 'phone', + region: 'west', + salesPrice: 30, + quantity: 2, + date: Timestamp.fromDate(new Date('2026-07-02T12:00:00Z')), + }, + sale3: { + product: 'tablet', + region: 'east', + salesPrice: 30, + quantity: 3, + date: Timestamp.fromDate(new Date('2026-07-02T18:00:00Z')), + }, + sale4: { + product: 'tablet', + region: 'west', + salesPrice: 60, + quantity: 4, + date: Timestamp.fromDate(new Date('2026-07-04T12:00:00Z')), + }, + sale5: { + product: 'tablet', + region: 'east', + salesPrice: 60, + quantity: 5, + date: Timestamp.fromDate(new Date('2026-07-15T12:00:00Z')), + }, +}; + +async function populateWindowDocs( + targetCol: CollectionReference, +): Promise { + for (const [id, doc] of Object.entries(windowTestDocs)) { + await targetCol.doc(id).set(doc); + } +} + +function execute(ppl: Pipeline): Promise { + return ppl.execute(); +} + +describe.skipClassic('Pipeline window functions', () => { + let firestore: Firestore; + let productSales: CollectionReference; + + before(async () => { + productSales = getTestRoot(); + await populateWindowDocs(productSales); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore)); + + it('computes running aggregates over a documents frame partitioned by a field and an expression and sorted by multiple keys', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product', toUpper('region')], + sort: [ascending('date'), descending('salesPrice')], + documents: {preceding: 'unbounded', following: constant(0)}, + }, + sum('salesPrice').as('stats.runningTotal'), + arrayAgg('quantity').as('quantities'), + countAll().as('count'), + ) + .sort(ascending('date')) + .select('product', 'stats', 'quantities', 'count'), + ); + + expectResults( + snapshot, + { + product: 'phone', + stats: {runningTotal: 12}, + quantities: [1], + count: 1, + }, + { + product: 'phone', + stats: {runningTotal: 30}, + quantities: [2], + count: 1, + }, + { + product: 'tablet', + stats: {runningTotal: 30}, + quantities: [3], + count: 1, + }, + { + product: 'tablet', + stats: {runningTotal: 60}, + quantities: [4], + count: 1, + }, + { + product: 'tablet', + stats: {runningTotal: 90}, + quantities: [3, 5], + count: 2, + }, + ); + }); + + it('counts documents in a time unit range frame using the options overload', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales.path) + .addWindowFields({ + window: { + sort: ascending('date'), + range: { + preceding: 12, + following: 'current', + unit: 'hour', + }, + }, + fields: [count('quantity').as('windowCount')], + }) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 2}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); + + it('computes look ahead and nearby aggregates with accumulator level frames', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('quantity')}, + average('salesPrice') + .over({documents: {preceding: -1, following: 2}}) + .as('lookAheadAverage'), + sum('salesPrice') + .over({range: {preceding: 1.5, following: 0.5}}) + .as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'nearbyTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 30, nearbyTotal: 12}, + {product: 'phone', lookAheadAverage: 45, nearbyTotal: 42}, + {product: 'tablet', lookAheadAverage: 60, nearbyTotal: 60}, + {product: 'tablet', lookAheadAverage: 60, nearbyTotal: 90}, + {product: 'tablet', lookAheadAverage: null, nearbyTotal: 120}, + ); + }); + + it('rejects a range frame without a sort', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {range: {preceding: 'unbounded', following: 'current'}}, + count('quantity').as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error, /range/i); + }); + + it('rejects duplicate aliases and unsupported field values', () => { + expect(() => + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('total'), + average('salesPrice').as('total'), + ), + ).to.throw(Error, /Duplicate alias or field 'total'/); + + expect(() => { + void execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: [constant(undefined as unknown as string)]}, + countAll().as('windowCount'), + ), + ); + }).to.throw(Error, /Cannot use "undefined" as a Firestore value/); + }); +}); + +// Comprehensive window function functional tests. Skipped by default on CI and +// local runs because the backend already tests window function semantics, and +// the active suite above exercises the SDK's window function code paths, server +// errors, and client-side validation. Set FIRESTORE_RUN_COMPREHENSIVE_TESTS=true +// to run the full matrix. +const runComprehensiveTests = + process.env.FIRESTORE_RUN_COMPREHENSIVE_TESTS === 'true' || + process.env.FIRESTORE_RUN_COMPREHENSIVE_TESTS === 'YES'; + +const comprehensiveDescribe = runComprehensiveTests + ? describe.skipClassic + : describe.skip; + +comprehensiveDescribe('Pipeline window functions (count)', () => { + let firestore: Firestore; + let productSales: CollectionReference; + + before(async () => { + productSales = getTestRoot(); + await populateWindowDocs(productSales); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore)); + + describe('partitioning', () => { + it('treats an empty window spec as a single global window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields({}, count('quantity').as('windowCount')) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 5}, + {product: 'phone', windowCount: 5}, + {product: 'tablet', windowCount: 5}, + {product: 'tablet', windowCount: 5}, + {product: 'tablet', windowCount: 5}, + ); + }); + + it('partitions by a single field name', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + + it('partitions by multiple fields', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product', 'region']}, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'region', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', region: 'east', windowCount: 1}, + {product: 'phone', region: 'west', windowCount: 1}, + {product: 'tablet', region: 'east', windowCount: 2}, + {product: 'tablet', region: 'west', windowCount: 1}, + {product: 'tablet', region: 'east', windowCount: 2}, + ); + }); + + it('partitions by an expression', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: [toUpper('region')]}, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('region', 'windowCount'), + ); + + expectResults( + snapshot, + {region: 'east', windowCount: 3}, + {region: 'west', windowCount: 2}, + {region: 'east', windowCount: 3}, + {region: 'west', windowCount: 2}, + {region: 'east', windowCount: 3}, + ); + }); + }); + + describe('sorting and default framing', () => { + it('computes a running count with the default (sorted) frame', async () => { + // The implicit default frame when `sort` is specified is a `range` frame + // from `'unbounded'` preceding to `'current'` following (peer-inclusive). + // `quantity` is 1..5 in the same order as `date` and has no ties, so the + // running count is 1..5. + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('quantity')}, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1}, + {product: 'phone', runningCount: 2}, + {product: 'tablet', runningCount: 3}, + {product: 'tablet', runningCount: 4}, + {product: 'tablet', runningCount: 5}, + ); + }); + + it('computes a running count within each partition', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product'], sort: ascending('quantity')}, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1}, + {product: 'phone', runningCount: 2}, + {product: 'tablet', runningCount: 1}, + {product: 'tablet', runningCount: 2}, + {product: 'tablet', runningCount: 3}, + ); + }); + + it('honors a descending sort', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: descending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 5}, + {product: 'phone', runningCount: 4}, + {product: 'tablet', runningCount: 3}, + {product: 'tablet', runningCount: 2}, + {product: 'tablet', runningCount: 1}, + ); + }); + + it('honors multiple sort orderings', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: [ascending('product'), ascending('date')], + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('runningCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1}, + {product: 'phone', runningCount: 2}, + {product: 'tablet', runningCount: 3}, + {product: 'tablet', runningCount: 4}, + {product: 'tablet', runningCount: 5}, + ); + }); + }); + + describe('documents framing', () => { + it("supports 'unbounded' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 4}, + {product: 'tablet', windowCount: 5}, + ); + }); + + it("supports 'unbounded' preceding to 'unbounded' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + + it("supports 'current' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'current', following: 'current'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); + + it("excludes tied peers with 'unbounded' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('runningCount'), + ) + .sort(ascending('runningCount')) + .select('salesPrice', 'runningCount'), + ); + + expectResults( + snapshot, + {salesPrice: 12, runningCount: 1}, + {salesPrice: 30, runningCount: 2}, + {salesPrice: 30, runningCount: 3}, + {salesPrice: 60, runningCount: 4}, + {salesPrice: 60, runningCount: 5}, + ); + }); + }); + + describe('range framing', () => { + // In a `range` frame the `'current'` sentinel is peer-inclusive (like SQL + // `CURRENT ROW` in RANGE mode), equivalent to a numeric `0` offset: it + // includes every document whose sort value(s) tie with the current + // document. + it("counts tied peers with 'current' to 'current'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('salesPrice'), + range: {preceding: 'current', following: 'current'}, + }, + count('quantity').as('samePriceCount'), + ) + .sort(ascending('date')) + .select('product', 'salesPrice', 'samePriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', salesPrice: 12, samePriceCount: 1}, + {product: 'phone', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + ); + }); + + it('counts tied peers with a zero offset on both bounds', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('salesPrice'), + range: {preceding: 0, following: 0}, + }, + count('quantity').as('samePriceCount'), + ) + .sort(ascending('date')) + .select('product', 'salesPrice', 'samePriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', salesPrice: 12, samePriceCount: 1}, + {product: 'phone', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + ); + }); + + it("includes tied peers with 'unbounded' preceding to 'current' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('salesPrice', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {salesPrice: 12, cumulativeCount: 1}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 60, cumulativeCount: 5}, + {salesPrice: 60, cumulativeCount: 5}, + ); + }); + + it("includes tied peers with 'unbounded' preceding to a zero offset", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: 0}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('salesPrice', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {salesPrice: 12, cumulativeCount: 1}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 30, cumulativeCount: 3}, + {salesPrice: 60, cumulativeCount: 5}, + {salesPrice: 60, cumulativeCount: 5}, + ); + }); + + it("supports a string sort key when bounds are 'unbounded' and 'current'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('product'), + range: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('product', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {product: 'phone', cumulativeCount: 2}, + {product: 'phone', cumulativeCount: 2}, + {product: 'tablet', cumulativeCount: 5}, + {product: 'tablet', cumulativeCount: 5}, + {product: 'tablet', cumulativeCount: 5}, + ); + }); + + it("supports multiple sort orderings when bounds are 'unbounded' and 'current'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: [ascending('product'), ascending('region')], + range: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity').as('cumulativeCount'), + ) + .sort(ascending('date')) + .select('product', 'region', 'cumulativeCount'), + ); + + expectResults( + snapshot, + {product: 'phone', region: 'east', cumulativeCount: 1}, + {product: 'phone', region: 'west', cumulativeCount: 2}, + {product: 'tablet', region: 'east', cumulativeCount: 4}, + {product: 'tablet', region: 'west', cumulativeCount: 5}, + {product: 'tablet', region: 'east', cumulativeCount: 4}, + ); + }); + + it("supports 'unbounded' to 'unbounded'", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: 'unbounded'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + + it('applies the time unit to a range frame offset', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + range: { + preceding: 12, + following: 'current', + unit: 'hour', + }, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 2}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); + }); + + describe('accumulator level framing', () => { + it('evaluates each accumulator over its own frame', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product'], sort: ascending('date')}, + count('quantity') + .over({ + documents: {preceding: 'unbounded', following: 'current'}, + }) + .as('runningCount'), + count('quantity') + .over({ + documents: {preceding: 'unbounded', following: 'unbounded'}, + }) + .as('partitionCount'), + ) + .sort(ascending('date')) + .select('product', 'runningCount', 'partitionCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1, partitionCount: 2}, + {product: 'phone', runningCount: 2, partitionCount: 2}, + {product: 'tablet', runningCount: 1, partitionCount: 3}, + {product: 'tablet', runningCount: 2, partitionCount: 3}, + {product: 'tablet', runningCount: 3, partitionCount: 3}, + ); + }); + + it('supports a range frame in over()', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product'], sort: ascending('salesPrice')}, + count('quantity') + .over({range: {preceding: 0, following: 0}}) + .as('samePriceCount'), + ) + .sort(ascending('date')) + .select('product', 'salesPrice', 'samePriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', salesPrice: 12, samePriceCount: 1}, + {product: 'phone', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 30, samePriceCount: 1}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + {product: 'tablet', salesPrice: 60, samePriceCount: 2}, + ); + }); + }); + + describe('output fields and composition', () => { + it('supports multiple output fields in a single stage', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + count('quantity').as('productCountCopy'), + ) + .sort(ascending('date')) + .select('product', 'productCount', 'productCountCopy'), + ); + + expectResults( + snapshot, + {product: 'phone', productCount: 2, productCountCopy: 2}, + {product: 'phone', productCount: 2, productCountCopy: 2}, + {product: 'tablet', productCount: 3, productCountCopy: 3}, + {product: 'tablet', productCount: 3, productCountCopy: 3}, + {product: 'tablet', productCount: 3, productCountCopy: 3}, + ); + }); + + it('supports nested output field paths', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('stats.productCount'), + ) + .sort(ascending('date')) + .select('product', 'stats'), + ); + + expectResults( + snapshot, + {product: 'phone', stats: {productCount: 2}}, + {product: 'phone', stats: {productCount: 2}}, + {product: 'tablet', stats: {productCount: 3}}, + {product: 'tablet', stats: {productCount: 3}}, + {product: 'tablet', stats: {productCount: 3}}, + ); + }); + + it('supports chaining multiple addWindowFields stages', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + ) + .addWindowFields( + {partition: ['region']}, + count('quantity').as('regionCount'), + ) + .sort(ascending('date')) + .select('product', 'region', 'productCount', 'regionCount'), + ); + + expectResults( + snapshot, + { + product: 'phone', + region: 'east', + productCount: 2, + regionCount: 3, + }, + { + product: 'phone', + region: 'west', + productCount: 2, + regionCount: 2, + }, + { + product: 'tablet', + region: 'east', + productCount: 3, + regionCount: 3, + }, + { + product: 'tablet', + region: 'west', + productCount: 3, + regionCount: 2, + }, + { + product: 'tablet', + region: 'east', + productCount: 3, + regionCount: 3, + }, + ); + }); + + it('supports filtering on a window field in a later stage', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + ) + .where(field('productCount').greaterThan(2)) + .sort(ascending('date')) + .select('product', 'productCount'), + ); + + expectResults( + snapshot, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + ); + }); + + it('supports sorting on a window field in a later stage', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + count('quantity').as('productCount'), + ) + .sort(ascending('productCount'), ascending('date')) + .select('product', 'productCount'), + ); + + expectResults( + snapshot, + {product: 'phone', productCount: 2}, + {product: 'phone', productCount: 2}, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + {product: 'tablet', productCount: 3}, + ); + }); + }); + + describe('options overload', () => { + it('supports the options object signature', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales.path) + .addWindowFields({ + window: { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + fields: [ + count('quantity').as('runningCount'), + count('salesPrice').as('runningPriceCount'), + ], + }) + .sort(ascending('date')) + .select('product', 'runningCount', 'runningPriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', runningCount: 1, runningPriceCount: 1}, + {product: 'phone', runningCount: 2, runningPriceCount: 2}, + {product: 'tablet', runningCount: 1, runningPriceCount: 1}, + {product: 'tablet', runningCount: 2, runningPriceCount: 2}, + {product: 'tablet', runningCount: 3, runningPriceCount: 3}, + ); + }); + }); + + describe('error handling', () => { + it('rejects a range frame without a sort', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {range: {preceding: 'unbounded', following: 'current'}}, + count('quantity').as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error, /range/i); + }); + + it('rejects mixing stage level and accumulator level framing', async () => { + await expect( + execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + count('quantity') + .over({ + documents: {preceding: 'unbounded', following: 'unbounded'}, + }) + .as('windowCount'), + ), + ), + ).to.be.rejectedWith(Error); + }); + + it('evaluates accumulators without over() using the default frame when others use over()', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: ascending('date')}, + count('quantity') + .over({documents: {preceding: 0, following: 0}}) + .as('framed'), + count('quantity').as('unframed'), + ) + .sort(ascending('date')) + .select('product', 'framed', 'unframed'), + ); + + expectResults( + snapshot, + {product: 'phone', framed: 1, unframed: 1}, + {product: 'phone', framed: 1, unframed: 2}, + {product: 'tablet', framed: 1, unframed: 3}, + {product: 'tablet', framed: 1, unframed: 4}, + {product: 'tablet', framed: 1, unframed: 5}, + ); + }); + }); +}); + +comprehensiveDescribe('Pipeline window functions (aggregators)', () => { + let firestore: Firestore; + let productSales: CollectionReference; + + before(async () => { + productSales = getTestRoot(); + await populateWindowDocs(productSales); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore)); + + describe('numeric aggregators over a partition', () => { + it('computes sum, average, minimum and maximum', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum('salesPrice').as('total'), + average('salesPrice').as('averagePrice'), + minimum('salesPrice').as('minimumPrice'), + maximum('salesPrice').as('maximumPrice'), + ) + .sort(ascending('date')) + .select( + 'product', + 'total', + 'averagePrice', + 'minimumPrice', + 'maximumPrice', + ), + ); + + expectResults( + snapshot, + { + product: 'phone', + total: 42, + averagePrice: 21, + minimumPrice: 12, + maximumPrice: 30, + }, + { + product: 'phone', + total: 42, + averagePrice: 21, + minimumPrice: 12, + maximumPrice: 30, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + { + product: 'tablet', + total: 150, + averagePrice: 50, + minimumPrice: 30, + maximumPrice: 60, + }, + ); + }); + + // Un-skip once the backend supports countIf and countDistinct in addWindowFields. + it.skip('computes countIf', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countIf(field('salesPrice').greaterThan(20)).as('expensiveCount'), + ) + .sort(ascending('date')) + .select('product', 'expensiveCount'), + ); + + expectResults( + snapshot, + {product: 'phone', expensiveCount: 1}, + {product: 'phone', expensiveCount: 1}, + {product: 'tablet', expensiveCount: 3}, + {product: 'tablet', expensiveCount: 3}, + {product: 'tablet', expensiveCount: 3}, + ); + }); + + // Un-skip once the backend supports countIf and countDistinct in addWindowFields. + it.skip('computes countDistinct', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countDistinct('salesPrice').as('distinctPriceCount'), + ) + .sort(ascending('date')) + .select('product', 'distinctPriceCount'), + ); + + expectResults( + snapshot, + {product: 'phone', distinctPriceCount: 2}, + {product: 'phone', distinctPriceCount: 2}, + {product: 'tablet', distinctPriceCount: 2}, + {product: 'tablet', distinctPriceCount: 2}, + {product: 'tablet', distinctPriceCount: 2}, + ); + }); + + it('aggregates over a computed expression', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + sum(multiply(field('salesPrice'), field('quantity'))).as( + 'totalRevenue', + ), + ) + .sort(ascending('date')) + .select('product', 'totalRevenue'), + ); + + expectResults( + snapshot, + {product: 'phone', totalRevenue: 72}, + {product: 'phone', totalRevenue: 72}, + {product: 'tablet', totalRevenue: 630}, + {product: 'tablet', totalRevenue: 630}, + {product: 'tablet', totalRevenue: 630}, + ); + }); + }); + + describe('ordered aggregators', () => { + it('computes first and last over the whole partition', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + first('salesPrice').as('firstPrice'), + last('salesPrice').as('lastPrice'), + ) + .sort(ascending('date')) + .select('product', 'firstPrice', 'lastPrice'), + ); + + expectResults( + snapshot, + {product: 'phone', firstPrice: 12, lastPrice: 30}, + {product: 'phone', firstPrice: 12, lastPrice: 30}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + {product: 'tablet', firstPrice: 30, lastPrice: 60}, + ); + }); + + it('computes arrayAgg and arrayAggDistinct', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + partition: ['product'], + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'unbounded'}, + }, + arrayAgg('salesPrice').as('allPrices'), + arrayAggDistinct('salesPrice').as('distinctPrices'), + ) + .sort(ascending('date')) + .select('product', 'allPrices', 'distinctPrices'), + ); + + const results = snapshot.results.map(r => { + const data = r.data(); + data['distinctPrices'] = [...data['distinctPrices']].sort( + (a: number, b: number) => a - b, + ); + return data; + }); + + expect(results).to.deep.equal([ + { + product: 'phone', + allPrices: [12, 30], + distinctPrices: [12, 30], + }, + { + product: 'phone', + allPrices: [12, 30], + distinctPrices: [12, 30], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + { + product: 'tablet', + allPrices: [30, 60, 60], + distinctPrices: [30, 60], + }, + ]); + }); + + it('computes a running total', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 'unbounded', following: 'current'}, + }, + sum('salesPrice').as('runningTotal'), + ) + .sort(ascending('date')) + .select('product', 'runningTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', runningTotal: 12}, + {product: 'phone', runningTotal: 42}, + {product: 'tablet', runningTotal: 72}, + {product: 'tablet', runningTotal: 132}, + {product: 'tablet', runningTotal: 192}, + ); + }); + }); + + describe('numeric documents offsets', () => { + it('computes a centered moving average', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 1, following: 1}, + }, + average('salesPrice').as('movingAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'movingAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', movingAverage: 21, windowCount: 2}, + {product: 'phone', movingAverage: 24, windowCount: 3}, + {product: 'tablet', movingAverage: 40, windowCount: 3}, + {product: 'tablet', movingAverage: 50, windowCount: 3}, + {product: 'tablet', movingAverage: 60, windowCount: 2}, + ); + }); + + it('computes a trailing sum', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 2, following: 0}, + }, + sum('salesPrice').as('trailingTotal'), + ) + .sort(ascending('date')) + .select('product', 'trailingTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', trailingTotal: 12}, + {product: 'phone', trailingTotal: 42}, + {product: 'tablet', trailingTotal: 72}, + {product: 'tablet', trailingTotal: 120}, + {product: 'tablet', trailingTotal: 150}, + ); + }); + + it('computes a look ahead average with a negative preceding bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to "documents between 1 following and 2 + // following": the window is [index - preceding, index + + // following]. + sort: ascending('date'), + documents: {preceding: -1, following: 2}, + }, + average('salesPrice').as('lookAheadAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 30, windowCount: 2}, + {product: 'phone', lookAheadAverage: 45, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 1}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + ); + }); + + it('computes a look behind average with a negative following bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to "documents between 2 preceding and 1 + // preceding": the window is [index - preceding, index + + // following]. + sort: ascending('date'), + documents: {preceding: 2, following: -1}, + }, + average('salesPrice').as('lookBehindAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookBehindAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookBehindAverage: null, windowCount: 0}, + {product: 'phone', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 21, windowCount: 2}, + {product: 'tablet', lookBehindAverage: 30, windowCount: 2}, + {product: 'tablet', lookBehindAverage: 45, windowCount: 2}, + ); + }); + + it('computes a range look behind average with a negative following bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to a range frame of [value - Infinity, value - 1], + // including only documents with a strictly lower salesPrice. + sort: ascending('salesPrice'), + range: {preceding: 'unbounded', following: -1}, + }, + average('salesPrice').as('lookBehindAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookBehindAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookBehindAverage: null, windowCount: 0}, + {product: 'phone', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 12, windowCount: 1}, + {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, + {product: 'tablet', lookBehindAverage: 24, windowCount: 3}, + ); + }); + + it('computes a range look ahead average with a negative preceding bound', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + // Equivalent to a range frame of [value + 1, value + Infinity], + // including only documents with a strictly higher salesPrice. + sort: ascending('salesPrice'), + range: {preceding: -1, following: 'unbounded'}, + }, + average('salesPrice').as('lookAheadAverage'), + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'lookAheadAverage', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', lookAheadAverage: 45, windowCount: 4}, + {product: 'phone', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: 60, windowCount: 2}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + {product: 'tablet', lookAheadAverage: null, windowCount: 0}, + ); + }); + }); + + describe('numeric and time range offsets', () => { + it('computes a value based range window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 20, following: 0}, + }, + sum('salesPrice').as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('salesPrice', 'nearbyTotal'), + ); + + expectResults( + snapshot, + {salesPrice: 12, nearbyTotal: 12}, + {salesPrice: 30, nearbyTotal: 72}, + {salesPrice: 30, nearbyTotal: 72}, + {salesPrice: 60, nearbyTotal: 120}, + {salesPrice: 60, nearbyTotal: 120}, + ); + }); + + it('computes a value based range window with fractional offsets', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('salesPrice'), + range: {preceding: 17.5, following: 0.5}, + }, + sum('salesPrice').as('nearbyTotal'), + ) + .sort(ascending('date')) + .select('salesPrice', 'nearbyTotal'), + ); + + expectResults( + snapshot, + {salesPrice: 12, nearbyTotal: 12}, + {salesPrice: 30, nearbyTotal: 60}, + {salesPrice: 30, nearbyTotal: 60}, + {salesPrice: 60, nearbyTotal: 120}, + {salesPrice: 60, nearbyTotal: 120}, + ); + }); + + it('computes a trailing 3 day total', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + range: {preceding: 3, following: 'current', unit: 'day'}, + }, + sum('salesPrice').as('threeDayTotal'), + ) + .sort(ascending('date')) + .select('product', 'threeDayTotal'), + ); + + expectResults( + snapshot, + {product: 'phone', threeDayTotal: 12}, + {product: 'phone', threeDayTotal: 42}, + {product: 'tablet', threeDayTotal: 72}, + {product: 'tablet', threeDayTotal: 132}, + {product: 'tablet', threeDayTotal: 60}, + ); + }); + }); + + describe('countAll', () => { + it('counts every document in the window', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {partition: ['product']}, + countAll().as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 2}, + {product: 'phone', windowCount: 2}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 3}, + ); + }); + }); + + describe('documents framing with numeric offsets', () => { + it('supports zero preceding and zero following', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 0, following: 0}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 1}, + {product: 'phone', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + {product: 'tablet', windowCount: 1}, + ); + }); + + it("supports zero preceding to 'unbounded' following", async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + { + sort: ascending('date'), + documents: {preceding: 0, following: 'unbounded'}, + }, + count('quantity').as('windowCount'), + ) + .sort(ascending('date')) + .select('product', 'windowCount'), + ); + + expectResults( + snapshot, + {product: 'phone', windowCount: 5}, + {product: 'phone', windowCount: 4}, + {product: 'tablet', windowCount: 3}, + {product: 'tablet', windowCount: 2}, + {product: 'tablet', windowCount: 1}, + ); + }); + }); +}); + +// Un-skip once the backend supports `rank()`. +describe.skip('Pipeline window functions (ranking)', () => { + let firestore: Firestore; + let productSales: CollectionReference; + + before(async () => { + productSales = getTestRoot(); + await populateWindowDocs(productSales); + firestore = productSales.firestore; + }); + + afterEach(() => verifyInstance(firestore)); + + it('computes rank', async () => { + const snapshot = await execute( + firestore + .pipeline() + .collection(productSales) + .addWindowFields( + {sort: [ascending('salesPrice'), ascending('date')]}, + rank().as('priceRank'), + ) + .sort(ascending('date')) + .select('salesPrice', 'priceRank'), + ); + + expectResults( + snapshot, + { + salesPrice: 12, + priceRank: 1, + }, + { + salesPrice: 30, + priceRank: 2, + }, + { + salesPrice: 30, + priceRank: 2, + }, + { + salesPrice: 60, + priceRank: 4, + }, + { + salesPrice: 60, + priceRank: 4, + }, + ); + }); +});