diff --git a/common/api-review/firestore-pipelines.api.md b/common/api-review/firestore-pipelines.api.md index baedbf6b08a..d4ba37ec848 100644 --- a/common/api-review/firestore-pipelines.api.md +++ b/common/api-review/firestore-pipelines.api.md @@ -16,6 +16,8 @@ export function add(fieldName: string, second: Expr | unknown, ...others: Array< export class AddFields implements Stage { constructor(fields: Map); // (undocumented) + readonly fields: Map; + // (undocumented) name: string; } @@ -23,6 +25,10 @@ export class AddFields implements Stage { export class Aggregate implements Stage { constructor(accumulators: Map, groups: Map); // (undocumented) + readonly accumulators: Map; + // (undocumented) + readonly groups: Map; + // (undocumented) name: string; } @@ -32,7 +38,11 @@ export class AggregateFunction { as(name: string): AggregateWithAlias; // (undocumented) exprType: ExprType; - } + // (undocumented) + readonly name: string; + // (undocumented) + readonly params: Expr[]; +} // @beta export class AggregateWithAlias { @@ -91,6 +101,9 @@ export function arrayContainsAny(array: Expr, values: Expr): BooleanExpr; // @beta export function arrayContainsAny(fieldName: string, values: Expr): BooleanExpr; +// @beta +export function arrayLength(fieldName: string): FunctionExpr; + // @beta export function arrayLength(array: Expr): FunctionExpr; @@ -210,6 +223,8 @@ export function charLength(stringExpression: Expr): FunctionExpr; export class CollectionGroupSource implements Stage { constructor(collectionId: string); // (undocumented) + readonly collectionId: string; + // (undocumented) name: string; } @@ -217,6 +232,8 @@ export class CollectionGroupSource implements Stage { export class CollectionSource implements Stage { constructor(collectionPath: string); // (undocumented) + readonly collectionPath: string; + // (undocumented) name: string; } @@ -225,14 +242,25 @@ export function cond(condition: BooleanExpr, thenExpr: Expr, elseExpr: Expr): Fu // @beta export class Constant extends Expr { + constructor(value: any, options?: { + preferIntegers: boolean; + } | undefined); // (undocumented) readonly exprType: ExprType; - } + // (undocumented) + readonly options?: { + preferIntegers: boolean; + } | undefined; + // (undocumented) + readonly value: any; +} // Warning: (ae-incompatible-release-tags) The symbol "constant" is marked as @public, but its signature references "Constant" which is marked as @beta // // @public -export function constant(value: number): Constant; +export function constant(value: number, options?: { + preferIntegers: boolean; +}): Constant; // Warning: (ae-incompatible-release-tags) The symbol "constant" is marked as @public, but its signature references "Constant" which is marked as @beta // @@ -328,6 +356,8 @@ export function descending(fieldName: string): Ordering; export class Distinct implements Stage { constructor(groups: Map); // (undocumented) + readonly groups: Map; + // (undocumented) name: string; } @@ -353,6 +383,8 @@ export function documentId(documentPathExpr: Expr): FunctionExpr; export class DocumentsSource implements Stage { constructor(docPaths: string[]); // (undocumented) + readonly docPaths: string[]; + // (undocumented) name: string; // (undocumented) static of(refs: Array): DocumentsSource; @@ -493,7 +525,7 @@ export abstract class Expr { /* Excluded from this release type: _readUserData */ divide(other: Expr): FunctionExpr; /* Excluded from this release type: _readUserData */ - divide(other: unknown): FunctionExpr; + divide(other: number): FunctionExpr; /* Excluded from this release type: _readUserData */ documentId(): FunctionExpr; /* Excluded from this release type: _readUserData */ @@ -562,10 +594,6 @@ export abstract class Expr { /* Excluded from this release type: _readUserData */ lte(value: unknown): BooleanExpr; /* Excluded from this release type: _readUserData */ - manhattanDistance(vector: VectorValue | number[]): FunctionExpr; - /* Excluded from this release type: _readUserData */ - manhattanDistance(vectorExpression: Expr): FunctionExpr; - /* Excluded from this release type: _readUserData */ mapGet(subfield: string): FunctionExpr; /* Excluded from this release type: _readUserData */ mapMerge(secondMap: Record | Expr, ...otherMaps: Array | Expr>): FunctionExpr; @@ -580,9 +608,9 @@ export abstract class Expr { /* Excluded from this release type: _readUserData */ mod(expression: Expr): FunctionExpr; /* Excluded from this release type: _readUserData */ - mod(value: unknown): FunctionExpr; + mod(value: number): FunctionExpr; /* Excluded from this release type: _readUserData */ - multiply(second: Expr | unknown, ...others: Array): FunctionExpr; + multiply(second: Expr | number, ...others: Array): FunctionExpr; /* Excluded from this release type: _readUserData */ neq(expression: Expr): BooleanExpr; /* Excluded from this release type: _readUserData */ @@ -626,7 +654,7 @@ export abstract class Expr { /* Excluded from this release type: _readUserData */ subtract(other: Expr): FunctionExpr; /* Excluded from this release type: _readUserData */ - subtract(other: unknown): FunctionExpr; + subtract(other: number): FunctionExpr; /* Excluded from this release type: _readUserData */ sum(): AggregateFunction; /* Excluded from this release type: _readUserData */ @@ -725,7 +753,11 @@ export class FunctionExpr extends Expr { constructor(name: string, params: Expr[]); // (undocumented) readonly exprType: ExprType; - } + // (undocumented) + readonly name: string; + // (undocumented) + readonly params: Expr[]; +} // @beta (undocumented) export class GenericStage implements Stage { @@ -858,18 +890,6 @@ export function lte(fieldName: string, expression: Expr): BooleanExpr; // @beta export function lte(fieldName: string, value: unknown): BooleanExpr; -// @beta -export function manhattanDistance(fieldName: string, vector: number[] | VectorValue): FunctionExpr; - -// @beta -export function manhattanDistance(fieldName: string, vectorExpression: Expr): FunctionExpr; - -// @beta -export function manhattanDistance(vectorExpression: Expr, vector: number[] | VectorValue): FunctionExpr; - -// @beta -export function manhattanDistance(vectorExpression: Expr, otherVectorExpression: Expr): FunctionExpr; - // @beta export function map(elements: Record): FunctionExpr; @@ -959,7 +979,9 @@ export class Offset implements Stage { constructor(offset: number); // (undocumented) name: string; - } + // (undocumented) + readonly offset: number; +} // @beta export function or(first: BooleanExpr, second: BooleanExpr, ...more: BooleanExpr[]): BooleanExpr; @@ -992,8 +1014,9 @@ export class Pipeline { readUserData: any; // Warning: (ae-incompatible-release-tags) The symbol "removeFields" is marked as @public, but its signature references "Field" which is marked as @beta removeFields(fieldValue: Field | string, ...additionalFields: Array): Pipeline; - // Warning: (ae-incompatible-release-tags) The symbol "replaceWith" is marked as @public, but its signature references "Field" which is marked as @beta - replaceWith(fieldValue: Field | string): Pipeline; + replaceWith(fieldName: string): Pipeline; + // Warning: (ae-incompatible-release-tags) The symbol "replaceWith" is marked as @public, but its signature references "Expr" which is marked as @beta + replaceWith(expr: Expr): Pipeline; sample(documents: number): Pipeline; sample(options: { percentage: number; } | { documents: number; }): Pipeline; // Warning: (ae-incompatible-release-tags) The symbol "select" is marked as @public, but its signature references "Selectable" which is marked as @beta @@ -1002,32 +1025,61 @@ export class Pipeline { selectablesToMap: any; // Warning: (ae-incompatible-release-tags) The symbol "sort" is marked as @public, but its signature references "Ordering" which is marked as @beta sort(ordering: Ordering, ...additionalOrderings: Ordering[]): Pipeline; + // Warning: (ae-incompatible-release-tags) The symbol "stages" is marked as @public, but its signature references "Stage" which is marked as @beta + // // (undocumented) - stages: any; + stages: Stage[]; union(other: Pipeline): Pipeline; // Warning: (ae-incompatible-release-tags) The symbol "unnest" is marked as @public, but its signature references "Selectable" which is marked as @beta unnest(selectable: Selectable, indexField?: string): Pipeline; - // (undocumented) - userDataReader: any; // Warning: (ae-incompatible-release-tags) The symbol "where" is marked as @public, but its signature references "BooleanExpr" which is marked as @beta where(condition: BooleanExpr): Pipeline; } -// Warning: (ae-forgotten-export) The symbol "DocumentData" needs to be exported by the entry point pipelines.d.ts -// // @beta -export class PipelineResult { +export class PipelineResult { + get createTime(): Timestamp | undefined; /* Excluded from this release type: _ref */ /* Excluded from this release type: _fields */ /* Excluded from this release type: __constructor */ - get createTime(): Timestamp | undefined; - data(): AppModelType | undefined; + /* Excluded from this release type: fromDocument */ + // Warning: (ae-forgotten-export) The symbol "DocumentData" needs to be exported by the entry point pipelines.d.ts + data(): DocumentData | undefined; + /* Excluded from this release type: _ref */ + /* Excluded from this release type: _fields */ + /* Excluded from this release type: __constructor */ + /* Excluded from this release type: fromDocument */ get(fieldPath: string | FieldPath | Field): any; + /* Excluded from this release type: _ref */ + /* Excluded from this release type: _fields */ + /* Excluded from this release type: __constructor */ + /* Excluded from this release type: fromDocument */ get id(): string | undefined; + /* Excluded from this release type: _ref */ + /* Excluded from this release type: _fields */ + /* Excluded from this release type: __constructor */ + /* Excluded from this release type: fromDocument */ + // Warning: (ae-forgotten-export) The symbol "SnapshotMetadata" needs to be exported by the entry point pipelines.d.ts + // + // (undocumented) + readonly metadata?: SnapshotMetadata | undefined; + /* Excluded from this release type: _ref */ + /* Excluded from this release type: _fields */ + /* Excluded from this release type: __constructor */ + /* Excluded from this release type: fromDocument */ get ref(): DocumentReference | undefined; + /* Excluded from this release type: _ref */ + /* Excluded from this release type: _fields */ + /* Excluded from this release type: __constructor */ + /* Excluded from this release type: fromDocument */ get updateTime(): Timestamp | undefined; } +// Warning: (ae-incompatible-release-tags) The symbol "pipelineResultEqual" is marked as @public, but its signature references "PipelineResult" which is marked as @beta +// +// @public (undocumented) +export function pipelineResultEqual(left: PipelineResult, right: PipelineResult): boolean; + // @public (undocumented) export class PipelineSnapshot { // Warning: (ae-incompatible-release-tags) The symbol "__constructor" is marked as @public, but its signature references "PipelineResult" which is marked as @beta @@ -1050,7 +1102,7 @@ export class PipelineSource { collectionGroup(collectionId: string): PipelineType; /* Excluded from this release type: _createPipeline */ /* Excluded from this release type: __constructor */ - createFrom(query: Query): Pipeline; + createFrom(query: Query): PipelineType; /* Excluded from this release type: _createPipeline */ /* Excluded from this release type: __constructor */ database(): PipelineType; @@ -1115,7 +1167,9 @@ export class Select implements Stage { constructor(projections: Map); // (undocumented) name: string; - } + // (undocumented) + readonly projections: Map; +} // @beta export interface Selectable { @@ -1132,7 +1186,9 @@ export class Sort implements Stage { constructor(orders: Ordering[]); // (undocumented) name: string; - } + // (undocumented) + readonly orders: Ordering[]; +} // @beta (undocumented) export interface Stage { @@ -1282,6 +1338,8 @@ export function vectorLength(fieldName: string): FunctionExpr; export class Where implements Stage { constructor(condition: BooleanExpr); // (undocumented) + readonly condition: BooleanExpr; + // (undocumented) name: string; } @@ -1291,8 +1349,8 @@ export function xor(first: BooleanExpr, second: BooleanExpr, ...additionalCondit // Warnings were encountered during analysis: // -// /Users/markduckworth/projects/firebase-js-sdk/packages/firestore/dist/pipelines.d.ts:4552:26 - (ae-incompatible-release-tags) The symbol "accumulators" is marked as @public, but its signature references "AggregateWithAlias" which is marked as @beta -// /Users/markduckworth/projects/firebase-js-sdk/packages/firestore/dist/pipelines.d.ts:4552:62 - (ae-incompatible-release-tags) The symbol "groups" is marked as @public, but its signature references "Selectable" which is marked as @beta +// /Users/wuandy/projects/firebase-js-sdk/packages/firestore/dist/pipelines.d.ts:4487:26 - (ae-incompatible-release-tags) The symbol "accumulators" is marked as @public, but its signature references "AggregateWithAlias" which is marked as @beta +// /Users/wuandy/projects/firebase-js-sdk/packages/firestore/dist/pipelines.d.ts:4487:62 - (ae-incompatible-release-tags) The symbol "groups" is marked as @public, but its signature references "Selectable" which is marked as @beta // (No @packageDocumentation comment for this package) diff --git a/packages/firestore/src/api/realtime_pipeline.ts b/packages/firestore/src/api/realtime_pipeline.ts new file mode 100644 index 00000000000..d7dafa8cf11 --- /dev/null +++ b/packages/firestore/src/api/realtime_pipeline.ts @@ -0,0 +1,176 @@ +import { Firestore } from '../lite-api/database'; +import { BooleanExpr, Ordering } from '../lite-api/expressions'; +import { isReadableUserData, ReadableUserData } from '../lite-api/pipeline'; +import { Limit, Sort, Stage, Where } from '../lite-api/stage'; +import { UserDataReader } from '../lite-api/user_data_reader'; +import { AbstractUserDataWriter } from '../lite-api/user_data_writer'; +import { + Stage as ProtoStage, + StructuredPipeline +} from '../protos/firestore_proto_api'; +import { JsonProtoSerializer } from '../remote/serializer'; + +/** + * Base-class implementation + */ +export class RealtimePipeline { + /** + * @internal + * @private + * @param _db + * @param userDataReader + * @param _userDataWriter + * @param _documentReferenceFactory + * @param stages + * @param converter + */ + constructor( + /** + * @internal + * @private + */ + public _db: Firestore, + /** + * @internal + * @private + */ + readonly userDataReader: UserDataReader, + /** + * @internal + * @private + */ + public _userDataWriter: AbstractUserDataWriter, + readonly stages: Stage[], + readonly converter: unknown = {} + ) {} + + /** + * Reads user data for each expression in the expressionMap. + * @param name Name of the calling function. Used for error messages when invalid user data is encountered. + * @param expressionMap + * @return the expressionMap argument. + * @private + * @internal + */ + protected readUserData< + T extends + | Map + | ReadableUserData[] + | ReadableUserData + >(name: string, expressionMap: T): T { + if (isReadableUserData(expressionMap)) { + expressionMap._readUserData(this.userDataReader); + } else if (Array.isArray(expressionMap)) { + expressionMap.forEach(readableData => + readableData._readUserData(this.userDataReader) + ); + } else { + expressionMap.forEach(expr => expr._readUserData(this.userDataReader)); + } + return expressionMap; + } + + /** + * @internal + * @private + * @param db + * @param userDataReader + * @param userDataWriter + * @param stages + * @param converter + */ + newPipeline( + db: Firestore, + userDataReader: UserDataReader, + userDataWriter: AbstractUserDataWriter, + stages: Stage[], + converter: unknown = {} + ): RealtimePipeline { + return new RealtimePipeline(db, userDataReader, userDataWriter, stages); + } + + where(condition: BooleanExpr): RealtimePipeline { + const copy = this.stages.map(s => s); + this.readUserData('where', condition); + copy.push(new Where(condition)); + return this.newPipeline( + this._db, + this.userDataReader, + this._userDataWriter, + copy, + this.converter + ); + } + + limit(limit: number): RealtimePipeline { + const copy = this.stages.map(s => s); + copy.push(new Limit(limit)); + return this.newPipeline( + this._db, + this.userDataReader, + this._userDataWriter, + copy + ); + } + + _limit(limit: number, convertedFromLimitTolast: boolean): RealtimePipeline { + const copy = this.stages.map(s => s); + copy.push(new Limit(limit, convertedFromLimitTolast)); + return new RealtimePipeline( + this._db, + this.userDataReader, + this._userDataWriter, + copy + ); + } + + sort(...orderings: Ordering[]): RealtimePipeline; + sort(options: { orderings: Ordering[] }): RealtimePipeline; + sort( + optionsOrOrderings: + | Ordering + | { + orderings: Ordering[]; + }, + ...rest: Ordering[] + ): RealtimePipeline { + const copy = this.stages.map(s => s); + // Option object + if ('orderings' in optionsOrOrderings) { + copy.push( + new Sort( + this.readUserData( + 'sort', + this.readUserData('sort', optionsOrOrderings.orderings) + ) + ) + ); + } else { + // Ordering object + copy.push( + new Sort(this.readUserData('sort', [optionsOrOrderings, ...rest])) + ); + } + + return this.newPipeline( + this._db, + this.userDataReader, + this._userDataWriter, + copy, + this.converter + ); + } + + /** + * @internal + * @private + */ + _toStructuredPipeline( + jsonProtoSerializer: JsonProtoSerializer + ): StructuredPipeline { + const stages: ProtoStage[] = this.stages.map(stage => + stage._toProto(jsonProtoSerializer) + ); + return { pipeline: { stages } }; + } +} diff --git a/packages/firestore/src/core/expressions.ts b/packages/firestore/src/core/expressions.ts new file mode 100644 index 00000000000..72a845aedf6 --- /dev/null +++ b/packages/firestore/src/core/expressions.ts @@ -0,0 +1,2915 @@ +// Copyright 2024 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 { RE2JS } from 're2js'; + +import { + Field, + Constant, + Expr, + FunctionExpr, + AggregateFunction, + ListOfExprs, + isNan, + isError +} from '../lite-api/expressions'; +import { Timestamp } from '../lite-api/timestamp'; +import { + CREATE_TIME_NAME, + DOCUMENT_KEY_NAME, + UPDATE_TIME_NAME +} from '../model/path'; +import { + FALSE_VALUE, + getVectorValue, + isArray, + isBoolean, + isBytes, + isDouble, + isInteger, + isMapValue, + isNanValue, + isNullValue, + isNumber, + isString, + isTimestampValue, + isVectorValue, + MIN_VALUE, + TRUE_VALUE, + typeOrder, + valueCompare, + valueEquals as valueEqualsWithOptions +} from '../model/values'; +import { + ArrayValue, + Value, + Timestamp as ProtoTimestamp, + LatLng, + MapValue +} from '../protos/firestore_proto_api'; +import { fromTimestamp, toName, toVersion } from '../remote/serializer'; +import { hardAssert } from '../util/assert'; +import { logWarn } from '../util/log'; +import { isNegativeZero } from '../util/types'; + +import { EvaluationContext, PipelineInputOutput } from './pipeline_run'; +import { objectSize } from '../util/obj'; + +export type EvaluateResultType = + | 'ERROR' + | 'UNSET' + | 'NULL' + | 'BOOLEAN' + | 'INT' + | 'DOUBLE' + | 'TIMESTAMP' + | 'STRING' + | 'BYTES' + | 'REFERENCE' + | 'GEO_POINT' + | 'ARRAY' + | 'MAP' + | 'FIELD_REFERENCE' + | 'VECTOR'; + +export class EvaluateResult { + private constructor( + readonly type: EvaluateResultType, + readonly value?: Value + ) {} + + static newError(): EvaluateResult { + return new EvaluateResult('ERROR', undefined); + } + + static newUnset(): EvaluateResult { + return new EvaluateResult('UNSET', undefined); + } + + static newNull(): EvaluateResult { + return new EvaluateResult('NULL', MIN_VALUE); + } + + static newValue(value: Value): EvaluateResult { + if (isNullValue(value)) { + return new EvaluateResult('NULL', MIN_VALUE); + } else if (isBoolean(value)) { + return new EvaluateResult('BOOLEAN', value); + } else if (isInteger(value)) { + return new EvaluateResult('INT', value); + } else if (isDouble(value)) { + return new EvaluateResult('DOUBLE', value); + } else if (isTimestampValue(value)) { + return new EvaluateResult('TIMESTAMP', value); + } else if (isString(value)) { + return new EvaluateResult('STRING', value); + } else if (isBytes(value)) { + return new EvaluateResult('BYTES', value); + } else if (value.referenceValue) { + return new EvaluateResult('REFERENCE', value); + } else if (value.geoPointValue) { + return new EvaluateResult('GEO_POINT', value); + } else if (isArray(value)) { + return new EvaluateResult('ARRAY', value); + } else if (isVectorValue(value)) { + // vector value must be before map value + return new EvaluateResult('VECTOR', value); + } else if (isMapValue(value)) { + return new EvaluateResult('MAP', value); + } else { + return new EvaluateResult('ERROR', undefined); + } + } + + isErrorOrUnset(): boolean { + return this.type === 'ERROR' || this.type === 'UNSET'; + } + + isNull(): boolean { + return this.type === 'NULL'; + } +} + +export function valueOrUndefined(value: EvaluateResult): Value | undefined { + if (value.isErrorOrUnset()) { + return undefined; + } + return value.value!; +} + +export interface EvaluableExpr { + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult; +} + +export function toEvaluable(expr: T): EvaluableExpr { + if (expr instanceof Field) { + return new CoreField(expr); + } else if (expr instanceof Constant) { + return new CoreConstant(expr); + } else if (expr instanceof ListOfExprs) { + return new CoreListOfExprs(expr); + } else if (expr.exprType === 'Function') { + const functionExpr = expr as unknown as FunctionExpr; + if (functionExpr.name === 'add') { + return new CoreAdd(functionExpr); + } else if (functionExpr.name === 'subtract') { + return new CoreSubtract(functionExpr); + } else if (functionExpr.name === 'multiply') { + return new CoreMultiply(functionExpr); + } else if (functionExpr.name === 'divide') { + return new CoreDivide(functionExpr); + } else if (functionExpr.name === 'mod') { + return new CoreMod(functionExpr); + } else if (functionExpr.name === 'and') { + return new CoreAnd(functionExpr); + } else if (functionExpr.name === 'eq') { + return new CoreEq(functionExpr); + } else if (functionExpr.name === 'neq') { + return new CoreNeq(functionExpr); + } else if (functionExpr.name === 'lt') { + return new CoreLt(functionExpr); + } else if (functionExpr.name === 'lte') { + return new CoreLte(functionExpr); + } else if (functionExpr.name === 'gt') { + return new CoreGt(functionExpr); + } else if (functionExpr.name === 'gte') { + return new CoreGte(functionExpr); + } else if (functionExpr.name === 'array_concat') { + return new CoreArrayConcat(functionExpr); + } else if (functionExpr.name === 'array_reverse') { + return new CoreArrayReverse(functionExpr); + } else if (functionExpr.name === 'array_contains') { + return new CoreArrayContains(functionExpr); + } else if (functionExpr.name === 'array_contains_all') { + return new CoreArrayContainsAll(functionExpr); + } else if (functionExpr.name === 'array_contains_any') { + return new CoreArrayContainsAny(functionExpr); + } else if (functionExpr.name === 'array_length') { + return new CoreArrayLength(functionExpr); + } else if (functionExpr.name === 'array_element') { + return new CoreArrayElement(functionExpr); + } else if (functionExpr.name === 'eq_any') { + return new CoreEqAny(functionExpr); + } else if (functionExpr.name === 'not_eq_any') { + return new CoreNotEqAny(functionExpr); + } else if (functionExpr.name === 'is_nan') { + return new CoreIsNan(functionExpr); + } else if (functionExpr.name === 'is_not_nan') { + return new CoreIsNotNan(functionExpr); + } else if (functionExpr.name === 'is_null') { + return new CoreIsNull(functionExpr); + } else if (functionExpr.name === 'is_not_null') { + return new CoreIsNotNull(functionExpr); + } else if (functionExpr.name === 'exists') { + return new CoreExists(functionExpr); + } else if (functionExpr.name === 'not') { + return new CoreNot(functionExpr); + } else if (functionExpr.name === 'or') { + return new CoreOr(functionExpr); + } else if (functionExpr.name === 'xor') { + return new CoreXor(functionExpr); + } else if (functionExpr.name === 'cond') { + return new CoreCond(functionExpr); + } else if (functionExpr.name === 'logical_maximum') { + return new CoreLogicalMaximum(functionExpr); + } else if (functionExpr.name === 'logical_minimum') { + return new CoreLogicalMinimum(functionExpr); + } else if (functionExpr.name === 'reverse') { + return new CoreReverse(functionExpr); + } else if (functionExpr.name === 'replace_first') { + return new CoreReplaceFirst(functionExpr); + } else if (functionExpr.name === 'replace_all') { + return new CoreReplaceAll(functionExpr); + } else if (functionExpr.name === 'char_length') { + return new CoreCharLength(functionExpr); + } else if (functionExpr.name === 'byte_length') { + return new CoreByteLength(functionExpr); + } else if (functionExpr.name === 'like') { + return new CoreLike(functionExpr); + } else if (functionExpr.name === 'regex_contains') { + return new CoreRegexContains(functionExpr); + } else if (functionExpr.name === 'regex_match') { + return new CoreRegexMatch(functionExpr); + } else if (functionExpr.name === 'str_contains') { + return new CoreStrContains(functionExpr); + } else if (functionExpr.name === 'starts_with') { + return new CoreStartsWith(functionExpr); + } else if (functionExpr.name === 'ends_with') { + return new CoreEndsWith(functionExpr); + } else if (functionExpr.name === 'to_lower') { + return new CoreToLower(functionExpr); + } else if (functionExpr.name === 'to_upper') { + return new CoreToUpper(functionExpr); + } else if (functionExpr.name === 'trim') { + return new CoreTrim(functionExpr); + } else if (functionExpr.name === 'str_concat') { + return new CoreStrConcat(functionExpr); + } else if (functionExpr.name === 'map_get') { + return new CoreMapGet(functionExpr); + } else if (functionExpr.name === 'cosine_distance') { + return new CoreCosineDistance(functionExpr); + } else if (functionExpr.name === 'dot_product') { + return new CoreDotProduct(functionExpr); + } else if (functionExpr.name === 'euclidean_distance') { + return new CoreEuclideanDistance(functionExpr); + } else if (functionExpr.name === 'vector_length') { + return new CoreVectorLength(functionExpr); + } else if (functionExpr.name === 'unix_micros_to_timestamp') { + return new CoreUnixMicrosToTimestamp(functionExpr); + } else if (functionExpr.name === 'timestamp_to_unix_micros') { + return new CoreTimestampToUnixMicros(functionExpr); + } else if (functionExpr.name === 'unix_millis_to_timestamp') { + return new CoreUnixMillisToTimestamp(functionExpr); + } else if (functionExpr.name === 'timestamp_to_unix_millis') { + return new CoreTimestampToUnixMillis(functionExpr); + } else if (functionExpr.name === 'unix_seconds_to_timestamp') { + return new CoreUnixSecondsToTimestamp(functionExpr); + } else if (functionExpr.name === 'timestamp_to_unix_seconds') { + return new CoreTimestampToUnixSeconds(functionExpr); + } else if (functionExpr.name === 'timestamp_add') { + return new CoreTimestampAdd(functionExpr); + } else if (functionExpr.name === 'timestamp_sub') { + return new CoreTimestampSub(functionExpr); + } + } else if (expr.exprType === 'AggregateFunction') { + const functionExpr = expr as unknown as AggregateFunction; + if (functionExpr.name === 'count') { + return new CoreCount(functionExpr); + } else if (functionExpr.name === 'sum') { + return new CoreSum(functionExpr); + } else if (functionExpr.name === 'avg') { + return new CoreAvg(functionExpr); + } else if (functionExpr.name === 'minimum') { + return new CoreMinimum(functionExpr); + } else if (functionExpr.name === 'maximum') { + return new CoreMaximum(functionExpr); + } + } + + throw new Error(`Unknown Expr : ${expr}`); +} + +export class CoreField implements EvaluableExpr { + constructor(private expr: Field) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + if (this.expr.fieldName() === DOCUMENT_KEY_NAME) { + return EvaluateResult.newValue({ + referenceValue: toName(context.serializer, input.key) + }); + } + if (this.expr.fieldName() === UPDATE_TIME_NAME) { + return EvaluateResult.newValue({ + timestampValue: toVersion(context.serializer, input.version) + }); + } + if (this.expr.fieldName() === CREATE_TIME_NAME) { + return EvaluateResult.newValue({ + timestampValue: toVersion(context.serializer, input.createTime) + }); + } + // Return 'UNSET' if the field doesn't exist, otherwise the Value. + const result = input.data.field(this.expr._fieldPath); + if (!!result) { + return EvaluateResult.newValue(result); + } else { + return EvaluateResult.newUnset(); + } + } +} + +export class CoreConstant implements EvaluableExpr { + constructor(private expr: Constant) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + return EvaluateResult.newValue(this.expr._getValue()); + } +} + +export class CoreListOfExprs implements EvaluableExpr { + constructor(private expr: ListOfExprs) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + const results: EvaluateResult[] = this.expr.exprs.map(expr => + toEvaluable(expr).evaluate(context, input) + ); + // If any sub-expression resulted in error or was unset, the list evaluation fails. + if (results.some(value => value.isErrorOrUnset())) { + return EvaluateResult.newError(); + } + + return EvaluateResult.newValue({ + arrayValue: { values: results.map(value => value.value!) } + }); + } +} + +function asDouble( + protoNumber: + | { doubleValue: number | string } + | { integerValue: number | string } +): number { + if (isDouble(protoNumber)) { + return Number(protoNumber.doubleValue); + } + return Number(protoNumber.integerValue); +} + +function asBigInt(protoNumber: { integerValue: number | string }): bigint { + return BigInt(protoNumber.integerValue); +} + +export const LongMaxValue = BigInt('0x7fffffffffffffff'); +export const LongMinValue = -BigInt('0x8000000000000000'); + +abstract class BigIntOrDoubleArithmetics implements EvaluableExpr { + protected constructor(protected expr: FunctionExpr) {} + + abstract bigIntArith( + left: { integerValue: number | string }, + right: { + integerValue: number | string; + } + ): bigint | number | undefined; + abstract doubleArith( + left: + | { doubleValue: number | string } + | { + integerValue: number | string; + }, + right: + | { doubleValue: number | string } + | { + integerValue: number | string; + } + ): + | { + doubleValue: number; + } + | undefined; + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length >= 2, + 'Arithmetics should have at least 2 params' + ); + const left = toEvaluable(this.expr.params[0]).evaluate(context, input); + const right = toEvaluable(this.expr.params[1]).evaluate(context, input); + let result = this.applyArithmetics(left, right); + + for (const expr of this.expr.params.slice(2)) { + const evaluated = toEvaluable(expr).evaluate(context, input); + result = this.applyArithmetics(result, evaluated); + } + + return result; + } + + applyArithmetics( + left: EvaluateResult, + right: EvaluateResult + ): EvaluateResult { + // If any operand is error or unset, the result is error. + if (left.isErrorOrUnset() || right.isErrorOrUnset()) { + return EvaluateResult.newError(); + } + if (left.isNull() || right.isNull()) { + return EvaluateResult.newNull(); + } + + // Type check: Both must be numbers (integer or double). + // We know left and right are Value here due to the check above. + const leftVal = left.value; + const rightVal = right.value; + if ( + (!isDouble(leftVal) && !isInteger(leftVal)) || + (!isDouble(rightVal) && !isInteger(rightVal)) + ) { + return EvaluateResult.newError(); // Type error + } + + // Perform arithmetic based on types. + if (isDouble(leftVal) || isDouble(rightVal)) { + const result = this.doubleArith(leftVal, rightVal); + if (!result) { + return EvaluateResult.newError(); + } + return EvaluateResult.newValue(result); + } + + if (isInteger(leftVal) && isInteger(rightVal)) { + // Pass the narrowed Value types + const result = this.bigIntArith(leftVal, rightVal); + if (result === undefined) { + return EvaluateResult.newError(); // Specific arithmetic error (e.g., divide by zero for integers) + } + + if (typeof result === 'number') { + // Result was double (e.g., integer divide by zero) + return EvaluateResult.newValue({ doubleValue: result }); + } + // Check for BigInt overflow + else if (result < LongMinValue || result > LongMaxValue) { + return EvaluateResult.newError(); // Simulate overflow error + } else { + return EvaluateResult.newValue({ integerValue: `${result}` }); + } + } + // Should not be reached due to initial type checks + return EvaluateResult.newError(); + } +} + +type Equality = 'NULL' | 'EQ' | 'NOT_EQ'; +function strictValueEquals(left: Value, right: Value): Equality { + if (isNullValue(left) || isNullValue(right)) { + return 'NULL'; + } + + if (isArray(left) && isArray(right)) { + return strictArrayValueEquals(left.arrayValue, right.arrayValue); + } + if ( + (isVectorValue(left) && isVectorValue(right)) || + (isMapValue(left) && isMapValue(right)) + ) { + return strictObjectValueEquals(left.mapValue!, right.mapValue!); + } + + return valueEquals(left, right) ? 'EQ' : 'NOT_EQ'; +} + +function strictArrayValueEquals(left: ArrayValue, right: ArrayValue): Equality { + if (left.values?.length !== right.values?.length) { + return 'NOT_EQ'; + } + + let foundNull = false; + for (let index = 0; index < (left.values?.length ?? 0); index++) { + const leftValue = left.values![index]; + const rightValue = right.values![index]; + switch (strictValueEquals(leftValue, rightValue)) { + case 'NOT_EQ': { + return 'NOT_EQ'; + } + case 'NULL': { + foundNull = true; + break; + } + } + } + + if (foundNull) { + return 'NULL'; + } + + return 'EQ'; +} + +function strictObjectValueEquals(left: MapValue, right: MapValue): Equality { + const leftMap = left.fields || {}; + const rightMap = right.fields || {}; + + if (objectSize(leftMap) !== objectSize(rightMap)) { + return 'NOT_EQ'; + } + + let foundNull = false; + for (const key in leftMap) { + if (leftMap.hasOwnProperty(key)) { + if (rightMap[key] === undefined) { + return 'NOT_EQ'; + } + + switch (strictValueEquals(leftMap[key], rightMap[key])) { + case 'NOT_EQ': { + return 'NOT_EQ'; + } + case 'NULL': { + foundNull = true; + } + } + } + } + + if (foundNull) { + return 'NULL'; + } + + return 'EQ'; +} + +function valueEquals(left: Value, right: Value): boolean { + return valueEqualsWithOptions(left, right, { + nanEqual: false, + mixIntegerDouble: true, + semanticsEqual: true + }); +} + +export class CoreAdd extends BigIntOrDoubleArithmetics { + constructor(expr: FunctionExpr) { + super(expr); + } + + bigIntArith( + left: { integerValue: number | string }, + right: { + integerValue: number | string; + } + ): bigint | undefined { + return asBigInt(left) + asBigInt(right); + } + + doubleArith( + left: + | { doubleValue: number | string } + | { + integerValue: number | string; + }, + right: + | { doubleValue: number | string } + | { + integerValue: number | string; + } + ): + | { + doubleValue: number; + } + | undefined { + return { doubleValue: asDouble(left) + asDouble(right) }; + } +} + +export class CoreSubtract extends BigIntOrDoubleArithmetics { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + bigIntArith( + left: { integerValue: number | string }, + right: { + integerValue: number | string; + } + ): bigint | undefined { + return asBigInt(left) - asBigInt(right); + } + + doubleArith( + left: + | { doubleValue: number | string } + | { + integerValue: number | string; + }, + right: + | { doubleValue: number | string } + | { + integerValue: number | string; + } + ): + | { + doubleValue: number; + } + | undefined { + return { doubleValue: asDouble(left) - asDouble(right) }; + } +} + +export class CoreMultiply extends BigIntOrDoubleArithmetics { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + bigIntArith( + left: { integerValue: number | string }, + right: { + integerValue: number | string; + } + ): bigint | undefined { + return asBigInt(left) * asBigInt(right); + } + + doubleArith( + left: + | { doubleValue: number | string } + | { + integerValue: number | string; + }, + right: + | { doubleValue: number | string } + | { + integerValue: number | string; + } + ): + | { + doubleValue: number; + } + | undefined { + return { doubleValue: asDouble(left) * asDouble(right) }; + } +} + +export class CoreDivide extends BigIntOrDoubleArithmetics { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + bigIntArith( + left: { integerValue: number | string }, + right: { + integerValue: number | string; + } + ): bigint | number | undefined { + const rightValue = asBigInt(right); + if (rightValue === BigInt(0)) { + return undefined; // Integer division by zero is an error + } + return asBigInt(left) / rightValue; + } + + doubleArith( + left: + | { doubleValue: number | string } + | { + integerValue: number | string; + }, + right: + | { doubleValue: number | string } + | { + integerValue: number | string; + } + ): + | { + doubleValue: number; + } + | undefined { + const rightValue = asDouble(right); + if (rightValue === 0) { + // Double division by zero results in Infinity + return { + doubleValue: isNegativeZero(rightValue) + ? Number.NEGATIVE_INFINITY + : Number.POSITIVE_INFINITY + }; + } + return { doubleValue: asDouble(left) / rightValue }; + } +} + +export class CoreMod extends BigIntOrDoubleArithmetics { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + bigIntArith( + left: { integerValue: number | string }, + right: { + integerValue: number | string; + } + ): bigint | undefined { + const rightValue = asBigInt(right); + if (rightValue === BigInt(0)) { + return undefined; // Modulo by zero is an error + } + return asBigInt(left) % rightValue; + } + + doubleArith( + left: + | { doubleValue: number | string } + | { + integerValue: number | string; + }, + right: + | { doubleValue: number | string } + | { + integerValue: number | string; + } + ): + | { + doubleValue: number; + } + | undefined { + const rightValue = asDouble(right); + if (rightValue === 0) { + return undefined; // Modulo by zero is an error + } + + return { doubleValue: asDouble(left) % rightValue }; + } +} + +export class CoreAnd implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + let hasError = false; + let hasNull = false; + for (const param of this.expr.params) { + const result = toEvaluable(param).evaluate(context, input); + switch (result.type) { + case 'BOOLEAN': { + if (!result.value?.booleanValue) { + return EvaluateResult.newValue(FALSE_VALUE); + } + break; + } + case 'NULL': { + hasNull = true; + break; + } + default: { + hasError = true; + } + } + } + + if (hasError) { + return EvaluateResult.newError(); + } + if (hasNull) { + return EvaluateResult.newNull(); + } + + return EvaluateResult.newValue(TRUE_VALUE); + } +} + +export class CoreNot implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'not() function should have exactly 1 param' + ); + const result = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (result.type) { + case 'BOOLEAN': { + return EvaluateResult.newValue({ + booleanValue: !result.value?.booleanValue + }); + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: + return EvaluateResult.newError(); + } + } +} + +export class CoreOr implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + let hasError = false; + let hasNull = false; + for (const param of this.expr.params) { + const result = toEvaluable(param).evaluate(context, input); + switch (result.type) { + case 'BOOLEAN': { + if (result.value?.booleanValue) { + return EvaluateResult.newValue(TRUE_VALUE); + } + break; + } + case 'NULL': { + hasNull = true; + break; + } + default: { + hasError = true; + } + } + } + + if (hasError) { + return EvaluateResult.newError(); + } + if (hasNull) { + return EvaluateResult.newNull(); + } + + return EvaluateResult.newValue(FALSE_VALUE); + } +} + +export class CoreXor implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + let result = false; + let hasNull = false; + for (const param of this.expr.params) { + const evaluated = toEvaluable(param).evaluate(context, input); + switch (evaluated.type) { + case 'BOOLEAN': { + result = CoreXor.xor(result, !!evaluated.value?.booleanValue); + break; + } + case 'NULL': { + hasNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + } + + if (hasNull) { + return EvaluateResult.newNull(); + } + return EvaluateResult.newValue({ booleanValue: result }); + } + + // XOR(a, b) is equivalent to (a OR b) AND NOT(a AND b) + // It is required to evaluate all arguments to ensure that the correct error semantics are + // applied. + static xor(a: boolean, b: boolean): boolean { + return (a || b) && !(a && b); + } +} + +export class CoreEqAny implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + 'eq_any() function should have exactly 2 params' + ); + + let foundNull = false; + const searchExpr = this.expr.params[0]; + const searchValue = toEvaluable(searchExpr).evaluate(context, input); + switch (searchValue.type) { + case 'NULL': { + foundNull = true; + break; + } + case 'ERROR': + return EvaluateResult.newError(); + case 'UNSET': + return EvaluateResult.newError(); + } + + const arrayExpr = this.expr.params[1]; + const arrayValue = toEvaluable(arrayExpr).evaluate(context, input); + switch (arrayValue.type) { + case 'ARRAY': + break; + case 'NULL': { + foundNull = true; + break; + } + default: + return EvaluateResult.newError(); + } + + if (foundNull) { + return EvaluateResult.newNull(); + } + + for (const candidate of arrayValue.value?.arrayValue?.values ?? []) { + switch (strictValueEquals(searchValue.value!, candidate)) { + case 'EQ': + return EvaluateResult.newValue(TRUE_VALUE); + case 'NOT_EQ': { + break; + } + case 'NULL': + foundNull = true; + } + } + + if (foundNull) { + return EvaluateResult.newNull(); + } + + return EvaluateResult.newValue(FALSE_VALUE); + } +} + +export class CoreNotEqAny implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + const equivalent = new CoreNot( + new FunctionExpr('not', [new FunctionExpr('eq_any', this.expr.params)]) + ); + return equivalent.evaluate(context, input); + } +} + +export class CoreIsNan implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'is_nan() function should have exactly 1 param' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'INT': + return EvaluateResult.newValue(FALSE_VALUE); + case 'DOUBLE': + return EvaluateResult.newValue({ + booleanValue: isNaN( + asDouble(evaluated.value as { doubleValue: number | string }) + ) + }); + case 'NULL': + return EvaluateResult.newNull(); + default: + return EvaluateResult.newError(); + } + } +} + +export class CoreIsNotNan implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'is_not_nan() function should have exactly 1 param' + ); + + const equivalent = new CoreNot( + new FunctionExpr('not', [new FunctionExpr('is_nan', this.expr.params)]) + ); + return equivalent.evaluate(context, input); + } +} + +export class CoreIsNull implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'is_null() function should have exactly 1 param' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'NULL': + return EvaluateResult.newValue(TRUE_VALUE); + case 'UNSET': + return EvaluateResult.newError(); + case 'ERROR': + return EvaluateResult.newError(); + default: + return EvaluateResult.newValue(FALSE_VALUE); + } + } +} + +export class CoreIsNotNull implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'is_not_null() function should have exactly 1 param' + ); + const equivalent = new CoreNot( + new FunctionExpr('not', [new FunctionExpr('is_null', this.expr.params)]) + ); + return equivalent.evaluate(context, input); + } +} + +export class CoreExists implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'exists() function should have exactly 1 param' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'ERROR': + return EvaluateResult.newError(); + case 'UNSET': + return EvaluateResult.newValue(FALSE_VALUE); + default: + return EvaluateResult.newValue(TRUE_VALUE); + } + } +} + +export class CoreCond implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 3, + 'cond() function should have exactly 3 param' + ); + + const condition = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (condition.type) { + case 'BOOLEAN': { + if (condition.value?.booleanValue) { + return toEvaluable(this.expr.params[1]).evaluate(context, input); + } else { + return toEvaluable(this.expr.params[2]).evaluate(context, input); + } + } + case 'NULL': { + return toEvaluable(this.expr.params[2]).evaluate(context, input); + } + default: + return EvaluateResult.newError(); + } + } +} + +export class CoreLogicalMaximum implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + const results = this.expr.params.map(param => + toEvaluable(param).evaluate(context, input) + ); + + let maxValue: EvaluateResult | undefined; + + for (const result of results) { + switch (result.type) { + case 'ERROR': + case 'UNSET': + case 'NULL': + continue; + default: { + if (maxValue === undefined) { + maxValue = result; + } else { + maxValue = + valueCompare(result.value!, maxValue.value!) > 0 + ? result + : maxValue; + } + } + } + } + + return maxValue === undefined ? EvaluateResult.newNull() : maxValue; + } +} + +export class CoreLogicalMinimum implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + const results = this.expr.params.map(param => + toEvaluable(param).evaluate(context, input) + ); + let minValue: EvaluateResult | undefined; + + for (const result of results) { + switch (result.type) { + case 'ERROR': + case 'UNSET': + case 'NULL': + continue; + default: { + if (minValue === undefined) { + minValue = result; + } else { + minValue = + valueCompare(result.value!, minValue.value!) < 0 + ? result + : minValue; + } + } + } + } + + return minValue === undefined ? EvaluateResult.newNull() : minValue; + } +} + +abstract class ComparisonBase implements EvaluableExpr { + protected constructor(protected expr: FunctionExpr) {} + + abstract compareToResult( + left: EvaluateResult, + right: EvaluateResult + ): EvaluateResult; + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + `${this.expr.name}() function should have exactly 2 params` + ); + + const left = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (left.type) { + case 'ERROR': + return EvaluateResult.newError(); + case 'UNSET': + return EvaluateResult.newError(); + } + + const right = toEvaluable(this.expr.params[1]).evaluate(context, input); + switch (right.type) { + case 'ERROR': + return EvaluateResult.newError(); + case 'UNSET': + return EvaluateResult.newError(); + } + + if (left.isNull() || right.isNull()) { + return EvaluateResult.newNull(); + } + + return this.compareToResult(left, right); + } +} + +export class CoreEq extends ComparisonBase { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + compareToResult(left: EvaluateResult, right: EvaluateResult): EvaluateResult { + if (typeOrder(left.value!) !== typeOrder(right.value!)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + if (isNanValue(left.value) || isNanValue(right.value)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + + switch (strictValueEquals(left.value!, right.value!)) { + case 'EQ': + return EvaluateResult.newValue(TRUE_VALUE); + case 'NOT_EQ': + return EvaluateResult.newValue(FALSE_VALUE); + case 'NULL': + return EvaluateResult.newNull(); + } + } +} + +export class CoreNeq extends ComparisonBase { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + compareToResult(left: EvaluateResult, right: EvaluateResult): EvaluateResult { + switch (strictValueEquals(left.value!, right.value!)) { + case 'EQ': + return EvaluateResult.newValue(FALSE_VALUE); + case 'NOT_EQ': + return EvaluateResult.newValue(TRUE_VALUE); + case 'NULL': + return EvaluateResult.newNull(); + } + } +} + +export class CoreLt extends ComparisonBase { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + compareToResult(left: EvaluateResult, right: EvaluateResult): EvaluateResult { + if (typeOrder(left.value!) !== typeOrder(right.value!)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + if (isNanValue(left.value) || isNanValue(right.value)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + return EvaluateResult.newValue({ + booleanValue: valueCompare(left.value!, right.value!) < 0 + }); + } +} + +export class CoreLte extends ComparisonBase { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + compareToResult(left: EvaluateResult, right: EvaluateResult): EvaluateResult { + if (typeOrder(left.value!) !== typeOrder(right.value!)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + if (isNanValue(left.value!) || isNanValue(right.value!)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + + if (strictValueEquals(left.value!, right.value!) === 'EQ') { + return EvaluateResult.newValue(TRUE_VALUE); + } + + return EvaluateResult.newValue({ + booleanValue: valueCompare(left.value!, right.value!) < 0 + }); + } +} + +export class CoreGt extends ComparisonBase { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + compareToResult(left: EvaluateResult, right: EvaluateResult): EvaluateResult { + if (typeOrder(left.value!) !== typeOrder(right.value!)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + if (isNanValue(left.value) || isNanValue(right.value)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + return EvaluateResult.newValue({ + booleanValue: valueCompare(left.value!, right.value!) > 0 + }); + } +} + +export class CoreGte extends ComparisonBase { + constructor(protected expr: FunctionExpr) { + super(expr); + } + + compareToResult(left: EvaluateResult, right: EvaluateResult): EvaluateResult { + if (typeOrder(left.value!) !== typeOrder(right.value!)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + if (isNanValue(left.value!) || isNanValue(right.value!)) { + return EvaluateResult.newValue(FALSE_VALUE); + } + + if (strictValueEquals(left.value!, right.value!) === 'EQ') { + return EvaluateResult.newValue(TRUE_VALUE); + } + + return EvaluateResult.newValue({ + booleanValue: valueCompare(left.value!, right.value!) > 0 + }); + } +} + +export class CoreArrayConcat implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Unimplemented'); + } +} + +export class CoreArrayReverse implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'array_reverse() function should have exactly one parameter' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'NULL': + return EvaluateResult.newNull(); + case 'ARRAY': { + const values = evaluated.value!.arrayValue?.values ?? []; + return EvaluateResult.newValue({ + arrayValue: { values: [...values].reverse() } + }); + } + default: + return EvaluateResult.newError(); + } + } +} + +export class CoreArrayContains implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + 'array_contains() function should have exactly two parameters' + ); + return new CoreEqAny( + new FunctionExpr('eq_any', [this.expr.params[1], this.expr.params[0]]) + ).evaluate(context, input); + } +} + +export class CoreArrayContainsAll implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + 'array_contains_all() function should have exactly two parameters' + ); + + let foundNull = false; + const arrayToSearch = toEvaluable(this.expr.params[0]).evaluate( + context, + input + ); + switch (arrayToSearch.type) { + case 'ARRAY': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const elementsToFind = toEvaluable(this.expr.params[1]).evaluate( + context, + input + ); + switch (elementsToFind.type) { + case 'ARRAY': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + if (foundNull) { + return EvaluateResult.newNull(); + } + + const searchValues = elementsToFind.value?.arrayValue?.values ?? []; + const arrayValues = arrayToSearch.value?.arrayValue?.values ?? []; + let foundNullAtLeastOnce = false; + for (const search of searchValues) { + let found = false; + foundNull = false; + for (const value of arrayValues) { + switch (strictValueEquals(search, value)) { + case 'EQ': { + found = true; + break; + } + case 'NOT_EQ': { + break; + } + case 'NULL': { + foundNull = true; + foundNullAtLeastOnce = true; + } + } + + if (found) { + // short circuit + break; + } + } + + if (found) { + // true case - do nothing, we found a match, make sure all other values are also found + } else { + // false case - we didn't find a match, short circuit + if (!foundNull) { + return EvaluateResult.newValue(FALSE_VALUE); + } + + // null case - do nothing, we found at least one null value, keep going + } + } + + if (foundNullAtLeastOnce) { + return EvaluateResult.newNull(); + } + + return EvaluateResult.newValue(TRUE_VALUE); + } +} + +export class CoreArrayContainsAny implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + 'array_contains_any() function should have exactly two parameters' + ); + + let foundNull = false; + const arrayToSearch = toEvaluable(this.expr.params[0]).evaluate( + context, + input + ); + switch (arrayToSearch.type) { + case 'ARRAY': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const elementsToFind = toEvaluable(this.expr.params[1]).evaluate( + context, + input + ); + switch (elementsToFind.type) { + case 'ARRAY': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + if (foundNull) { + return EvaluateResult.newNull(); + } + + const searchValues = elementsToFind.value?.arrayValue?.values ?? []; + const arrayValues = arrayToSearch.value?.arrayValue?.values ?? []; + + for (const value of arrayValues) { + for (const search of searchValues) { + switch (strictValueEquals(value, search)) { + case 'EQ': { + return EvaluateResult.newValue(TRUE_VALUE); + } + case 'NOT_EQ': { + break; + } + case 'NULL': + foundNull = true; + } + } + } + + if (foundNull) { + return EvaluateResult.newNull(); + } + + return EvaluateResult.newValue(FALSE_VALUE); + } +} + +export class CoreArrayLength implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'array_length() function should have exactly one parameter' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'NULL': + return EvaluateResult.newNull(); + case 'ARRAY': { + return EvaluateResult.newValue({ + integerValue: `${evaluated.value?.arrayValue?.values?.length ?? 0}` + }); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +export class CoreArrayElement implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Unimplemented'); + } +} + +export class CoreReverse implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'reverse() function should have exactly one parameter' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'NULL': + return EvaluateResult.newNull(); + case 'STRING': { + return EvaluateResult.newValue({ + stringValue: evaluated.value?.stringValue + ?.split('') + .reverse() + .join('') + }); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +export class CoreReplaceFirst implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Unimplemented'); + } +} + +export class CoreReplaceAll implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Unimplemented'); + } +} + +function getUnicodePointCount(str: string) { + let count = 0; + for (let i = 0; i < str.length; i++) { + const codePoint = str.codePointAt(i); + + if (codePoint === undefined) { + return undefined; // Should not happen with valid JS strings + } + + // BMP character (including lone surrogates, which count as 1) + if (codePoint <= 0xffff) { + // Check specifically for lone surrogates which are invalid UTF-16 sequences + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + // High surrogate: check if followed by low surrogate + if (codePoint <= 0xdbff) { + const nextCodePoint = str.codePointAt(i + 1); + if ( + nextCodePoint === undefined || + !(nextCodePoint >= 0xdc00 && nextCodePoint <= 0xdfff) + ) { + // Lone high surrogate - treat as one character for length, but invalid for byte length + count += 1; + } else { + // Valid surrogate pair (counts as one character) + count += 1; + i++; // Skip the low surrogate + } + } else { + // Lone low surrogate - treat as one character + count += 1; + } + } else { + // Regular BMP character + count += 1; + } + } + // Astral plane character (SMP) - should have been handled by surrogate pair check + // This case might be redundant if surrogate logic is correct, but kept for clarity + else if (codePoint <= 0x10ffff) { + count += 1; + i++; // Code points > 0xFFFF take two JS string indices + } else { + return undefined; // Invalid code point + } + } + return count; +} + +export class CoreCharLength implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'char_length() function should have exactly one parameter' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'NULL': + return EvaluateResult.newNull(); + case 'STRING': { + const length = getUnicodePointCount(evaluated.value!.stringValue!); + // If counting failed (e.g., invalid sequence), return error + return length === undefined + ? EvaluateResult.newError() + : EvaluateResult.newValue({ integerValue: length }); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +function getUtf8ByteLength(str: string) { + let byteLength = 0; + for (let i = 0; i < str.length; i++) { + const codePoint = str.codePointAt(i); + + // Check for out of range of lone surrogate + if (codePoint === undefined) { + return undefined; + } + + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + // If it is a high surrogate, check if a low surrogate follows + if (codePoint <= 0xdbff) { + const lowSurrogate = str.codePointAt(i + 1); + if ( + lowSurrogate === undefined || + !(lowSurrogate >= 0xdc00 && lowSurrogate <= 0xdfff) + ) { + return undefined; // Lone high surrogate + } + // Valid surrogate pair + byteLength += 4; + i++; // Move past the low surrogate + } else { + return undefined; // Lone low surrogate + } + } else if (codePoint <= 0x7f) { + byteLength += 1; + } else if (codePoint <= 0x7ff) { + byteLength += 2; + } else if (codePoint <= 0xffff) { + byteLength += 3; + } else if (codePoint <= 0x10ffff) { + byteLength += 4; + i++; // Increment i to skip the next code unit of the surrogate pair + } else { + return undefined; // Invalid code point (should not normally happen) + } + } + return byteLength; +} + +export class CoreByteLength implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'byte_length() function should have exactly one parameter' + ); + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'BYTES': { + return EvaluateResult.newValue({ + integerValue: evaluated.value?.bytesValue?.length + }); + } + case 'STRING': { + // return the number of bytes in the string + const result = getUtf8ByteLength(evaluated.value?.stringValue!); + return result === undefined + ? EvaluateResult.newError() + : EvaluateResult.newValue({ + integerValue: result + }); + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +abstract class StringSearchFunctionBase implements EvaluableExpr { + protected constructor(readonly expr: FunctionExpr) {} + + abstract performSearch(value: string, search: string): EvaluateResult; + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + `${this.expr.name}() function should have exactly two parameters` + ); + + let foundNull = false; + const value = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (value.type) { + case 'STRING': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const pattern = toEvaluable(this.expr.params[1]).evaluate(context, input); + switch (pattern.type) { + case 'STRING': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + if (foundNull) { + return EvaluateResult.newNull(); + } + + return this.performSearch( + value.value?.stringValue!, + pattern.value?.stringValue! + ); + } +} + +function likeToRegex(like: string): string { + let result = ''; + for (let i = 0; i < like.length; i++) { + const c = like.charAt(i); + switch (c) { + case '_': + result += '.'; + break; + case '%': + result += '.*'; + break; + // Escape regex special characters + case '\\': // Need to escape backslash itself + case '.': + case '*': + case '?': + case '+': + case '^': + case '$': + case '|': + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + result += '\\' + c; + break; + default: + result += c; + break; + } + } + // Anchor the regex to match the entire string + return '^' + result + '$'; +} + +export class CoreLike extends StringSearchFunctionBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + performSearch(value: string, search: string): EvaluateResult { + try { + const regexPattern = likeToRegex(search); + const regex = RE2JS.compile(regexPattern); + return EvaluateResult.newValue({ booleanValue: regex.matches(value) }); + } catch (e) { + logWarn( + `Invalid LIKE pattern converted to regex: ${search}, returning error. Error: ${e}` + ); + return EvaluateResult.newError(); + } + } +} + +export class CoreRegexContains extends StringSearchFunctionBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + performSearch(value: string, search: string): EvaluateResult { + try { + const regex = RE2JS.compile(search); + return EvaluateResult.newValue({ + booleanValue: regex.matcher(value).find() + }); + } catch (RE2JSError) { + logWarn( + `Invalid regex pattern found in regex_contains: ${search}, returning error` + ); + return EvaluateResult.newError(); + } + } +} + +export class CoreRegexMatch extends StringSearchFunctionBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + performSearch(value: string, search: string): EvaluateResult { + try { + // Use matches() for full string match semantics + return EvaluateResult.newValue({ + booleanValue: RE2JS.compile(search).matches(value) + }); + } catch (RE2JSError) { + logWarn( + `Invalid regex pattern found in regex_match: ${search}, returning error` + ); + return EvaluateResult.newError(); + } + } +} + +export class CoreStrContains extends StringSearchFunctionBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + performSearch(value: string, search: string): EvaluateResult { + return EvaluateResult.newValue({ booleanValue: value.includes(search) }); + } +} + +export class CoreStartsWith extends StringSearchFunctionBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + performSearch(value: string, search: string): EvaluateResult { + return EvaluateResult.newValue({ booleanValue: value.startsWith(search) }); + } +} + +export class CoreEndsWith extends StringSearchFunctionBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + performSearch(value: string, search: string): EvaluateResult { + return EvaluateResult.newValue({ booleanValue: value.endsWith(search) }); + } +} + +export class CoreToLower implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'to_lower() function should have exactly one parameter' + ); + + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'STRING': { + return EvaluateResult.newValue({ + stringValue: evaluated.value?.stringValue?.toLowerCase() + }); + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +export class CoreToUpper implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'to_upper() function should have exactly one parameter' + ); + + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'STRING': { + return EvaluateResult.newValue({ + stringValue: evaluated.value?.stringValue?.toUpperCase() + }); + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +export class CoreTrim implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'trim() function should have exactly one parameter' + ); + + const evaluated = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (evaluated.type) { + case 'STRING': { + return EvaluateResult.newValue({ + stringValue: evaluated.value?.stringValue?.trim() + }); + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +export class CoreStrConcat implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + const evaluated = this.expr.params.map(val => + toEvaluable(val).evaluate(context, input) + ); + // If any part is error or unset, or not a string (and not null), result is error + // If any part is null, result is null + let resultString = ''; + let hasNull = false; + for (const val of evaluated) { + switch (val.type) { + case 'STRING': { + resultString += val.value!.stringValue; + break; + } + case 'NULL': { + hasNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + } + if (hasNull) { + return EvaluateResult.newNull(); + } + return EvaluateResult.newValue({ stringValue: resultString }); + } +} + +export class CoreMapGet implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + 'map_get() function should have exactly two parameters' + ); + + const evaluatedMap = toEvaluable(this.expr.params[0]).evaluate( + context, + input + ); + switch (evaluatedMap.type) { + case 'UNSET': { + return EvaluateResult.newUnset(); + } + case 'MAP': { + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const subfield = toEvaluable(this.expr.params[1]).evaluate(context, input); + switch (subfield.type) { + case 'STRING': { + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const value = + evaluatedMap.value?.mapValue?.fields?.[subfield.value?.stringValue!]; + return value === undefined + ? EvaluateResult.newUnset() + : EvaluateResult.newValue(value); + } +} + +// Aggregate functions are handled differently during pipeline execution +// Their evaluate methods here might not be directly called in the same way. +export class CoreCount implements EvaluableExpr { + constructor(private expr: AggregateFunction) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Aggregate evaluate() should not be called directly'); + } +} + +export class CoreSum implements EvaluableExpr { + constructor(private expr: AggregateFunction) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Aggregate evaluate() should not be called directly'); + } +} + +export class CoreAvg implements EvaluableExpr { + constructor(private expr: AggregateFunction) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Aggregate evaluate() should not be called directly'); + } +} + +export class CoreMinimum implements EvaluableExpr { + constructor(private expr: AggregateFunction) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Aggregate evaluate() should not be called directly'); + } +} + +export class CoreMaximum implements EvaluableExpr { + constructor(private expr: AggregateFunction) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + throw new Error('Aggregate evaluate() should not be called directly'); + } +} + +abstract class DistanceBase implements EvaluableExpr { + protected constructor(private expr: FunctionExpr) {} + + abstract calculateDistance( + vec1: ArrayValue | undefined, + vec2: ArrayValue | undefined + ): number | undefined; + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 2, + `${this.expr.name}() function should have exactly 2 params` + ); + + let hasNull = false; + const vector1 = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (vector1.type) { + case 'VECTOR': { + break; + } + case 'NULL': { + hasNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const vector2 = toEvaluable(this.expr.params[1]).evaluate(context, input); + switch (vector2.type) { + case 'VECTOR': { + break; + } + case 'NULL': { + hasNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + if (hasNull) { + return EvaluateResult.newNull(); + } + + const vectorValue1 = getVectorValue(vector1.value!); + const vectorValue2 = getVectorValue(vector2.value!); + + // Mismatched lengths or undefined vectors result in error + if ( + vectorValue1 === undefined || + vectorValue2 === undefined || + vectorValue1.values?.length !== vectorValue2.values?.length + ) { + return EvaluateResult.newError(); + } + + const distance = this.calculateDistance(vectorValue1, vectorValue2); + // NaN or undefined distance calculation results in error + if (distance === undefined || isNaN(distance)) { + return EvaluateResult.newError(); + } + + return EvaluateResult.newValue({ doubleValue: distance }); + } +} + +export class CoreCosineDistance extends DistanceBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + calculateDistance( + vec1: ArrayValue | undefined, + vec2: ArrayValue | undefined + ): number | undefined { + const values1 = vec1?.values ?? []; + const values2 = vec2?.values ?? []; + if (values1.length === 0) return undefined; // Distance undefined for empty vectors + + let dotProduct = 0; + let magnitude1 = 0; + let magnitude2 = 0; + for (let i = 0; i < values1.length; i++) { + // Error if any element is not a number + if (!isNumber(values1[i]) || !isNumber(values2[i])) return undefined; + const val1 = asDouble(values1[i] as { doubleValue: number | string }); + const val2 = asDouble(values2[i] as { doubleValue: number | string }); + dotProduct += val1 * val2; + magnitude1 += val1 * val1; + magnitude2 += val2 * val2; + } + const magnitude = Math.sqrt(magnitude1) * Math.sqrt(magnitude2); + // Distance undefined if either vector has zero magnitude + if (magnitude === 0) { + return undefined; + } + + // Clamp cosine similarity to [-1, 1] due to potential floating point inaccuracies + const cosineSimilarity = Math.max(-1, Math.min(1, dotProduct / magnitude)); + return 1 - cosineSimilarity; + } +} + +export class CoreDotProduct extends DistanceBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + calculateDistance( + vec1: ArrayValue | undefined, + vec2: ArrayValue | undefined + ): number | undefined { + const values1 = vec1?.values ?? []; + const values2 = vec2?.values ?? []; + if (values1.length === 0) return 0.0; // Dot product of empty vectors is 0 + + let dotProduct = 0; + for (let i = 0; i < values1.length; i++) { + // Error if any element is not a number + if (!isNumber(values1[i]) || !isNumber(values2[i])) return undefined; + const val1 = asDouble(values1[i] as { doubleValue: number | string }); + const val2 = asDouble(values2[i] as { doubleValue: number | string }); + dotProduct += val1 * val2; + } + + return dotProduct; + } +} + +export class CoreEuclideanDistance extends DistanceBase { + constructor(expr: FunctionExpr) { + super(expr); + } + + calculateDistance( + vec1: ArrayValue | undefined, + vec2: ArrayValue | undefined + ): number | undefined { + const values1 = vec1?.values ?? []; + const values2 = vec2?.values ?? []; + if (values1.length === 0) return 0.0; // Distance between empty vectors is 0 + + let euclideanDistanceSq = 0; + for (let i = 0; i < values1.length; i++) { + // Error if any element is not a number + if (!isNumber(values1[i]) || !isNumber(values2[i])) return undefined; + const val1 = asDouble(values1[i] as { doubleValue: number | string }); + const val2 = asDouble(values2[i] as { doubleValue: number | string }); + euclideanDistanceSq += Math.pow(val1 - val2, 2); + } + + return Math.sqrt(euclideanDistanceSq); + } +} + +export class CoreVectorLength implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + 'vector_length() function should have exactly one parameter' + ); + + const vector = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (vector.type) { + case 'VECTOR': { + const vectorValue = getVectorValue(vector.value!); + return EvaluateResult.newValue({ + integerValue: vectorValue?.values?.length ?? 0 + }); + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: { + return EvaluateResult.newError(); + } + } + } +} + +// 0001-01-01T00:00:00Z +const TIMESTAMP_MIN_SECONDS: bigint = BigInt(-62135596800); +// 9999-12-31T23:59:59Z - but the max timestamp has 999,999,999 nanoseconds +const TIMESTAMP_MAX_SECONDS: bigint = BigInt(253402300799); + +const MILLISECONDS_PER_SECOND: bigint = BigInt(1000); +const MICROSECONDS_PER_SECOND: bigint = BigInt(1000000); + +// 0001-01-01T00:00:00.000Z +const TIMESTAMP_MIN_MILLISECONDS: bigint = + TIMESTAMP_MIN_SECONDS * MILLISECONDS_PER_SECOND; +// 9999-12-31T23:59:59.999Z - but the max timestamp has 999,999,999 nanoseconds +const TIMESTAMP_MAX_MILLISECONDS: bigint = + TIMESTAMP_MAX_SECONDS * MILLISECONDS_PER_SECOND + BigInt(999); // Max sub-second millis + +// 0001-01-01T00:00:00.000000Z +const TIMESTAMP_MIN_MICROSECONDS: bigint = + TIMESTAMP_MIN_SECONDS * MICROSECONDS_PER_SECOND; +// 9999-12-31T23:59:59.999999Z - but the max timestamp has 999,999,999 nanoseconds +const TIMESTAMP_MAX_MICROSECONDS: bigint = + TIMESTAMP_MAX_SECONDS * MICROSECONDS_PER_SECOND + BigInt(999999); // Max sub-second micros + +function isMicrosInBounds(micros: bigint): boolean { + return ( + micros >= TIMESTAMP_MIN_MICROSECONDS && micros <= TIMESTAMP_MAX_MICROSECONDS + ); +} + +function isMillisInBounds(millis: bigint): boolean { + return ( + millis >= TIMESTAMP_MIN_MILLISECONDS && millis <= TIMESTAMP_MAX_MILLISECONDS + ); +} + +function isSecondsInBounds(seconds: bigint): boolean { + return seconds >= TIMESTAMP_MIN_SECONDS && seconds <= TIMESTAMP_MAX_SECONDS; +} + +function isTimestampInBounds(seconds: number, nanos: number) { + const sBig = BigInt(seconds); + if (sBig < TIMESTAMP_MIN_SECONDS || sBig > TIMESTAMP_MAX_SECONDS) { + return false; + } + // Nanos must be non-negative and less than 1 second + if (nanos < 0 || nanos >= 1_000_000_000) { + return false; + } + // Additional check for min/max boundaries + if (sBig === TIMESTAMP_MIN_SECONDS && nanos !== 0) return false; // Min timestamp has 0 nanos + if (sBig === TIMESTAMP_MAX_SECONDS && nanos > 999_999_999) return false; // Max timestamp allows up to 999_999_999 nanos + + return true; +} + +// Helper function to adjust timestamp for negative nanoseconds +function adjustTimestamp( + seconds: number, + nanos: number +): { seconds: number; nanos: number } { + if (nanos < 0) { + // Ensure nanos is within [-1e9 + 1, -1] before adding 1e9 + // Although the modulo operation should guarantee this range for negative results. + return { + seconds: seconds - 1, + nanos: nanos + 1_000_000_000 + }; + } + return { seconds, nanos }; +} + +function timestampToMicros(timestamp: Timestamp): bigint { + return ( + BigInt(timestamp.seconds) * MICROSECONDS_PER_SECOND + + // Integer division truncates towards zero + BigInt(Math.trunc(timestamp.nanoseconds / 1000)) + ); +} + +abstract class UnixToTimestamp implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + `${this.expr.name}() function should have exactly one parameter` + ); + + const value = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (value.type) { + case 'INT': { + return this.toTimestamp(BigInt(value.value!.integerValue!)); + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: { + return EvaluateResult.newError(); + } + } + } + + abstract toTimestamp(value: bigint): EvaluateResult; +} + +export class CoreUnixMicrosToTimestamp extends UnixToTimestamp { + constructor(expr: FunctionExpr) { + super(expr); + } + + toTimestamp(value: bigint): EvaluateResult { + if (!isMicrosInBounds(value)) { + return EvaluateResult.newError(); + } + + let seconds = Number(value / MICROSECONDS_PER_SECOND); + // Note: BigInt modulo can result in negative values if the dividend is negative. + let nanos = Number((value % MICROSECONDS_PER_SECOND) * BigInt(1000)); + + // Adjust for negative nanoseconds + const adjusted = adjustTimestamp(seconds, nanos); + seconds = adjusted.seconds; + nanos = adjusted.nanos; + + // Final bounds check after adjustment + if (!isTimestampInBounds(seconds, nanos)) { + return EvaluateResult.newError(); + } + + return EvaluateResult.newValue({ timestampValue: { seconds, nanos } }); + } +} + +export class CoreUnixMillisToTimestamp extends UnixToTimestamp { + constructor(expr: FunctionExpr) { + super(expr); + } + + toTimestamp(value: bigint): EvaluateResult { + if (!isMillisInBounds(value)) { + return EvaluateResult.newError(); + } + + let seconds = Number(value / MILLISECONDS_PER_SECOND); + let nanos = Number((value % MILLISECONDS_PER_SECOND) * BigInt(1000 * 1000)); + + // Adjust for negative nanoseconds + const adjusted = adjustTimestamp(seconds, nanos); + seconds = adjusted.seconds; + nanos = adjusted.nanos; + + // Final bounds check after adjustment + if (!isTimestampInBounds(seconds, nanos)) { + return EvaluateResult.newError(); + } + + return EvaluateResult.newValue({ timestampValue: { seconds, nanos } }); + } +} + +export class CoreUnixSecondsToTimestamp extends UnixToTimestamp { + constructor(expr: FunctionExpr) { + super(expr); + } + + toTimestamp(value: bigint): EvaluateResult { + if (!isSecondsInBounds(value)) { + return EvaluateResult.newError(); + } + + const seconds = Number(value); + return EvaluateResult.newValue({ timestampValue: { seconds, nanos: 0 } }); + } +} + +abstract class TimestampToUnix implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 1, + `${this.expr.name}() function should have exactly one parameter` + ); + + const value = toEvaluable(this.expr.params[0]).evaluate(context, input); + switch (value.type) { + case 'TIMESTAMP': { + break; + } + case 'NULL': { + return EvaluateResult.newNull(); + } + default: { + return EvaluateResult.newError(); + } + } + + const timestamp = fromTimestamp(value.value!.timestampValue!); + // Check if the input timestamp is within valid bounds + if (!isTimestampInBounds(timestamp.seconds, timestamp.nanoseconds)) { + return EvaluateResult.newError(); + } + + return this.toUnix(timestamp); + } + + abstract toUnix(value: Timestamp): EvaluateResult; +} + +export class CoreTimestampToUnixMicros extends TimestampToUnix { + constructor(expr: FunctionExpr) { + super(expr); + } + + toUnix(timestamp: Timestamp): EvaluateResult { + const micros = timestampToMicros(timestamp); + // Check if the resulting micros are within representable bounds + if (!isMicrosInBounds(micros)) { + return EvaluateResult.newError(); + } + return EvaluateResult.newValue({ integerValue: `${micros.toString()}` }); + } +} + +export class CoreTimestampToUnixMillis extends TimestampToUnix { + constructor(expr: FunctionExpr) { + super(expr); + } + + toUnix(timestamp: Timestamp): EvaluateResult { + const micros = timestampToMicros(timestamp); + // Perform division, truncating towards zero (default JS BigInt division) + const millis = micros / BigInt(1000); + const submillis = micros % BigInt(1000); + if (millis > BigInt(0) || submillis === BigInt(0)) { + return EvaluateResult.newValue({ integerValue: millis.toString() }); + } else { + return EvaluateResult.newValue({ + integerValue: (millis - BigInt(1)).toString() + }); + } + } +} + +export class CoreTimestampToUnixSeconds extends TimestampToUnix { + constructor(expr: FunctionExpr) { + super(expr); + } + + toUnix(timestamp: Timestamp): EvaluateResult { + // Seconds are directly available + const seconds = BigInt(timestamp.seconds); + // Check if the resulting seconds are within representable bounds + if (!isSecondsInBounds(seconds)) { + return EvaluateResult.newError(); + } + return EvaluateResult.newValue({ integerValue: seconds.toString() }); + } +} + +type TimeUnit = + | 'microsecond' + | 'millisecond' + | 'second' + | 'minute' + | 'hour' + | 'day'; +function asTimeUnit(unit?: string): TimeUnit | undefined { + switch (unit) { + case 'microsecond': + return 'microsecond'; + case 'millisecond': + return 'millisecond'; + case 'second': + return 'second'; + case 'minute': + return 'minute'; + case 'hour': + return 'hour'; + case 'day': + return 'day'; + default: + return undefined; + } +} + +abstract class TimestampArithmetic implements EvaluableExpr { + constructor(private expr: FunctionExpr) {} + evaluate( + context: EvaluationContext, + input: PipelineInputOutput + ): EvaluateResult { + hardAssert( + this.expr.params.length === 3, + `${this.expr.name}() function should have exactly 3 parameters` + ); + + let foundNull = false; + const timestampVal = toEvaluable(this.expr.params[0]).evaluate( + context, + input + ); + switch (timestampVal.type) { + case 'TIMESTAMP': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const unitVal = toEvaluable(this.expr.params[1]).evaluate(context, input); + let timeUnit: TimeUnit | undefined; + switch (unitVal.type) { + case 'STRING': { + timeUnit = asTimeUnit(unitVal.value!.stringValue); + if (timeUnit === undefined) { + return EvaluateResult.newError(); + } + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + const amountVal = toEvaluable(this.expr.params[2]).evaluate(context, input); + switch (amountVal.type) { + case 'INT': { + break; + } + case 'NULL': { + foundNull = true; + break; + } + default: { + return EvaluateResult.newError(); + } + } + + if (foundNull) { + return EvaluateResult.newNull(); + } + + const amount = BigInt(amountVal.value!.integerValue!); + let microsToOperate: bigint; + try { + switch (timeUnit) { + case 'microsecond': + microsToOperate = amount; + break; + case 'millisecond': + microsToOperate = amount * BigInt(1000); + break; + case 'second': + microsToOperate = amount * BigInt(1000000); + break; + case 'minute': + microsToOperate = amount * BigInt(60000000); + break; + case 'hour': + microsToOperate = amount * BigInt(3600000000); + break; + case 'day': + microsToOperate = amount * BigInt(86400000000); + break; + default: + return EvaluateResult.newError(); + } + // Check for potential overflow during multiplication + if ( + timeUnit !== 'microsecond' && + amount !== BigInt(0) && + microsToOperate / amount !== BigInt(this.getMultiplier(timeUnit)) + ) { + return EvaluateResult.newError(); + } + } catch (e) { + // Catch potential BigInt errors (though unlikely with isInteger check) + logWarn(`Error during timestamp arithmetic: ${e}`); + return EvaluateResult.newError(); + } + + const initialTimestamp = fromTimestamp(timestampVal.value!.timestampValue!); + // Check initial timestamp bounds + if ( + !isTimestampInBounds( + initialTimestamp.seconds, + initialTimestamp.nanoseconds + ) + ) { + return EvaluateResult.newError(); + } + + const initialMicros = timestampToMicros(initialTimestamp); + const newMicros = this.newMicros(initialMicros, microsToOperate); + + // Check final microsecond bounds + if (!isMicrosInBounds(newMicros)) { + return EvaluateResult.newError(); + } + + // Convert back to seconds and nanos + const newSeconds = Number(newMicros / MICROSECONDS_PER_SECOND); + const nanosRemainder = newMicros % MICROSECONDS_PER_SECOND; + const newNanos = Number( + (nanosRemainder < 0 + ? nanosRemainder + MICROSECONDS_PER_SECOND + : nanosRemainder) * BigInt(1000) + ); + const adjustedSeconds = nanosRemainder < 0 ? newSeconds - 1 : newSeconds; + + // Final check on calculated timestamp bounds + if (!isTimestampInBounds(adjustedSeconds, newNanos)) { + return EvaluateResult.newError(); + } + + return EvaluateResult.newValue({ + timestampValue: { seconds: adjustedSeconds, nanos: newNanos } + }); + } + + private getMultiplier(unit: TimeUnit): number { + switch (unit) { + case 'millisecond': + return 1000; + case 'second': + return 1000000; + case 'minute': + return 60000000; + case 'hour': + return 3600000000; + case 'day': + return 86400000000; + default: + return 1; // microsecond + } + } + + abstract newMicros(initialMicros: bigint, microsToOperation: bigint): bigint; +} + +export class CoreTimestampAdd extends TimestampArithmetic { + constructor(expr: FunctionExpr) { + super(expr); + } + + newMicros(initialMicros: bigint, microsToAdd: bigint): bigint { + return initialMicros + microsToAdd; + } +} + +export class CoreTimestampSub extends TimestampArithmetic { + constructor(expr: FunctionExpr) { + super(expr); + } + + newMicros(initialMicros: bigint, microsToSub: bigint): bigint { + return initialMicros - microsToSub; + } +} diff --git a/packages/firestore/src/core/pipeline-util.ts b/packages/firestore/src/core/pipeline-util.ts index ec25e258834..d846b86a99c 100644 --- a/packages/firestore/src/core/pipeline-util.ts +++ b/packages/firestore/src/core/pipeline-util.ts @@ -15,21 +15,56 @@ * limitations under the License. */ +import { RealtimePipeline } from '../api/realtime_pipeline'; import { Firestore } from '../lite-api/database'; import { Constant, + Expr, + Field, BooleanExpr, and, or, Ordering, lt, gt, - field + lte, + gte, + eq, + field, + FunctionExpr, + ListOfExprs, + AggregateFunction } from '../lite-api/expressions'; -import { Pipeline } from '../lite-api/pipeline'; -import { doc } from '../lite-api/reference'; -import { isNanValue, isNullValue } from '../model/values'; -import { fail } from '../util/assert'; +import { Pipeline, Pipeline as ApiPipeline } from '../lite-api/pipeline'; +import { doc, DocumentReference } from '../lite-api/reference'; +import { + AddFields, + Aggregate, + CollectionGroupSource, + CollectionSource, + DatabaseSource, + Distinct, + DocumentsSource, + FindNearest, + Limit, + Offset, + Select, + Sort, + Stage, + Where +} from '../lite-api/stage'; +import { + CREATE_TIME_NAME, + DOCUMENT_KEY_NAME, + ResourcePath, + UPDATE_TIME_NAME +} from '../model/path'; +import { + isNanValue, + isNullValue, + VECTOR_MAP_VECTORS_KEY +} from '../model/values'; +import { debugAssert, fail } from '../util/assert'; import { Bound } from './bound'; import { @@ -40,13 +75,24 @@ import { Operator } from './filter'; import { Direction } from './order_by'; +import { CorePipeline } from './pipeline'; import { + canonifyQuery, isCollectionGroupQuery, isDocumentQuery, LimitType, Query, - queryNormalizedOrderBy + queryEquals, + queryNormalizedOrderBy, + stringifyQuery } from './query'; +import { + canonifyTarget, + Target, + targetEquals, + targetIsPipelineTarget +} from './target'; +import { VectorValue } from '../api'; /* eslint @typescript-eslint/no-explicit-any: 0 */ @@ -166,7 +212,7 @@ function reverseOrderings(orderings: Ordering[]): Ordering[] { ); } -export function toPipeline(query: Query, db: Firestore): Pipeline { +export function toPipelineStages(query: Query, db: Firestore): Stage[] { let pipeline: Pipeline; if (isCollectionGroupQuery(query)) { pipeline = db.pipeline().collectionGroup(query.collectionGroup!); @@ -242,7 +288,7 @@ export function toPipeline(query: Query, db: Firestore): Pipeline { } } - return pipeline; + return pipeline.stages; } function whereConditionsFromCursor( @@ -250,33 +296,314 @@ function whereConditionsFromCursor( orderings: Ordering[], position: 'before' | 'after' ): BooleanExpr { - // The filterFunc is either greater than or less than - const filterFunc = position === 'before' ? lt : gt; const cursors = bound.position.map(value => Constant._fromProto(value)); - const size = cursors.length; + const filterFunc = position === 'before' ? lt : gt; + const filterInclusiveFunc = position === 'before' ? lte : gte; + + const orConditions: BooleanExpr[] = []; + for (let i = 1; i <= orderings.length; i++) { + const cursorSubset = cursors.slice(0, i); + + const conditions: BooleanExpr[] = cursorSubset.map((cursor, index) => { + if (index < cursorSubset.length - 1) { + return eq(orderings[index].expr as Field, cursor); + } else if (bound.inclusive && i === orderings.length - 1) { + return filterInclusiveFunc(orderings[index].expr as Field, cursor); + } else { + return filterFunc(orderings[index].expr as Field, cursor); + } + }); + + if (conditions.length === 1) { + orConditions.push(conditions[0]); + } else { + orConditions.push( + and(conditions[0], conditions[1], ...conditions.slice(2)) + ); + } + } - let field = orderings[size - 1].expr; - let value = cursors[size - 1]; + if (orConditions.length === 1) { + return orConditions[0]; + } else { + return or(orConditions[0], orConditions[1], ...orConditions.slice(2)); + } +} - // Add condition for last bound - let condition: BooleanExpr = filterFunc(field, value); - if (bound.inclusive) { - // When the cursor bound is inclusive, then the last bound - // can be equal to the value, otherwise it's not equal - condition = or(condition, field.eq(value)); +function canonifyConstantValue(value: unknown): string { + if (value === null) { + return 'null'; + } else if (typeof value === 'number') { + return value.toString(); + } else if (typeof value === 'string') { + return `"${value}"`; + } else if (value instanceof DocumentReference) { + return `ref(${value.path})`; + } else if (value instanceof VectorValue) { + return `vec(${JSON.stringify(value)})`; } + { + return JSON.stringify(value); + } +} - // Iterate backwards over the remaining bounds, adding - // a condition for each one - for (let i = size - 2; i >= 0; i--) { - field = orderings[i].expr; - value = cursors[i]; +export function canonifyExpr(expr: Expr): string { + if (expr instanceof Field) { + return `fld(${expr.fieldName()})`; + } + if (expr instanceof Constant) { + return `cst(${canonifyConstantValue(expr.value)})`; + } + if (expr instanceof FunctionExpr || expr instanceof AggregateFunction) { + return `fn(${expr.name},[${expr.params.map(canonifyExpr).join(',')}])`; + } + if (expr instanceof ListOfExprs) { + return `list([${expr.exprs.map(canonifyExpr).join(',')}])`; + } + throw new Error(`Unrecognized expr ${JSON.stringify(expr, null, 2)}`); +} + +function canonifySortOrderings(orders: Ordering[]): string { + return orders.map(o => `${canonifyExpr(o.expr)}${o.direction}`).join(','); +} - // For each field in the orderings, the condition is either - // a) lt|gt the cursor value, - // b) or equal the cursor value and lt|gt the cursor values for other fields - condition = or(filterFunc(field, value), and(field.eq(value), condition)); +function canonifyStage(stage: Stage): string { + if (stage instanceof AddFields) { + return `${stage.name}(${canonifyExprMap(stage.fields)})`; } + if (stage instanceof Aggregate) { + let result = `${stage.name}(${canonifyExprMap( + stage.accumulators as unknown as Map + )})`; + if (stage.groups.size > 0) { + result = result + `grouping(${canonifyExprMap(stage.groups)})`; + } + return result; + } + if (stage instanceof Distinct) { + return `${stage.name}(${canonifyExprMap(stage.groups)})`; + } + if (stage instanceof CollectionSource) { + return `${stage.name}(${stage.collectionPath})`; + } + if (stage instanceof CollectionGroupSource) { + return `${stage.name}(${stage.collectionId})`; + } + if (stage instanceof DatabaseSource) { + return `${stage.name}()`; + } + if (stage instanceof DocumentsSource) { + return `${stage.name}(${stage.docPaths.sort()})`; + } + if (stage instanceof Where) { + return `${stage.name}(${canonifyExpr(stage.condition)})`; + } + if (stage instanceof FindNearest) { + const vector = stage._vectorValue.value.mapValue.fields![ + VECTOR_MAP_VECTORS_KEY + ].arrayValue?.values?.map(value => value.doubleValue); + let result = `${stage.name}(${canonifyExpr(stage._field)},${ + stage._distanceMeasure + },[${vector}]`; + if (!!stage._limit) { + result = result + `,${stage._limit}`; + } + if (!!stage._distanceField) { + result = result + `,${stage._distanceField}`; + } + return result + ')'; + } + if (stage instanceof Limit) { + return `${stage.name}(${stage.limit})`; + } + if (stage instanceof Offset) { + return `${stage.name}(${stage.offset})`; + } + if (stage instanceof Select) { + return `${stage.name}(${canonifyExprMap(stage.projections)})`; + } + if (stage instanceof Sort) { + return `${stage.name}(${canonifySortOrderings(stage.orders)})`; + } + + throw new Error(`Unrecognized stage ${stage.name}`); +} + +function canonifyExprMap(map: Map): string { + const sortedEntries = Array.from(map.entries()).sort(); + return `${sortedEntries + .map(([key, val]) => `${key}=${canonifyExpr(val)}`) + .join(',')}`; +} + +export function canonifyPipeline(p: CorePipeline): string; +export function canonifyPipeline(p: CorePipeline): string { + return p.stages.map(s => canonifyStage(s)).join('|'); +} + +// TODO(pipeline): do a proper implementation for eq. +export function pipelineEq(left: CorePipeline, right: CorePipeline): boolean { + return canonifyPipeline(left) === canonifyPipeline(right); +} + +export type PipelineFlavor = 'exact' | 'augmented' | 'keyless'; + +export type PipelineSourceType = + | 'collection' + | 'collection_group' + | 'database' + | 'documents'; + +export function asCollectionPipelineAtPath( + pipeline: CorePipeline, + path: ResourcePath +): CorePipeline { + const newStages = pipeline.stages.map(s => { + if (s instanceof CollectionGroupSource) { + return new CollectionSource(path.canonicalString()); + } + + return s; + }); + + return new CorePipeline(pipeline.serializer, newStages); +} + +export type QueryOrPipeline = Query | CorePipeline; + +export function isPipeline(q: QueryOrPipeline): q is CorePipeline { + return q instanceof CorePipeline; +} + +export function stringifyQueryOrPipeline(q: QueryOrPipeline): string { + if (isPipeline(q)) { + return canonifyPipeline(q); + } + + return stringifyQuery(q); +} + +export function canonifyQueryOrPipeline(q: QueryOrPipeline): string { + if (isPipeline(q)) { + return canonifyPipeline(q); + } + + return canonifyQuery(q); +} + +export function queryOrPipelineEqual( + left: QueryOrPipeline, + right: QueryOrPipeline +): boolean { + if (left instanceof CorePipeline && right instanceof CorePipeline) { + return pipelineEq(left, right); + } + if ( + (left instanceof CorePipeline && !(right instanceof CorePipeline)) || + (!(left instanceof CorePipeline) && right instanceof CorePipeline) + ) { + return false; + } + + return queryEquals(left as Query, right as Query); +} + +export type TargetOrPipeline = Target | CorePipeline; + +export function canonifyTargetOrPipeline(q: TargetOrPipeline): string { + if (targetIsPipelineTarget(q)) { + return canonifyPipeline(q); + } + + return canonifyTarget(q as Target); +} + +export function targetOrPipelineEqual( + left: TargetOrPipeline, + right: TargetOrPipeline +): boolean { + if (left instanceof CorePipeline && right instanceof CorePipeline) { + return pipelineEq(left, right); + } + if ( + (left instanceof CorePipeline && !(right instanceof CorePipeline)) || + (!(left instanceof CorePipeline) && right instanceof CorePipeline) + ) { + return false; + } + + return targetEquals(left as Target, right as Target); +} + +export function pipelineHasRanges(pipeline: CorePipeline): boolean { + return pipeline.stages.some( + stage => stage instanceof Limit || stage instanceof Offset + ); +} + +function rewriteStages(stages: Stage[]): Stage[] { + let hasOrder = false; + const newStages: Stage[] = []; + for (const stage of stages) { + // For stages that provide ordering semantics + if (stage instanceof Sort) { + hasOrder = true; + // add exists to force sparse semantics + // Is this really needed? + // newStages.push(new Where(new And(stage.orders.map(order => order.expr.exists())))); + + // Ensure we have a stable ordering + if ( + stage.orders.some( + order => + order.expr instanceof Field && + order.expr.fieldName() === DOCUMENT_KEY_NAME + ) + ) { + newStages.push(stage); + } else { + const copy = stage.orders.map(o => o); + copy.push(field(DOCUMENT_KEY_NAME).ascending()); + newStages.push(new Sort(copy)); + } + } + // For stages whose semantics depend on ordering + else if (stage instanceof Limit) { + if (!hasOrder) { + newStages.push(new Sort([field(DOCUMENT_KEY_NAME).ascending()])); + hasOrder = true; + } + newStages.push(stage); + } + // For stages augmenting outputs + else if (stage instanceof AddFields || stage instanceof Select) { + if (stage instanceof AddFields) { + newStages.push(new AddFields(addSystemFields(stage.fields))); + } else { + newStages.push(new Select(addSystemFields(stage.projections))); + } + } else { + newStages.push(stage); + } + } + + if (!hasOrder) { + newStages.push(new Sort([field(DOCUMENT_KEY_NAME).ascending()])); + } + + return newStages; +} + +function addSystemFields(fields: Map): Map { + const newFields = new Map(fields); + newFields.set(DOCUMENT_KEY_NAME, field(DOCUMENT_KEY_NAME)); + newFields.set(CREATE_TIME_NAME, field(CREATE_TIME_NAME)); + newFields.set(UPDATE_TIME_NAME, field(UPDATE_TIME_NAME)); + return newFields; +} - return condition; +export function toCorePipeline( + p: ApiPipeline | RealtimePipeline +): CorePipeline { + return new CorePipeline(p.userDataReader.serializer, rewriteStages(p.stages)); } diff --git a/packages/firestore/src/core/pipeline.ts b/packages/firestore/src/core/pipeline.ts new file mode 100644 index 00000000000..f8486714855 --- /dev/null +++ b/packages/firestore/src/core/pipeline.ts @@ -0,0 +1,130 @@ +// Copyright 2024 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 { + AddFields, + Aggregate, + CollectionGroupSource, + CollectionSource, + DatabaseSource, + Distinct, + DocumentsSource, + Select, + Stage +} from '../lite-api/stage'; +import { ResourcePath } from '../model/path'; +import { JsonProtoSerializer } from '../remote/serializer'; +import { debugAssert } from '../util/assert'; + +import { PipelineFlavor, PipelineSourceType } from './pipeline-util'; + +export class CorePipeline { + isCorePipeline = true; + constructor( + readonly serializer: JsonProtoSerializer, + readonly stages: Stage[] + ) {} + getPipelineCollection(): string | undefined { + return getPipelineCollection(this); + } + getPipelineCollectionGroup(): string | undefined { + return getPipelineCollectionGroup(this); + } + getPipelineCollectionId(): string | undefined { + return getPipelineCollectionId(this); + } + getPipelineDocuments(): string[] | undefined { + return getPipelineDocuments(this); + } + getPipelineFlavor(): PipelineFlavor { + return getPipelineFlavor(this); + } + getPipelineSourceType(): PipelineSourceType | 'unknown' { + return getPipelineSourceType(this); + } +} + +export function getPipelineSourceType( + p: CorePipeline +): PipelineSourceType | 'unknown' { + debugAssert(p.stages.length > 0, 'Pipeline must have at least one stage'); + const source = p.stages[0]; + + if ( + source instanceof CollectionSource || + source instanceof CollectionGroupSource || + source instanceof DatabaseSource || + source instanceof DocumentsSource + ) { + return source.name as PipelineSourceType; + } + + return 'unknown'; +} + +export function getPipelineCollection(p: CorePipeline): string | undefined { + if (getPipelineSourceType(p) === 'collection') { + return (p.stages[0] as CollectionSource).collectionPath; + } + return undefined; +} + +export function getPipelineCollectionGroup( + p: CorePipeline +): string | undefined { + if (getPipelineSourceType(p) === 'collection_group') { + return (p.stages[0] as CollectionGroupSource).collectionId; + } + return undefined; +} + +export function getPipelineCollectionId(p: CorePipeline): string | undefined { + switch (getPipelineSourceType(p)) { + case 'collection': + return ResourcePath.fromString(getPipelineCollection(p)!).lastSegment(); + case 'collection_group': + return getPipelineCollectionGroup(p); + default: + return undefined; + } +} + +export function getPipelineDocuments(p: CorePipeline): string[] | undefined { + if (getPipelineSourceType(p) === 'documents') { + return (p.stages[0] as DocumentsSource).docPaths; + } + return undefined; +} + +export function getPipelineFlavor(p: CorePipeline): PipelineFlavor { + let flavor: PipelineFlavor = 'exact'; + p.stages.forEach((stage, index) => { + if (stage.name === Distinct.name || stage.name === Aggregate.name) { + flavor = 'keyless'; + } + if (stage.name === Select.name && flavor === 'exact') { + flavor = 'augmented'; + } + // TODO(pipeline): verify the last stage is addFields, and it is added by the SDK. + if ( + stage.name === AddFields.name && + index < p.stages.length - 1 && + flavor === 'exact' + ) { + flavor = 'augmented'; + } + }); + + return flavor; +} diff --git a/packages/firestore/src/core/pipeline_run.ts b/packages/firestore/src/core/pipeline_run.ts new file mode 100644 index 00000000000..6bbcffd28dc --- /dev/null +++ b/packages/firestore/src/core/pipeline_run.ts @@ -0,0 +1,319 @@ +// Copyright 2024 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 { FirestoreError } from '../api'; +import { Field, Ordering } from '../lite-api/expressions'; +import { + CollectionGroupSource, + CollectionSource, + DatabaseSource, + DocumentsSource, + Limit, + Offset, + Sort, + Stage, + Where +} from '../lite-api/stage'; +import { Document, MutableDocument } from '../model/document'; +import { DOCUMENT_KEY_NAME } from '../model/path'; +import { + MIN_VALUE, + TRUE_VALUE, + valueCompare, + valueEquals +} from '../model/values'; +import { JsonProtoSerializer } from '../remote/serializer'; +import { Code } from '../util/error'; + +import { toEvaluable, valueOrUndefined } from './expressions'; +import { isPipeline, QueryOrPipeline } from './pipeline-util'; +import { queryMatches } from './query'; +import { CorePipeline } from './pipeline'; + +export type PipelineInputOutput = MutableDocument; + +export interface EvaluationContext { + serializer: JsonProtoSerializer; +} + +export function runPipeline( + pipeline: CorePipeline, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + let current = input; + for (const stage of pipeline.stages) { + current = evaluate({ serializer: pipeline.serializer }, stage, current); + } + + return current; +} + +export function pipelineMatches( + pipeline: CorePipeline, + data: PipelineInputOutput +): boolean { + // TODO(pipeline): this is not true for aggregations, and we need to examine if there are other + // stages that will not work this way. + return runPipeline(pipeline, [data]).length > 0; +} + +export function queryOrPipelineMatches( + query: QueryOrPipeline, + data: PipelineInputOutput +): boolean { + return isPipeline(query) + ? pipelineMatches(query, data) + : queryMatches(query, data); +} + +export function pipelineMatchesAllDocuments(pipeline: CorePipeline): boolean { + for (const stage of pipeline.stages) { + if (stage instanceof Limit || stage instanceof Offset) { + return false; + } + if (stage instanceof Where) { + if ( + stage.condition.name === 'exists' && + stage.condition.params[0] instanceof Field && + stage.condition.params[0].fieldName() === DOCUMENT_KEY_NAME + ) { + continue; + } + return false; + } + } + + return true; +} + +function evaluate( + context: EvaluationContext, + stage: Stage, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + if (stage instanceof CollectionSource) { + return evaluateCollection(context, stage, input); + } else if (stage instanceof Where) { + return evaluateWhere(context, stage, input); + } /*else if (stage instanceof AddFields) { + return evaluateAddFields(context, stage, input); + } else if (stage instanceof Aggregate) { + return evaluateAggregate(context, stage, input); + } else if (stage instanceof Distinct) { + return evaluateDistinct(context, stage, input); + } */ else if (stage instanceof CollectionGroupSource) { + return evaluateCollectionGroup(context, stage, input); + } else if (stage instanceof DatabaseSource) { + return evaluateDatabase(context, stage, input); + } else if (stage instanceof DocumentsSource) { + return evaluateDocuments(context, stage, input); + } /* else if (stage instanceof FindNearest) { + return evaluateFindNearest(context, stage, input); + } */ else if (stage instanceof Limit) { + return evaluateLimit(context, stage, input); + } else if (stage instanceof Offset) { + return evaluateOffset(context, stage, input); + } /* else if (stage instanceof Select) { + return evaluateSelect(context, stage, input); + }*/ else if (stage instanceof Sort) { + return evaluateSort(context, stage, input); + } + + throw new Error(`Unknown stage: ${stage.name}`); +} + +function evaluateWhere( + context: EvaluationContext, + where: Where, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + return input.filter(value => { + const result = valueOrUndefined( + toEvaluable(where.condition).evaluate(context, value) + ); + return result === undefined ? false : valueEquals(result, TRUE_VALUE); + }); +} + +function evaluateLimit( + context: EvaluationContext, + stage: Limit, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + return input.slice(0, stage.limit); +} + +function evaluateOffset( + context: EvaluationContext, + stage: Offset, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + return input.slice(stage.offset); +} + +function evaluateSort( + context: EvaluationContext, + stage: Sort, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + return input.sort((left, right): number => { + // Evaluate expressions in stage.orderings against left and right, and use them to compare + // the documents + for (const ordering of stage.orders) { + const leftValue = valueOrUndefined( + toEvaluable(ordering.expr).evaluate(context, left) + ); + const rightValue = valueOrUndefined( + toEvaluable(ordering.expr).evaluate(context, right) + ); + + const comparison = valueCompare( + leftValue ?? MIN_VALUE, + rightValue ?? MIN_VALUE + ); + if (comparison !== 0) { + // Return the comparison result if documents are not equal + return ordering.direction === 'ascending' ? comparison : -comparison; + } + } + + return 0; + }); +} + +function evaluateCollection( + _: EvaluationContext, + coll: CollectionSource, + inputs: PipelineInputOutput[] +): PipelineInputOutput[] { + return inputs.filter(input => { + return ( + input.isFoundDocument() && + `/${input.key.getCollectionPath().canonicalString()}` === + coll.collectionPath + ); + }); +} + +function evaluateCollectionGroup( + context: EvaluationContext, + stage: CollectionGroupSource, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + // return those records in input whose collection id is stage.collectionId + return input.filter(input => { + return ( + input.isFoundDocument() && + input.key.getCollectionPath().lastSegment() === stage.collectionId + ); + }); +} + +function evaluateDatabase( + context: EvaluationContext, + stage: DatabaseSource, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + return input.filter(input => input.isFoundDocument()); +} + +function evaluateDocuments( + context: EvaluationContext, + stage: DocumentsSource, + input: PipelineInputOutput[] +): PipelineInputOutput[] { + if (stage.docPaths.length === 0) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + 'Empty document paths are not allowed in DocumentsSource' + ); + } + if (stage.docPaths) { + const uniqueDocPaths = new Set(stage.docPaths); + if (uniqueDocPaths.size !== stage.docPaths.length) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + 'Duplicate document paths are not allowed in DocumentsSource' + ); + } + } + + return input.filter(input => { + return ( + input.isFoundDocument() && + stage.docPaths.includes(input.key.path.toStringWithLeadingSlash()) + ); + }); +} + +export function newPipelineComparator( + pipeline: CorePipeline +): (d1: Document, d2: Document) => number { + const orderings = lastEffectiveSort(pipeline); + return (d1: Document, d2: Document): number => { + for (const ordering of orderings) { + const leftValue = valueOrUndefined( + toEvaluable(ordering.expr).evaluate( + { serializer: pipeline.serializer }, + d1 as MutableDocument + ) + ); + const rightValue = valueOrUndefined( + toEvaluable(ordering.expr).evaluate( + { serializer: pipeline.serializer }, + d2 as MutableDocument + ) + ); + const comparison = valueCompare( + leftValue || MIN_VALUE, + rightValue || MIN_VALUE + ); + if (comparison !== 0) { + return ordering.direction === 'ascending' ? comparison : -comparison; + } + } + return 0; + }; +} + +function lastEffectiveSort(pipeline: CorePipeline): Ordering[] { + // return the last sort stage, throws exception if it doesn't exist + // TODO(pipeline): this implementation is wrong, there are stages that can invalidate + // the orderings later. The proper way to manipulate the pipeline so that last Sort + // always has effects. + for (let i = pipeline.stages.length - 1; i >= 0; i--) { + const stage = pipeline.stages[i]; + if (stage instanceof Sort) { + return stage.orders; + } + } + throw new Error('Pipeline must contain at least one Sort stage'); +} + +export function getLastEffectiveLimit( + pipeline: CorePipeline +): { limit: number; convertedFromLimitToLast: boolean } | undefined { + // TODO(pipeline): this implementation is wrong, there are stages that can change + // the limit later (findNearest). + for (let i = pipeline.stages.length - 1; i >= 0; i--) { + const stage = pipeline.stages[i]; + if (stage instanceof Limit) { + return { + limit: stage.limit, + convertedFromLimitToLast: stage.convertedFromLimitTolast + }; + } + } + return undefined; +} diff --git a/packages/firestore/src/core/pipeline_serialize.ts b/packages/firestore/src/core/pipeline_serialize.ts new file mode 100644 index 00000000000..8836a23750a --- /dev/null +++ b/packages/firestore/src/core/pipeline_serialize.ts @@ -0,0 +1,101 @@ +// Copyright 2024 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 { + BooleanExpr, + Constant, + Expr, + Field, + FunctionExpr, + Ordering +} from '../lite-api/expressions'; +import { + CollectionGroupSource, + CollectionSource, + DatabaseSource, + DocumentsSource, + Limit, + Sort, + Stage, + Where +} from '../lite-api/stage'; +import { fieldPathFromArgument } from '../lite-api/user_data_reader'; +import { + Value as ProtoValue, + Stage as ProtoStage +} from '../protos/firestore_proto_api'; + +export function stageFromProto(protoStage: ProtoStage): Stage { + switch (protoStage.name) { + case 'collection': { + return new CollectionSource(protoStage.args![0].referenceValue!); + } + case 'collection_group': { + return new CollectionGroupSource(protoStage.args![1].stringValue!); + } + case 'database': { + return new DatabaseSource(); + } + case 'documents': { + return new DocumentsSource( + protoStage.args!.map(arg => arg.referenceValue!) + ); + } + case 'where': { + return new Where(exprFromProto(protoStage.args![0]) as BooleanExpr); + } + case 'limit': { + const limitValue = + protoStage.args![0].integerValue ?? protoStage.args![0].doubleValue!; + return new Limit( + typeof limitValue === 'number' ? limitValue : Number(limitValue) + ); + } + case 'sort': { + return new Sort(protoStage.args!.map(arg => orderingFromProto(arg))); + } + default: { + throw new Error(`Stage type: ${protoStage.name} not supported.`); + } + } +} + +export function exprFromProto(value: ProtoValue): Expr { + if (!!value.fieldReferenceValue) { + return new Field( + fieldPathFromArgument('_exprFromProto', value.fieldReferenceValue) + ); + } else if (!!value.functionValue) { + return functionFromProto(value); + } else { + return Constant._fromProto(value); + } +} + +function functionFromProto(value: ProtoValue): FunctionExpr { + // TODO(pipeline): When aggregation is supported, we need to return AggregateFunction for the functions + // with aggregate names (sum, count, etc). + return new FunctionExpr( + value.functionValue!.name!, + value.functionValue!.args?.map(exprFromProto) || [] + ); +} + +function orderingFromProto(value: ProtoValue): Ordering { + const fields = value.mapValue?.fields!; + return new Ordering( + exprFromProto(fields.expression), + fields.direction?.stringValue! as 'ascending' | 'descending' + ); +} diff --git a/packages/firestore/src/core/target.ts b/packages/firestore/src/core/target.ts index 4b12857fc2a..b1adb0192d7 100644 --- a/packages/firestore/src/core/target.ts +++ b/packages/firestore/src/core/target.ts @@ -52,6 +52,8 @@ import { orderByEquals, stringifyOrderBy } from './order_by'; +import type { CorePipeline } from './pipeline'; +import { TargetOrPipeline } from './pipeline-util'; /** * A Target represents the WatchTarget representation of a Query, which is used @@ -215,8 +217,16 @@ export function targetEquals(left: Target, right: Target): boolean { return boundEquals(left.endAt, right.endAt); } +export function targetIsPipelineTarget( + target: TargetOrPipeline +): target is CorePipeline { + // Workaround for circular dependency + return !!(target as CorePipeline).isCorePipeline; +} + export function targetIsDocumentTarget(target: Target): boolean { return ( + !!target.path && DocumentKey.isDocumentKey(target.path) && target.collectionGroup === null && target.filters.length === 0 diff --git a/packages/firestore/src/lite-api/expressions.ts b/packages/firestore/src/lite-api/expressions.ts index 03182ae3227..12c7698be9a 100644 --- a/packages/firestore/src/lite-api/expressions.ts +++ b/packages/firestore/src/lite-api/expressions.ts @@ -36,6 +36,7 @@ import { isString } from '../util/types'; import { Bytes } from './bytes'; import { documentId as documentIdFieldPath, FieldPath } from './field_path'; import { GeoPoint } from './geo_point'; +import { Pipeline } from './pipeline'; import { DocumentReference } from './reference'; import { Timestamp } from './timestamp'; import { @@ -2022,7 +2023,7 @@ export class AggregateFunction implements ProtoValueSerializable, UserData { */ _createdFromLiteral: boolean = false; - constructor(private name: string, private params: Expr[]) {} + constructor(readonly name: string, readonly params: Expr[]) {} /** * Assigns an alias to this AggregateFunction. The alias specifies the name that @@ -2132,12 +2133,12 @@ export class ExprWithAlias implements Selectable, UserData { } /** + * @private * @internal */ -class ListOfExprs extends Expr { +export class ListOfExprs extends Expr { exprType: ExprType = 'ListOfExprs'; - - constructor(private exprs: Expr[]) { + constructor(readonly exprs: Expr[]) { super(); } @@ -2187,15 +2188,16 @@ export class Field extends Expr implements Selectable { /** * @internal * @private - * @hideconstructor - * @param fieldPath */ - constructor(private fieldPath: InternalFieldPath) { + constructor( + readonly _fieldPath: InternalFieldPath, + private pipeline: Pipeline | null = null + ) { super(); } fieldName(): string { - return this.fieldPath.canonicalString(); + return this._fieldPath.canonicalString(); } get alias(): string { @@ -2212,7 +2214,7 @@ export class Field extends Expr implements Selectable { */ _toProto(serializer: JsonProtoSerializer): ProtoValue { return { - fieldReferenceValue: this.fieldPath.canonicalString() + fieldReferenceValue: this._fieldPath.canonicalString() }; } @@ -2247,7 +2249,7 @@ export function field(nameOrPath: string | FieldPath): Field { if (DOCUMENT_KEY_NAME === nameOrPath) { return new Field(documentIdFieldPath()._internalPath); } - return new Field(fieldPathFromArgument('field', nameOrPath)); + return new Field(fieldPathFromArgument('of', nameOrPath)); } else { return new Field(nameOrPath._internalPath); } @@ -2273,13 +2275,10 @@ export class Constant extends Expr { private _protoValue?: ProtoValue; - /** - * @private - * @internal - * @hideconstructor - * @param value The value of the constant. - */ - constructor(private value: unknown) { + constructor( + readonly value: any, + readonly options?: { preferIntegers: boolean } + ) { super(); } @@ -2318,9 +2317,17 @@ export class Constant extends Expr { if (isFirestoreValue(this._protoValue)) { return; } else { - this._protoValue = parseData(this.value, context)!; + this._protoValue = parseData(this.value, context, this.options)!; } } + + _getValue(): ProtoValue { + hardAssert( + this._protoValue !== undefined, + 'Value of this constant has not been serialized to proto value' + ); + return this._protoValue; + } } /** @@ -2329,7 +2336,10 @@ export class Constant extends Expr { * @param value The number value. * @return A new `Constant` instance. */ -export function constant(value: number): Constant; +export function constant( + value: number, + options?: { preferIntegers: boolean } +): Constant; /** * Creates a `Constant` instance for a string value. @@ -2413,8 +2423,11 @@ export function constant(value: ProtoValue): Constant; */ export function constant(value: VectorValue): Constant; -export function constant(value: unknown): Constant { - return new Constant(value); +export function constant( + value: unknown, + options?: { preferIntegers: boolean } +): Constant { + return new Constant(value, options); } /** @@ -2476,7 +2489,7 @@ export class MapValue extends Expr { export class FunctionExpr extends Expr { readonly exprType: ExprType = 'Function'; - constructor(private name: string, private params: Expr[]) { + constructor(readonly name: string, readonly params: Expr[]) { super(); } @@ -3043,6 +3056,16 @@ export function arrayOffset( return fieldOrExpression(array).arrayOffset(valueToDefaultExpr(offset)); } +/** + * @beta + * Creates an Expr that returns a map of all values in the current expression context. + * + * @return A new {@code Expr} representing the 'current_context' function. + */ +export function currentContext(): FunctionExpr { + return new FunctionExpr('current_context', []); +} + /** * @beta * @@ -4943,26 +4966,26 @@ export function logicalMinimum( /** * @beta * - * Creates an expression that checks if a field exists. + * Creates an expression that checks if an expression evaluates to 'NaN' (Not a Number). * * ```typescript * // Check if the document has a field named "phoneNumber" * exists(field("phoneNumber")); * ``` * - * @param value An expression evaluates to the name of the field to check. - * @return A new {@code Expr} representing the 'exists' check. + * @param value The expression to check. + * @return A new {@code Expr} representing the 'isNaN' check. */ export function exists(value: Expr): BooleanExpr; /** * @beta * - * Creates an expression that checks if a field exists. + * Creates an expression that checks if a field's value evaluates to 'NaN' (Not a Number). * * ```typescript - * // Check if the document has a field named "phoneNumber" - * exists("phoneNumber"); + * // Check if the result of a calculation is NaN + * isNaN("value"); * ``` * * @param fieldName The field name to check. @@ -4976,7 +4999,7 @@ export function exists(valueOrField: Expr | string): BooleanExpr { /** * @beta * - * Creates an expression that checks if an expression evaluates to 'NaN' (Not a Number). + * Creates an expression that checks if an expression evaluates to 'null'. * * ```typescript * // Check if the result of a calculation is NaN @@ -4984,18 +5007,18 @@ export function exists(valueOrField: Expr | string): BooleanExpr { * ``` * * @param value The expression to check. - * @return A new {@code Expr} representing the 'isNaN' check. + * @return A new {@code Expr} representing the 'isNull' check. */ export function isNan(value: Expr): BooleanExpr; /** * @beta * - * Creates an expression that checks if a field's value evaluates to 'NaN' (Not a Number). + * Creates an expression that checks if a field's value evaluates to 'null'. * * ```typescript - * // Check if the result of a calculation is NaN - * isNaN("value"); + * // Check if the result of a calculation is null. + * isNull("value"); * ``` * * @param fieldName The name of the field to check. diff --git a/packages/firestore/src/lite-api/pipeline-result.ts b/packages/firestore/src/lite-api/pipeline-result.ts index 635636ac46b..98f1b9168b8 100644 --- a/packages/firestore/src/lite-api/pipeline-result.ts +++ b/packages/firestore/src/lite-api/pipeline-result.ts @@ -15,6 +15,9 @@ * limitations under the License. */ +import { RealtimePipeline } from '../api/realtime_pipeline'; +import { SnapshotMetadata } from '../api/snapshot'; +import { Document } from '../model/document'; import { ObjectValue } from '../model/object_value'; import { isOptionalEqual } from '../util/misc'; @@ -79,9 +82,10 @@ export class PipelineSnapshot { *

If the PipelineResult represents a non-document result, `ref` will return a undefined * value. */ -export class PipelineResult { +export class PipelineResult { private readonly _userDataWriter: AbstractUserDataWriter; + private readonly _executionTime: Timestamp | undefined; private readonly _createTime: Timestamp | undefined; private readonly _updateTime: Timestamp | undefined; @@ -98,6 +102,7 @@ export class PipelineResult { readonly _fields: ObjectValue | undefined; /** + * @hideconstructor * @private * @internal * @@ -114,16 +119,44 @@ export class PipelineResult { userDataWriter: AbstractUserDataWriter, ref?: DocumentReference, fields?: ObjectValue, + executionTime?: Timestamp, createTime?: Timestamp, - updateTime?: Timestamp + updateTime?: Timestamp, + readonly metadata?: SnapshotMetadata ) { this._ref = ref; this._userDataWriter = userDataWriter; + this._executionTime = executionTime; this._createTime = createTime; this._updateTime = updateTime; this._fields = fields; } + /** + * @private + * @internal + * @param userDataWriter + * @param doc + * @param ref + * @param metadata + */ + static fromDocument( + userDataWriter: AbstractUserDataWriter, + doc: Document, + ref?: DocumentReference, + metadata?: SnapshotMetadata + ): PipelineResult { + return new PipelineResult( + userDataWriter, + ref, + doc.data, + doc.readTime.toTimestamp(), + doc.createTime.toTimestamp(), + doc.version.toTimestamp(), + metadata + ); + } + /** * The reference of the document, if it is a document; otherwise `undefined`. */ @@ -180,14 +213,14 @@ export class PipelineResult { * }); * ``` */ - data(): AppModelType | undefined { + data(): DocumentData | undefined { if (this._fields === undefined) { return undefined; } return this._userDataWriter.convertValue( this._fields.value - ) as AppModelType; + ) as DocumentData; } /** @@ -238,3 +271,16 @@ export function pipelineResultEqual( isOptionalEqual(left._fields, right._fields, (l, r) => l.isEqual(r)) ); } + +export function toPipelineResult( + doc: Document, + pipeline: RealtimePipeline +): PipelineResult { + return PipelineResult.fromDocument( + pipeline._userDataWriter, + doc, + doc.key.path + ? new DocumentReference(pipeline._db, null, doc.key) + : undefined + ); +} diff --git a/packages/firestore/src/lite-api/pipeline-source.ts b/packages/firestore/src/lite-api/pipeline-source.ts index 421fc759bfb..4b3257974c1 100644 --- a/packages/firestore/src/lite-api/pipeline-source.ts +++ b/packages/firestore/src/lite-api/pipeline-source.ts @@ -16,7 +16,7 @@ */ import { DatabaseId } from '../core/database_info'; -import { toPipeline } from '../core/pipeline-util'; +import { toPipelineStages } from '../core/pipeline-util'; import { FirestoreError, Code } from '../util/error'; import { Pipeline } from './pipeline'; @@ -114,8 +114,10 @@ export class PipelineSource { * * @throws {@FirestoreError} Thrown if any of the provided DocumentReferences target a different project or database than the pipeline. */ - createFrom(query: Query): Pipeline { - return toPipeline(query._query, query.firestore); + createFrom(query: Query): PipelineType { + return this._createPipeline( + toPipelineStages(query._query, query.firestore) + ); } _validateReference(reference: CollectionReference | DocumentReference): void { diff --git a/packages/firestore/src/lite-api/pipeline.ts b/packages/firestore/src/lite-api/pipeline.ts index e07c7a37b9f..c9ef58b5b52 100644 --- a/packages/firestore/src/lite-api/pipeline.ts +++ b/packages/firestore/src/lite-api/pipeline.ts @@ -63,11 +63,14 @@ import { } from './user_data_reader'; import { AbstractUserDataWriter } from './user_data_writer'; -interface ReadableUserData { +/** + * @private + */ +export interface ReadableUserData { _readUserData(dataReader: UserDataReader): void; } -function isReadableUserData(value: unknown): value is ReadableUserData { +export function isReadableUserData(value: unknown): value is ReadableUserData { return typeof (value as ReadableUserData)._readUserData === 'function'; } @@ -118,6 +121,7 @@ export class Pipeline implements ProtoSerializable { /** * @internal * @private + * @hideconstructor * @param _db * @param userDataReader * @param _userDataWriter @@ -129,13 +133,17 @@ export class Pipeline implements ProtoSerializable { * @private */ public _db: Firestore, - private userDataReader: UserDataReader, + /** + * @internal + * @private + */ + readonly userDataReader: UserDataReader, /** * @internal * @private */ public _userDataWriter: AbstractUserDataWriter, - private stages: Stage[] + readonly stages: Stage[] ) {} /** diff --git a/packages/firestore/src/lite-api/pipeline_impl.ts b/packages/firestore/src/lite-api/pipeline_impl.ts index c1ca940a56b..a3be73b3a84 100644 --- a/packages/firestore/src/lite-api/pipeline_impl.ts +++ b/packages/firestore/src/lite-api/pipeline_impl.ts @@ -88,6 +88,7 @@ export function execute(pipeline: Pipeline): Promise { ? new DocumentReference(pipeline._db, null, element.key) : undefined, element.fields, + element.executionTime?.toTimestamp(), element.createTime?.toTimestamp(), element.updateTime?.toTimestamp() ) diff --git a/packages/firestore/src/lite-api/stage.ts b/packages/firestore/src/lite-api/stage.ts index 1d8ae06eaf6..e1c77ecf8b0 100644 --- a/packages/firestore/src/lite-api/stage.ts +++ b/packages/firestore/src/lite-api/stage.ts @@ -55,7 +55,7 @@ export interface Stage extends ProtoSerializable { export class AddFields implements Stage { name = 'add_fields'; - constructor(private fields: Map) {} + constructor(readonly fields: Map) {} /** * @internal @@ -96,8 +96,8 @@ export class Aggregate implements Stage { name = 'aggregate'; constructor( - private accumulators: Map, - private groups: Map + readonly accumulators: Map, + readonly groups: Map ) {} /** @@ -121,7 +121,7 @@ export class Aggregate implements Stage { export class Distinct implements Stage { name = 'distinct'; - constructor(private groups: Map) {} + constructor(readonly groups: Map) {} /** * @internal @@ -141,7 +141,7 @@ export class Distinct implements Stage { export class CollectionSource implements Stage { name = 'collection'; - constructor(private collectionPath: string) { + constructor(readonly collectionPath: string) { if (!this.collectionPath.startsWith('/')) { this.collectionPath = '/' + this.collectionPath; } @@ -165,7 +165,7 @@ export class CollectionSource implements Stage { export class CollectionGroupSource implements Stage { name = 'collection_group'; - constructor(private collectionId: string) {} + constructor(readonly collectionId: string) {} /** * @internal @@ -202,7 +202,7 @@ export class DatabaseSource implements Stage { export class DocumentsSource implements Stage { name = 'documents'; - constructor(private docPaths: string[]) {} + constructor(readonly docPaths: string[]) {} static of(refs: Array): DocumentsSource { return new DocumentsSource( @@ -236,7 +236,7 @@ export class DocumentsSource implements Stage { export class Where implements Stage { name = 'where'; - constructor(private condition: BooleanExpr) {} + constructor(readonly condition: BooleanExpr) {} /** * @internal @@ -278,11 +278,11 @@ export class FindNearest implements Stage { * @param _distanceField */ constructor( - private _field: Field, - private _vectorValue: ObjectValue, - private _distanceMeasure: 'euclidean' | 'cosine' | 'dot_product', - private _limit?: number, - private _distanceField?: string + readonly _field: Field, + readonly _vectorValue: ObjectValue, + readonly _distanceMeasure: 'euclidean' | 'cosine' | 'dot_product', + readonly _limit?: number, + readonly _distanceField?: string ) {} /** @@ -347,7 +347,7 @@ export class Limit implements Stage { export class Offset implements Stage { name = 'offset'; - constructor(private offset: number) {} + constructor(readonly offset: number) {} /** * @internal @@ -367,7 +367,7 @@ export class Offset implements Stage { export class Select implements Stage { name = 'select'; - constructor(private projections: Map) {} + constructor(readonly projections: Map) {} /** * @internal @@ -387,7 +387,7 @@ export class Select implements Stage { export class Sort implements Stage { name = 'sort'; - constructor(private orders: Ordering[]) {} + constructor(readonly orders: Ordering[]) {} /** * @internal diff --git a/packages/firestore/src/lite-api/user_data_reader.ts b/packages/firestore/src/lite-api/user_data_reader.ts index e3e0deaa479..96ba702fe3c 100644 --- a/packages/firestore/src/lite-api/user_data_reader.ts +++ b/packages/firestore/src/lite-api/user_data_reader.ts @@ -66,6 +66,7 @@ import { Dict, forEach, isEmpty } from '../util/obj'; import { Bytes } from './bytes'; import { Firestore } from './database'; +import type { Constant } from './expressions'; import { FieldPath } from './field_path'; import { FieldValue } from './field_value'; import { GeoPoint } from './geo_point'; @@ -305,7 +306,7 @@ class ParseContextImpl implements ParseContext { * classes. */ export class UserDataReader { - private readonly serializer: JsonProtoSerializer; + readonly serializer: JsonProtoSerializer; constructor( private readonly databaseId: DatabaseId, @@ -703,12 +704,18 @@ export function parseQueryValue( */ export function parseData( input: unknown, - context: ParseContext + context: ParseContext, + options?: { preferIntegers: boolean } ): ProtoValue | null { // Unwrap the API type from the Compat SDK. This will return the API type // from firestore-exp. input = getModularInstance(input); + // Workaround for circular dependency + if ((input as Constant)?.exprType === 'Constant') { + return (input as Constant)._getValue(); + } + if (looksLikeJsonObject(input)) { validatePlainObject('Unsupported field value:', context, input); return parseObject(input, context); @@ -747,7 +754,7 @@ export function parseData( } return parseArray(input as unknown[], context); } else { - return parseScalarValue(input, context); + return parseScalarValue(input, context, options); } } } @@ -828,14 +835,15 @@ function parseSentinelFieldValue( */ export function parseScalarValue( value: unknown, - context: ParseContext + context: ParseContext, + options?: { preferIntegers: boolean } ): ProtoValue | null { value = getModularInstance(value); if (value === null) { return { nullValue: 'NULL_VALUE' }; } else if (typeof value === 'number') { - return toNumber(context.serializer, value); + return toNumber(context.serializer, value, options); } else if (typeof value === 'boolean') { return { booleanValue: value }; } else if (typeof value === 'string') { diff --git a/packages/firestore/src/model/path.ts b/packages/firestore/src/model/path.ts index c375b4c56d2..7a546f5b926 100644 --- a/packages/firestore/src/model/path.ts +++ b/packages/firestore/src/model/path.ts @@ -22,6 +22,8 @@ import { Code, FirestoreError } from '../util/error'; import { compareUtf8Strings, primitiveComparator } from '../util/misc'; export const DOCUMENT_KEY_NAME = '__name__'; +export const UPDATE_TIME_NAME = '__update_time__'; +export const CREATE_TIME_NAME = '__create_time__'; /** * Path represents an ordered sequence of string segments. @@ -243,6 +245,10 @@ export class ResourcePath extends BasePath { return this.canonicalString(); } + toStringWithLeadingSlash(): string { + return `/${this.canonicalString()}`; + } + /** * Returns a string representation of this path * where each path segment has been encoded with diff --git a/packages/firestore/src/model/values.ts b/packages/firestore/src/model/values.ts index 30d8688b776..0e335bc454e 100644 --- a/packages/firestore/src/model/values.ts +++ b/packages/firestore/src/model/values.ts @@ -63,6 +63,14 @@ export const MIN_VALUE: Value = { nullValue: 'NULL_VALUE' }; +export const TRUE_VALUE: Value = { + booleanValue: true +}; + +export const FALSE_VALUE: Value = { + booleanValue: false +}; + /** Extracts the backend's type order for the provided value. */ export function typeOrder(value: Value): TypeOrder { if ('nullValue' in value) { @@ -97,8 +105,18 @@ export function typeOrder(value: Value): TypeOrder { } } +export interface EqualOptions { + nanEqual: boolean; + mixIntegerDouble: boolean; + semanticsEqual: boolean; +} + /** Tests `left` and `right` for equality based on the backend semantics. */ -export function valueEquals(left: Value, right: Value): boolean { +export function valueEquals( + left: Value, + right: Value, + options?: EqualOptions +): boolean { if (left === right) { return true; } @@ -127,16 +145,16 @@ export function valueEquals(left: Value, right: Value): boolean { case TypeOrder.GeoPointValue: return geoPointEquals(left, right); case TypeOrder.NumberValue: - return numberEquals(left, right); + return numberEquals(left, right, options); case TypeOrder.ArrayValue: return arrayEquals( left.arrayValue!.values || [], right.arrayValue!.values || [], - valueEquals + (l, r) => valueEquals(l, r, options) ); case TypeOrder.VectorValue: case TypeOrder.ObjectValue: - return objectEquals(left, right); + return objectEquals(left, right, options); case TypeOrder.MaxValue: return true; default: @@ -177,26 +195,43 @@ function blobEquals(left: Value, right: Value): boolean { ); } -export function numberEquals(left: Value, right: Value): boolean { +export function numberEquals( + left: Value, + right: Value, + options?: EqualOptions +): boolean { if ('integerValue' in left && 'integerValue' in right) { return ( normalizeNumber(left.integerValue) === normalizeNumber(right.integerValue) ); - } else if ('doubleValue' in left && 'doubleValue' in right) { - const n1 = normalizeNumber(left.doubleValue!); - const n2 = normalizeNumber(right.doubleValue!); + } - if (n1 === n2) { - return isNegativeZero(n1) === isNegativeZero(n2); - } else { - return isNaN(n1) && isNaN(n2); - } + let n1: number, n2: number; + if ('doubleValue' in left && 'doubleValue' in right) { + n1 = normalizeNumber(left.doubleValue!); + n2 = normalizeNumber(right.doubleValue!); + } else if (options?.mixIntegerDouble) { + n1 = normalizeNumber(left.integerValue ?? left.doubleValue); + n2 = normalizeNumber(right.integerValue ?? right.doubleValue); + } else { + return false; } - return false; + if (n1 === n2) { + return options?.semanticsEqual + ? true + : isNegativeZero(n1) === isNegativeZero(n2); + } else { + const nanEqual = options === undefined ? true : options.nanEqual; + return nanEqual ? isNaN(n1) && isNaN(n2) : false; + } } -function objectEquals(left: Value, right: Value): boolean { +function objectEquals( + left: Value, + right: Value, + options?: EqualOptions +): boolean { const leftMap = left.mapValue!.fields || {}; const rightMap = right.mapValue!.fields || {}; @@ -208,7 +243,7 @@ function objectEquals(left: Value, right: Value): boolean { if (leftMap.hasOwnProperty(key)) { if ( rightMap[key] === undefined || - !valueEquals(leftMap[key], rightMap[key]) + !valueEquals(leftMap[key], rightMap[key], options) ) { return false; } @@ -356,7 +391,7 @@ function compareArrays(left: ArrayValue, right: ArrayValue): number { for (let i = 0; i < leftArray.length && i < rightArray.length; ++i) { const compare = valueCompare(leftArray[i], rightArray[i]); - if (compare) { + if (compare !== undefined && compare !== 0) { return compare; } } @@ -569,6 +604,13 @@ export function refValue(databaseId: DatabaseId, key: DocumentKey): Value { }; } +/** Returns true if `value` is an BooleanValue . */ +export function isBoolean( + value?: Value | null +): value is { booleanValue: boolean } { + return !!value && 'booleanValue' in value; +} + /** Returns true if `value` is an IntegerValue . */ export function isInteger( value?: Value | null @@ -595,6 +637,18 @@ export function isArray( return !!value && 'arrayValue' in value; } +/** Returns true if `value` is an ArrayValue. */ +export function isString( + value?: Value | null +): value is { stringValue: string } { + return !!value && 'stringValue' in value; +} + +/** Returns true if `value` is an BytesValue. */ +export function isBytes(value?: Value | null): value is { bytesValue: string } { + return !!value && 'bytesValue' in value; +} + /** Returns true if `value` is a ReferenceValue. */ export function isReferenceValue( value?: Value | null @@ -616,6 +670,13 @@ export function isNanValue( return !!value && 'doubleValue' in value && isNaN(Number(value.doubleValue)); } +/** Returns true if `value` is Timestamp. */ +export function isTimestampValue( + value?: Value | null +): value is { timestampValue: Timestamp } { + return !!value && 'timestampValue' in value && !!value.timestampValue; +} + /** Returns true if `value` is a MapValue. */ export function isMapValue( value?: Value | null @@ -629,6 +690,13 @@ export function isVectorValue(value: ProtoValue | null): boolean { return type === VECTOR_VALUE_SENTINEL; } +/** Returns true if `value` is a VetorValue. */ +export function getVectorValue( + value: ProtoValue | null +): ArrayValue | undefined { + return (value?.mapValue?.fields || {})[VECTOR_MAP_VECTORS_KEY]?.arrayValue; +} + /** Creates a deep copy of `source`. */ export function deepClone(source: Value): Value { if (source.geoPointValue) { diff --git a/packages/firestore/src/remote/number_serializer.ts b/packages/firestore/src/remote/number_serializer.ts index 8d5f66e3caa..63ad0f86bc2 100644 --- a/packages/firestore/src/remote/number_serializer.ts +++ b/packages/firestore/src/remote/number_serializer.ts @@ -52,6 +52,13 @@ export function toInteger(value: number): ProtoValue { * The return value is an IntegerValue if it can safely represent the value, * otherwise a DoubleValue is returned. */ -export function toNumber(serializer: Serializer, value: number): ProtoValue { +export function toNumber( + serializer: Serializer, + value: number, + options?: { preferIntegers: boolean } +): ProtoValue { + if (Number.isInteger(value) && options?.preferIntegers) { + return toInteger(value); + } return isSafeInteger(value) ? toInteger(value) : toDouble(serializer, value); } diff --git a/packages/firestore/test/unit/core/expressions/arithmetic.test.ts b/packages/firestore/test/unit/core/expressions/arithmetic.test.ts new file mode 100644 index 00000000000..149b0929a91 --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/arithmetic.test.ts @@ -0,0 +1,1200 @@ +/** + * @license + * Copyright 2025 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 { + add, + constant, + divide, + mod, + multiply, + subtract +} from '../../../../src/lite-api/expressions'; +import { EvaluateResult } from '../../../../src/core/expressions'; +import { evaluateToResult, evaluateToValue, expectEqual } from './utils'; + +describe('Arithmetic Expressions', () => { + describe('add', () => { + it('basic_add_numerics', () => { + expectEqual( + evaluateToValue(add(constant(1), constant(2))), + constant(3), + `add(1, 2)` + ); + expectEqual( + evaluateToValue(add(constant(1), constant(2.5))), + constant(3.5), + `add(1, 2.5)` + ); + expectEqual( + evaluateToValue(add(constant(1.0), constant(2))), + constant(3.0), + `add(1.0, 2)` + ); + expectEqual( + evaluateToValue(add(constant(1.0), constant(2.0))), + constant(3.0), + `add(1.0, 2.0)` + ); + }); + + it('basic_add_nonNumerics', () => { + expect(evaluateToResult(add(constant(1), constant('1')))).to.deep.equal( + EvaluateResult.newError() + ); + expect(evaluateToResult(add(constant('1'), constant(1.0)))).to.deep.equal( + EvaluateResult.newError() + ); + expect(evaluateToResult(add(constant('1'), constant('1')))).to.deep.equal( + EvaluateResult.newError() + ); + }); + + it('doubleLongAddition_overflow', () => { + expectEqual( + evaluateToValue(add(constant(9223372036854775807), constant(1.0))), + constant(9.223372036854776e18), + `add(Long.MAX_VALUE, 1.0)` + ); + expectEqual( + evaluateToValue(add(constant(9223372036854775807.0), constant(100))), + constant(9.223372036854776e18), + `add(Long.MAX_VALUE as double, 100)` + ); + }); + + it('doubleAddition_overflow', () => { + expectEqual( + evaluateToValue( + add(constant(Number.MAX_VALUE), constant(Number.MAX_VALUE)) + ), + constant(Number.POSITIVE_INFINITY), + `add(Number.MAX_VALUE, Number.MAX_VALUE)` + ); + expectEqual( + evaluateToValue( + add(constant(-Number.MAX_VALUE), constant(-Number.MAX_VALUE)) + ), + constant(Number.NEGATIVE_INFINITY), + `add(-Number.MAX_VALUE, -Number.MAX_VALUE)` + ); + }); + + it('sumPosAndNegInfinity_returnNaN', () => { + expectEqual( + evaluateToValue( + add( + constant(Number.POSITIVE_INFINITY), + constant(Number.NEGATIVE_INFINITY) + ) + ), + constant(NaN), + `add(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY)` + ); + }); + + // TODO(pipeline): It is not possible to do long overflow in javascript because + // the number will be converted to double by UserDataReader first. + it('longAddition_overflow', () => { + expect( + evaluateToValue( + add( + constant(0x7fffffffffffffff, { preferIntegers: true }), + constant(1) + ) + ) + ).to.be.undefined; + expect( + evaluateToValue( + add( + constant(0x8000000000000000, { preferIntegers: true }), + constant(-1) + ) + ) + ).to.be.undefined; + expect( + evaluateToValue( + add( + constant(1), + constant(0x7fffffffffffffff, { preferIntegers: true }) + ) + ) + ).to.be.undefined; + }); + + it('nan_number_returnNaN', () => { + expectEqual( + evaluateToValue(add(constant(1), constant(NaN))), + constant(NaN), + `add(1, NaN)` + ); + expectEqual( + evaluateToValue(add(constant(1.0), constant(NaN))), + constant(NaN), + `add(1.0, NaN)` + ); + expectEqual( + evaluateToValue(add(constant(Number.MAX_SAFE_INTEGER), constant(NaN))), + constant(NaN), + `add(Number.MAX_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue(add(constant(Number.MIN_SAFE_INTEGER), constant(NaN))), + constant(NaN), + `add(Number.MIN_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue(add(constant(Number.MAX_VALUE), constant(NaN))), + constant(NaN), + `add(Number.MAX_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(add(constant(Number.MIN_VALUE), constant(NaN))), + constant(NaN), + `add(Number.MIN_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(add(constant(Number.POSITIVE_INFINITY), constant(NaN))), + constant(NaN), + `add(Number.POSITIVE_INFINITY, NaN)` + ); + expectEqual( + evaluateToValue(add(constant(Number.NEGATIVE_INFINITY), constant(NaN))), + constant(NaN), + `add(Number.NEGATIVE_INFINITY, NaN)` + ); + }); + + it('nan_notNumberType_returnError', () => { + expect(evaluateToValue(add(constant(NaN), constant('hello world')))).to.be + .undefined; + }); + + it('multiArgument', () => { + expectEqual( + evaluateToValue(add(add(constant(1), constant(2)), constant(3))), + constant(6), + `add(add(1, 2), 3)` + ); + expectEqual( + evaluateToValue(add(add(constant(1.0), constant(2)), constant(3))), + constant(6.0), + `add(add(1.0, 2), 3)` + ); + }); + }); // end describe('add') + + describe('subtract', () => { + it('basic_subtract_numerics', () => { + expectEqual( + evaluateToValue(subtract(constant(1), constant(2))), + constant(-1), + `subtract(1, 2)` + ); + expectEqual( + evaluateToValue(subtract(constant(1), constant(2.5))), + constant(-1.5), + `subtract(1, 2.5)` + ); + expectEqual( + evaluateToValue(subtract(constant(1.0), constant(2))), + constant(-1.0), + `subtract(1.0, 2)` + ); + expectEqual( + evaluateToValue(subtract(constant(1.0), constant(2.0))), + constant(-1.0), + `subtract(1.0, 2.0)` + ); + }); + + it('basic_subtract_nonNumerics', () => { + expect(evaluateToValue(subtract(constant(1), constant('1')))).to.be + .undefined; + expect(evaluateToValue(subtract(constant('1'), constant(1.0)))).to.be + .undefined; + expect(evaluateToValue(subtract(constant('1'), constant('1')))).to.be + .undefined; + }); + + // TODO(pipeline): Overflow behavior is different in Javascript than backend. + it.skip('doubleLongSubtraction_overflow', () => { + expectEqual( + evaluateToValue(subtract(constant(0x8000000000000000), constant(1.0))), + constant(-9.223372036854776e18), + `subtract(Long.MIN_VALUE, 1.0)` + ); + expectEqual( + evaluateToValue(subtract(constant(0x8000000000000000), constant(100))), + constant(-9.223372036854776e18), + `subtract(Long.MIN_VALUE, 100)` + ); + }); + + it('doubleSubtraction_overflow', () => { + expectEqual( + evaluateToValue( + subtract(constant(-Number.MAX_VALUE), constant(Number.MAX_VALUE)) + ), + constant(Number.NEGATIVE_INFINITY), + `subtract(-Number.MAX_VALUE, Number.MAX_VALUE)` + ); + expectEqual( + evaluateToValue( + subtract(constant(Number.MAX_VALUE), constant(-Number.MAX_VALUE)) + ), + constant(Number.POSITIVE_INFINITY), + `subtract(Number.MAX_VALUE, -Number.MAX_VALUE)` + ); + }); + + it('longSubtraction_overflow', () => { + expect( + evaluateToValue( + subtract( + constant(0x8000000000000000, { preferIntegers: true }), + constant(-1) + ) + ) + ).to.be.undefined; + expect( + evaluateToValue( + subtract( + constant(-0x7fffffffffffffff, { preferIntegers: true }), + constant(1) + ) + ) + ).to.be.undefined; + }); + + it('nan_number_returnNaN', () => { + expectEqual( + evaluateToValue(subtract(constant(1), constant(NaN))), + constant(NaN), + `subtract(1, NaN)` + ); + expectEqual( + evaluateToValue(subtract(constant(1.0), constant(NaN))), + constant(NaN), + `subtract(1.0, NaN)` + ); + expectEqual( + evaluateToValue( + subtract(constant(Number.MAX_SAFE_INTEGER), constant(NaN)) + ), + constant(NaN), + `subtract(Number.MAX_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue( + subtract(constant(Number.MIN_SAFE_INTEGER), constant(NaN)) + ), + constant(NaN), + `subtract(Number.MIN_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue(subtract(constant(Number.MAX_VALUE), constant(NaN))), + constant(NaN), + `subtract(Number.MAX_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(subtract(constant(Number.MIN_VALUE), constant(NaN))), + constant(NaN), + `subtract(Number.MIN_VALUE, NaN)` + ); + expectEqual( + evaluateToValue( + subtract(constant(Number.POSITIVE_INFINITY), constant(NaN)) + ), + constant(NaN), + `subtract(Number.POSITIVE_INFINITY, NaN)` + ); + expectEqual( + evaluateToValue( + subtract(constant(Number.NEGATIVE_INFINITY), constant(NaN)) + ), + constant(NaN), + `subtract(Number.NEGATIVE_INFINITY, NaN)` + ); + }); + + it('nan_notNumberType_returnError', () => { + expect(evaluateToValue(subtract(constant(NaN), constant('hello world')))) + .to.be.undefined; + }); + + it('positiveInfinity', () => { + expectEqual( + evaluateToValue( + subtract(constant(Number.POSITIVE_INFINITY), constant(1)) + ), + constant(Number.POSITIVE_INFINITY), + `subtract(Number.POSITIVE_INFINITY, 1)` + ); + + expectEqual( + evaluateToValue( + subtract(constant(1), constant(Number.POSITIVE_INFINITY)) + ), + constant(Number.NEGATIVE_INFINITY), + `subtract(1, Number.POSITIVE_INFINITY)` + ); + }); + + it('negativeInfinity', () => { + expectEqual( + evaluateToValue( + subtract(constant(Number.NEGATIVE_INFINITY), constant(1)) + ), + constant(Number.NEGATIVE_INFINITY), + `subtract(Number.NEGATIVE_INFINITY, 1)` + ); + + expectEqual( + evaluateToValue( + subtract(constant(1), constant(Number.NEGATIVE_INFINITY)) + ), + constant(Number.POSITIVE_INFINITY), + `subtract(1, Number.NEGATIVE_INFINITY)` + ); + }); + + it('positiveInfinity_negativeInfinity', () => { + expectEqual( + evaluateToValue( + subtract( + constant(Number.POSITIVE_INFINITY), + constant(Number.NEGATIVE_INFINITY) + ) + ), + constant(Number.POSITIVE_INFINITY), + `subtract(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY)` + ); + + expectEqual( + evaluateToValue( + subtract( + constant(Number.NEGATIVE_INFINITY), + constant(Number.POSITIVE_INFINITY) + ) + ), + constant(Number.NEGATIVE_INFINITY), + `subtract(Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY)` + ); + }); + }); // end describe('subtract') + + describe('multiply', () => { + it('basic_multiply_numerics', () => { + expectEqual( + evaluateToValue(multiply(constant(1), constant(2))), + constant(2), + `multiply(1, 2)` + ); + expectEqual( + evaluateToValue(multiply(constant(3), constant(2.5))), + constant(7.5), + `multiply(3, 2.5)` + ); + expectEqual( + evaluateToValue(multiply(constant(1.0), constant(2))), + constant(2.0), + `multiply(1.0, 2)` + ); + expectEqual( + evaluateToValue(multiply(constant(1.32), constant(2.0))), + constant(2.64), + `multiply(1.32, 2.0)` + ); + }); + + it('basic_multiply_nonNumerics', () => { + expect(evaluateToValue(multiply(constant(1), constant('1')))).to.be + .undefined; + expect(evaluateToValue(multiply(constant('1'), constant(1.0)))).to.be + .undefined; + expect(evaluateToValue(multiply(constant('1'), constant('1')))).to.be + .undefined; + }); + + it('doubleLongMultiplication_overflow', () => { + expectEqual( + evaluateToValue( + multiply(constant(9223372036854775807), constant(100.0)) + ), + constant(922337203685477600000), + `multiply(Long.MAX_VALUE, 100.0)` + ); + expectEqual( + evaluateToValue(multiply(constant(9223372036854775807), constant(100))), + constant(922337203685477600000), + `multiply(Long.MAX_VALUE, 100)` + ); + }); + + it('doubleMultiplication_overflow', () => { + expectEqual( + evaluateToValue( + multiply(constant(Number.MAX_VALUE), constant(Number.MAX_VALUE)) + ), + constant(Number.POSITIVE_INFINITY), + `multiply(Number.MAX_VALUE, Number.MAX_VALUE)` + ); + expectEqual( + evaluateToValue( + multiply(constant(-Number.MAX_VALUE), constant(Number.MAX_VALUE)) + ), + constant(Number.NEGATIVE_INFINITY), + `multiply(-Number.MAX_VALUE, Number.MAX_VALUE)` + ); + }); + + it('longMultiplication_overflow', () => { + expect( + evaluateToValue( + multiply( + constant(9223372036854775807, { preferIntegers: true }), + constant(10) + ) + ) + ).to.be.undefined; + expect( + evaluateToValue( + multiply( + constant(0x8000000000000000, { preferIntegers: true }), + constant(10) + ) + ) + ).to.be.undefined; + expect( + evaluateToValue( + multiply( + constant(-10), + constant(9223372036854775807, { preferIntegers: true }) + ) + ) + ).to.be.undefined; + expect( + evaluateToValue( + multiply( + constant(-10), + constant(0x8000000000000000, { preferIntegers: true }) + ) + ) + ).to.be.undefined; + }); + + it('nan_number_returnNaN', () => { + expectEqual( + evaluateToValue(multiply(constant(1), constant(NaN))), + constant(NaN), + `multiply(1, NaN)` + ); + expectEqual( + evaluateToValue(multiply(constant(1.0), constant(NaN))), + constant(NaN), + `multiply(1.0, NaN)` + ); + expectEqual( + evaluateToValue( + multiply(constant(Number.MAX_SAFE_INTEGER), constant(NaN)) + ), + constant(NaN), + `multiply(Number.MAX_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue( + multiply(constant(Number.MIN_SAFE_INTEGER), constant(NaN)) + ), + constant(NaN), + `multiply(Number.MIN_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue(multiply(constant(Number.MAX_VALUE), constant(NaN))), + constant(NaN), + `multiply(Number.MAX_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(multiply(constant(Number.MIN_VALUE), constant(NaN))), + constant(NaN), + `multiply(Number.MIN_VALUE, NaN)` + ); + expectEqual( + evaluateToValue( + multiply(constant(Number.POSITIVE_INFINITY), constant(NaN)) + ), + constant(NaN), + `multiply(Number.POSITIVE_INFINITY, NaN)` + ); + expectEqual( + evaluateToValue( + multiply(constant(Number.NEGATIVE_INFINITY), constant(NaN)) + ), + constant(NaN), + `multiply(Number.NEGATIVE_INFINITY, NaN)` + ); + }); + + it('nan_notNumberType_returnError', () => { + expect(evaluateToValue(multiply(constant(NaN), constant('hello world')))) + .to.be.undefined; + }); + + it('positiveInfinity', () => { + expectEqual( + evaluateToValue( + multiply(constant(Number.POSITIVE_INFINITY), constant(1)) + ), + constant(Number.POSITIVE_INFINITY), + `multiply(Number.POSITIVE_INFINITY, 1)` + ); + + expectEqual( + evaluateToValue( + multiply(constant(1), constant(Number.POSITIVE_INFINITY)) + ), + constant(Number.POSITIVE_INFINITY), + `multiply(1, Number.POSITIVE_INFINITY)` + ); + }); + + it('negativeInfinity', () => { + expectEqual( + evaluateToValue( + multiply(constant(Number.NEGATIVE_INFINITY), constant(1)) + ), + constant(Number.NEGATIVE_INFINITY), + `multiply(Number.NEGATIVE_INFINITY, 1)` + ); + + expectEqual( + evaluateToValue( + multiply(constant(1), constant(Number.NEGATIVE_INFINITY)) + ), + constant(Number.NEGATIVE_INFINITY), + `multiply(1, Number.NEGATIVE_INFINITY)` + ); + }); + + it('positiveInfinity_negativeInfinity_returnsNegativeInfinity', () => { + expectEqual( + evaluateToValue( + multiply( + constant(Number.POSITIVE_INFINITY), + constant(Number.NEGATIVE_INFINITY) + ) + ), + constant(Number.NEGATIVE_INFINITY), + `multiply(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY)` + ); + + expectEqual( + evaluateToValue( + multiply( + constant(Number.NEGATIVE_INFINITY), + constant(Number.POSITIVE_INFINITY) + ) + ), + constant(Number.NEGATIVE_INFINITY), + `multiply(Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY)` + ); + }); + + it('multiArgument', () => { + expectEqual( + evaluateToValue( + multiply(multiply(constant(1), constant(2)), constant(3)) + ), + constant(6), + `multiply(multiply(1, 2, 3))` + ); + expectEqual( + evaluateToValue( + multiply(constant(1.0), multiply(constant(2), constant(3))) + ), + constant(6.0), + `multiply(1.0, multiply(2, 3))` + ); + }); + }); // end describe('multiply') + + describe('divide', () => { + it('basic_divide_numerics', () => { + expectEqual( + evaluateToValue(divide(constant(10), constant(2))), + constant(5), + `divide(10, 2)` + ); + expectEqual( + evaluateToValue(divide(constant(10), constant(2.0))), + constant(5.0), + `divide(10, 2.0)` + ); + // TODO(pipeline): Constant.of is problematic here. + // expectEqual( + // evaluate(divide(constant(10.0), constant(3))), + // constant(10.0 / 3), + // `divide(10.0, 3)` + // ); + // expectEqual( + // evaluate(divide(constant(10.0), constant(7.0))), + // constant(10.0 / 7.0), + // `divide(10.0, 7.0)` + // ); + }); + + it('basic_divide_nonNumerics', () => { + expect(evaluateToValue(divide(constant(1), constant('1')))).to.be + .undefined; + expect(evaluateToValue(divide(constant('1'), constant(1.0)))).to.be + .undefined; + expect(evaluateToValue(divide(constant('1'), constant('1')))).to.be + .undefined; + }); + + it('long_division', () => { + expectEqual( + evaluateToValue(divide(constant(10), constant(3))), + constant(3), // Integer division in JavaScript + `divide(10, 3)` + ); + expectEqual( + evaluateToValue(divide(constant(-10), constant(3))), + constant(-3), // Integer division in JavaScript + `divide(-10, 3)` + ); + expectEqual( + evaluateToValue(divide(constant(10), constant(-3))), + constant(-3), // Integer division in JavaScript + `divide(10, -3)` + ); + expectEqual( + evaluateToValue(divide(constant(-10), constant(-3))), + constant(3), // Integer division in JavaScript + `divide(-10, -3)` + ); + }); + + it('doubleLongDivision_overflow', () => { + expectEqual( + evaluateToValue( + divide(constant(Number.MAX_SAFE_INTEGER), constant(0.1)) + ), + constant(90071992547409910), // Note: JS limitation, see explanation below + `divide(Number.MAX_SAFE_INTEGER, 0.1)` + ); + expectEqual( + evaluateToValue( + divide(constant(Number.MAX_SAFE_INTEGER), constant(0.1)) + ), + constant(90071992547409910), // Note: JS limitation, see explanation below + `divide(Number.MAX_SAFE_INTEGER, 0.1)` + ); + }); + + it('doubleDivision_overflow', () => { + expectEqual( + evaluateToValue( + divide(constant(Number.MAX_VALUE), constant(Number.MIN_VALUE)) + ), + constant(Number.POSITIVE_INFINITY), + `divide(Number.MAX_VALUE, Number.MIN_VALUE)` + ); + expectEqual( + evaluateToValue( + divide(constant(-Number.MAX_VALUE), constant(Number.MIN_VALUE)) + ), + constant(Number.NEGATIVE_INFINITY), + `divide(-Number.MAX_VALUE, Number.MIN_VALUE)` + ); + }); + + it('divideByZero', () => { + expect(evaluateToValue(divide(constant(1), constant(0)))).to.be.undefined; // Or your error handling + expectEqual( + evaluateToValue(divide(constant(1.1), constant(0.0))), + constant(Number.POSITIVE_INFINITY), + `divide(1, 0.0)` + ); + expectEqual( + evaluateToValue(divide(constant(1.1), constant(-0.0))), + constant(Number.NEGATIVE_INFINITY), + `divide(1, -0.0)` + ); + }); + + it('nan_number_returnNaN', () => { + expectEqual( + evaluateToValue(divide(constant(1), constant(NaN))), + constant(NaN), + `divide(1, NaN)` + ); + expectEqual( + evaluateToValue(divide(constant(NaN), constant(1))), + constant(NaN), + `divide(NaN, 1)` + ); + + expectEqual( + evaluateToValue(divide(constant(1.0), constant(NaN))), + constant(NaN), + `divide(1.0, NaN)` + ); + expectEqual( + evaluateToValue(divide(constant(NaN), constant(1.0))), + constant(NaN), + `divide(NaN, 1.0)` + ); + + expectEqual( + evaluateToValue( + divide(constant(Number.MAX_SAFE_INTEGER), constant(NaN)) + ), + constant(NaN), + `divide(Number.MAX_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue( + divide(constant(NaN), constant(Number.MAX_SAFE_INTEGER)) + ), + constant(NaN), + `divide(NaN, Number.MAX_SAFE_INTEGER)` + ); + + expectEqual( + evaluateToValue( + divide(constant(Number.MIN_SAFE_INTEGER), constant(NaN)) + ), + constant(NaN), + `divide(Number.MIN_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue( + divide(constant(NaN), constant(Number.MIN_SAFE_INTEGER)) + ), + constant(NaN), + `divide(NaN, Number.MIN_SAFE_INTEGER)` + ); + + expectEqual( + evaluateToValue(divide(constant(Number.MAX_VALUE), constant(NaN))), + constant(NaN), + `divide(Number.MAX_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(divide(constant(NaN), constant(Number.MAX_VALUE))), + constant(NaN), + `divide(NaN, Number.MAX_VALUE)` + ); + + expectEqual( + evaluateToValue(divide(constant(Number.MIN_VALUE), constant(NaN))), + constant(NaN), + `divide(Number.MIN_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(divide(constant(NaN), constant(Number.MIN_VALUE))), + constant(NaN), + `divide(NaN, Number.MIN_VALUE)` + ); + + expectEqual( + evaluateToValue( + divide(constant(Number.POSITIVE_INFINITY), constant(NaN)) + ), + constant(NaN), + `divide(Number.POSITIVE_INFINITY, NaN)` + ); + expectEqual( + evaluateToValue(divide(constant(NaN), constant(NaN))), + constant(NaN), + `divide(NaN, NaN)` + ); + + expectEqual( + evaluateToValue( + divide(constant(Number.NEGATIVE_INFINITY), constant(NaN)) + ), + constant(NaN), + `divide(Number.NEGATIVE_INFINITY, NaN)` + ); + expectEqual( + evaluateToValue( + divide(constant(NaN), constant(Number.NEGATIVE_INFINITY)) + ), + constant(NaN), + `divide(NaN, Number.NEGATIVE_INFINITY)` + ); + }); + + it('nan_notNumberType_returnError', () => { + expect(evaluateToValue(divide(constant(NaN), constant('hello world')))).to + .be.undefined; + }); + + it('positiveInfinity', () => { + expectEqual( + evaluateToValue( + divide(constant(Number.POSITIVE_INFINITY), constant(1)) + ), + constant(Number.POSITIVE_INFINITY), + `divide(Number.POSITIVE_INFINITY, 1)` + ); + // TODO(pipeline): Constant.of is problematic here. + // expectEqual( + // evaluate( + // divide(constant(1), constant(Number.POSITIVE_INFINITY)) + // ), + // constant(0.0), + // `divide(1, Number.POSITIVE_INFINITY)` + // ); + }); + + it('negativeInfinity', () => { + expectEqual( + evaluateToValue( + divide(constant(Number.NEGATIVE_INFINITY), constant(1)) + ), + constant(Number.NEGATIVE_INFINITY), + `divide(Number.NEGATIVE_INFINITY, 1)` + ); + expectEqual( + evaluateToValue( + divide(constant(1), constant(Number.NEGATIVE_INFINITY)) + ), + constant(-0.0), + `divide(1, Number.NEGATIVE_INFINITY)` + ); + }); + + it('positiveInfinity_negativeInfinity_returnsNan', () => { + expectEqual( + evaluateToValue( + divide( + constant(Number.POSITIVE_INFINITY), + constant(Number.NEGATIVE_INFINITY) + ) + ), + constant(NaN), + `divide(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY)` + ); + expectEqual( + evaluateToValue( + divide( + constant(Number.NEGATIVE_INFINITY), + constant(Number.POSITIVE_INFINITY) + ) + ), + constant(NaN), + `divide(Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY)` + ); + }); + }); // end describe('divide') + + describe('mod', () => { + it('divisorZero_throwsError', () => { + expect(evaluateToValue(mod(constant(42), constant(0)))).to.be.undefined; + expect(evaluateToValue(mod(constant(42), constant(-0)))).to.be.undefined; + + expect(evaluateToValue(mod(constant(42), constant(0.0)))).to.be.undefined; + expect(evaluateToValue(mod(constant(42), constant(-0.0)))).to.be + .undefined; + }); + + it('dividendZero_returnsZero', () => { + expectEqual( + evaluateToValue(mod(constant(0), constant(42))), + constant(0), + `mod(0, 42)` + ); + expectEqual( + evaluateToValue(mod(constant(-0), constant(42))), + constant(0), + `mod(-0, 42)` + ); + + expectEqual( + evaluateToValue(mod(constant(0.0), constant(42))), + constant(0.0), + `mod(0.0, 42)` + ); + expectEqual( + evaluateToValue(mod(constant(-0.0), constant(42))), + constant(-0.0), + `mod(-0.0, 42)` + ); + }); + + it('long_positive_positive', () => { + expectEqual( + evaluateToValue(mod(constant(10), constant(3))), + constant(1), + `mod(10, 3)` + ); + }); + + it('long_negative_negative', () => { + expectEqual( + evaluateToValue(mod(constant(-10), constant(-3))), + constant(-1), + `mod(-10, -3)` + ); + }); + + it('long_positive_negative', () => { + expectEqual( + evaluateToValue(mod(constant(10), constant(-3))), + constant(1), + `mod(10, -3)` + ); + }); + + it('long_negative_positive', () => { + expectEqual( + evaluateToValue(mod(constant(-10), constant(3))), + constant(-1), + `mod(-10, 3)` + ); + }); + + it('double_positive_positive', () => { + expect( + evaluateToValue(mod(constant(10.5), constant(3.0)))?.doubleValue + ).to.be.closeTo(1.5, 1e-6); + }); + + it('double_negative_negative', () => { + expect( + evaluateToValue(mod(constant(-7.3), constant(-1.8)))?.doubleValue + ).to.be.closeTo(-0.1, 1e-6); + }); + + it('double_positive_negative', () => { + expect( + evaluateToValue(mod(constant(9.8), constant(-2.5)))?.doubleValue + ).to.be.closeTo(2.3, 1e-6); + }); + + it('double_negative_positive', () => { + expect( + evaluateToValue(mod(constant(-7.5), constant(2.3)))?.doubleValue + ).to.be.closeTo(-0.6, 1e-6); + }); + + it('long_perfectlyDivisible', () => { + expectEqual( + evaluateToValue(mod(constant(10), constant(5))), + constant(0), + `mod(10, 5)` + ); + expectEqual( + evaluateToValue(mod(constant(-10), constant(5))), + constant(0), + `mod(-10, 5)` + ); + expectEqual( + evaluateToValue(mod(constant(10), constant(-5))), + constant(0), + `mod(10, -5)` + ); + expectEqual( + evaluateToValue(mod(constant(-10), constant(-5))), + constant(0), + `mod(-10, -5)` + ); + }); + + it('double_perfectlyDivisible', () => { + expectEqual( + evaluateToValue(mod(constant(10), constant(2.5))), + constant(0.0), + `mod(10, 2.5)` + ); + expectEqual( + evaluateToValue(mod(constant(10), constant(-2.5))), + constant(0.0), + `mod(10, -2.5)` + ); + expectEqual( + evaluateToValue(mod(constant(-10), constant(2.5))), + constant(-0.0), + `mod(-10, 2.5)` + ); + expectEqual( + evaluateToValue(mod(constant(-10), constant(-2.5))), + constant(-0.0), + `mod(-10, -2.5)` + ); + }); + + it('nonNumerics_returnError', () => { + expect(evaluateToValue(mod(constant(10), constant('1')))).to.be.undefined; + expect(evaluateToValue(mod(constant('1'), constant(10)))).to.be.undefined; + expect(evaluateToValue(mod(constant('1'), constant('1')))).to.be + .undefined; + }); + + it('nan_number_returnNaN', () => { + expectEqual( + evaluateToValue(mod(constant(1), constant(NaN))), + constant(NaN), + `mod(1, NaN)` + ); + expectEqual( + evaluateToValue(mod(constant(1.0), constant(NaN))), + constant(NaN), + `mod(1.0, NaN)` + ); + expectEqual( + evaluateToValue(mod(constant(Number.MAX_SAFE_INTEGER), constant(NaN))), + constant(NaN), + `mod(Number.MAX_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue(mod(constant(Number.MIN_SAFE_INTEGER), constant(NaN))), + constant(NaN), + `mod(Number.MIN_SAFE_INTEGER, NaN)` + ); + expectEqual( + evaluateToValue(mod(constant(Number.MAX_VALUE), constant(NaN))), + constant(NaN), + `mod(Number.MAX_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(mod(constant(Number.MIN_VALUE), constant(NaN))), + constant(NaN), + `mod(Number.MIN_VALUE, NaN)` + ); + expectEqual( + evaluateToValue(mod(constant(Number.POSITIVE_INFINITY), constant(NaN))), + constant(NaN), + `mod(Number.POSITIVE_INFINITY, NaN)` + ); + expectEqual( + evaluateToValue(mod(constant(Number.NEGATIVE_INFINITY), constant(NaN))), + constant(NaN), + `mod(Number.NEGATIVE_INFINITY, NaN)` + ); + }); + + it('nan_notNumberType_returnError', () => { + expect(evaluateToValue(mod(constant(NaN), constant('hello world')))).to.be + .undefined; + }); + + it('number_posInfinity_returnSelf', () => { + expectEqual( + evaluateToValue(mod(constant(1), constant(Number.POSITIVE_INFINITY))), + constant(1.0), + `mod(1, Number.POSITIVE_INFINITY)` + ); + expectEqual( + evaluateToValue( + mod(constant(42.123456789), constant(Number.POSITIVE_INFINITY)) + ), + constant(42.123456789), + `mod(42.123456789, Number.POSITIVE_INFINITY)` + ); + expectEqual( + evaluateToValue( + mod(constant(-99.9), constant(Number.POSITIVE_INFINITY)) + ), + constant(-99.9), + `mod(-99.9, Number.POSITIVE_INFINITY)` + ); + }); + + it('posInfinity_number_returnNaN', () => { + expectEqual( + evaluateToValue(mod(constant(Number.POSITIVE_INFINITY), constant(1))), + constant(NaN), + `mod(Number.POSITIVE_INFINITY, 1)` + ); + expectEqual( + evaluateToValue( + mod(constant(Number.POSITIVE_INFINITY), constant(42.123456789)) + ), + constant(NaN), + `mod(Number.POSITIVE_INFINITY, 42.123456789)` + ); + expectEqual( + evaluateToValue( + mod(constant(Number.POSITIVE_INFINITY), constant(-99.9)) + ), + constant(NaN), + `mod(Number.POSITIVE_INFINITY, -99.9)` + ); + }); + + it('number_negInfinity_returnSelf', () => { + expectEqual( + evaluateToValue(mod(constant(1), constant(Number.NEGATIVE_INFINITY))), + constant(1.0), + `mod(1, Number.NEGATIVE_INFINITY)` + ); + expectEqual( + evaluateToValue( + mod(constant(42.123456789), constant(Number.NEGATIVE_INFINITY)) + ), + constant(42.123456789), + `mod(42.123456789, Number.NEGATIVE_INFINITY)` + ); + expectEqual( + evaluateToValue( + mod(constant(-99.9), constant(Number.NEGATIVE_INFINITY)) + ), + constant(-99.9), + `mod(-99.9, Number.NEGATIVE_INFINITY)` + ); + }); + + it('negInfinity_number_returnNaN', () => { + expectEqual( + evaluateToValue(mod(constant(Number.NEGATIVE_INFINITY), constant(1))), + constant(NaN), + `mod(Number.NEGATIVE_INFINITY, 1)` + ); + expectEqual( + evaluateToValue( + mod(constant(Number.NEGATIVE_INFINITY), constant(42.123456789)) + ), + constant(NaN), + `mod(Number.NEGATIVE_INFINITY, 42.123456789)` + ); + expectEqual( + evaluateToValue( + mod(constant(Number.NEGATIVE_INFINITY), constant(-99.9)) + ), + constant(NaN), + `mod(Number.NEGATIVE_INFINITY, -99.9)` + ); + }); + + it('posAndNegInfinity_returnNaN', () => { + expectEqual( + evaluateToValue( + mod( + constant(Number.POSITIVE_INFINITY), + constant(Number.NEGATIVE_INFINITY) + ) + ), + constant(NaN), + `mod(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY)` + ); + }); + }); // end describe('mod') +}); // end describe('Arithmetic Expressions') diff --git a/packages/firestore/test/unit/core/expressions/array.test.ts b/packages/firestore/test/unit/core/expressions/array.test.ts new file mode 100644 index 00000000000..54c8343199d --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/array.test.ts @@ -0,0 +1,355 @@ +/** + * @license + * Copyright 2025 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 { evaluateToResult, evaluateToValue, expectEqual } from './utils'; +import { + arrayContains, + arrayContainsAll, + arrayContainsAny, + arrayLength, + BooleanExpr, + constant, + field, + not +} from '../../../../src/lite-api/expressions'; +import { constantArray, constantMap } from '../../../util/pipelines'; +import { + FALSE_VALUE, + MIN_VALUE, + TRUE_VALUE +} from '../../../../src/model/values'; +import { EvaluateResult } from '../../../../src/core/expressions'; +import { VectorValue } from '../../../../src'; + +describe('Array Expressions', () => { + describe('arrayContainsAll', () => { + it('containsAll', () => { + expect( + evaluateToValue( + arrayContainsAll( + constantArray([ + '1', + 42, + true, + 'additional', + 'values', + 'in', + 'array' + ]), + [constant('1'), constant(42), constant(true)] + ) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('doesNotContainAll', () => { + expect( + evaluateToValue( + arrayContainsAll(constantArray(['1', 42, true]), [ + constant('1'), + constant(99) + ]) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('equivalentNumerics', () => { + expect( + evaluateToValue( + arrayContainsAll( + constantArray([42, true, 'additional', 'values', 'in', 'array']), + [constant(42.0), constant(true)] + ) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('arrayToSearch_isEmpty', () => { + expect( + evaluateToValue( + arrayContainsAll(constantArray([]), [constant(42.0), constant(true)]) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('searchValue_isEmpty', () => { + expect( + evaluateToValue(arrayContainsAll(constantArray([42.0, true]), [])) + ).to.deep.equal(TRUE_VALUE); + }); + + it('searchValue_isNaN', () => { + expect( + evaluateToValue( + arrayContainsAll(constantArray([NaN, 42.0]), [constant(NaN)]) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('searchValue_hasDuplicates', () => { + expect( + evaluateToValue( + arrayContainsAll(constantArray([true, 'hi']), [ + constant(true), + constant(true), + constant(true) + ]) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('arrayToSearch_isEmpty_searchValue_isEmpty', () => { + expect( + evaluateToValue(arrayContainsAll(constantArray([]), [])) + ).to.deep.equal(TRUE_VALUE); + }); + + it('largeNumberOfElements', () => { + const elements = Array.from({ length: 500 }, (_, i) => i + 1); + expect( + evaluateToValue( + arrayContainsAll( + constantArray(elements), + elements.map(e => constant(e)) + ) + ) + ).to.deep.equal(TRUE_VALUE); + }); + }); + + describe('arrayContainsAny', () => { + const ARRAY_TO_SEARCH = constantArray([42, 'matang', true]); + const SEARCH_VALUES = [constant('matang'), constant(false)]; + + it('valueFoundInArray', () => { + expect( + evaluateToValue(arrayContainsAny(ARRAY_TO_SEARCH, SEARCH_VALUES)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('equivalentNumerics', () => { + expect( + evaluateToValue( + arrayContainsAny(ARRAY_TO_SEARCH, [constant(42.0), constant(2)]) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('valuesNotFoundInArray', () => { + expect( + evaluateToValue( + arrayContainsAny(ARRAY_TO_SEARCH, [constant(99), constant('false')]) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + // TODO(pipeline): Nested arrays are not supported in documents. We need to + // support creating nested arrays as expressions however. + it.skip('bothInputTypeIsArray', () => { + expect( + evaluateToValue( + arrayContainsAny( + constantArray([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ]), + [constantArray([1, 2, 3]), constantArray([4, 5, 6])] + ) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('search_isNull_returnsNull', () => { + expect( + evaluateToResult( + arrayContainsAny(constantArray([null, 1, 'matang', true]), [ + constant(null) + ]) + ) + ).to.deep.equal(EvaluateResult.newNull()); + }); + + it('array_isNotArrayType_returnsError', () => { + expect( + evaluateToValue(arrayContainsAny(constant('matang'), SEARCH_VALUES)) + ).to.be.undefined; + }); + + it('search_isNotArrayType_returnsError', () => { + expect( + evaluateToValue( + arrayContainsAny(constant('values'), [constant('values')]) + ) + ).to.be.undefined; + }); + + it('array_notFound_returnsError', () => { + expect( + evaluateToValue(arrayContainsAny(field('not-exist'), SEARCH_VALUES)) + ).to.be.undefined; + }); + + it('searchNotFound_returnsError', () => { + expect( + evaluateToValue(arrayContainsAny(ARRAY_TO_SEARCH, [field('not-exist')])) + ).to.be.undefined; + }); + }); // end describe('arrayContainsAny') + + describe('arrayContains', () => { + const ARRAY_TO_SEARCH = constantArray([42, 'matang', true]); + + it('valueFoundInArray', () => { + expect( + evaluateToValue( + arrayContains(constantArray(['hello', 'world']), constant('hello')) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('valueNotFoundInArray', () => { + expect( + evaluateToValue(arrayContains(ARRAY_TO_SEARCH, constant(4))) + ).to.deep.equal(FALSE_VALUE); + }); + + it('notArrayContainsFunction_valueNotFoundInArray', () => { + const child = arrayContains(ARRAY_TO_SEARCH, constant(4)); + const f = not(child as BooleanExpr); + expect(evaluateToValue(f)).to.deep.equal(TRUE_VALUE); + }); + + it('equivalentNumerics', () => { + expect( + evaluateToValue(arrayContains(ARRAY_TO_SEARCH, constant(42.0))) + ).to.deep.equal(TRUE_VALUE); + }); + + // TODO(pipeline): Nested arrays are not supported in documents. We need to + // support creating nested arrays as expressions however. + it.skip('bothInputTypeIsArray', () => { + expect( + evaluateToValue( + arrayContains( + constantArray([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ]), + constantArray([1, 2, 3]) + ) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('searchValue_isNull_returnsNull', () => { + expect( + evaluateToValue( + arrayContains( + constantArray([null, 1, 'matang', true]), + constant(null) + ) + ) + ).to.deep.equal(MIN_VALUE); + }); + + it('searchValue_isNull_emptyValuesArray_returnsNull', () => { + expect( + evaluateToValue(arrayContains(constantArray([]), constant(null))) + ).to.deep.equal(MIN_VALUE); + }); + + it('searchValue_isMap', () => { + expect( + evaluateToValue( + arrayContains( + constantArray([123, { foo: 123 }, { bar: 42 }, { foo: 42 }]), + constantMap({ foo: 42 }) + ) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('searchValue_isNaN', () => { + expect( + evaluateToValue( + arrayContains(constantArray([NaN, 'foo']), constant(NaN)) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('arrayToSearch_isNotArrayType_returnsError', () => { + expect( + evaluateToValue(arrayContains(constant('matang'), constant('values'))) + ).to.be.undefined; + }); + + it('arrayToSearch_notFound_returnsError', () => { + expect( + evaluateToValue(arrayContains(field('not-exist'), constant('matang'))) + ).to.be.undefined; + }); + + it('arrayToSearch_isEmpty_returnsFalse', () => { + expect( + evaluateToValue(arrayContains(constantArray([]), constant('matang'))) + ).to.deep.equal(FALSE_VALUE); + }); + + it('searchValue_reference_notFound_returnsError', () => { + expect( + evaluateToValue(arrayContains(ARRAY_TO_SEARCH, field('not-exist'))) + ).to.be.undefined; + }); + }); // end describe('arrayContains') + + describe('arrayLength', () => { + it('length', () => { + expectEqual( + evaluateToValue(arrayLength(constantArray(['1', 42, true]))), + constant(3), + `arrayLength(['1', 42, true])` + ); + }); + + it('emptyArray', () => { + expectEqual( + evaluateToValue(arrayLength(constantArray([]))), + constant(0), + `arrayLength([])` + ); + }); + + it('arrayWithDuplicateElements', () => { + expectEqual( + evaluateToValue(arrayLength(constantArray([true, true]))), + constant(2), + `arrayLength([true, true])` + ); + }); + + it('notArrayType_returnsError', () => { + expect( + evaluateToValue(arrayLength(constant(new VectorValue([0.0, 1.0])))) + ).to.be.undefined; // Assuming double[] is not considered an array + expect(evaluateToValue(arrayLength(constant('notAnArray')))).to.be + .undefined; + }); + }); // end describe('arrayLength') +}); diff --git a/packages/firestore/test/unit/core/expressions/comparison.test.ts b/packages/firestore/test/unit/core/expressions/comparison.test.ts new file mode 100644 index 00000000000..0ce97661e4f --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/comparison.test.ts @@ -0,0 +1,644 @@ +/** + * @license + * Copyright 2025 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 { + constant, + eq, + field, + gt, + gte, + lt, + lte, + neq +} from '../../../../src/lite-api/expressions'; +import { canonifyExpr } from '../../../../src/core/pipeline-util'; +import { FALSE_VALUE, TRUE_VALUE } from '../../../../src/model/values'; +import { EvaluateResult } from '../../../../src/core/expressions'; +import { constantArray, constantMap } from '../../../util/pipelines'; +import { + ComparisonValueTestData, + ERROR_VALUE, + errorExpr, + evaluateToResult, + evaluateToValue +} from './utils'; + +describe('Comparison Expressions', () => { + describe('eq', () => { + it('returns false for lessThan values', () => { + ComparisonValueTestData.lessThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(eq(left, right)), + `eq(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns false for greaterThan values', () => { + ComparisonValueTestData.greaterThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(eq(left, right)), + `eq(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns false for mixedType values', () => { + ComparisonValueTestData.mixedTypeValues().forEach(({ left, right }) => { + expect( + evaluateToValue(eq(left, right)), + `eq(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('null_any_returnsNull', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + expect( + evaluateToResult(eq(constant(null), v)), + `eq(null, ${canonifyExpr(v)})` + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(eq(v, constant(null))), + `eq(${canonifyExpr(v)}, null)` + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + }); + + it('null_null_returnsNull', () => { + expect( + evaluateToResult(eq(constant(null), constant(null))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('Null and missing evaluates to undefined (error)', () => { + expect(evaluateToValue(eq(constant(null), field('not-exist')))).to.be + .undefined; + }); + + it('nullInArray_equality', () => { + expect( + evaluateToValue(eq(constantArray([null]), constant(1))) + ).to.be.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(eq(constantArray([null]), constant('1'))) + ).to.be.deep.equal(FALSE_VALUE); + expect( + evaluateToResult(eq(constantArray([null]), constant(null))) + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToValue(eq(constantArray([null]), constant(NaN))) + ).to.be.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(eq(constantArray([null]), constantArray([]))) + ).to.be.deep.equal(FALSE_VALUE); + expect( + evaluateToResult(eq(constantArray([null]), constantArray([NaN]))) + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(eq(constantArray([null]), constantArray([null]))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('nullInMap_equality_returnsNull', () => { + expect( + evaluateToResult( + eq(constantMap({ foo: null }), constantMap({ foo: null })) + ) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('null_missingInMap_equality_returnsFalse', () => { + expect( + evaluateToValue(eq(constantMap({ foo: null }), constant({}))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + describe('NaN tests', () => { + it('nan_number_returnsFalse', () => { + ComparisonValueTestData.NUMERIC_VALUES.forEach(v => { + expect( + evaluateToValue(eq(constant(NaN), v)), + `eq(NaN, ${canonifyExpr(v)})` + ).to.be.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(eq(v, constant(NaN))), + `eq(${canonifyExpr(v)}, NaN)` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('nan_nan_returnsFalse', () => { + expect( + evaluateToValue(eq(constant(NaN), constant(NaN))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('nan_otherType_returnsFalse', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + // Exclude numeric values as they are already tested above + if (!ComparisonValueTestData.NUMERIC_VALUES.includes(v)) { + expect( + evaluateToValue(eq(constant(NaN), v)), + `eq(NaN, ${canonifyExpr(v)})` + ).to.be.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(eq(v, constant(NaN))), + `eq(${canonifyExpr(v)}, NaN)` + ).to.be.deep.equal(FALSE_VALUE); + } + }); + }); + + it('nanInArray_equality_returnsFalse', () => { + expect( + evaluateToValue(eq(constantArray([NaN]), constantArray([NaN]))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('nanInMap_equality_returnsFalse', () => { + expect( + evaluateToValue( + eq(constantMap({ foo: NaN }), constantMap({ foo: NaN })) + ) + ).to.be.deep.equal(FALSE_VALUE); + }); + }); // end describe NaN tests + + describe('Array tests', () => { + it('array_ambiguousNumerics', () => { + expect( + evaluateToValue(eq(constantArray([1]), constantArray([1.0]))) + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + describe('Map tests', () => { + it('map_ambiguousNumerics', () => { + expect( + evaluateToValue( + eq( + constantMap({ foo: 1, bar: 42.0 }), + constantMap({ bar: 42, foo: 1.0 }) + ) + ) + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + describe('Error tests', () => { + it('error_any_returnsError', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + expect(evaluateToValue(eq(errorExpr(), v))).to.be.deep.equal( + ERROR_VALUE + ); + expect(evaluateToValue(eq(v, errorExpr()))).to.be.deep.equal( + ERROR_VALUE + ); + }); + }); + + it('error_error_returnsError', () => { + expect(evaluateToValue(eq(errorExpr(), errorExpr()))).to.be.deep.equal( + ERROR_VALUE + ); + }); + + it('error_null_returnsError', () => { + expect( + evaluateToValue(eq(errorExpr(), constant(null))) + ).to.be.deep.equal(ERROR_VALUE); + }); + }); // end describe Error tests + }); + + describe('gte', () => { + it('returns false for lessThan values', () => { + ComparisonValueTestData.lessThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(gte(left, right)), + `gte(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns true for greaterThan values', () => { + ComparisonValueTestData.greaterThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(gte(left, right)), + `gte(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + it('returns false for mixedType values', () => { + ComparisonValueTestData.mixedTypeValues().forEach(({ left, right }) => { + expect( + evaluateToValue(gte(left, right)), + `gte(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('null_any_returnsNull', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + expect( + evaluateToResult(gte(constant(null), v)), + `gte(null, ${canonifyExpr(v)})` + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(gte(v, constant(null))), + `gte(${canonifyExpr(v)}, null)` + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + }); + + it('null_null_returnsTrue', () => { + expect( + evaluateToResult(gte(constant(null), constant(null))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('nan_number_returnsFalse', () => { + ComparisonValueTestData.NUMERIC_VALUES.forEach(v => { + expect( + evaluateToValue(gte(constant(NaN), v)), + `gte(NaN, ${canonifyExpr(v)})` + ).to.be.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(gte(v, constant(NaN))), + `gte(${canonifyExpr(v)}, NaN)` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('nan_nan_returnsFalse', () => { + expect( + evaluateToValue(gte(constant(NaN), constant(NaN))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('nanInArray_returnsFalse', () => { + expect( + evaluateToValue(gte(constantArray([NaN]), constantArray([NaN]))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('referenceFieldNotFound_returnsError', () => { + // Adapt as needed for references + expect(evaluateToValue(gte(field('not-exist'), constant(1)))).to.be + .undefined; // Or appropriate error handling + }); + }); // end describe('gte') + + describe('gt', () => { + it('returns false for equal values', () => { + ComparisonValueTestData.equivalentValues().forEach(({ left, right }) => { + expect( + evaluateToValue(gt(left, right)), + `gt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns false for lessThan values', () => { + ComparisonValueTestData.lessThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(gt(left, right)), + `gt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns true for greaterThan values', () => { + ComparisonValueTestData.greaterThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(gt(left, right)), + `gt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + it('returns false for mixedType values', () => { + ComparisonValueTestData.mixedTypeValues().forEach(({ left, right }) => { + expect( + evaluateToValue(gt(left, right)), + `gt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('null_any_returnsNull', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + expect( + evaluateToResult(gt(constant(null), v)), + `gt(null, ${canonifyExpr(v)})` + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(gt(v, constant(null))), + `gt(${canonifyExpr(v)}, null)` + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + }); + + it('null_null_returnsFalse', () => { + expect( + evaluateToResult(gt(constant(null), constant(null))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('nan_number_returnsFalse', () => { + ComparisonValueTestData.NUMERIC_VALUES.forEach(v => { + expect(evaluateToValue(gt(constant(NaN), v))).to.be.deep.equal( + FALSE_VALUE + ); + expect(evaluateToValue(gt(v, constant(NaN)))).to.be.deep.equal( + FALSE_VALUE + ); + }); + }); + + it('nan_nan_returnsFalse', () => { + expect( + evaluateToValue(gt(constant(NaN), constant(NaN))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('nanInArray_returnsFalse', () => { + expect( + evaluateToValue(gt(constantArray([NaN]), constantArray([NaN]))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('referenceFieldNotFound_returnsError', () => { + // Adapt as needed for references + expect(evaluateToValue(gt(field('not-exist'), constant(1)))).to.be + .undefined; // Or appropriate error handling + }); + }); // end describe('gt') + + describe('lte', () => { + it('returns true for lessThan values', () => { + ComparisonValueTestData.lessThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(lte(left, right)), + `lte(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + it('returns false for greaterThan values', () => { + ComparisonValueTestData.greaterThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(lte(left, right)), + `lte(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns false for mixedType values', () => { + ComparisonValueTestData.mixedTypeValues().forEach(({ left, right }) => { + expect( + evaluateToValue(lte(left, right)), + `lte(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('null_any_returnsNull', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + expect( + evaluateToResult(lte(constant(null), v)), + `lte(null, ${canonifyExpr(v)})` + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(lte(v, constant(null))), + `lte(${canonifyExpr(v)}, null)` + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + }); + + it('null_null_returnsNull', () => { + expect( + evaluateToResult(lte(constant(null), constant(null))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('nan_number_returnsFalse', () => { + ComparisonValueTestData.NUMERIC_VALUES.forEach(v => { + expect(evaluateToValue(lte(constant(NaN), v))).to.be.deep.equal( + FALSE_VALUE + ); + expect(evaluateToValue(lte(v, constant(NaN)))).to.be.deep.equal( + FALSE_VALUE + ); + }); + }); + + it('nan_nan_returnsFalse', () => { + expect( + evaluateToValue(lte(constant(NaN), constant(NaN))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('nanInArray_returnsFalse', () => { + expect( + evaluateToValue(lte(constantArray([NaN]), constantArray([NaN]))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('referenceFieldNotFound_returnsError', () => { + // Adapt as needed for references + expect(evaluateToValue(lte(field('not-exist'), constant(1)))).to.be + .undefined; // Or appropriate error handling + }); + }); // end describe('lte') + + describe('lt', () => { + it('returns false for equal values', () => { + ComparisonValueTestData.equivalentValues().forEach(({ left, right }) => { + expect( + evaluateToValue(lt(left, right)), + `lt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns true for lessThan values', () => { + ComparisonValueTestData.lessThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(lt(left, right)), + `lt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + it('returns false for greaterThan values', () => { + ComparisonValueTestData.greaterThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(lt(left, right)), + `lt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('returns false for mixedType values', () => { + ComparisonValueTestData.mixedTypeValues().forEach(({ left, right }) => { + expect( + evaluateToValue(lt(left, right)), + `lt(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(FALSE_VALUE); + }); + }); + + it('null_any_returnsNull', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + expect( + evaluateToResult(lt(constant(null), v)), + `lt(null, ${canonifyExpr(v)})` + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(lt(v, constant(null))), + `lt(${canonifyExpr(v)}, null)` + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + }); + + it('null_null_returnsNull', () => { + expect( + evaluateToResult(lt(constant(null), constant(null))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('nan_number_returnsFalse', () => { + ComparisonValueTestData.NUMERIC_VALUES.forEach(v => { + expect(evaluateToValue(lt(constant(NaN), v))).to.be.deep.equal( + FALSE_VALUE + ); + expect(evaluateToValue(lt(v, constant(NaN)))).to.be.deep.equal( + FALSE_VALUE + ); + }); + }); + + it('nan_nan_returnsFalse', () => { + expect( + evaluateToValue(lt(constant(NaN), constant(NaN))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('nanInArray_returnsFalse', () => { + expect( + evaluateToValue(lt(constantArray([NaN]), constantArray([NaN]))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('referenceFieldNotFound_returnsError', () => { + // Adapt as needed for references + expect(evaluateToValue(lt(field('not-exist'), constant(1)))).to.be + .undefined; // Or appropriate error handling + }); + }); // end describe('lt') + + describe('neq', () => { + it('returns true for lessThan values', () => { + ComparisonValueTestData.lessThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(neq(left, right)), + `neq(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + it('returns true for greaterThan values', () => { + ComparisonValueTestData.greaterThanValues().forEach(({ left, right }) => { + expect( + evaluateToValue(neq(left, right)), + `neq(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + it('returns true for mixedType values', () => { + ComparisonValueTestData.mixedTypeValues().forEach(({ left, right }) => { + expect( + evaluateToValue(neq(left, right)), + `neq(${canonifyExpr(left)}, ${canonifyExpr(right)})` + ).to.be.deep.equal(TRUE_VALUE); + }); + }); + + it('null_any_returnsNull', () => { + expect( + evaluateToResult(neq(constant(null), constant(42))) + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(neq(constant(null), constant('matang'))) + ).to.be.deep.equal(EvaluateResult.newNull()); + expect( + evaluateToResult(neq(constant(null), constant(true))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('null_null_returnsNull', () => { + expect( + evaluateToResult(neq(constant(null), constant(null))) + ).to.be.deep.equal(EvaluateResult.newNull()); + }); + + it('nan_number_returnsTrue', () => { + ComparisonValueTestData.NUMERIC_VALUES.forEach(v => { + expect(evaluateToValue(neq(constant(NaN), v))).to.be.deep.equal( + TRUE_VALUE + ); + expect(evaluateToValue(neq(v, constant(NaN)))).to.be.deep.equal( + TRUE_VALUE + ); + }); + }); + + it('nan_nan_returnsTrue', () => { + expect( + evaluateToValue(neq(constant(NaN), constant(NaN))) + ).to.be.deep.equal(TRUE_VALUE); + }); + + it('map_ambiguousNumerics', () => { + expect( + evaluateToValue( + neq( + constantMap({ foo: 1, bar: 42.0 }), + constantMap({ foo: 1.0, bar: 42 }) + ) + ) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('array_ambiguousNumerics', () => { + expect( + evaluateToValue(neq(constantArray([1]), constantArray([1.0]))) + ).to.be.deep.equal(FALSE_VALUE); + }); + + it('referenceFieldNotFound_returnsError', () => { + expect(evaluateToValue(neq(field('not-exist'), constant(1)))).to.be + .undefined; // Or appropriate error handling + }); + }); // end describe('neq') +}); diff --git a/packages/firestore/test/unit/core/expressions/debug.test.ts b/packages/firestore/test/unit/core/expressions/debug.test.ts new file mode 100644 index 00000000000..17e63f18285 --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/debug.test.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 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 { + ComparisonValueTestData, + errorExpr, + evaluateToValue, + UNSET_VALUE +} from './utils'; +import { constant, exists, not } from '../../../../src/lite-api/expressions'; +import { canonifyExpr } from '../../../../src/core/pipeline-util'; +import { FALSE_VALUE, TRUE_VALUE } from '../../../../src/model/values'; +import { constantArray, constantMap } from '../../../util/pipelines'; + +describe('Debugging Functions', () => { + describe('exists', () => { + it('anythingButUnset_returnsTrue', () => { + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.forEach(v => { + expect( + evaluateToValue(exists(v)), + `exists(${canonifyExpr(v)})` + ).to.deep.equal(TRUE_VALUE); + }); + }); + + it('null_returnsTrue', () => { + expect(evaluateToValue(exists(constant(null)))).to.deep.equal(TRUE_VALUE); + }); + + it('error_returnsError', () => { + expect(evaluateToValue(exists(errorExpr()))).to.be.undefined; + }); + + it('unset_withNotExists_returnsTrue', () => { + const functionExpr = exists(UNSET_VALUE); + expect(evaluateToValue(not(functionExpr))).to.deep.equal(TRUE_VALUE); + }); + + it('unset_returnsFalse', () => { + expect(evaluateToValue(exists(UNSET_VALUE))).to.deep.equal(FALSE_VALUE); + }); + + it('emptyArray_returnsTrue', () => { + expect(evaluateToValue(exists(constantArray([])))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('emptyMap_returnsTrue', () => { + expect(evaluateToValue(exists(constantMap({})))).to.deep.equal( + TRUE_VALUE + ); + }); + }); +}); diff --git a/packages/firestore/test/unit/core/expressions/field.test.ts b/packages/firestore/test/unit/core/expressions/field.test.ts new file mode 100644 index 00000000000..319610fd8db --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/field.test.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 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 { + constant, + eq, + field, + gt, + gte, + lt, + lte, + neq +} from '../../../../src/lite-api/expressions'; +import { evaluateToResult, evaluateToValue } from './utils'; +import { EvaluateResult } from '../../../../src/core/expressions'; +import { TRUE_VALUE } from '../../../../src/model/values'; + +describe('Field expression', () => { + it('can get field', () => { + expect(evaluateToValue(field('exists'), { exists: true })).to.deep.equal( + TRUE_VALUE + ); + }); + + it('error if not found', () => { + expect(evaluateToResult(field('not-exists'))).to.deep.equal( + EvaluateResult.newUnset() + ); + }); +}); diff --git a/packages/firestore/test/unit/core/expressions/logical.test.ts b/packages/firestore/test/unit/core/expressions/logical.test.ts new file mode 100644 index 00000000000..94915a0ff38 --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/logical.test.ts @@ -0,0 +1,1219 @@ +/** + * @license + * Copyright 2025 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 { + ComparisonValueTestData, + errorExpr, + errorFilterCondition, + evaluateToValue, + expectEqual, + falseExpr, + trueExpr +} from './utils'; +import { + and, + constant, + eqAny, + isNan, + isNotNan, + not, + or, + xor, + field, + logicalMaximum, + logicalMinimum, + cond, + add, + isNull, + isNotNull +} from '../../../../src/lite-api/expressions'; +import { + FALSE_VALUE, + MIN_VALUE, + TRUE_VALUE, + valueEquals +} from '../../../../src/model/values'; +import { constantArray, constantMap } from '../../../util/pipelines'; +import { canonifyExpr } from '../../../../src/core/pipeline-util'; + +describe('Logical Functions', () => { + describe('and', () => { + it('false_false_isFalse', () => { + expect(evaluateToValue(and(falseExpr, falseExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('false_error_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, errorFilterCondition())) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_true_isFalse', () => { + expect(evaluateToValue(and(falseExpr, trueExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('error_false_isFalse', () => { + expect( + evaluateToValue(and(errorFilterCondition(), falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('error_error_isError', () => { + expect( + evaluateToValue(and(errorFilterCondition(), errorFilterCondition())) + ).to.be.undefined; + }); + + it('error_true_isError', () => { + expect(evaluateToValue(and(errorFilterCondition(), trueExpr))).to.be + .undefined; + }); + + it('true_false_isFalse', () => { + expect(evaluateToValue(and(trueExpr, falseExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('true_error_isError', () => { + expect(evaluateToValue(and(trueExpr, errorFilterCondition()))).to.be + .undefined; + }); + + it('true_true_isTrue', () => { + expect(evaluateToValue(and(trueExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('false_false_false_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, falseExpr, falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_false_error_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, falseExpr, errorFilterCondition())) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_false_true_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, falseExpr, trueExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_error_false_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, errorFilterCondition(), falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_error_error_isFalse', () => { + expect( + evaluateToValue( + and(falseExpr, errorFilterCondition(), errorFilterCondition()) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_error_true_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, errorFilterCondition(), trueExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_true_false_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, trueExpr, falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_true_error_isFalse', () => { + expect( + evaluateToValue(and(falseExpr, trueExpr, errorFilterCondition())) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_true_true_isFalse', () => { + expect(evaluateToValue(and(falseExpr, trueExpr, trueExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('error_false_false_isFalse', () => { + expect( + evaluateToValue(and(errorFilterCondition(), falseExpr, falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('error_false_error_isFalse', () => { + expect( + evaluateToValue( + and(errorFilterCondition(), falseExpr, errorFilterCondition()) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('error_false_true_isFalse', () => { + expect( + evaluateToValue(and(errorFilterCondition(), falseExpr, trueExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('error_error_false_isFalse', () => { + expect( + evaluateToValue( + and(errorFilterCondition(), errorFilterCondition(), falseExpr) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('error_error_error_isError', () => { + expect( + evaluateToValue( + and( + errorFilterCondition(), + errorFilterCondition(), + errorFilterCondition() + ) + ) + ).to.be.undefined; + }); + + it('error_error_true_isError', () => { + expect( + evaluateToValue( + and(errorFilterCondition(), errorFilterCondition(), trueExpr) + ) + ).to.be.undefined; + }); + + it('error_true_false_isFalse', () => { + expect( + evaluateToValue(and(errorFilterCondition(), trueExpr, falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('error_true_error_isError', () => { + expect( + evaluateToValue( + and(errorFilterCondition(), trueExpr, errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('error_true_true_isError', () => { + expect(evaluateToValue(and(errorFilterCondition(), trueExpr, trueExpr))) + .to.be.undefined; + }); + + it('true_false_false_isFalse', () => { + expect( + evaluateToValue(and(trueExpr, falseExpr, falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('true_false_error_isFalse', () => { + expect( + evaluateToValue(and(trueExpr, falseExpr, errorFilterCondition())) + ).to.deep.equal(FALSE_VALUE); + }); + + it('true_false_true_isFalse', () => { + expect(evaluateToValue(and(trueExpr, falseExpr, trueExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('true_error_false_isFalse', () => { + expect( + evaluateToValue(and(trueExpr, errorFilterCondition(), falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('true_error_error_isError', () => { + expect( + evaluateToValue( + and(trueExpr, errorFilterCondition(), errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('true_error_true_isError', () => { + expect(evaluateToValue(and(trueExpr, errorFilterCondition(), trueExpr))) + .to.be.undefined; + }); + + it('true_true_false_isFalse', () => { + expect(evaluateToValue(and(trueExpr, trueExpr, falseExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('true_true_error_isError', () => { + expect(evaluateToValue(and(trueExpr, trueExpr, errorFilterCondition()))) + .to.be.undefined; + }); + + it('true_true_true_isTrue', () => { + expect(evaluateToValue(and(trueExpr, trueExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('nested_and', () => { + const child = and(trueExpr, falseExpr); + const f = and(child, trueExpr); + expect(evaluateToValue(f)).to.deep.equal(FALSE_VALUE); + }); + + it('multipleArguments', () => { + expect(evaluateToValue(and(trueExpr, trueExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + }); // end describe('and') + + describe('cond', () => { + it('trueCondition_returnsTrueCase', () => { + const func = cond(trueExpr, constant('true case'), errorExpr()); + expect(evaluateToValue(func)).to.deep.equal({ + stringValue: 'true case' + }); + }); + + it('falseCondition_returnsFalseCase', () => { + const func = cond(falseExpr, errorExpr(), constant('false case')); + expect(evaluateToValue(func)).to.deep.equal({ + stringValue: 'false case' + }); + }); + + it('errorCondition_returnsFalseCase', () => { + const func = cond(errorFilterCondition(), errorExpr(), constant('false')); + expect(evaluateToValue(func)).to.be.undefined; + }); + }); // end describe('cond') + + describe('eqAny', () => { + it('valueFoundInArray', () => { + expect( + evaluateToValue( + eqAny(constant('hello'), [constant('hello'), constant('world')]) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('valueNotFoundInArray', () => { + expect( + evaluateToValue( + eqAny(constant(4), [constant(42), constant('matang'), constant(true)]) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('notEqAnyFunction_valueNotFoundInArray', () => { + const child = eqAny(constant(4), [ + constant(42), + constant('matang'), + constant(true) + ]); + const f = not(child); + expect(evaluateToValue(f)).to.deep.equal(TRUE_VALUE); + }); + + it('equivalentNumerics', () => { + expect( + evaluateToValue( + eqAny(constant(42), [ + constant(42.0), + constant('matang'), + constant(true) + ]) + ) + ).to.deep.equal(TRUE_VALUE); + expect( + evaluateToValue( + eqAny(constant(42.0), [ + constant(42), + constant('matang'), + constant(true) + ]) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('bothInputTypeIsArray', () => { + expect( + evaluateToValue( + eqAny(constantArray([1, 2, 3]), [ + constantArray([1, 2, 3]), + constantArray([4, 5, 6]), + constantArray([7, 8, 9]) + ]) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('array_notFound_returnsError', () => { + expect(evaluateToValue(eqAny(constant('matang'), [field('not-exist')]))) + .to.be.undefined; + }); + + it('array_isEmpty_returnsFalse', () => { + expect(evaluateToValue(eqAny(constant(42), []))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('search_reference_notFound_returnsError', () => { + expect( + evaluateToValue( + eqAny(field('not-exist'), [ + constant(42), + constant('matang'), + constant(true) + ]) + ) + ).to.be.undefined; + }); + + it('search_isNull', () => { + expect( + evaluateToValue( + eqAny(constant(null), [ + constant(null), + constant(1), + constant('matang'), + constant(true) + ]) + ) + ).to.deep.equal(MIN_VALUE); + }); + + it('search_isNull_emptyValuesArray_returnsFalse', () => { + expect(evaluateToValue(eqAny(constant(null), []))).to.deep.equal( + MIN_VALUE + ); + }); + + it('search_isNaN', () => { + expect( + evaluateToValue( + eqAny(constant(NaN), [constant(NaN), constant(42), constant(3.14)]) + ) + ).to.deep.equal(FALSE_VALUE); + }); + + it('search_isEmpty_array_isEmpty', () => { + expect(evaluateToValue(eqAny(constantArray([]), []))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('search_isEmpty_array_containsEmptyArray_returnsTrue', () => { + expect( + evaluateToValue(eqAny(constantArray([]), [constantArray([])])) + ).to.deep.equal(TRUE_VALUE); + }); + + it('search_isMap', () => { + expect( + evaluateToValue( + eqAny(constantMap({ foo: 42 }), [ + constant(123), + constantMap({ foo: 123 }), + constantMap({ bar: 42 }), + constantMap({ foo: 42 }) + ]) + ) + ).to.deep.equal(TRUE_VALUE); + }); + }); // end describe('eqAny') + + describe('isNaN', () => { + it('nan_returnsTrue', () => { + expect(evaluateToValue(isNan(constant(NaN)))).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(isNan(field('nanValue')))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('notNan_returnsFalse', () => { + expect(evaluateToValue(isNan(constant(42.0)))).to.deep.equal(FALSE_VALUE); + expect(evaluateToValue(isNan(constant(42)))).to.deep.equal(FALSE_VALUE); + }); + + it('isNotNan', () => { + expect(evaluateToValue(isNotNan(constant(42.0)))).to.deep.equal( + TRUE_VALUE + ); + expect(evaluateToValue(isNotNan(constant(42)))).to.deep.equal(TRUE_VALUE); + }); + + it('otherNanRepresentations_returnsTrue', () => { + const v1 = NaN; // In JS, any operation with NaN results in NaN + expect(Number.isNaN(v1)).to.be.true; + expect(evaluateToValue(isNan(constant(v1)))).to.deep.equal(TRUE_VALUE); + + expect( + evaluateToValue( + isNan( + add( + constant(Number.POSITIVE_INFINITY), + constant(Number.NEGATIVE_INFINITY) + ) + ) + ) + ).to.deep.equal(TRUE_VALUE); + + expect( + evaluateToValue(isNan(add(constant(NaN), constant(1)))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('nonNumeric_returnsError', () => { + expect(evaluateToValue(isNan(constant(true)))).to.be.undefined; + expect(evaluateToValue(isNan(constant('abc')))).to.be.undefined; + }); + }); // end describe('isNaN') + + describe('logicalMaximum', () => { + it('numericType', () => { + expectEqual( + evaluateToValue( + logicalMaximum( + constant(1), + logicalMaximum(constant(2.0), constant(3)) + ) + ), + constant(3), + `logicalMaximum(1, logicalMaximum(2.0, 3))` + ); + }); + + it('stringType', () => { + expectEqual( + evaluateToValue( + logicalMaximum( + logicalMaximum(constant('a'), constant('b')), + constant('c') + ) + ), + constant('c'), + `logicalMaximum(logicalMaximum('a', 'b'), 'c')` + ); + }); + + it('mixedType', () => { + expectEqual( + evaluateToValue( + logicalMaximum( + constant(1), + logicalMaximum(constant('1'), constant(0)) + ) + ), + constant('1'), + `logicalMaximum(1, logicalMaximum('1', 0))` + ); + }); + + it('onlyNullAndError_returnsNull', () => { + expectEqual( + evaluateToValue(logicalMaximum(constant(null), errorExpr())), + constant(null), + `logicalMaximum(null, ERROR_VALUE)` + ); + }); + + it('nanAndNumbers', () => { + expectEqual( + evaluateToValue(logicalMaximum(constant(NaN), constant(0))), + constant(0), + `logicalMaximum(NaN, 0)` + ); + }); + + it('errorInput_skip', () => { + expectEqual( + evaluateToValue(logicalMaximum(errorExpr(), constant(1))), + constant(1), + `logicalMaximum(ERROR_VALUE, 1)` + ); + }); + + it('nullInput_skip', () => { + expectEqual( + evaluateToValue(logicalMaximum(constant(null), constant(1))), + constant(1), + `logicalMaximum(null, 1)` + ); + }); + + it('equivalent_numerics', () => { + expectEqual( + evaluateToValue(logicalMaximum(constant(1), constant(1.0))), + constant(1), + `logicalMaximum(1, 1.0)` + ); + }); + }); // end describe('logicalMaximum') + + describe('logicalMinimum', () => { + it('numericType', () => { + expectEqual( + evaluateToValue( + logicalMinimum( + constant(1), + logicalMinimum(constant(2.0), constant(3)) + ) + ), + constant(1), + `logicalMinimum(1, logicalMinimum(2.0, 3))` + ); + }); + + it('stringType', () => { + expectEqual( + evaluateToValue( + logicalMinimum( + logicalMinimum(constant('a'), constant('b')), + constant('c') + ) + ), + constant('a'), + `logicalMinimum(logicalMinimum('a', 'b'), 'c')` + ); + }); + + it('mixedType', () => { + expectEqual( + evaluateToValue( + logicalMinimum( + constant(1), + logicalMinimum(constant('1'), constant(0)) + ) + ), + constant(0), + `logicalMinimum(1, logicalMinimum('1', 0))` + ); + }); + + it('onlyNullAndError_returnsNull', () => { + expectEqual( + evaluateToValue(logicalMinimum(constant(null), errorExpr())), + constant(null), + `logicalMinimum(null, ERROR_VALUE)` + ); + }); + + it('nanAndNumbers', () => { + expectEqual( + evaluateToValue(logicalMinimum(constant(NaN), constant(0))), + constant(NaN), + `logicalMinimum(NaN, 0)` + ); + }); + + it('errorInput_skip', () => { + expectEqual( + evaluateToValue(logicalMinimum(errorExpr(), constant(1))), + constant(1), + `logicalMinimum(ERROR_VALUE, 1)` + ); + }); + + it('nullInput_skip', () => { + expectEqual( + evaluateToValue(logicalMinimum(constant(null), constant(1))), + constant(1), + `logicalMinimum(null, 1)` + ); + }); + + it('equivalent_numerics', () => { + expectEqual( + evaluateToValue(logicalMinimum(constant(1), constant(1.0))), + constant(1), + `logicalMinimum(1, 1.0)` + ); + }); + }); // end describe('logicalMinimum') + + describe('not', () => { + it('true_to_false', () => { + expect(evaluateToValue(not(constant(1).eq(1)))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('false_to_true', () => { + expect(evaluateToValue(not(constant(1).neq(1)))).to.deep.equal( + TRUE_VALUE + ); + }); + }); // end describe('not') + + describe('or', () => { + it('false_false_isFalse', () => { + expect(evaluateToValue(or(falseExpr, falseExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('false_error_isError', () => { + expect(evaluateToValue(or(falseExpr, errorFilterCondition()))).to.be + .undefined; + }); + + it('false_true_isTrue', () => { + expect(evaluateToValue(or(falseExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('error_false_isError', () => { + expect(evaluateToValue(or(errorFilterCondition(), falseExpr))).to.be + .undefined; + }); + + it('error_error_isError', () => { + expect( + evaluateToValue(or(errorFilterCondition(), errorFilterCondition())) + ).to.be.undefined; + }); + + it('error_true_isTrue', () => { + expect( + evaluateToValue(or(errorFilterCondition(), trueExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_false_isTrue', () => { + expect(evaluateToValue(or(trueExpr, falseExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('true_error_isTrue', () => { + expect( + evaluateToValue(or(trueExpr, errorFilterCondition())) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_true_isTrue', () => { + expect(evaluateToValue(or(trueExpr, trueExpr))).to.deep.equal(TRUE_VALUE); + }); + + it('false_false_false_isFalse', () => { + expect( + evaluateToValue(or(falseExpr, falseExpr, falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_false_error_isError', () => { + expect(evaluateToValue(or(falseExpr, falseExpr, errorFilterCondition()))) + .to.be.undefined; + }); + + it('false_false_true_isTrue', () => { + expect(evaluateToValue(or(falseExpr, falseExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('false_error_false_isError', () => { + expect(evaluateToValue(or(falseExpr, errorFilterCondition(), falseExpr))) + .to.be.undefined; + }); + + it('false_error_error_isError', () => { + expect( + evaluateToValue( + or(falseExpr, errorFilterCondition(), errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('false_error_true_isTrue', () => { + expect( + evaluateToValue(or(falseExpr, errorFilterCondition(), trueExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('false_true_false_isTrue', () => { + expect(evaluateToValue(or(falseExpr, trueExpr, falseExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('false_true_error_isTrue', () => { + expect( + evaluateToValue(or(falseExpr, trueExpr, errorFilterCondition())) + ).to.deep.equal(TRUE_VALUE); + }); + + it('false_true_true_isTrue', () => { + expect(evaluateToValue(or(falseExpr, trueExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('error_false_false_isError', () => { + expect(evaluateToValue(or(errorFilterCondition(), falseExpr, falseExpr))) + .to.be.undefined; + }); + + it('error_false_error_isError', () => { + expect( + evaluateToValue( + or(errorFilterCondition(), falseExpr, errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('error_false_true_isTrue', () => { + expect( + evaluateToValue(or(errorFilterCondition(), falseExpr, trueExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('error_error_false_isError', () => { + expect( + evaluateToValue( + or(errorFilterCondition(), errorFilterCondition(), falseExpr) + ) + ).to.be.undefined; + }); + + it('error_error_error_isError', () => { + expect( + evaluateToValue( + or( + errorFilterCondition(), + errorFilterCondition(), + errorFilterCondition() + ) + ) + ).to.be.undefined; + }); + + it('error_error_true_isTrue', () => { + expect( + evaluateToValue( + or(errorFilterCondition(), errorFilterCondition(), trueExpr) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('error_true_false_isTrue', () => { + expect( + evaluateToValue(or(errorFilterCondition(), trueExpr, falseExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('error_true_error_isTrue', () => { + expect( + evaluateToValue( + or(errorFilterCondition(), trueExpr, errorFilterCondition()) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('error_true_true_isTrue', () => { + expect( + evaluateToValue(or(errorFilterCondition(), trueExpr, trueExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_false_false_isTrue', () => { + expect(evaluateToValue(or(trueExpr, falseExpr, falseExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('true_false_error_isTrue', () => { + expect( + evaluateToValue(or(trueExpr, falseExpr, errorFilterCondition())) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_false_true_isTrue', () => { + expect(evaluateToValue(or(trueExpr, falseExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('true_error_false_isTrue', () => { + expect( + evaluateToValue(or(trueExpr, errorFilterCondition(), falseExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_error_error_isTrue', () => { + expect( + evaluateToValue( + or(trueExpr, errorFilterCondition(), errorFilterCondition()) + ) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_error_true_isTrue', () => { + expect( + evaluateToValue(or(trueExpr, errorFilterCondition(), trueExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_true_false_isTrue', () => { + expect(evaluateToValue(or(trueExpr, trueExpr, falseExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('true_true_error_isTrue', () => { + expect( + evaluateToValue(or(trueExpr, trueExpr, errorFilterCondition())) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_true_true_isTrue', () => { + expect(evaluateToValue(or(trueExpr, trueExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('nested_or', () => { + const child = or(trueExpr, falseExpr); + const f = or(child, falseExpr); + expect(evaluateToValue(f)).to.deep.equal(TRUE_VALUE); + }); + + it('multipleArguments', () => { + expect(evaluateToValue(or(trueExpr, falseExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + }); // end describe('or') + + describe('xor', () => { + it('false_false_isFalse', () => { + expect(evaluateToValue(xor(falseExpr, falseExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('false_error_isError', () => { + expect(evaluateToValue(xor(falseExpr, errorFilterCondition()))).to.be + .undefined; + }); + + it('false_true_isTrue', () => { + expect(evaluateToValue(xor(falseExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('error_false_isError', () => { + expect(evaluateToValue(xor(errorFilterCondition(), falseExpr))).to.be + .undefined; + }); + + it('error_error_isError', () => { + expect( + evaluateToValue(xor(errorFilterCondition(), errorFilterCondition())) + ).to.be.undefined; + }); + + it('error_true_isError', () => { + expect(evaluateToValue(xor(errorFilterCondition(), trueExpr))).to.be + .undefined; + }); + + it('true_false_isTrue', () => { + expect(evaluateToValue(xor(trueExpr, falseExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('true_error_isError', () => { + expect(evaluateToValue(xor(trueExpr, errorFilterCondition()))).to.be + .undefined; + }); + + it('true_true_isFalse', () => { + expect(evaluateToValue(xor(trueExpr, trueExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('false_false_false_isFalse', () => { + expect( + evaluateToValue(xor(falseExpr, falseExpr, falseExpr)) + ).to.deep.equal(FALSE_VALUE); + }); + + it('false_false_error_isError', () => { + expect(evaluateToValue(xor(falseExpr, falseExpr, errorFilterCondition()))) + .to.be.undefined; + }); + + it('false_false_true_isTrue', () => { + expect( + evaluateToValue(xor(falseExpr, falseExpr, trueExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('false_error_false_isError', () => { + expect(evaluateToValue(xor(falseExpr, errorFilterCondition(), falseExpr))) + .to.be.undefined; + }); + + it('false_error_error_isError', () => { + expect( + evaluateToValue( + xor(falseExpr, errorFilterCondition(), errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('false_error_true_isError', () => { + expect(evaluateToValue(xor(falseExpr, errorFilterCondition(), trueExpr))) + .to.be.undefined; + }); + + it('false_true_false_isTrue', () => { + expect( + evaluateToValue(xor(falseExpr, trueExpr, falseExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('false_true_error_isError', () => { + expect(evaluateToValue(xor(falseExpr, trueExpr, errorFilterCondition()))) + .to.be.undefined; + }); + + it('false_true_true_isFalse', () => { + expect(evaluateToValue(xor(falseExpr, trueExpr, trueExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('error_false_false_isError', () => { + expect(evaluateToValue(xor(errorFilterCondition(), falseExpr, falseExpr))) + .to.be.undefined; + }); + + it('error_false_error_isError', () => { + expect( + evaluateToValue( + xor(errorFilterCondition(), falseExpr, errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('error_false_true_isError', () => { + expect(evaluateToValue(xor(errorFilterCondition(), falseExpr, trueExpr))) + .to.be.undefined; + }); + + it('error_error_false_isError', () => { + expect( + evaluateToValue( + xor(errorFilterCondition(), errorFilterCondition(), falseExpr) + ) + ).to.be.undefined; + }); + + it('error_error_error_isError', () => { + expect( + evaluateToValue( + xor( + errorFilterCondition(), + errorFilterCondition(), + errorFilterCondition() + ) + ) + ).to.be.undefined; + }); + + it('error_error_true_isError', () => { + expect( + evaluateToValue( + xor(errorFilterCondition(), errorFilterCondition(), trueExpr) + ) + ).to.be.undefined; + }); + + it('error_true_false_isError', () => { + expect(evaluateToValue(xor(errorFilterCondition(), trueExpr, falseExpr))) + .to.be.undefined; + }); + + it('error_true_error_isError', () => { + expect( + evaluateToValue( + xor(errorFilterCondition(), trueExpr, errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('error_true_true_isError', () => { + expect(evaluateToValue(xor(errorFilterCondition(), trueExpr, trueExpr))) + .to.be.undefined; + }); + + it('true_false_false_isTrue', () => { + expect( + evaluateToValue(xor(trueExpr, falseExpr, falseExpr)) + ).to.deep.equal(TRUE_VALUE); + }); + + it('true_false_error_isError', () => { + expect(evaluateToValue(xor(trueExpr, falseExpr, errorFilterCondition()))) + .to.be.undefined; + }); + + it('true_false_true_isFalse', () => { + expect(evaluateToValue(xor(trueExpr, falseExpr, trueExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('true_error_false_isError', () => { + expect(evaluateToValue(xor(trueExpr, errorFilterCondition(), falseExpr))) + .to.be.undefined; + }); + + it('true_error_error_isError', () => { + expect( + evaluateToValue( + xor(trueExpr, errorFilterCondition(), errorFilterCondition()) + ) + ).to.be.undefined; + }); + + it('true_error_true_isError', () => { + expect(evaluateToValue(xor(trueExpr, errorFilterCondition(), trueExpr))) + .to.be.undefined; + }); + + it('true_true_false_isFalse', () => { + expect(evaluateToValue(xor(trueExpr, trueExpr, falseExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('true_true_error_isError', () => { + expect(evaluateToValue(xor(trueExpr, trueExpr, errorFilterCondition()))) + .to.be.undefined; + }); + + it('true_true_true_isTrue', () => { + expect(evaluateToValue(xor(trueExpr, trueExpr, trueExpr))).to.deep.equal( + TRUE_VALUE + ); + }); + + it('nested_xor', () => { + const child = xor(trueExpr, falseExpr); + const f = xor(child, trueExpr); + expect(evaluateToValue(f)).to.deep.equal(FALSE_VALUE); + }); + + it('multipleArguments', () => { + expect(evaluateToValue(xor(trueExpr, falseExpr, trueExpr))).to.deep.equal( + FALSE_VALUE + ); + }); + }); // end describe('xor') + + describe('isNull', () => { + it('null_returnsTrue', () => { + expect(evaluateToValue(isNull(constant(null)))).to.deep.equal(TRUE_VALUE); + }); + + it('error_returnsError', () => { + expect(evaluateToValue(isNull(errorExpr()))).to.be.undefined; + }); + + it('unset_returnsError', () => { + expect(evaluateToValue(isNull(field('non-existent-field')))).to.be + .undefined; + }); + + it('anythingButNull_returnsFalse', () => { + // Filter out null if it exists in the test data (it shouldn't based on definition) + const nonNullValues = + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES; + + nonNullValues.forEach(v => { + expect( + evaluateToValue(isNull(v)), + `isNull(${canonifyExpr(v)})` + ).to.deep.equal(FALSE_VALUE); + }); + + // Explicitly test NaN as well + expect(evaluateToValue(isNull(constant(NaN)))).to.deep.equal(FALSE_VALUE); + }); + }); // end describe('isNull') + + describe('isNotNull', () => { + it('null_returnsFalse', () => { + expect(evaluateToValue(isNotNull(constant(null)))).to.deep.equal( + FALSE_VALUE + ); + }); + + it('error_returnsFalse', () => { + expect(evaluateToValue(isNotNull(errorExpr()))).to.be.undefined; + }); + + it('unset_returnsFalse', () => { + expect(evaluateToValue(isNotNull(field('non-existent-field')))).to.be + .undefined; + }); + + it('anythingButNull_returnsTrue', () => { + // Filter out null if it exists in the test data + const nonNullValues = + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.filter( + v => !valueEquals(v._getValue(), { nullValue: 'NULL_VALUE' }) + ); + + nonNullValues.forEach(v => { + expect( + evaluateToValue(isNotNull(v)), + `isNotNull(${canonifyExpr(v)})` + ).to.deep.equal(TRUE_VALUE); + }); + + // Explicitly test NaN as well + expect(evaluateToValue(isNotNull(constant(NaN)))).to.deep.equal( + TRUE_VALUE + ); + }); + }); // end describe('isNotNull') +}); // end describe('Logical Functions') diff --git a/packages/firestore/test/unit/core/expressions/map.test.ts b/packages/firestore/test/unit/core/expressions/map.test.ts new file mode 100644 index 00000000000..3ff93e03986 --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/map.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 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 { constant, mapGet } from '../../../../src/lite-api/expressions'; +import { constantMap } from '../../../util/pipelines'; +import { EvaluateResult } from '../../../../src/core/expressions'; +import { evaluateToResult, evaluateToValue, expectEqual } from './utils'; + +describe('Map Functions', () => { + describe('mapGet', () => { + it('get_existingKey_returnsValue', () => { + const map = { a: 1, b: 2, c: 3 }; + expectEqual(evaluateToValue(mapGet(constantMap(map), 'b')), constant(2)); + }); + + it('get_missingKey_returnsUnset', () => { + const map = { a: 1, b: 2, c: 3 }; + expect(evaluateToResult(mapGet(constantMap(map), 'd'))).to.deep.equal( + EvaluateResult.newUnset() + ); + }); + + it('get_emptyMap_returnsUnset', () => { + const map = {}; + expect(evaluateToResult(mapGet(constantMap(map), 'd'))).to.deep.equal( + EvaluateResult.newUnset() + ); + }); + + it('get_wrongMapType_returnsError', () => { + const map = 'not a map'; + expect(evaluateToValue(mapGet(constant(map), 'd'))).to.be.undefined; + }); + + // it('get_wrongKeyType_returnsError', () => { + // const map = {a: 1, b: 2, c: 3}; + // expect(evaluate(mapGet(constantMap(map), constant(42)))).to.be.undefined; + // }); + }); // end describe('mapGet') +}); diff --git a/packages/firestore/test/unit/core/expressions/mirroring.semantics.test.ts b/packages/firestore/test/unit/core/expressions/mirroring.semantics.test.ts new file mode 100644 index 00000000000..e49e9ac1d1a --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/mirroring.semantics.test.ts @@ -0,0 +1,318 @@ +/** + * @license + * Copyright 2025 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 { + add, + arrayContains, + arrayContainsAll, + arrayContainsAny, + arrayLength, + byteLength, + charLength, + constant, + cosineDistance, + divide, + dotProduct, + endsWith, + eq, + eqAny, + euclideanDistance, + Expr, + field, + FunctionExpr, + gt, + gte, + isNan, + isNotNan, + like, + lt, + lte, + mod, + multiply, + neq, + notEqAny, + regexContains, + regexMatch, + startsWith, + strConcat, + strContains, + subtract, + timestampToUnixMicros, + timestampToUnixMillis, + timestampToUnixSeconds, + toLower, + toUpper, + trim, + unixMicrosToTimestamp, + unixMillisToTimestamp, + unixSecondsToTimestamp, + vectorLength +} from '../../../../src/lite-api/expressions'; +import { EvaluateResult } from '../../../../src/core/expressions'; +import { + ERROR_VALUE, + errorExpr, + evaluateToResult, + evaluateToValue, + expectEqual +} from './utils'; + +describe('Unary Function Input Mirroring', () => { + const unaryFunctionBuilders: Array<(v: Expr) => FunctionExpr> = [ + isNan, + isNotNan, + arrayLength, + // TODO(b/351084804): reverse is not implemented yet + // reverse, + charLength, + byteLength, + toLower, + toUpper, + trim, + vectorLength, + unixMicrosToTimestamp, + timestampToUnixMicros, + unixMillisToTimestamp, + timestampToUnixMillis, + unixSecondsToTimestamp, + timestampToUnixSeconds + // TODO(b/351084804): timestampAdd is not unary + // timestampAdd + ]; + + const testCases = [ + { + inputExpr: constant(null), + expectedResult: constant(null), + description: 'NULL' + }, + { + inputExpr: errorExpr(), + expectedResult: ERROR_VALUE, + description: 'ERROR' + }, + { + inputExpr: field('non-existent-field'), + expectedResult: ERROR_VALUE, + description: 'UNSET' + } + ]; + + unaryFunctionBuilders.forEach(builder => { + const funcName = builder(constant('dummy')).name; + + it(`mirrors input for ${funcName}()`, () => { + testCases.forEach(testCase => { + let exprToEvaluate; + try { + exprToEvaluate = builder(testCase.inputExpr); + } catch (e) { + throw new Error( + `Builder ${funcName} threw unexpectedly for input ${testCase.description}: ${e}` + ); + } + + const actualResult = evaluateToValue(exprToEvaluate); + + if (testCase.expectedResult === ERROR_VALUE) { + expect( + actualResult, + `${funcName}(${testCase.description}) should evaluate to ERROR (undefined)` + ).to.be.undefined; + } else { + expectEqual( + actualResult, + testCase.expectedResult, + `${funcName}(${testCase.description}) should evaluate to NULL` + ); + } + }); + }); + }); +}); // end describe('Unary Function Input Mirroring') + +describe('Binary Function Input Mirroring', () => { + // List of functions to test (builders accepting two Expr args) + const binaryFunctionBuilders: Array<(v1: Expr, v2: Expr) => FunctionExpr> = [ + // Arithmetic (Variadic, base is binary) + add, + subtract, + multiply, + divide, + mod, + // Comparison + eq, + neq, + lt, + lte, + gt, + gte, + // Array + arrayContains, + arrayContainsAll, + arrayContainsAny, + eqAny, + notEqAny, + // String + like, + regexContains, + regexMatch, + strContains, + startsWith, + endsWith, + strConcat, // strConcat is variadic + // Map + // mapGet, + // Vector + cosineDistance, + dotProduct, + euclideanDistance + ]; + + // Define test inputs + const NULL_INPUT = constant(null); + const ERROR_INPUT = errorExpr(); // Use existing helper + const UNSET_INPUT = field('non-existent-field'); // Use existing helper (UNSET_VALUE) + const VALID_INPUT = constant(42); // A simple valid input for cases needing one + + // Define test cases based on the rules + const testCases = [ + // Rule 1: NULL, NULL -> NULL + { + left: NULL_INPUT, + right: NULL_INPUT, + expected: NULL_INPUT, + description: 'NULL, NULL -> NULL' + }, + // Rule 2: Error/Unset propagation + { + left: NULL_INPUT, + right: ERROR_INPUT, + expected: ERROR_VALUE, + description: 'NULL, ERROR -> ERROR' + }, + { + left: ERROR_INPUT, + right: NULL_INPUT, + expected: ERROR_VALUE, + description: 'ERROR, NULL -> ERROR' + }, + { + left: NULL_INPUT, + right: UNSET_INPUT, + expected: ERROR_VALUE, + description: 'NULL, UNSET -> ERROR' + }, + { + left: UNSET_INPUT, + right: NULL_INPUT, + expected: ERROR_VALUE, + description: 'UNSET, NULL -> ERROR' + }, + { + left: ERROR_INPUT, + right: ERROR_INPUT, + expected: ERROR_VALUE, + description: 'ERROR, ERROR -> ERROR' + }, + { + left: ERROR_INPUT, + right: UNSET_INPUT, + expected: ERROR_VALUE, + description: 'ERROR, UNSET -> ERROR' + }, + { + left: UNSET_INPUT, + right: ERROR_INPUT, + expected: ERROR_VALUE, + description: 'UNSET, ERROR -> ERROR' + }, + { + left: UNSET_INPUT, + right: UNSET_INPUT, + expected: ERROR_VALUE, + description: 'UNSET, UNSET -> ERROR' + }, + { + left: VALID_INPUT, + right: ERROR_INPUT, + expected: ERROR_VALUE, + description: 'VALID, ERROR -> ERROR' + }, + { + left: ERROR_INPUT, + right: VALID_INPUT, + expected: ERROR_VALUE, + description: 'ERROR, VALID -> ERROR' + }, + { + left: VALID_INPUT, + right: UNSET_INPUT, + expected: ERROR_VALUE, + description: 'VALID, UNSET -> ERROR' + }, + { + left: UNSET_INPUT, + right: VALID_INPUT, + expected: ERROR_VALUE, + description: 'UNSET, VALID -> ERROR' + } + ]; + + binaryFunctionBuilders.forEach(builder => { + const funcName = builder(constant('dummy'), constant('dummy')).name; + + it(`mirrors input for ${funcName}()`, () => { + testCases.forEach(testCase => { + let exprToEvaluate: Expr; + try { + // Builders take the first two arguments for variadic functions + exprToEvaluate = builder(testCase.left, testCase.right); + } catch (e) { + // Catch errors during expression construction + throw new Error( + `Builder ${funcName} threw unexpectedly for inputs (${testCase.description}): ${e}` + ); + } + + const actualResult = evaluateToResult(exprToEvaluate); + + if (testCase.expected === ERROR_VALUE) { + expect( + actualResult, + `${funcName}(${testCase.description}) should evaluate to ERROR (undefined)` + ).to.deep.equal(EvaluateResult.newError()); + } else if (testCase.expected === NULL_INPUT) { + expect( + actualResult, + `${funcName}(${testCase.description}) should evaluate to NULL` + ).to.deep.equal(EvaluateResult.newNull()); + } else { + // This case shouldn't be hit by current test definitions + expect( + actualResult, + `${funcName}(${ + testCase.description + }) should evaluate to ${JSON.stringify(testCase.expected)}` + ).to.deep.equal(testCase.expected); + } + }); + }); + }); +}); // end describe('Binary Function Input Mirroring') diff --git a/packages/firestore/test/unit/core/expressions/string.test.ts b/packages/firestore/test/unit/core/expressions/string.test.ts new file mode 100644 index 00000000000..b51af8c4169 --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/string.test.ts @@ -0,0 +1,572 @@ +/** + * @license + * Copyright 2025 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 { + byteLength, + charLength, + constant, + endsWith, + field, + like, + regexContains, + regexMatch, + startsWith, + strConcat, + strContains +} from '../../../../src/lite-api/expressions'; +import { Bytes } from '../../../../src'; +import { FALSE_VALUE, TRUE_VALUE } from '../../../../src/model/values'; +import { evaluateToValue, expectEqual } from './utils'; + +describe('String Functions', () => { + describe('byteLength', () => { + it('emptyString', () => { + expectEqual(evaluateToValue(byteLength(constant(''))), constant(0)); + }); + + it('emptyByte', () => { + expectEqual( + evaluateToValue( + byteLength(constant(Bytes.fromUint8Array(new Uint8Array()))) + ), + constant(0) + ); + }); + + it('nonStringOrBytes_returnsError', () => { + expect(evaluateToValue(byteLength(constant(123)))).to.be.undefined; + }); + + it('highSurrogateOnly', () => { + const s = '\uD83C'; // high surrogate, missing low surrogate + expect(evaluateToValue(byteLength(constant(s)))).to.be.undefined; + }); + + it('lowSurrogateOnly', () => { + const s = '\uDF53'; // low surrogate, missing high surrogate + expect(evaluateToValue(byteLength(constant(s)))).to.be.undefined; + }); + + it('lowAndHighSurrogate_swapped', () => { + const s = '\uDF53\uD83C'; // swapped high with low, invalid sequence + expect(evaluateToValue(byteLength(constant(s)))).to.be.undefined; + }); + + it('ascii', () => { + expectEqual(evaluateToValue(byteLength(constant('abc'))), constant(3)); + expectEqual(evaluateToValue(byteLength(constant('1234'))), constant(4)); + expectEqual( + evaluateToValue(byteLength(constant('abc123!@'))), + constant(8) + ); + }); + + it('largeString', () => { + expectEqual( + evaluateToValue(byteLength(constant('a'.repeat(1500)))), + constant(1500) + ); + expectEqual( + evaluateToValue(byteLength(constant('ab'.repeat(1500)))), + constant(3000) + ); + }); + + it('twoBytes_perCharacter', () => { + expectEqual(evaluateToValue(byteLength(constant('éçñöü'))), constant(10)); + expectEqual( + evaluateToValue( + byteLength( + constant(Bytes.fromUint8Array(new TextEncoder().encode('éçñöü'))) + ) + ), + constant(10) + ); + }); + + it('threeBytes_perCharacter', () => { + expectEqual( + evaluateToValue(byteLength(constant('你好世界'))), + constant(12) + ); + expectEqual( + evaluateToValue( + byteLength( + constant(Bytes.fromUint8Array(new TextEncoder().encode('你好世界'))) + ) + ), + constant(12) + ); + }); + + it('fourBytes_perCharacter', () => { + expectEqual(evaluateToValue(byteLength(constant('🀘🂡'))), constant(8)); + expectEqual( + evaluateToValue( + byteLength( + constant(Bytes.fromUint8Array(new TextEncoder().encode('🀘🂡'))) + ) + ), + constant(8) + ); + }); + + it('mixOfDifferentEncodedLengths', () => { + expectEqual(evaluateToValue(byteLength(constant('aé好🂡'))), constant(10)); + expectEqual( + evaluateToValue( + byteLength( + constant(Bytes.fromUint8Array(new TextEncoder().encode('aé好🂡'))) + ) + ), + constant(10) + ); + }); + }); // end describe('byteLength') + + describe('charLength', () => { + it('emptyString', () => { + expectEqual(evaluateToValue(charLength(constant(''))), constant(0)); + }); + + it('bytesType_returnsError', () => { + expect( + evaluateToValue( + charLength( + constant(Bytes.fromUint8Array(new TextEncoder().encode('abc'))) + ) + ) + ).to.be.undefined; + }); + + it('baseCase_bmp', () => { + expectEqual(evaluateToValue(charLength(constant('abc'))), constant(3)); + expectEqual(evaluateToValue(charLength(constant('1234'))), constant(4)); + expectEqual( + evaluateToValue(charLength(constant('abc123!@'))), + constant(8) + ); + expectEqual( + evaluateToValue(charLength(constant('你好世界'))), + constant(4) + ); + expectEqual( + evaluateToValue(charLength(constant('cafétéria'))), + constant(9) + ); + expectEqual(evaluateToValue(charLength(constant('абвгд'))), constant(5)); + expectEqual( + evaluateToValue(charLength(constant('¡Hola! ¿Cómo estás?'))), + constant(19) + ); + expectEqual(evaluateToValue(charLength(constant('☺'))), constant(1)); + }); + + it('spaces', () => { + expectEqual(evaluateToValue(charLength(constant(''))), constant(0)); + expectEqual(evaluateToValue(charLength(constant(' '))), constant(1)); + expectEqual(evaluateToValue(charLength(constant(' '))), constant(2)); + expectEqual(evaluateToValue(charLength(constant('a b'))), constant(3)); + }); + + it('specialCharacters', () => { + expectEqual(evaluateToValue(charLength(constant('\n'))), constant(1)); + expectEqual(evaluateToValue(charLength(constant('\t'))), constant(1)); + expectEqual(evaluateToValue(charLength(constant('\\'))), constant(1)); + }); + + it('bmp_smp_mix', () => { + const s = 'Hello\uD83D\uDE0A'; // Hello followed by emoji + expectEqual(evaluateToValue(charLength(constant(s))), constant(6)); + }); + + it('smp', () => { + const s = '\uD83C\uDF53\uD83C\uDF51'; // a strawberry and peach emoji + expectEqual(evaluateToValue(charLength(constant(s))), constant(2)); + }); + + it('highSurrogateOnly', () => { + const s = '\uD83C'; // high surrogate, missing low surrogate + expectEqual(evaluateToValue(charLength(constant(s))), constant(1)); + }); + + it('lowSurrogateOnly', () => { + const s = '\uDF53'; // low surrogate, missing high surrogate + expectEqual(evaluateToValue(charLength(constant(s))), constant(1)); + }); + + it('lowAndHighSurrogate_swapped', () => { + const s = '\uDF53\uD83C'; // swapped high with low, invalid sequence + expectEqual(evaluateToValue(charLength(constant(s))), constant(2)); + }); + + it('largeString', () => { + expectEqual( + evaluateToValue(charLength(constant('a'.repeat(1500)))), + constant(1500) + ); + expectEqual( + evaluateToValue(charLength(constant('ab'.repeat(1500)))), + constant(3000) + ); + }); + }); // end describe('charLength') + + describe('concat', () => { + it('multipleStringChildren_returnsCombination', () => { + expectEqual( + evaluateToValue( + strConcat(constant('foo'), constant(' '), constant('bar')) + ), + constant('foo bar'), + `strConcat('foo', ' ', 'bar')` + ); + }); + + it('multipleNonStringChildren_returnsError', () => { + expect( + evaluateToValue( + strConcat(constant('foo'), constant(42), constant('bar')) + ) + ).to.be.undefined; + }); + + it('multipleCalls', () => { + const func = strConcat(constant('foo'), constant(' '), constant('bar')); + expectEqual(evaluateToValue(func), constant('foo bar'), 'First call'); + expectEqual(evaluateToValue(func), constant('foo bar'), 'Second call'); + expectEqual(evaluateToValue(func), constant('foo bar'), 'Third call'); + }); + + it('largeNumberOfInputs', () => { + const args = []; + for (let i = 0; i < 500; i++) { + args.push(constant('a')); + } + expectEqual( + evaluateToValue(strConcat(args[0], args[1], ...args.slice(2))), + constant('a'.repeat(500)) + ); + }); + + it('largeStrings', () => { + const func = strConcat( + constant('a'.repeat(500)), + constant('b'.repeat(500)), + constant('c'.repeat(500)) + ); + expectEqual( + evaluateToValue(func), + constant('a'.repeat(500) + 'b'.repeat(500) + 'c'.repeat(500)) + ); + }); + }); // end describe('concat') + + describe('endsWith', () => { + it('get_nonStringValue_isError', () => { + expect(evaluateToValue(endsWith(constant(42), constant('search')))).to.be + .undefined; + }); + + it('get_nonStringSuffix_isError', () => { + expect(evaluateToValue(endsWith(constant('search'), constant(42)))).to.be + .undefined; + }); + + it('get_emptyInputs_returnsTrue', () => { + expect( + evaluateToValue(endsWith(constant(''), constant(''))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('get_emptyValue_returnsFalse', () => { + expect( + evaluateToValue(endsWith(constant(''), constant('v'))) + ).to.deep.equal(FALSE_VALUE); + }); + + it('get_emptySuffix_returnsTrue', () => { + expect( + evaluateToValue(endsWith(constant('value'), constant(''))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('get_returnsTrue', () => { + expect( + evaluateToValue(endsWith(constant('search'), constant('rch'))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('get_returnsFalse', () => { + expect( + evaluateToValue(endsWith(constant('search'), constant('rcH'))) + ).to.deep.equal(FALSE_VALUE); + }); + + it('get_largeSuffix_returnsFalse', () => { + expect( + evaluateToValue( + endsWith(constant('val'), constant('a very long suffix')) + ) + ).to.deep.equal(FALSE_VALUE); + }); + }); // end describe('endsWith') + + describe('like', () => { + it('get_nonStringLike_isError', () => { + expect(evaluateToValue(like(constant(42), constant('search')))).to.be + .undefined; + }); + + it('get_nonStringValue_isError', () => { + expect(evaluateToValue(like(constant('ear'), constant(42)))).to.be + .undefined; + }); + + it('get_staticLike', () => { + const func = like(constant('yummy food'), constant('%food')); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + }); + + it('get_emptySearchString', () => { + const func = like(constant(''), constant('%hi%')); + expect(evaluateToValue(func)).to.deep.equal(FALSE_VALUE); + }); + + it('get_emptyLike', () => { + const func = like(constant('yummy food'), constant('')); + expect(evaluateToValue(func)).to.deep.equal(FALSE_VALUE); + }); + + it('get_escapedLike', () => { + const func = like(constant('yummy food??'), constant('%food??')); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + }); + + it('get_dynamicLike', () => { + const func = like(constant('yummy food'), field('regex')); + expect(evaluateToValue(func, { regex: 'yummy%' })).to.deep.equal( + TRUE_VALUE + ); + expect(evaluateToValue(func, { regex: 'food%' })).to.deep.equal( + FALSE_VALUE + ); + expect(evaluateToValue(func, { regex: 'yummy_food' })).to.deep.equal( + TRUE_VALUE + ); + }); + }); // end describe('like') + + describe('regexContains', () => { + it('get_nonStringRegex_isError', () => { + expect(evaluateToValue(regexContains(constant(42), constant('search')))) + .to.be.undefined; + }); + + it('get_nonStringValue_isError', () => { + expect(evaluateToValue(regexContains(constant('ear'), constant(42)))).to + .be.undefined; + }); + + it('get_invalidRegex_isError', () => { + const func = regexContains(constant('abcabc'), constant('(abc)\\1')); + expect(evaluateToValue(func)).to.be.undefined; + expect(evaluateToValue(func)).to.be.undefined; + expect(evaluateToValue(func)).to.be.undefined; + }); + + it('get_staticRegex', () => { + const func = regexContains(constant('yummy food'), constant('.*oo.*')); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + }); + + it('get_subString_literal', () => { + const func = regexContains(constant('yummy good food'), constant('good')); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + }); + + it('get_subString_regex', () => { + const func = regexContains(constant('yummy good food'), constant('go*d')); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + }); + + it('get_dynamicRegex', () => { + const func = regexContains(constant('yummy food'), field('regex')); + expect(evaluateToValue(func, { regex: '^yummy.*' })).to.deep.equal( + TRUE_VALUE + ); + expect(evaluateToValue(func, { regex: 'fooood$' })).to.deep.equal( + FALSE_VALUE + ); + expect(evaluateToValue(func, { regex: '.*' })).to.deep.equal(TRUE_VALUE); + }); + }); // end describe('regexContains') + + describe('regexMatch', () => { + it('get_nonStringRegex_isError', () => { + expect(evaluateToValue(regexMatch(constant(42), constant('search')))).to + .be.undefined; + }); + + it('get_nonStringValue_isError', () => { + expect(evaluateToValue(regexMatch(constant('ear'), constant(42)))).to.be + .undefined; + }); + + it('get_invalidRegex_isError', () => { + const func = regexMatch(constant('abcabc'), constant('(abc)\\1')); + expect(evaluateToValue(func)).to.be.undefined; + expect(evaluateToValue(func)).to.be.undefined; + expect(evaluateToValue(func)).to.be.undefined; + }); + + it('get_staticRegex', () => { + const func = regexMatch(constant('yummy food'), constant('.*oo.*')); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + expect(evaluateToValue(func)).to.deep.equal(TRUE_VALUE); + }); + + it('get_subString_literal', () => { + const func = regexMatch(constant('yummy good food'), constant('good')); + expect(evaluateToValue(func)).to.deep.equal(FALSE_VALUE); + }); + + it('get_subString_regex', () => { + const func = regexMatch(constant('yummy good food'), constant('go*d')); + expect(evaluateToValue(func)).to.deep.equal(FALSE_VALUE); + }); + + it('get_dynamicRegex', () => { + const func = regexMatch(constant('yummy food'), field('regex')); + expect(evaluateToValue(func, { regex: '^yummy.*' })).to.deep.equal( + TRUE_VALUE + ); + expect(evaluateToValue(func, { regex: 'fooood$' })).to.deep.equal( + FALSE_VALUE + ); + expect(evaluateToValue(func, { regex: '.*' })).to.deep.equal(TRUE_VALUE); + }); + }); // end describe('regexMatch') + + describe('startsWith', () => { + it('get_nonStringValue_isError', () => { + expect(evaluateToValue(startsWith(constant(42), constant('search')))).to + .be.undefined; + }); + + it('get_nonStringPrefix_isError', () => { + expect(evaluateToValue(startsWith(constant('search'), constant(42)))).to + .be.undefined; + }); + + it('get_emptyInputs_returnsTrue', () => { + expect( + evaluateToValue(startsWith(constant(''), constant(''))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('get_emptyValue_returnsFalse', () => { + expect( + evaluateToValue(startsWith(constant(''), constant('v'))) + ).to.deep.equal(FALSE_VALUE); + }); + + it('get_emptyPrefix_returnsTrue', () => { + expect( + evaluateToValue(startsWith(constant('value'), constant(''))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('get_returnsTrue', () => { + expect( + evaluateToValue(startsWith(constant('search'), constant('sea'))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('get_returnsFalse', () => { + expect( + evaluateToValue(startsWith(constant('search'), constant('Sea'))) + ).to.deep.equal(FALSE_VALUE); + }); + + it('get_largePrefix_returnsFalse', () => { + expect( + evaluateToValue( + startsWith(constant('val'), constant('a very long prefix')) + ) + ).to.deep.equal(FALSE_VALUE); + }); + }); // end describe('startsWith') + + describe('strContains', () => { + it('value_nonString_isError', () => { + expect(evaluateToValue(strContains(constant(42), constant('value')))).to + .be.undefined; + }); + + it('subString_nonString_isError', () => { + expect( + evaluateToValue(strContains(constant('search space'), constant(42))) + ).to.be.undefined; + }); + + it('execute_true', () => { + expect( + evaluateToValue(strContains(constant('abc'), constant('c'))) + ).to.deep.equal(TRUE_VALUE); + expect( + evaluateToValue(strContains(constant('abc'), constant('bc'))) + ).to.deep.equal(TRUE_VALUE); + expect( + evaluateToValue(strContains(constant('abc'), constant('abc'))) + ).to.deep.equal(TRUE_VALUE); + expect( + evaluateToValue(strContains(constant('abc'), constant(''))) + ).to.deep.equal(TRUE_VALUE); + expect( + evaluateToValue(strContains(constant(''), constant(''))) + ).to.deep.equal(TRUE_VALUE); + expect( + evaluateToValue(strContains(constant('☃☃☃'), constant('☃'))) + ).to.deep.equal(TRUE_VALUE); + }); + + it('execute_false', () => { + expect( + evaluateToValue(strContains(constant('abc'), constant('abcd'))) + ).to.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(strContains(constant('abc'), constant('d'))) + ).to.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(strContains(constant(''), constant('a'))) + ).to.deep.equal(FALSE_VALUE); + expect( + evaluateToValue(strContains(constant(''), constant('abcde'))) + ).to.deep.equal(FALSE_VALUE); + }); + }); // end describe('strContains') +}); // end describe('String Functions') diff --git a/packages/firestore/test/unit/core/expressions/timestamp.test.ts b/packages/firestore/test/unit/core/expressions/timestamp.test.ts new file mode 100644 index 00000000000..43edadbe122 --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/timestamp.test.ts @@ -0,0 +1,724 @@ +/** + * @license + * Copyright 2025 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 { + constant, + subtract, + timestampToUnixMicros, + timestampToUnixMillis, + timestampToUnixSeconds, + unixMicrosToTimestamp, + unixMillisToTimestamp, + unixSecondsToTimestamp +} from '../../../../src/lite-api/expressions'; +import { Timestamp } from '../../../../src'; +import { evaluateToValue, expectEqual } from './utils'; + +describe('Timestamp Functions', () => { + describe('UnixMicrosToTimestamp', () => { + it('stringType_returnsError', () => { + expect(evaluateToValue(unixMicrosToTimestamp(constant('abc')))).to.be + .undefined; + }); + + it('zeroValue_returnsTimestampEpoch', () => { + const result = evaluateToValue(unixMicrosToTimestamp(constant(0))); + expect(result?.timestampValue).to.deep.equal({ + seconds: 0, + nanos: 0 + }); + }); + + it('intType_returnsTimestamp', () => { + const result = evaluateToValue(unixMicrosToTimestamp(constant(1000000))); + expect(result?.timestampValue).to.deep.equal({ + seconds: 1, + nanos: 0 + }); + }); + + it('longType_returnsTimestamp', () => { + const result = evaluateToValue( + unixMicrosToTimestamp(constant(9876543210)) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 9876, + nanos: 543210000 + }); + }); + + it('longType_negative_returnsTimestamp', () => { + const result = evaluateToValue(unixMicrosToTimestamp(constant(-10000))); + expect(result?.timestampValue).to.deep.equal({ + seconds: -1, + nanos: 990000000 + }); + }); + + it('longType_negative_overflow_returnsError', () => { + const result1 = evaluateToValue( + unixMicrosToTimestamp( + constant(-62135596800000000, { + preferIntegers: true + }) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: -62135596800, + nanos: 0 + }); + + const result2 = evaluateToValue( + unixMicrosToTimestamp( + subtract( + constant(-62135596800000000, { preferIntegers: true }), + constant(1) + ) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + + it('longType_positive_overflow_returnsError', () => { + const result1 = evaluateToValue( + unixMicrosToTimestamp( + subtract( + constant(253402300800000000, { preferIntegers: true }), + constant(1) + ) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: 253402300799, + nanos: 999999000 + }); + + const result2 = evaluateToValue( + unixMicrosToTimestamp( + constant(253402300800000000, { + preferIntegers: true + }) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + }); + + describe('UnixMillisToTimestamp', () => { + it('stringType_returnsError', () => { + expect(evaluateToValue(unixMillisToTimestamp(constant('abc')))).to.be + .undefined; + }); + + it('zeroValue_returnsTimestampEpoch', () => { + const result = evaluateToValue(unixMillisToTimestamp(constant(0))); + expect(result?.timestampValue).to.deep.equal({ + seconds: 0, + nanos: 0 + }); + }); + + it('intType_returnsTimestamp', () => { + const result = evaluateToValue(unixMillisToTimestamp(constant(1000))); + expect(result?.timestampValue).to.deep.equal({ + seconds: 1, + nanos: 0 + }); + }); + + it('longType_returnsTimestamp', () => { + const result = evaluateToValue( + unixMillisToTimestamp(constant(9876543210)) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 9876543, + nanos: 210000000 + }); + }); + + it('longType_negative_returnsTimestamp', () => { + const result = evaluateToValue(unixMillisToTimestamp(constant(-10000))); + expect(result?.timestampValue).to.deep.equal({ + seconds: -10, + nanos: 0 + }); + }); + + it('longType_negative_overflow_returnsError', () => { + const result1 = evaluateToValue( + unixMillisToTimestamp( + constant(-62135596800000, { + preferIntegers: true + }) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: -62135596800, + nanos: 0 + }); + + const result2 = evaluateToValue( + unixMillisToTimestamp( + constant(-62135596800001, { + preferIntegers: true + }) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + + it('longType_positive_overflow_returnsError', () => { + const result1 = evaluateToValue( + unixMillisToTimestamp( + constant(253402300799999, { + preferIntegers: true + }) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: 253402300799, + nanos: 999000000 + }); + + const result2 = evaluateToValue( + unixMillisToTimestamp( + constant(253402300800000, { + preferIntegers: true + }) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + }); + + describe('UnixSecondsToTimestamp', () => { + it('stringType_returnsError', () => { + expect(evaluateToValue(unixSecondsToTimestamp(constant('abc')))).to.be + .undefined; + }); + + it('zeroValue_returnsTimestampEpoch', () => { + const result = evaluateToValue(unixSecondsToTimestamp(constant(0))); + expect(result?.timestampValue).to.deep.equal({ + seconds: 0, + nanos: 0 + }); + }); + + it('intType_returnsTimestamp', () => { + const result = evaluateToValue(unixSecondsToTimestamp(constant(1))); + expect(result?.timestampValue).to.deep.equal({ + seconds: 1, + nanos: 0 + }); + }); + + it('longType_returnsTimestamp', () => { + const result = evaluateToValue( + unixSecondsToTimestamp(constant(9876543210)) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 9876543210, + nanos: 0 + }); + }); + + it('longType_negative_returnsTimestamp', () => { + const result = evaluateToValue(unixSecondsToTimestamp(constant(-10000))); + expect(result?.timestampValue).to.deep.equal({ + seconds: -10000, + nanos: 0 + }); + }); + + it('longType_negative_overflow_returnsError', () => { + const result1 = evaluateToValue( + unixSecondsToTimestamp( + constant(-62135596800, { + preferIntegers: true + }) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: -62135596800, + nanos: 0 + }); + + const result2 = evaluateToValue( + unixSecondsToTimestamp( + constant(-62135596801, { + preferIntegers: true + }) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + + it('longType_positive_overflow_returnsError', () => { + const result1 = evaluateToValue( + unixSecondsToTimestamp( + constant(253402300799, { + preferIntegers: true + }) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: 253402300799, + nanos: 0 + }); + + const result2 = evaluateToValue( + unixSecondsToTimestamp( + constant(253402300800, { + preferIntegers: true + }) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + }); + + describe('TimestampToUnixMicros', () => { + it('nonTimestampType_returnsError', () => { + expect(evaluateToValue(timestampToUnixMicros(constant(123)))).to.be + .undefined; + }); + + it('timestamp_returnsMicros', () => { + const timestamp = new Timestamp(347068800, 0); + const result = evaluateToValue( + timestampToUnixMicros(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('347068800000000'); + }); + + it('epochTimestamp_returnsMicros', () => { + const timestamp = new Timestamp(0, 0); + const result = evaluateToValue( + timestampToUnixMicros(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('0'); + }); + + it('currentTimestamp_returnsMicros', () => { + const now = Timestamp.now(); + const result = evaluateToValue(timestampToUnixMicros(constant(now))); + expect(result?.integerValue).to.equal( + (BigInt(now.toMillis()) * BigInt(1000)).toString() + ); + }); + + it('maxTimestamp_returnsMicros', () => { + const maxTimestamp = new Timestamp(253402300799, 999999999); + const result = evaluateToValue( + timestampToUnixMicros(constant(maxTimestamp)) + ); + expect(result?.integerValue).to.equal('253402300799999999'); + }); + + it('minTimestamp_returnsMicros', () => { + const minTimestamp = new Timestamp(-62135596800, 0); + const result = evaluateToValue( + timestampToUnixMicros(constant(minTimestamp)) + ); + expect(result?.integerValue).to.equal('-62135596800000000'); + }); + + it('timestampOverflow_returnsError', () => { + expect( + evaluateToValue( + timestampToUnixMicros( + constant({ + timestampValue: { + seconds: Number.MAX_SAFE_INTEGER, + nanos: 999999999 + } + }) + ) + ) + ).to.be.undefined; + }); + + it('timestampTruncatesToMicros', () => { + const timestamp = new Timestamp(-1, 999999999); + const result = evaluateToValue( + timestampToUnixMicros(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('-1'); + }); + }); + + describe('TimestampToUnixMillisFunction', () => { + it('nonTimestampType_returnsError', () => { + expect(evaluateToValue(timestampToUnixMillis(constant(123)))).to.be + .undefined; + }); + + it('timestamp_returnsMillis', () => { + const timestamp = new Timestamp(347068800, 0); + const result = evaluateToValue( + timestampToUnixMillis(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('347068800000'); + }); + + it('epochTimestamp_returnsMillis', () => { + const timestamp = new Timestamp(0, 0); + const result = evaluateToValue( + timestampToUnixMillis(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('0'); + }); + + it('currentTimestamp_returnsMillis', () => { + const now = Timestamp.now(); + const result = evaluateToValue(timestampToUnixMillis(constant(now))); + expect(result?.integerValue).to.equal(now.toMillis().toString()); + }); + + it('maxTimestamp_returnsMillis', () => { + const maxTimestamp = new Timestamp(253402300799, 999000000); + const result = evaluateToValue( + timestampToUnixMillis(constant(maxTimestamp)) + ); + expect(result?.integerValue).to.equal('253402300799999'); + }); + + it('minTimestamp_returnsMillis', () => { + const minTimestamp = new Timestamp(-62135596800, 0); + const result = evaluateToValue( + timestampToUnixMillis(constant(minTimestamp)) + ); + expect(result?.integerValue).to.equal('-62135596800000'); + }); + + it('timestampTruncatesToMillis', () => { + const timestamp = new Timestamp(-1, 999999999); + const result = evaluateToValue( + timestampToUnixMillis(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('-1'); + }); + + it('timestampOverflow_returnsError', () => { + expect( + evaluateToValue( + timestampToUnixMillis( + constant({ + timestampValue: { + seconds: Number.MAX_SAFE_INTEGER, + nanos: 999999999 + } + }) + ) + ) + ).to.be.undefined; + }); + }); + + describe('TimestampToUnixSecondsFunctionTest', () => { + it('nonTimestampType_returnsError', () => { + expect(evaluateToValue(timestampToUnixSeconds(constant(123)))).to.be + .undefined; + }); + + it('timestamp_returnsSeconds', () => { + const timestamp = new Timestamp(347068800, 0); + const result = evaluateToValue( + timestampToUnixSeconds(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('347068800'); + }); + + it('epochTimestamp_returnsSeconds', () => { + const timestamp = new Timestamp(0, 0); + const result = evaluateToValue( + timestampToUnixSeconds(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('0'); + }); + + it('currentTimestamp_returnsSeconds', () => { + const now = Timestamp.now(); + const result = evaluateToValue(timestampToUnixSeconds(constant(now))); + expect(result?.integerValue).to.equal( + Math.floor(now.toMillis() / 1000).toString() + ); + }); + + it('maxTimestamp_returnsSeconds', () => { + const maxTimestamp = new Timestamp(253402300799, 999999000); + const result = evaluateToValue( + timestampToUnixSeconds(constant(maxTimestamp)) + ); + expect(result?.integerValue).to.equal('253402300799'); + }); + + it('minTimestamp_returnsSeconds', () => { + const minTimestamp = new Timestamp(-62135596800, 0); + const result = evaluateToValue( + timestampToUnixSeconds(constant(minTimestamp)) + ); + expect(result?.integerValue).to.equal('-62135596800'); + }); + + it('timestampTruncatesToSeconds', () => { + const timestamp = new Timestamp(-1, 999999999); + const result = evaluateToValue( + timestampToUnixSeconds(constant(timestamp)) + ); + expect(result?.integerValue).to.equal('-1'); + }); + + it('timestampOverflow_returnsError', () => { + expect( + evaluateToValue( + timestampToUnixSeconds( + constant({ + timestampValue: { + seconds: Number.MAX_SAFE_INTEGER, + nanos: 999999999 + } + }) + ) + ) + ).to.be.undefined; + }); + }); + + describe('timestampAdd() function', () => { + it('timestampAdd_stringType_returnsError', () => { + expect( + evaluateToValue( + constant('abc').timestampAdd(constant('second'), constant(1)) + ) + ).to.be.undefined; + }); + + it('timestampAdd_zeroValue_returnsTimestampEpoch', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('second'), + constant(0) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 0, + nanos: 0 + }); + }); + + it('timestampAdd_intType_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('second'), + constant(1) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 1, + nanos: 0 + }); + }); + + it('timestampAdd_longType_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('second'), + constant(9876543210) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 9876543210, + nanos: 0 + }); + }); + + it('timestampAdd_longType_negative_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('second'), + constant(-10000) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: -10000, + nanos: 0 + }); + }); + + it('timestampAdd_longType_negative_overflow_returnsError', () => { + const result1 = evaluateToValue( + constant(new Timestamp(-62135596800, 0)).timestampAdd( + constant('second'), + constant(0) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: -62135596800, + nanos: 0 + }); + + const result2 = evaluateToValue( + constant(new Timestamp(-62135596800, 0)).timestampAdd( + constant('second'), + constant(-1) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + + it('timestampAdd_longType_positive_overflow_returnsError', () => { + const result1 = evaluateToValue( + constant(new Timestamp(253402300799, 999999000)).timestampAdd( + constant('second'), + constant(0) + ) + ); + expect(result1?.timestampValue).to.deep.equal({ + seconds: 253402300799, + nanos: 999999000 + }); + + const result2 = evaluateToValue( + constant(new Timestamp(253402300799, 999999000)).timestampAdd( + constant('second'), + constant(1) + ) + ); + expect(result2).to.deep.equal(undefined); + }); + + it('timestampAdd_longType_minute_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('minute'), + constant(1) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 60, + nanos: 0 + }); + }); + + it('timestampAdd_longType_hour_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('hour'), + constant(1) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 3600, + nanos: 0 + }); + }); + + it('timestampAdd_longType_day_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd(constant('day'), constant(1)) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 86400, + nanos: 0 + }); + }); + + it('timestampAdd_longType_millisecond_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('millisecond'), + constant(1) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 0, + nanos: 1000000 + }); + }); + + it('timestampAdd_longType_microsecond_returnsTimestamp', () => { + const result = evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('microsecond'), + constant(1) + ) + ); + expect(result?.timestampValue).to.deep.equal({ + seconds: 0, + nanos: 1000 + }); + }); + + it('timestampAdd_invalidTimeUnit_returnsError', () => { + expect( + evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('abc'), + constant(1) + ) + ) + ).to.be.undefined; + }); + + it('timestampAdd_invalidAmount_returnsError', () => { + expect( + evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('second'), + constant('abc') + ) + ) + ).to.be.undefined; + }); + + it('timestampAdd_nullAmount_returnsNull', () => { + expectEqual( + evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant('second'), + constant(null) + ) + ), + constant(null) + ); + }); + + it('timestampAdd_nullTimeUnit_returnsNull', () => { + expectEqual( + evaluateToValue( + constant(new Timestamp(0, 0)).timestampAdd( + constant(null), + constant(1) + ) + ), + constant(null) + ); + }); + + it('timestampAdd_nullTimestamp_returnsNull', () => { + expectEqual( + evaluateToValue( + constant(null).timestampAdd(constant('second'), constant(1)) + ), + constant(null) + ); + }); + }); +}); diff --git a/packages/firestore/test/unit/core/expressions/utils.ts b/packages/firestore/test/unit/core/expressions/utils.ts new file mode 100644 index 00000000000..13c96bb69ef --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/utils.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright 2025 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 { newTestFirestore } from '../../../util/api_helpers'; +import { + BooleanExpr, + Constant, + constant, + Expr, + field +} from '../../../../src/lite-api/expressions'; +import { newUserDataReader } from '../../../../src/lite-api/user_data_reader'; +import { typeOrder, valueEquals } from '../../../../src/model/values'; +import { + Bytes, + doc as docRef, + GeoPoint, + Timestamp, + VectorValue +} from '../../../../src'; +import { constantArray, constantMap } from '../../../util/pipelines'; +import { JsonObject, ObjectValue } from '../../../../src/model/object_value'; +import { Value } from '../../../../src/protos/firestore_proto_api'; +import { EvaluateResult, toEvaluable } from '../../../../src/core/expressions'; +import { doc } from '../../../util/helpers'; + +const db = newTestFirestore(); +// Represents an evaluation error (e.g., field not found, type mismatch) +export const ERROR_VALUE = undefined; +// Represents an unset field (field does not exist in the document) +export const UNSET_VALUE = field('non-existent-field'); +export const falseExpr = constant(1).eq(2); +export const trueExpr = constant(1).eq(1); + +export function isTypeComparable(left: Constant, right: Constant): boolean { + left._readUserData(newUserDataReader(db)); + right._readUserData(newUserDataReader(db)); + + return typeOrder(left._getValue()) === typeOrder(right._getValue()); +} + +export class ComparisonValueTestData { + static BOOLEAN_VALUES = [constant(false), constant(true)]; + + static NUMERIC_VALUES = [ + constant(Number.NEGATIVE_INFINITY), + constant(-Number.MAX_VALUE), + constant(Number.MIN_SAFE_INTEGER), + constant(-9007199254740990), + constant(-1), + constant(-0.5), + constant(-Number.MIN_VALUE), + constant(0), + constant(Number.MIN_VALUE), + constant(0.5), + constant(1), + constant(42), + constant(9007199254740990), + constant(Number.MAX_SAFE_INTEGER), + constant(Number.MAX_VALUE), + constant(Number.POSITIVE_INFINITY) + ]; + + static TIMESTAMP_VALUES = [ + constant(new Timestamp(-42, 0)), // -42 seconds from epoch + constant(new Timestamp(-42, 42000)), // -42 seconds + 42 milliseconds (42000 microseconds) from epoch + constant(new Timestamp(0, 0)), // Epoch + constant(new Timestamp(0, 42000)), // 42 milliseconds from epoch + constant(new Timestamp(42, 0)), // 42 seconds from epoch + constant(new Timestamp(42, 42000)) // 42 seconds + 42 milliseconds from epoch + ]; + + static STRING_VALUES = [ + constant(''), + constant('abcdefgh'), + constant('fouxdufafa'.repeat(200)), + constant('santé'), + constant('santé et bonheur') + ]; + + static BYTE_VALUES = [ + constant(Bytes.fromUint8Array(new Uint8Array([]))), // Empty byte array + constant(Bytes.fromUint8Array(new Uint8Array([0, 2, 56, 42]))), + constant(Bytes.fromUint8Array(new Uint8Array([2, 26]))), + constant(Bytes.fromUint8Array(new Uint8Array([2, 26, 31]))), + constant( + Bytes.fromUint8Array(new TextEncoder().encode('fouxdufafa'.repeat(200))) + ) // Encode string to Uint8Array + ]; + + static ENTITY_REF_VALUES = [ + constant(docRef(db, 'foo', 'bar')), + constant(docRef(db, 'foo', 'bar', 'qux/a')), + constant(docRef(db, 'foo', 'bar', 'qux', 'bleh')), + constant(docRef(db, 'foo', 'bar', 'qux', 'hi')), + constant(docRef(db, 'foo', 'bar', 'tonk/a')), + constant(docRef(db, 'foo', 'baz')) + ]; + + static GEO_VALUES = [ + constant(new GeoPoint(-87.0, -92.0)), + constant(new GeoPoint(-87.0, 0.0)), + constant(new GeoPoint(-87.0, 42.0)), + constant(new GeoPoint(0.0, -92.0)), + constant(new GeoPoint(0.0, 0.0)), + constant(new GeoPoint(0.0, 42.0)), + constant(new GeoPoint(42.0, -92.0)), + constant(new GeoPoint(42.0, 0.0)), + constant(new GeoPoint(42.0, 42.0)) + ]; + + static ARRAY_VALUES = [ + constantArray([]), + constantArray([true, 15]), + constantArray([1, 2]), + constantArray([new Timestamp(12, 0)]), + constantArray(['foo']), + constantArray(['foo', 'bar']), + constantArray([new GeoPoint(0, 0)]), + constantArray([{}]) + ]; + + static VECTOR_VALUES = [ + constant(new VectorValue([42.0])), + constant(new VectorValue([21.2, 3.14])), + constant(new VectorValue([Number.NEGATIVE_INFINITY, 10.0, 1.0])), + constant(new VectorValue([-Number.MAX_VALUE, 9.0, 1.0])), + constant(new VectorValue([-Number.MIN_VALUE, 7.0, 1.0])), + constant(new VectorValue([-Number.MIN_VALUE, 8.0, 1.0])), + constant(new VectorValue([0.0, 5.0, 1.0])), + constant(new VectorValue([0.0, 6.0, 1.0])), + constant(new VectorValue([Number.MIN_VALUE, 3.0, 1.0])), + constant(new VectorValue([Number.MIN_VALUE, 4.0, 1.0])), + constant(new VectorValue([Number.MAX_VALUE, 2.0, 1.0])), + constant(new VectorValue([Number.POSITIVE_INFINITY, 1.0, 1.0])) + ]; + + static MAP_VALUES = [ + constantMap({}), + constantMap({ ABA: 'qux' }), + constantMap({ aba: 'hello' }), + constantMap({ aba: 'hello', foo: true }), + constantMap({ aba: 'qux' }), + constantMap({ foo: 'aaa' }) + ]; + + // Concatenation of values (implementation depends on your testing framework) + static ALL_SUPPORTED_COMPARABLE_VALUES = [ + ...ComparisonValueTestData.BOOLEAN_VALUES, + ...ComparisonValueTestData.NUMERIC_VALUES, + ...ComparisonValueTestData.TIMESTAMP_VALUES, + ...ComparisonValueTestData.STRING_VALUES, + ...ComparisonValueTestData.BYTE_VALUES, + ...ComparisonValueTestData.ENTITY_REF_VALUES, + ...ComparisonValueTestData.GEO_VALUES, + ...ComparisonValueTestData.ARRAY_VALUES, + ...ComparisonValueTestData.VECTOR_VALUES, + ...ComparisonValueTestData.MAP_VALUES + ]; + + static equivalentValues(): Array<{ left: Constant; right: Constant }> { + const results = ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.map( + value => { + return { left: value, right: value }; + } + ); + + return results.concat([ + { left: constant(-42), right: constant(-42.0) }, + { left: constant(-42.0), right: constant(-42) }, + { left: constant(42), right: constant(42.0) }, + { left: constant(42.0), right: constant(42) }, + + { left: constant(0), right: constant(-0) }, + { left: constant(-0), right: constant(0) }, + + { left: constant(0), right: constant(0.0) }, + { left: constant(0.0), right: constant(0) }, + + { left: constant(0), right: constant(-0.0) }, + { left: constant(-0.0), right: constant(0) }, + + { left: constant(-0), right: constant(0.0) }, + { left: constant(0.0), right: constant(-0) }, + + { left: constant(-0), right: constant(-0.0) }, + { left: constant(-0.0), right: constant(-0) }, + + { left: constant(0.0), right: constant(-0.0) }, + { left: constant(-0.0), right: constant(0.0) } + ]); + } + + static lessThanValues(): Array<{ left: Constant; right: Constant }> { + const results: Array<{ left: Constant; right: Constant }> = []; + + for ( + let i = 0; + i < ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.length; + i++ + ) { + for ( + let j = i + 1; + j < ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.length; + j++ + ) { + const left = ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES[i]; + const right = + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES[j]; + if (isTypeComparable(left, right)) { + results.push({ left, right }); + } + } + } + return results; + } + + static greaterThanValues(): Array<{ left: Constant; right: Constant }> { + const results: Array<{ left: Constant; right: Constant }> = []; + + for ( + let i = 0; + i < ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.length; + i++ + ) { + for ( + let j = i + 1; + j < ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.length; + j++ + ) { + const left = ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES[i]; + const right = + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES[j]; + if (isTypeComparable(right, left)) { + // Note the order of right and left + results.push({ left: right, right: left }); + } + } + } + return results; + } + + static mixedTypeValues(): Array<{ left: Constant; right: Constant }> { + const results: Array<{ left: Constant; right: Constant }> = []; + + for ( + let i = 0; + i < ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.length; + i++ + ) { + for ( + let j = 0; + j < ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES.length; + j++ + ) { + // Note: j starts from 0 here + const left = ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES[i]; + const right = + ComparisonValueTestData.ALL_SUPPORTED_COMPARABLE_VALUES[j]; + if (!isTypeComparable(left, right)) { + results.push({ left, right }); + } + } + } + return results; + } +} + +export function evaluateToValue( + expr: Expr, + data?: JsonObject | ObjectValue +): Value { + expr._readUserData(newUserDataReader(db)); + return toEvaluable(expr).evaluate( + // @ts-ignore + { serializer: newUserDataReader(db).serializer }, + // Should not matter for the purpose of tests here. + doc('foo/doc', 1000, data ?? { exists: true, nanValue: NaN }) + ).value!; +} + +export function evaluateToResult( + expr: Expr, + data?: JsonObject | ObjectValue +): EvaluateResult { + expr._readUserData(newUserDataReader(db)); + return toEvaluable(expr).evaluate( + // @ts-ignore + { serializer: newUserDataReader(db).serializer }, + // Should not matter for the purpose of tests here. + doc('foo/doc', 1000, data ?? { exists: true, nanValue: NaN }) + ); +} + +export function errorExpr(): Expr { + return field('not-an-array').arrayLength(); +} + +export function errorFilterCondition(): BooleanExpr { + return field('not-an-array').gt(0); +} + +export function expectEqual( + evaluated: Value, + expected: Constant, + message?: string +) { + expected._readUserData(newUserDataReader(db)); + return expect( + valueEquals(evaluated!, expected._getValue(), { + nanEqual: true, + mixIntegerDouble: true, + semanticsEqual: true + }), + `${message}: expected ${JSON.stringify( + expected._getValue(), + null, + 2 + )} to equal ${JSON.stringify(evaluated, null, 2)}` + ).to.be.true; +} diff --git a/packages/firestore/test/unit/core/expressions/vector.test.ts b/packages/firestore/test/unit/core/expressions/vector.test.ts new file mode 100644 index 00000000000..0b0696bf952 --- /dev/null +++ b/packages/firestore/test/unit/core/expressions/vector.test.ts @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2025 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 { + constant, + cosineDistance, + dotProduct, + euclideanDistance, + vectorLength +} from '../../../../src/lite-api/expressions'; +import { VectorValue } from '../../../../src'; +import { EvaluateResult } from '../../../../src/core/expressions'; +import { constantArray } from '../../../util/pipelines'; +import { evaluateToResult, evaluateToValue, expectEqual } from './utils'; + +describe('Vector Functions', () => { + describe('cosineDistance', () => { + it('cosineDistance', () => { + expect( + evaluateToValue( + cosineDistance( + constant(new VectorValue([0.0, 1.0])), + constant(new VectorValue([5.0, 100.0])) + ) + )?.doubleValue + ).to.be.closeTo(0.0012476611221553524, 1e-10); // Use closeTo for floating-point comparison + }); + + it('zeroVector_returnsError', () => { + expect( + evaluateToResult( + cosineDistance( + constant(new VectorValue([0.0, 0.0])), + constant(new VectorValue([5.0, 100.0])) + ) + ) + ).to.deep.equal(EvaluateResult.newError()); + }); + + it('emptyVectors_returnsError', () => { + expect( + evaluateToValue( + cosineDistance( + constant(new VectorValue([])), + constant(new VectorValue([])) + ) + ) + ).to.be.undefined; + }); + + it('differentVectorLengths_returnError', () => { + expect( + evaluateToValue( + cosineDistance( + constant(new VectorValue([1.0])), + constant(new VectorValue([2.0, 3.0])) + ) + ) + ).to.be.undefined; + }); + + it('wrongInputType_returnError', () => { + expect( + evaluateToValue( + cosineDistance( + constant(new VectorValue([1.0, 2.0])), + constantArray([3.0, 4.0]) + ) + ) + ).to.be.undefined; + }); + }); // end describe('cosineDistance') + + describe('dotProduct', () => { + it('dotProduct', () => { + expect( + evaluateToValue( + dotProduct( + constant(new VectorValue([2.0, 1.0])), + constant(new VectorValue([1.0, 5.0])) + ) + )!.doubleValue + ).to.equal(7.0); + }); + + it('orthogonalVectors', () => { + expect( + evaluateToValue( + dotProduct( + constant(new VectorValue([1.0, 0.0])), + constant(new VectorValue([0.0, 5.0])) + ) + )?.doubleValue + ).to.deep.equal(0.0); + }); + + it('zeroVector_returnsZero', () => { + expect( + evaluateToValue( + dotProduct( + constant(new VectorValue([0.0, 0.0])), + constant(new VectorValue([5.0, 100.0])) + ) + )?.doubleValue + ).to.equal(0.0); + }); + + it('emptyVectors_returnsZero', () => { + expect( + evaluateToValue( + dotProduct( + constant(new VectorValue([])), + constant(new VectorValue([])) + ) + )?.doubleValue + ).to.equal(0.0); + }); + + it('differentVectorLengths_returnError', () => { + expect( + evaluateToValue( + dotProduct( + constant(new VectorValue([1.0])), + constant(new VectorValue([2.0, 3.0])) + ) + ) + ).to.be.undefined; + }); + + it('wrongInputType_returnError', () => { + expect( + evaluateToValue( + dotProduct( + constant(new VectorValue([1.0, 2.0])), + constantArray([3.0, 4.0]) + ) + ) + ).to.be.undefined; + }); + }); // end describe('dotProduct') + + describe('euclideanDistance', () => { + it('euclideanDistance', () => { + expect( + evaluateToValue( + euclideanDistance( + constant(new VectorValue([0.0, 0.0])), + constant(new VectorValue([3.0, 4.0])) + ) + )?.doubleValue + ).to.equal(5.0); + }); + + it('zeroVector', () => { + expect( + evaluateToValue( + euclideanDistance( + constant(new VectorValue([0.0, 0.0])), + constant(new VectorValue([0.0, 0.0])) + ) + )?.doubleValue + ).to.equal(0.0); + }); + + it('emptyVectors', () => { + expect( + evaluateToValue( + euclideanDistance( + constant(new VectorValue([])), + constant(new VectorValue([])) + ) + )?.doubleValue + ).to.equal(0.0); + }); + + it('differentVectorLengths_returnError', () => { + expect( + evaluateToValue( + euclideanDistance( + constant(new VectorValue([1.0])), + constant(new VectorValue([2.0, 3.0])) + ) + ) + ).to.be.undefined; + }); + + it('wrongInputType_returnError', () => { + expect( + evaluateToValue( + euclideanDistance( + constant(new VectorValue([1.0, 2.0])), + constantArray([3.0, 4.0]) + ) + ) + ).to.be.undefined; + }); + }); // end describe('euclideanDistance') + + describe('vectorLength', () => { + it('length', () => { + expectEqual( + evaluateToValue(vectorLength(constant(new VectorValue([0.0, 1.0])))), + constant(2) + ); + }); + + it('emptyVector', () => { + expectEqual( + evaluateToValue(vectorLength(constant(new VectorValue([])))), + constant(0) + ); + }); + + it('zeroVector', () => { + expectEqual( + evaluateToValue(vectorLength(constant(new VectorValue([0.0])))), + constant(1) + ); + }); + + it('notVectorType_returnsError', () => { + expect(evaluateToValue(vectorLength(constantArray([1])))).to.be.undefined; + expect(evaluateToValue(vectorLength(constant('notAnArray')))).to.be + .undefined; + }); + }); // end describe('vectorLength') +}); // end describe('Vector Functions') diff --git a/packages/firestore/test/unit/core/pipeline/canonify_eq.test.ts b/packages/firestore/test/unit/core/pipeline/canonify_eq.test.ts new file mode 100644 index 00000000000..ccdcbbab39f --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/canonify_eq.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); +describe('Pipeline Canonify', () => { + it('works as expected for simple where clause', () => { + const p = db.pipeline().collection('test').where(eq(`foo`, 42)); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|where(fn(eq,[fld(foo),cst(42)]))|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for multiple stages', () => { + const p = db + .pipeline() + .collection('test') + .where(eq(`foo`, 42)) + .limit(10) + .sort(field('bar').descending()); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|where(fn(eq,[fld(foo),cst(42)]))|sort(fld(__name__)ascending)|limit(10)|sort(fld(bar)descending,fld(__name__)ascending)' + ); + }); + + it('works as expected for addFields stage', () => { + const p = db + .pipeline() + .collection('test') + .addFields(field('existingField'), constant(10).as('val')); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|add_fields(__create_time__=fld(__create_time__),__name__=fld(__name__),__update_time__=fld(__update_time__),existingField=fld(existingField),val=cst(10))|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for aggregate stage with grouping', () => { + const p = db + .pipeline() + .collection('test') + .aggregate({ + accumulators: [field('value').sum().as('totalValue')], + groups: ['category'] + }); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|aggregate(totalValue=fn(sum,[fld(value)]))grouping(category=fld(category))|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for distinct stage', () => { + const p = db.pipeline().collection('test').distinct('category', 'city'); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|distinct(category=fld(category),city=fld(city))|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for select stage', () => { + const p = db.pipeline().collection('test').select('name', field('age')); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|select(__create_time__=fld(__create_time__),__name__=fld(__name__),__update_time__=fld(__update_time__),age=fld(age),name=fld(name))|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for offset stage', () => { + const p = db.pipeline().collection('test').offset(5); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|offset(5)|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for FindNearest stage', () => { + const p = db + .pipeline() + .collection('test') + .findNearest({ + field: field('location'), + vectorValue: [1, 2, 3], + distanceMeasure: 'cosine', + limit: 10, + distanceField: 'distance' + }); + + // Note: The exact string representation of the mapValue might vary depending on + // how GeoPoint is implemented. Adjust the expected string accordingly. + expect(canonifyPipeline(p)).to.equal( + 'collection(/test)|find_nearest(fld(location),cosine,[1,2,3],10,distance)|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for CollectionGroupSource stage', () => { + const p = db.pipeline().collectionGroup('cities'); + + expect(canonifyPipeline(p)).to.equal( + 'collection_group(cities)|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for DatabaseSource stage', () => { + const p = db.pipeline().database(); // Assuming you have a `database()` method on your `db` object + + expect(canonifyPipeline(p)).to.equal( + 'database()|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for DocumentsSource stage', () => { + const p = db + .pipeline() + .documents([docRef(db, 'cities/SF'), docRef(db, 'cities/LA')]); + + expect(canonifyPipeline(p)).to.equal( + 'documents(/cities/LA,/cities/SF)|sort(fld(__name__)ascending)' + ); + }); + + it('works as expected for eqAny and arrays', () => { + const p = db + .pipeline() + .collection('foo') + .where(field('bar').eqAny(['a', 'b'])); + + expect(canonifyPipeline(p)).to.equal( + 'collection(/foo)|where(fn(eq_any,[fld(bar),list([cst("a"),cst("b")])]))|sort(fld(__name__)ascending)' + ); + }); +}); + +describe('pipelineEq', () => { + it('returns true for identical pipelines', () => { + const p1 = db.pipeline().collection('test').where(eq(`foo`, 42)); + const p2 = db.pipeline().collection('test').where(eq(`foo`, 42)); + + expect(pipelineEq(p1, p2)).to.be.true; + }); + + it('returns false for pipelines with different stages', () => { + const p1 = db.pipeline().collection('test').where(eq(`foo`, 42)); + const p2 = db.pipeline().collection('test').limit(10); + + expect(pipelineEq(p1, p2)).to.be.false; + }); + + it('returns false for pipelines with different parameters within a stage', () => { + const p1 = db.pipeline().collection('test').where(eq(`foo`, 42)); + const p2 = db + .pipeline() + .collection('test') + .where(eq(field(`bar`), 42)); + + expect(pipelineEq(p1, p2)).to.be.false; + }); + + it('returns false for pipelines with different order of stages', () => { + const p1 = db.pipeline().collection('test').where(eq(`foo`, 42)).limit(10); + const p2 = db.pipeline().collection('test').limit(10).where(eq(`foo`, 42)); + + expect(pipelineEq(p1, p2)).to.be.false; + }); + + it('returns true for for different select order', () => { + const p1 = db + .pipeline() + .collection('test') + .where(eq(`foo`, 42)) + .select('foo', 'bar'); + const p2 = db + .pipeline() + .collection('test') + .where(eq(`foo`, 42)) + .select('bar', 'foo'); + + expect(pipelineEq(p1, p2)).to.be.true; + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/collection.test.ts b/packages/firestore/test/unit/core/pipeline/collection.test.ts new file mode 100644 index 00000000000..2974571e580 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/collection.test.ts @@ -0,0 +1,415 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('collection stage', () => { + it('emptyDatabase_returnsNoResults', () => { + expect(runPipeline(db.pipeline().collection('/users'), [])).to.be.empty; + }); + + it('emptyCollection_otherCollectionIds_returnsNoResults', () => { + const doc1 = doc('users/alice/games/doc1', 1000, { title: 'minecraft' }); + const doc2 = doc('users/charlie/games/doc1', 1000, { title: 'halo' }); + + expect( + runPipeline(db.pipeline().collection('/users/bob/games'), [doc1, doc2]) + ).to.be.empty; + }); + + it('emptyCollection_otherParents_returnsNoResults', () => { + const doc1 = doc('users/bob/addresses/doc1', 1000, { city: 'New York' }); + const doc2 = doc('users/bob/inventories/doc1', 1000, { item_id: 42 }); + + expect( + runPipeline(db.pipeline().collection('/users/bob/games'), [doc1, doc2]) + ).to.be.empty; + }); + + it('singleton_atRoot_returnsSingleDocument', () => { + const doc1 = doc('games/42', 1000, { title: 'minecraft' }); + const doc2 = doc('users/bob', 1000, { score: 90, rank: 1 }); + expect( + runPipeline(db.pipeline().collection('/users'), [doc1, doc2]) + ).to.deep.equal([doc2]); + }); + + it('singleton_nestedCollection_returnsSingleDocument', () => { + const doc1 = doc('users/bob/addresses/doc1', 1000, { city: 'New York' }); + const doc2 = doc('users/bob/games/doc1', 1000, { title: 'minecraft' }); + const doc3 = doc('users/alice/games/doc1', 1000, { title: 'halo' }); + + expect( + runPipeline(db.pipeline().collection('/users/bob/games'), [ + doc1, + doc2, + doc3 + ]) + ).to.deep.equal([doc2]); + }); + + it('multipleDocuments_atRoot_returnsDocuments', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + const doc4 = doc('games/doc1', 1000, { title: 'minecraft' }); + + expect( + runPipeline(db.pipeline().collection('/users'), [doc1, doc2, doc3, doc4]) + ).to.deep.equal([doc2, doc1, doc3]); + }); + + it('multipleDocuments_nestedCollection_returnsDocuments', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + const doc4 = doc('games/doc1', 1000, { title: 'minecraft' }); + + expect( + runPipeline(db.pipeline().collection('/users'), [doc1, doc2, doc3, doc4]) + ).to.deep.equal([doc2, doc1, doc3]); + }); + + it('subcollection_notReturned', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/bob/games/minecraft', 1000, { + title: 'minecraft' + }); + const doc3 = doc('users/bob/games/minecraft/players/player1', 1000, { + location: 'sf' + }); + + expect( + runPipeline(db.pipeline().collection('/users'), [doc1, doc2, doc3]) + ).to.deep.equal([doc1]); + }); + + it('skipsOtherCollectionIds', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users-other/bob', 1000, { score: 90, rank: 1 }); + const doc3 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc4 = doc('users-other/alice', 1000, { score: 50, rank: 3 }); + const doc5 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + const doc6 = doc('users-other/charlie', 1000, { score: 97, rank: 2 }); + + expect( + runPipeline(db.pipeline().collection('/users'), [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6 + ]) + ).to.deep.equal([doc3, doc1, doc5]); + }); + + it('skipsOtherParents', () => { + const doc1 = doc('users/bob/games/doc1', 1000, { score: 90 }); + const doc2 = doc('users/alice/games/doc1', 1000, { score: 90 }); + const doc3 = doc('users/bob/games/doc2', 1000, { score: 20 }); + const doc4 = doc('users/charlie/games/doc1', 1000, { score: 20 }); + const doc5 = doc('users/bob/games/doc3', 1000, { score: 30 }); + const doc6 = doc('users/alice/games/doc1', 1000, { score: 30 }); + + expect( + runPipeline(db.pipeline().collection('/users/bob/games'), [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6 + ]) + ).to.deep.equal([doc1, doc3, doc5]); + }); + + it('where_onValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + const doc4 = doc('users/diane', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('score'), [constant(90), constant(97)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1, + doc3, + doc4 + ]); + }); + + // it('where_sameCollectionId_onPath', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline().collection('/users').where( + // eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('users')) + // ); + // + // expect( + // runPipeline(pipeline, [doc1, doc2, doc3]) + // ).to.deep.equal([doc1, doc2, doc3]); + // }); + + // it('where_sameCollectionId_onKey', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline().collection('/users').where( + // eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('users')) + // ); + // + // expect( + // runPipeline(pipeline, [doc1, doc2, doc3]) + // ).to.deep.equal([doc1, doc2, doc3]); + // }); + // + // it('where_differentCollectionId_onPath', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline().collection('/users').where( + // eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('games')) + // ); + // + // expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + // }); + // + // it('where_differentCollectionId_onKey', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline().collection('/users').where( + // eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('games')) + // ); + // + // expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + // }); + + it('where_onValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + const doc4 = doc('users/diane', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('score'), [constant(90), constant(97)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1, + doc3, + doc4 + ]); + }); + + it('where_inequalityOnValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('score'), constant(80))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_notEqualOnValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(neq(field('score'), constant(50))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_arrayContainsValues', () => { + const doc1 = doc('users/bob', 1000, { + score: 90, + rounds: ['round1', 'round3'] + }); + const doc2 = doc('users/alice', 1000, { + score: 50, + rounds: ['round2', 'round4'] + }); + const doc3 = doc('users/charlie', 1000, { + score: 97, + rounds: ['round2', 'round3', 'round4'] + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContains(field('rounds'), constant('round3')) as BooleanExpr); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('sort_onValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('score').descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1, + doc2 + ]); + }); + + it('sort_onPath', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1, + doc3 + ]); + }); + + it('limit', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field(DOCUMENT_KEY_NAME).ascending()) + .limit(2); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1 + ]); + }); + + it('sort_onKey_ascending', () => { + const doc1 = doc('users/bob/games/a', 1000, { title: 'minecraft' }); + const doc2 = doc('users/bob/games/b', 1000, { title: 'halo' }); + const doc3 = doc('users/bob/games/c', 1000, { title: 'mariocart' }); + const doc4 = doc('users/bob/inventories/a', 1000, { type: 'sword' }); + const doc5 = doc('users/alice/games/c', 1000, { title: 'skyrim' }); + + const pipeline = db + .pipeline() + .collection('/users/bob/games') + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3] + ); + }); + + it('sort_onKey_descending', () => { + const doc1 = doc('users/bob/games/a', 1000, { title: 'minecraft' }); + const doc2 = doc('users/bob/games/b', 1000, { title: 'halo' }); + const doc3 = doc('users/bob/games/c', 1000, { title: 'mariocart' }); + const doc4 = doc('users/bob/inventories/a', 1000, { type: 'sword' }); + const doc5 = doc('users/alice/games/c', 1000, { title: 'skyrim' }); + + const pipeline = db + .pipeline() + .collection('/users/bob/games') + .sort(field(DOCUMENT_KEY_NAME).descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc2, doc1] + ); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/collection_group.test.ts b/packages/firestore/test/unit/core/pipeline/collection_group.test.ts new file mode 100644 index 00000000000..b293a4a8898 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/collection_group.test.ts @@ -0,0 +1,384 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('collection group stage', () => { + it('returns no result from empty db', () => { + expect(runPipeline(db.pipeline().collectionGroup('users'), [])).to.be.empty; + }); + + it('returns single document', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + + expect( + runPipeline(db.pipeline().collectionGroup('users'), [doc1]) + ).to.deep.equal([doc1]); + }); + + it('returns multiple documents', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + expect( + runPipeline(db.pipeline().collectionGroup('users'), [doc1, doc2, doc3]) + ).to.deep.equal([doc2, doc1, doc3]); + }); + + it('skips other collection ids', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users-other/bob', 1000, { score: 90 }); + const doc3 = doc('users/alice', 1000, { score: 50 }); + const doc4 = doc('users-other/alice', 1000, { score: 50 }); + const doc5 = doc('users/charlie', 1000, { score: 97 }); + const doc6 = doc('users-other/charlie', 1000, { score: 97 }); + + expect( + runPipeline(db.pipeline().collectionGroup('users'), [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6 + ]) + ).to.deep.equal([doc3, doc1, doc5]); + }); + + it('different parents', () => { + const doc1 = doc('users/bob/games/game1', 1000, { score: 90, order: 1 }); + const doc2 = doc('users/alice/games/game1', 1000, { + score: 90, + order: 2 + }); + const doc3 = doc('users/bob/games/game2', 1000, { score: 20, order: 3 }); + const doc4 = doc('users/charlie/games/game1', 1000, { + score: 20, + order: 4 + }); + const doc5 = doc('users/bob/games/game3', 1000, { score: 30, order: 5 }); + const doc6 = doc('users/alice/games/game2', 1000, { + score: 30, + order: 6 + }); + const doc7 = doc('users/charlie/profiles/profile1', 1000, { order: 7 }); + + expect( + runPipeline( + db.pipeline().collectionGroup('games').sort(field('order').ascending()), + [doc1, doc2, doc3, doc4, doc5, doc6, doc7] + ) + ).to.deep.equal([doc1, doc2, doc3, doc4, doc5, doc6]); + }); + + it('different parents_stableOrdering_onPath', () => { + const doc1 = doc('users/bob/games/1', 1000, { score: 90 }); + const doc2 = doc('users/alice/games/2', 1000, { score: 90 }); + const doc3 = doc('users/bob/games/3', 1000, { score: 20 }); + const doc4 = doc('users/charlie/games/4', 1000, { score: 20 }); + const doc5 = doc('users/bob/games/5', 1000, { score: 30 }); + const doc6 = doc('users/alice/games/6', 1000, { score: 30 }); + const doc7 = doc('users/charlie/profiles/7', 1000, {}); + + const pipeline = db + .pipeline() + .collectionGroup('games') + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7]) + ).to.deep.equal([doc2, doc6, doc1, doc3, doc5, doc4]); + }); + + it('different parents_stableOrdering_onKey', () => { + const doc1 = doc('users/bob/games/1', 1000, { score: 90 }); + const doc2 = doc('users/alice/games/2', 1000, { score: 90 }); + const doc3 = doc('users/bob/games/3', 1000, { score: 20 }); + const doc4 = doc('users/charlie/games/4', 1000, { score: 20 }); + const doc5 = doc('users/bob/games/5', 1000, { score: 30 }); + const doc6 = doc('users/alice/games/6', 1000, { score: 30 }); + const doc7 = doc('users/charlie/profiles/7', 1000, {}); + + const pipeline = db + .pipeline() + .collectionGroup('games') + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7]) + ).to.deep.equal([doc2, doc6, doc1, doc3, doc5, doc4]); + }); + + // TODO(pipeline): Uncomment when we implement collection id + // it('where_sameCollectionId_onPath', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline() + // .collectionGroup('users') + // .where(eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('users'))); + // + // expect( + // runPipeline(pipeline, [doc1, doc2, doc3]) + // ).to.deep.equal([doc1, doc2, doc3]); + // }); + // + // it('where_sameCollectionId_onKey', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline() + // .collectionGroup('users') + // .where(eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('users'))); + // + // expect( + // runPipeline(pipeline, [doc1, doc2, doc3]) + // ).to.deep.equal([doc1, doc2, doc3]); + // }); + + // it('where_differentCollectionId_onPath', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline() + // .collectionGroup('users') + // .where(eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('games'))); + // + // expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + // }); + // + // it('where_differentCollectionId_onKey', () => { + // const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + // const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + // const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + // + // const pipeline = db.pipeline() + // .collectionGroup('users') + // .where(eq(collectionId(field('DOCUMENT_KEY_NAME')), constant('games'))); + // + // expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + // }); + + it('where_onValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + const doc4 = doc('users/diane', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(eqAny(field('score'), [constant(90), constant(97)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1, + doc3, + doc4 + ]); + }); + + it('where_inequalityOnValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(field('score').gt(constant(80))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_notEqualOnValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(field('score').neq(constant(50))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_arrayContainsValues', () => { + const doc1 = doc('users/bob', 1000, { + score: 90, + rounds: ['round1', 'round3'] + }); + const doc2 = doc('users/alice', 1000, { + score: 50, + rounds: ['round2', 'round4'] + }); + const doc3 = doc('users/charlie', 1000, { + score: 97, + rounds: ['round2', 'round3', 'round4'] + }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(arrayContains(field('rounds'), constant('round3')) as BooleanExpr); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('sort_onValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .sort(field('score').descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1, + doc2 + ]); + }); + + it('sort_onValues has dense semantics', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { number: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .sort(field('score').descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc2, + doc3 + ]); + }); + + it('sort_onPath', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1, + doc3 + ]); + }); + + it('limit', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .sort(field(DOCUMENT_KEY_NAME).ascending()) + .limit(2); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1 + ]); + }); + + it('offset', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .sort(field(DOCUMENT_KEY_NAME).ascending()) + .offset(1); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc3 + ]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/complex.test.ts b/packages/firestore/test/unit/core/pipeline/complex.test.ts new file mode 100644 index 00000000000..6817b99fff3 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/complex.test.ts @@ -0,0 +1,381 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Complex Queries', () => { + const COLLECTION_ID = 'test'; + let docIdCounter = 1; + + beforeEach(() => { + docIdCounter = 1; + }); + + function seedDatabase( + numOfDocuments: number, + numOfFields: number, + valueSupplier: () => any + ): MutableDocument[] { + const documents = []; + for (let i = 0; i < numOfDocuments; i++) { + const docData = {}; + for (let j = 1; j <= numOfFields; j++) { + // @ts-ignore + docData[`field_${j}`] = valueSupplier(); + } + const newDoc = doc(`${COLLECTION_ID}/${docIdCounter}`, 1000, docData); + documents.push(newDoc); + docIdCounter++; + } + return documents; + } + + it('where_withMaxNumberOfStages', () => { + const numOfFields = 127; + let valueCounter = 1; + const documents = seedDatabase(10, numOfFields, () => valueCounter++); + + // TODO(pipeline): Why do i need this hack? + let pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where(eq(constant(1), 1)); + for (let i = 1; i <= numOfFields; i++) { + pipeline = pipeline.where(gt(field(`field_${i}`), constant(0))); + } + + expect(runPipeline(pipeline, documents)).to.have.deep.members(documents); + }); + + it('eqAny_withMaxNumberOfElements', () => { + const numOfDocuments = 1000; + let valueCounter = 1; + const documents = seedDatabase(numOfDocuments, 1, () => valueCounter++); + // Add one more document not matching 'in' condition + documents.push( + doc(`${COLLECTION_ID}/${docIdCounter}`, 1000, { field_1: 3001 }) + ); + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where( + eqAny( + field('field_1'), + Array.from({ length: 3000 }, (_, i) => constant(i + 1)) + ) + ); + + expect(runPipeline(pipeline, documents)).to.have.deep.members( + documents.slice(0, -1) + ); // Exclude the last document + }); + + it('eqAny_withMaxNumberOfElements_onMultipleFields', () => { + const numOfFields = 10; + const numOfDocuments = 100; + let valueCounter = 1; + const documents = seedDatabase( + numOfDocuments, + numOfFields, + () => valueCounter++ + ); + // Add one more document not matching 'in' condition + documents.push( + doc(`${COLLECTION_ID}/${docIdCounter}`, 1000, { field_1: 3001 }) + ); + + const conditions = []; + for (let i = 1; i <= numOfFields; i++) { + conditions.push( + eqAny( + field(`field_${i}`), + Array.from({ length: 3000 }, (_, j) => constant(j + 1)) + ) + ); + } + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where(apiAnd(conditions[0], conditions[1], ...conditions.slice(2))); + + expect(runPipeline(pipeline, documents)).to.have.deep.members( + documents.slice(0, -1) + ); // Exclude the last document + }); + + it('notEqAny_withMaxNumberOfElements', () => { + const numOfDocuments = 1000; + let valueCounter = 1; + const documents = seedDatabase(numOfDocuments, 1, () => valueCounter++); + // Add one more document matching 'notEqAny' condition + const doc1 = doc(`${COLLECTION_ID}/${docIdCounter}`, 1000, { + field_1: 3001 + }); + documents.push(doc1); + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where( + notEqAny( + field('field_1'), + Array.from({ length: 3000 }, (_, i) => constant(i + 1)) + ) + ); + + expect(runPipeline(pipeline, documents)).to.have.deep.members([doc1]); + }); + + it('notEqAny_withMaxNumberOfElements_onMultipleFields', () => { + const numOfFields = 10; + const numOfDocuments = 100; + let valueCounter = 1; + const documents = seedDatabase( + numOfDocuments, + numOfFields, + () => valueCounter++ + ); + // Add one more document matching 'notEqAny' condition + const doc1 = doc(`${COLLECTION_ID}/${docIdCounter}`, 1000, { + field_1: 3001 + }); + documents.push(doc1); + + const conditions = []; + for (let i = 1; i <= numOfFields; i++) { + conditions.push( + notEqAny( + field(`field_${i}`), + Array.from({ length: 3000 }, (_, j) => constant(j + 1)) + ) + ); + } + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where(apiOr(conditions[0], conditions[1], ...conditions.slice(2))); + + expect(runPipeline(pipeline, documents)).to.have.deep.members([doc1]); + }); + + it('arrayContainsAny_withLargeNumberOfElements', () => { + const numOfDocuments = 1000; + let valueCounter = 1; + const documents = seedDatabase(numOfDocuments, 1, () => [valueCounter++]); + // Add one more document not matching 'arrayContainsAny' condition + documents.push( + doc(`${COLLECTION_ID}/${docIdCounter}`, 1000, { field_1: [3001] }) + ); + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where( + arrayContainsAny( + field('field_1'), + Array.from({ length: 3000 }, (_, i) => constant(i + 1)) + ) + ); + + expect(runPipeline(pipeline, documents)).to.have.deep.members( + documents.slice(0, -1) + ); // Exclude the last document + }); + + it('arrayContainsAny_withMaxNumberOfElements_onMultipleFields', () => { + const numOfFields = 10; + const numOfDocuments = 100; + let valueCounter = 1; + const documents = seedDatabase(numOfDocuments, numOfFields, () => [ + valueCounter++ + ]); + // Add one more document not matching 'arrayContainsAny' condition + documents.push( + doc(`${COLLECTION_ID}/${docIdCounter}`, 1000, { field_1: [3001] }) + ); + + const conditions = []; + for (let i = 1; i <= numOfFields; i++) { + conditions.push( + arrayContainsAny( + field(`field_${i}`), + Array.from({ length: 3000 }, (_, j) => constant(j + 1)) + ) + ); + } + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where(apiOr(conditions[0], conditions[1], ...conditions.slice(2))); + + expect(runPipeline(pipeline, documents)).to.have.deep.members( + documents.slice(0, -1) + ); // Exclude the last document + }); + + it('sortByMaxNumOfFields_withoutIndex', () => { + const numOfFields = 31; + const numOfDocuments = 100; + // Passing a constant value here to reduce the complexity on result assertion. + const documents = seedDatabase(numOfDocuments, numOfFields, () => 10); + // sort(field_1, field_2...) + const sortFields = []; + for (let i = 1; i <= numOfFields; i++) { + sortFields.push(field('field_' + i).ascending()); + } + // add __name__ as the last field in sort. + sortFields.push(field('__name__').ascending()); + + const pipeline = db + .pipeline() + .collection('/' + COLLECTION_ID) + .sort(sortFields[0], ...sortFields.slice(1)); + + expect(runPipeline(pipeline, documents)).to.have.deep.members(documents); + }); + + it('where_withNestedAddFunction_maxDepth', () => { + const numOfFields = 1; + const numOfDocuments = 10; + const documents = seedDatabase(numOfDocuments, numOfFields, () => 0); + + const depth = 31; + let addFunc = add(field('field_1'), constant(1)); + for (let i = 1; i < depth; i++) { + addFunc = add(addFunc, constant(1)); + } + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where(gt(addFunc, constant(0))); + + expect(runPipeline(pipeline, documents)).to.have.deep.members(documents); + }); + + it('where_withLargeNumberOrs', () => { + const numOfFields = 100; + const numOfDocuments = 50; + let valueCounter = 1; + const documents = seedDatabase( + numOfDocuments, + numOfFields, + () => valueCounter++ + ); + + const orConditions = []; + for (let i = 1; i <= numOfFields; i++) { + orConditions.push(lte(field(`field_${i}`), constant(valueCounter))); + } + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where(apiOr(orConditions[0], orConditions[1], ...orConditions.slice(2))); + + expect(runPipeline(pipeline, documents)).to.have.deep.members(documents); + }); + + it('where_withLargeNumberOfConjunctions', () => { + const numOfFields = 50; + const numOfDocuments = 100; + let valueCounter = 1; + const documents = seedDatabase( + numOfDocuments, + numOfFields, + () => valueCounter++ + ); + + const andConditions1 = []; + const andConditions2 = []; + for (let i = 1; i <= numOfFields; i++) { + andConditions1.push(gt(field(`field_${i}`), constant(0))); + andConditions2.push( + lt(field(`field_${i}`), constant(Number.MAX_SAFE_INTEGER)) + ); + } + + const pipeline = db + .pipeline() + .collection(`/${COLLECTION_ID}`) + .where( + or( + apiAnd( + andConditions1[0], + andConditions1[1], + ...andConditions1.slice(2) + ), + apiAnd( + andConditions2[0], + andConditions2[1], + ...andConditions2.slice(2) + ) + ) + ); + + expect(runPipeline(pipeline, documents)).to.have.deep.members(documents); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/database.test.ts b/packages/firestore/test/unit/core/pipeline/database.test.ts new file mode 100644 index 00000000000..ad86a133855 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/database.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('database stage', () => { + it('emptyDatabase_returnsEmptyResults', () => { + expect(runPipeline(db.pipeline().database(), [])).to.be.empty; + }); + + it('returnsAllDocuments', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + expect( + runPipeline(db.pipeline().database(), [doc1, doc2, doc3]) + ).to.deep.equal([doc2, doc1, doc3]); + }); + + it('returnsMultipleCollections', () => { + const doc1 = doc('a/doc1', 1000, { score: 90, rank: 1 }); + const doc2 = doc('b/doc1', 1000, { score: 50, rank: 3 }); + const doc3 = doc('c/doc1', 1000, { score: 97, rank: 2 }); + + expect( + runPipeline(db.pipeline().database(), [doc1, doc2, doc3]) + ).to.deep.equal([doc1, doc2, doc3]); + }); + + it('where_onKey', () => { + const doc1 = doc('a/1', 1000, { score: 90, rank: 1 }); + const doc2 = doc('b/2', 1000, { score: 50, rank: 3 }); + const doc3 = doc('c/3', 1000, { score: 97, rank: 2 }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field(DOCUMENT_KEY_NAME), constant(docRef(db, 'b/2')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/disjunctive.test.ts b/packages/firestore/test/unit/core/pipeline/disjunctive.test.ts new file mode 100644 index 00000000000..9513bcf18ac --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/disjunctive.test.ts @@ -0,0 +1,1672 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Disjunctive Queries', () => { + it('basicEqAny', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane'), + constant('eric') + ]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4, doc5] + ); + }); + + it('multipleEqAny', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane'), + constant('eric') + ]), + eqAny(field('age'), [constant(10), constant(25)]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc2, doc4, doc5] + ); + }); + + it('eqAny_multipleStages', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane'), + constant('eric') + ]) + ) + .where(eqAny(field('age'), [constant(10), constant(25)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc2, doc4, doc5] + ); + }); + + it('multipleEqAnys_withOr', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or( + eqAny(field('name'), [constant('alice'), constant('bob')]), + eqAny(field('age'), [constant(10), constant(25)]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc4, doc5] + ); + }); + + it('eqAny_onCollectionGroup', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('other_users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('root/child/users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('root/child/other_users/e', 1000, { + name: 'eric', + age: 10 + }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('diane'), + constant('eric') + ]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc4, doc1] + ); + }); + + it('eqAny_withSortOnDifferentField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('diane'), + constant('eric') + ]) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.members([doc4, doc5, doc2, doc1]); + }); + + it('eqAny_withSortOnEqAnyField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('diane'), + constant('eric') + ]) + ) + .sort(field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc1, doc2, doc4, doc5]); + }); + + it('eqAny_withAdditionalEquality_differentFields', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane'), + constant('eric') + ]), + eq(field('age'), constant(10)) + ) + ) + .sort(field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc5]); + }); + + it('eqAny_withAdditionalEquality_sameField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [ + constant('alice'), + constant('diane'), + constant('eric') + ]), + eq(field('name'), constant('eric')) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc5] + ); + }); + + it('eqAny_withAdditionalEquality_sameField_emptyResult', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [constant('alice'), constant('bob')]), + eq(field('name'), constant('other')) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('eqAny_withInequalities_exclusiveRange', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiAnd( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane') + ]), + gt(field('age'), constant(10)), + lt(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2] + ); + }); + + it('eqAny_withInequalities_inclusiveRange', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiAnd( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane') + ]), + gte(field('age'), constant(10)), + lte(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4] + ); + }); + + it('eqAny_withInequalitiesAndSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiAnd( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane') + ]), + gt(field('age'), constant(10)), + lt(field('age'), constant(100)) + ) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc2, doc1]); + }); + + it('eqAny_withNotEqual', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane') + ]), + neq(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc4] + ); + }); + + it('eqAny_sortOnEqAnyField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane') + ]) + ) + .sort(field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc1, doc2, doc3, doc4]); + }); + + it('eqAny_singleValue_sortOnInField_ambiguousOrder', () => { + const doc1 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc2 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc3 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('age'), [constant(10)])) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc3 + ]); + }); + + it('eqAny_withExtraEquality_sortOnEqAnyField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane'), + constant('eric') + ]), + eq(field('age'), constant(10)) + ) + ) + .sort(field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc5]); + }); + + it('eqAny_withExtraEquality_sortOnEquality', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane'), + constant('eric') + ]), + eq(field('age'), constant(10)) + ) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc5]); + }); + + it('eqAny_withInequality_onSameField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('age'), [constant(10), constant(25), constant(100)]), + gt(field('age'), constant(20)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc2, doc3] + ); + }); + + it('eqAny_withDifferentInequality_sortOnEqAnyField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('charlie'), + constant('diane') + ]), + gt(field('age'), constant(20)) + ) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc2, doc1, doc3]); + }); + + it('eqAny_containsNull', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: null, age: 25 }); + const doc3 = doc('users/c', 1000, { age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('name'), [constant(null), constant('alice')])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('arrayContains_null', () => { + const doc1 = doc('users/a', 1000, { field: [null, 42] }); + const doc2 = doc('users/b', 1000, { field: [101, null] }); + const doc3 = doc('users/b', 1000, { field: [null] }); + const doc4 = doc('users/c', 1000, { field: ['foo', 'bar'] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContains(field('field'), constant(null)) as BooleanExpr); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([]); + }); + + it('arrayContainsAny_null', () => { + const doc1 = doc('users/a', 1000, { field: [null, 42] }); + const doc2 = doc('users/b', 1000, { field: [101, null] }); + const doc3 = doc('users/c', 1000, { field: ['foo', 'bar'] }); + const doc4 = doc('users/c', 1000, { not_field: ['foo', 'bar'] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + arrayContainsAny(field('field'), [constant(null), constant('foo')]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc3 + ]); + }); + + it('eqAny_containsNullOnly', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: null }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('age'), [constant(null)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([]); + }); + + it('basicArrayContainsAny', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', groups: [1, 2, 3] }); + const doc2 = doc('users/b', 1000, { name: 'bob', groups: [1, 2, 4] }); + const doc3 = doc('users/c', 1000, { name: 'charlie', groups: [2, 3, 4] }); + const doc4 = doc('users/d', 1000, { name: 'diane', groups: [2, 3, 5] }); + const doc5 = doc('users/e', 1000, { name: 'eric', groups: [3, 4, 5] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContainsAny(field('groups'), [constant(1), constant(5)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc4, doc5] + ); + }); + + it('multipleArrayContainsAny', () => { + const doc1 = doc('users/a', 1000, { + name: 'alice', + groups: [1, 2, 3], + records: ['a', 'b', 'c'] + }); + const doc2 = doc('users/b', 1000, { + name: 'bob', + groups: [1, 2, 4], + records: ['b', 'c', 'd'] + }); + const doc3 = doc('users/c', 1000, { + name: 'charlie', + groups: [2, 3, 4], + records: ['b', 'c', 'e'] + }); + const doc4 = doc('users/d', 1000, { + name: 'diane', + groups: [2, 3, 5], + records: ['c', 'd', 'e'] + }); + const doc5 = doc('users/e', 1000, { + name: 'eric', + groups: [3, 4, 5], + records: ['c', 'd', 'f'] + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + arrayContainsAny(field('groups'), [constant(1), constant(5)]), + arrayContainsAny(field('records'), [constant('a'), constant('e')]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc4] + ); + }); + + it('arrayContainsAny_withInequality', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', groups: [1, 2, 3] }); + const doc2 = doc('users/b', 1000, { name: 'bob', groups: [1, 2, 4] }); + const doc3 = doc('users/c', 1000, { name: 'charlie', groups: [2, 3, 4] }); + const doc4 = doc('users/d', 1000, { name: 'diane', groups: [2, 3, 5] }); + const doc5 = doc('users/e', 1000, { name: 'eric', groups: [3, 4, 5] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + arrayContainsAny(field('groups'), [constant(1), constant(5)]), + lt(field('groups'), constantArray([3, 4, 5])) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc4] + ); + }); + + it('arrayContainsAny_withIn', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', groups: [1, 2, 3] }); + const doc2 = doc('users/b', 1000, { name: 'bob', groups: [1, 2, 4] }); + const doc3 = doc('users/c', 1000, { name: 'charlie', groups: [2, 3, 4] }); + const doc4 = doc('users/d', 1000, { name: 'diane', groups: [2, 3, 5] }); + const doc5 = doc('users/e', 1000, { name: 'eric', groups: [3, 4, 5] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + arrayContainsAny(field('groups'), [constant(1), constant(5)]), + eqAny(field('name'), [constant('alice'), constant('bob')]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2] + ); + }); + + it('basicOr', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(eq(field('name'), constant('bob')), eq(field('age'), constant(10))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2, + doc4 + ]); + }); + + it('multipleOr', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiOr( + eq(field('name'), constant('bob')), + eq(field('name'), constant('diane')), + eq(field('age'), constant(25)), + eq(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2, + doc3, + doc4 + ]); + }); + + it('or_multipleStages', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(eq(field('name'), constant('bob')), eq(field('age'), constant(10))) + ) + .where( + or( + eq(field('name'), constant('diane')), + eq(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc4 + ]); + }); + + it('or_twoConjunctions', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or( + and( + eq(field('name'), constant('bob')), + eq(field('age'), constant(25)) + ), + and( + eq(field('name'), constant('diane')), + eq(field('age'), constant(10)) + ) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2, + doc4 + ]); + }); + + it('or_withInAnd', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + or( + eq(field('name'), constant('bob')), + eq(field('age'), constant(10)) + ), + lt(field('age'), constant(80)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2, + doc4 + ]); + }); + + it('andOfTwoOrs', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + or( + eq(field('name'), constant('bob')), + eq(field('age'), constant(10)) + ), + or( + eq(field('name'), constant('diane')), + eq(field('age'), constant(100)) + ) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc4 + ]); + }); + + it('orOfTwoOrs', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or( + or( + eq(field('name'), constant('bob')), + eq(field('age'), constant(10)) + ), + or( + eq(field('name'), constant('diane')), + eq(field('age'), constant(100)) + ) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2, + doc3, + doc4 + ]); + }); + + it('or_withEmptyRangeInOneDisjunction', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or( + eq(field('name'), constant('bob')), + and(eq(field('age'), constant(10)), gt(field('age'), constant(20))) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2 + ]); + }); + + it('or_withSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(eq(field('name'), constant('diane')), gt(field('age'), constant(20))) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc4, doc2, doc1, doc3]); + }); + + it('or_withInequalityAndSort_sameField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(lt(field('age'), constant(20)), gt(field('age'), constant(50)))) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc4, doc1, doc3]); + }); + + it('or_withInequalityAndSort_differentFields', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(lt(field('age'), constant(20)), gt(field('age'), constant(50)))) + .sort(field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc1, doc3, doc4]); + }); + + it('or_withInequalityAndSort_multipleFields', () => { + const doc1 = doc('users/a', 1000, { + name: 'alice', + age: 25, + height: 170 + }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25, height: 180 }); + const doc3 = doc('users/c', 1000, { + name: 'charlie', + age: 100, + height: 155 + }); + const doc4 = doc('users/d', 1000, { + name: 'diane', + age: 10, + height: 150 + }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 25, height: 170 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(lt(field('age'), constant(80)), gt(field('height'), constant(160))) + ) + .sort( + field('age').ascending(), + field('height').descending(), + field('name').ascending() + ); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc2, doc1, doc5]); + }); + + it('or_withSortOnPartialMissingField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'diane' }); + const doc4 = doc('users/d', 1000, { name: 'diane', height: 150 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(eq(field('name'), constant('diane')), gt(field('age'), constant(20))) + ) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.members([ + doc3, + doc4, + doc2, + doc1 + ]); + }); + + it('or_withLimit', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(eq(field('name'), constant('diane')), gt(field('age'), constant(20))) + ) + .sort(field('age').ascending()) + .limit(2); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc4, doc2]); + }); + + // TODO(pipeline): uncomment when we have isNot implemented + it('or_isNullAndEqOnSameField', () => { + const doc1 = doc('users/a', 1000, { a: 1 }); + const doc2 = doc('users/b', 1000, { a: 1.0 }); + const doc3 = doc('users/c', 1000, { a: 1, b: 1 }); + const doc4 = doc('users/d', 1000, { a: null }); + const doc5 = doc('users/e', 1000, { a: NaN }); + const doc6 = doc('users/f', 1000, { b: 'abc' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(eq(field('a'), constant(1)), isNull(field('a')))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6]) + ).to.deep.equal([doc1, doc2, doc3, doc4]); + }); + + it('or_isNullAndEqOnDifferentField', () => { + const doc1 = doc('users/a', 1000, { a: 1 }); + const doc2 = doc('users/b', 1000, { a: 1.0 }); + const doc3 = doc('users/c', 1000, { a: 1, b: 1 }); + const doc4 = doc('users/d', 1000, { a: null }); + const doc5 = doc('users/e', 1000, { a: NaN }); + const doc6 = doc('users/f', 1000, { b: 'abc' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(eq(field('b'), constant(1)), isNull(field('a')))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6]) + ).to.deep.equal([doc3, doc4]); + }); + + it('or_isNotNullAndEqOnSameField', () => { + const doc1 = doc('users/a', 1000, { a: 1 }); + const doc2 = doc('users/b', 1000, { a: 1.0 }); + const doc3 = doc('users/c', 1000, { a: 1, b: 1 }); + const doc4 = doc('users/d', 1000, { a: null }); + const doc5 = doc('users/e', 1000, { a: NaN }); + const doc6 = doc('users/f', 1000, { b: 'abc' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(gt(field('a'), constant(1)), not(isNull(field('a'))))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6]) + ).to.deep.equal([doc1, doc2, doc3, doc5]); + }); + + it('or_isNotNullAndEqOnDifferentField', () => { + const doc1 = doc('users/a', 1000, { a: 1 }); + const doc2 = doc('users/b', 1000, { a: 1.0 }); + const doc3 = doc('users/c', 1000, { a: 1, b: 1 }); + const doc4 = doc('users/d', 1000, { a: null }); + const doc5 = doc('users/e', 1000, { a: NaN }); + const doc6 = doc('users/f', 1000, { b: 'abc' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(eq(field('b'), constant(1)), not(isNull(field('a'))))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6]) + ).to.deep.equal([doc1, doc2, doc3, doc5]); + }); + + it('or_isNullAndIsNaNOnSameField', () => { + const doc1 = doc('users/a', 1000, { a: null }); + const doc2 = doc('users/b', 1000, { a: NaN }); + const doc3 = doc('users/c', 1000, { a: 'abc' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(isNull(field('a')), isNan(field('a')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc2 + ]); + }); + + it('or_isNullAndIsNaNOnDifferentField', () => { + const doc1 = doc('users/a', 1000, { a: null }); + const doc2 = doc('users/b', 1000, { a: NaN }); + const doc3 = doc('users/c', 1000, { a: 'abc' }); + const doc4 = doc('users/d', 1000, { b: null }); + const doc5 = doc('users/e', 1000, { b: NaN }); + const doc6 = doc('users/f', 1000, { b: 'abc' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(or(isNull(field('a')), isNan(field('b')))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6]) + ).to.deep.equal([doc1, doc5]); + }); + + it('basicNotEqAny', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(notEqAny(field('name'), [constant('alice'), constant('bob')])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc4, doc5] + ); + }); + + it('multipleNotEqAnys', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('name'), [constant('alice'), constant('bob')]), + notEqAny(field('age'), [constant(10), constant(25)]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3] + ); + }); + + it('multipileNotEqAnys_withOr', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or( + notEqAny(field('name'), [constant('alice'), constant('bob')]), + notEqAny(field('age'), [constant(10), constant(25)]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc3, doc4, doc5] + ); + }); + + it('notEqAny_onCollectionGroup', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('other_users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('root/child/users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('root/child/other_users/e', 1000, { + name: 'eric', + age: 10 + }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where( + notEqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('diane') + ]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3] + ); + }); + + it('notEqAny_withSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(notEqAny(field('name'), [constant('alice'), constant('diane')])) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc5, doc2, doc3]); + }); + + it('notEqAny_withAdditionalEquality_differentFields', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('name'), [constant('alice'), constant('bob')]), + eq(field('age'), constant(10)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc4, doc5] + ); + }); + + it('notEqAny_withAdditionalEquality_sameField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('name'), [constant('alice'), constant('diane')]), + eq(field('name'), constant('eric')) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc5] + ); + }); + + it('notEqAny_withInequalities_exclusiveRange', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiAnd( + notEqAny(field('name'), [constant('alice'), constant('charlie')]), + gt(field('age'), constant(10)), + lt(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc2] + ); + }); + + it('notEqAny_withInequalities_inclusiveRange', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiAnd( + notEqAny(field('name'), [ + constant('alice'), + constant('bob'), + constant('eric') + ]), + gte(field('age'), constant(10)), + lte(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc4] + ); + }); + + it('notEqAny_withInequalitiesAndSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiAnd( + notEqAny(field('name'), [constant('alice'), constant('diane')]), + gt(field('age'), constant(10)), + lte(field('age'), constant(100)) + ) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc2, doc3]); + }); + + it('notEqAny_withNotEqual', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('name'), [constant('alice'), constant('bob')]), + neq(field('age'), constant(100)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc4, doc5] + ); + }); + + it('notEqAny_sortOnNotEqAnyField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(notEqAny(field('name'), [constant('alice'), constant('bob')])) + .sort(field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc4, doc5]); + }); + + it('notEqAny_singleValue_sortOnNotEqAnyField_ambiguousOrder', () => { + const doc1 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc2 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc3 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(notEqAny(field('age'), [constant(100)])) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.members([ + doc2, + doc3 + ]); + }); + + it('notEqAny_withExtraEquality_sortOnNotEqAnyField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('name'), [constant('alice'), constant('bob')]), + eq(field('age'), constant(10)) + ) + ) + .sort(field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc5]); + }); + + it('notEqAny_withExtraEquality_sortOnEquality', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('name'), [constant('alice'), constant('bob')]), + eq(field('age'), constant(10)) + ) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.members([doc4, doc5]); + }); + + it('notEqAny_withInequality_onSameField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('age'), [constant(10), constant(100)]), + gt(field('age'), constant(20)) + ) + ) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc2, doc1] + ); + }); + + it('notEqAny_withDifferentInequality_sortOnInField', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + notEqAny(field('name'), [constant('alice'), constant('diane')]), + gt(field('age'), constant(20)) + ) + ) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc2, doc3]); + }); + + it('noLimitOnNumOfDisjunctions', () => { + const doc1 = doc('users/a', 1000, { + name: 'alice', + age: 25, + height: 170 + }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25, height: 180 }); + const doc3 = doc('users/c', 1000, { + name: 'charlie', + age: 100, + height: 155 + }); + const doc4 = doc('users/d', 1000, { + name: 'diane', + age: 10, + height: 150 + }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 25, height: 170 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiOr( + eq(field('name'), constant('alice')), + eq(field('name'), constant('bob')), + eq(field('name'), constant('charlie')), + eq(field('name'), constant('diane')), + eq(field('age'), constant(10)), + eq(field('age'), constant(25)), + eq(field('age'), constant(40)), + eq(field('age'), constant(100)), + eq(field('height'), constant(150)), + eq(field('height'), constant(160)), + eq(field('height'), constant(170)), + eq(field('height'), constant(180)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4, doc5] + ); + }); + + it('eqAny_duplicateValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + eqAny(field('score'), [ + constant(50), + constant(97), + constant(97), + constant(97) + ]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc3 + ]); + }); + + it('notEqAny_duplicateValues', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + notEqAny(field('score'), [constant(50), constant(50), constant(true)]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('arrayContainsAny_duplicateValues', () => { + const doc1 = doc('users/a', 1000, { scores: [1, 2, 3] }); + const doc2 = doc('users/b', 1000, { scores: [4, 5, 6] }); + const doc3 = doc('users/c', 1000, { scores: [7, 8, 9] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + arrayContainsAny(field('scores'), [ + constant(1), + constant(2), + constant(2), + constant(2) + ]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('arrayContainsAll_duplicateValues', () => { + const doc1 = doc('users/a', 1000, { scores: [1, 2, 3] }); + const doc2 = doc('users/b', 1000, { scores: [1, 2, 2, 2, 3] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + arrayContainsAny(field('scores'), [ + constant(1), + constant(2), + constant(2), + constant(2), + constant(3) + ]) + ); + + expect(runPipeline(pipeline, [doc1, doc2])).to.deep.equal([doc1, doc2]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/documents.test.ts b/packages/firestore/test/unit/core/pipeline/documents.test.ts new file mode 100644 index 00000000000..32279dd2b77 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/documents.test.ts @@ -0,0 +1,285 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('documents stage', () => { + it('emptyRequest_isRejected', () => { + expect(() => runPipeline(db.pipeline().documents([]), [])).to.throw(); + }); + + it('duplicateKeys_isRejected', () => { + expect(() => + runPipeline( + db + .pipeline() + .documents([ + docRef(db, '/k/1'), + docRef(db, '/k/2'), + docRef(db, '/k/1') + ]), + [] + ) + ).to.throw(); + }); + + it('emptyDatabase_returnsNoResults', () => { + expect(runPipeline(db.pipeline().documents([docRef(db, '/users/a')]), [])) + .to.be.empty; + }); + + it('singleDocument_returnsDocument', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + expect( + runPipeline(db.pipeline().documents([docRef(db, '/users/bob')]), [doc1]) + ).to.deep.equal([doc1]); + }); + + it('singleMissingDocument_returnsNoResults', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + expect( + runPipeline(db.pipeline().documents([docRef(db, '/users/alice')]), [doc1]) + ).to.be.empty; + }); + + it('multipleDocuments_returnsDocuments', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + expect( + runPipeline( + db + .pipeline() + .documents([ + docRef(db, '/users/bob'), + docRef(db, '/users/alice'), + docRef(db, '/users/charlie') + ]), + [doc1, doc2, doc3] + ) + ).to.deep.equal([doc2, doc1, doc3]); + }); + + it('hugeDocumentCount_returnsDocuments', function () { + this.timeout(10000); // Increase timeout for this test case to 10 seconds + + const size = 5000; + const keys = []; + const docs = []; + for (let i = 0; i < size; i++) { + keys.push(docRef(db, '/k/' + (i + 1))); + docs.push(doc('k/' + (i + 1), 1000, { v: i })); + } + + expect( + runPipeline( + db.pipeline().documents(keys).sort(field('v').ascending()), + docs + ) + ).to.deep.equal(docs); + }); + + it('partiallyMissingDocuments_returnsDocuments', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/diane', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + expect( + runPipeline( + db + .pipeline() + .documents([ + docRef(db, '/users/bob'), + docRef(db, '/users/alice'), + docRef(db, '/users/charlie') + ]), + [doc1, doc2, doc3] + ) + ).to.deep.equal([doc1, doc3]); + }); + + it('multipleCollections_returnsDocuments', () => { + const doc1 = doc('c/1', 1000, { score: 90, rank: 1 }); + const doc2 = doc('b/2', 1000, { score: 50, rank: 3 }); + const doc3 = doc('a/3', 1000, { score: 97, rank: 2 }); + + expect( + runPipeline( + db + .pipeline() + .documents([ + docRef(db, '/a/3'), + docRef(db, '/b/2'), + docRef(db, '/c/1') + ]) + .sort(field(DOCUMENT_KEY_NAME).ascending()), + [doc1, doc2, doc3] + ) + ).to.deep.equal([doc3, doc2, doc1]); + }); + + it('sort_onPath_ascending', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + const pipeline = db + .pipeline() + .documents([ + docRef(db, '/users/bob'), + docRef(db, '/users/alice'), + docRef(db, '/users/charlie') + ]) + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1, + doc3 + ]); + }); + + it('sort_onPath_descending', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + const pipeline = db + .pipeline() + .documents([ + docRef(db, '/users/bob'), + docRef(db, '/users/alice'), + docRef(db, '/users/charlie') + ]) + .sort(field(DOCUMENT_KEY_NAME).descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1, + doc2 + ]); + }); + + it('sort_onKey_ascending', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + const pipeline = db + .pipeline() + .documents([ + docRef(db, '/users/bob'), + docRef(db, '/users/alice'), + docRef(db, '/users/charlie') + ]) + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1, + doc3 + ]); + }); + + it('sort_onKey_descending', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + const pipeline = db + .pipeline() + .documents([ + docRef(db, '/users/bob'), + docRef(db, '/users/alice'), + docRef(db, '/users/charlie') + ]) + .sort(field(DOCUMENT_KEY_NAME).descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1, + doc2 + ]); + }); + + it('limit', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 1 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 2 }); + + const pipeline = db + .pipeline() + .documents([ + docRef(db, '/users/bob'), + docRef(db, '/users/alice'), + docRef(db, '/users/charlie') + ]) + .sort(field(DOCUMENT_KEY_NAME).ascending()) + .limit(2); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1 + ]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/error_handling.test.ts b/packages/firestore/test/unit/core/pipeline/error_handling.test.ts new file mode 100644 index 00000000000..e0a61e721e3 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/error_handling.test.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Error Handling', () => { + it('where_partialError_or', () => { + const doc1 = doc('k/1', 1000, { a: 'true', b: true, c: false }); + const doc2 = doc('k/2', 1000, { a: true, b: 'true', c: false }); + const doc3 = doc('k/3', 1000, { a: true, b: false, c: 'true' }); + const doc4 = doc('k/4', 1000, { a: 'true', b: 'true', c: true }); + const doc5 = doc('k/5', 1000, { a: 'true', b: true, c: 'true' }); + const doc6 = doc('k/6', 1000, { a: true, b: 'true', c: 'true' }); + + const pipeline = db + .pipeline() + .database() + .where( + apiOr(eq(field('a'), true), eq(field('b'), true), eq(field('c'), true)) + ); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6]) + ).to.deep.equal([doc1, doc2, doc3, doc4, doc5, doc6]); + }); + + it('where_partialError_and', () => { + const doc1 = doc('k/1', 1000, { a: 'true', b: true, c: false }); + const doc2 = doc('k/2', 1000, { a: true, b: 'true', c: false }); + const doc3 = doc('k/3', 1000, { a: true, b: false, c: 'true' }); + const doc4 = doc('k/4', 1000, { a: 'true', b: 'true', c: true }); + const doc5 = doc('k/5', 1000, { a: 'true', b: true, c: 'true' }); + const doc6 = doc('k/6', 1000, { a: true, b: 'true', c: 'true' }); + const doc7 = doc('k/7', 1000, { a: true, b: true, c: true }); + + const pipeline = db + .pipeline() + .database() + .where( + apiAnd(eq(field('a'), true), eq(field('b'), true), eq(field('c'), true)) + ); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7]) + ).to.deep.equal([doc7]); + }); + + it('where_partialError_xor', () => { + const doc1 = doc('k/1', 1000, { a: 'true', b: true, c: false }); + const doc2 = doc('k/2', 1000, { a: true, b: 'true', c: false }); + const doc3 = doc('k/3', 1000, { a: true, b: false, c: 'true' }); + const doc4 = doc('k/4', 1000, { a: 'true', b: 'true', c: true }); + const doc5 = doc('k/5', 1000, { a: 'true', b: true, c: 'true' }); + const doc6 = doc('k/6', 1000, { a: true, b: 'true', c: 'true' }); + const doc7 = doc('k/7', 1000, { a: true, b: true, c: true }); + + const pipeline = db + .pipeline() + .database() + .where( + ApiXor( + field('a') as unknown as BooleanExpr, + field('b') as unknown as BooleanExpr, + field('c') as unknown as BooleanExpr + ) + ); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7]) + ).to.deep.equal([doc7]); + }); + + it('where_not_error', () => { + const doc1 = doc('k/1', 1000, { a: false }); + const doc2 = doc('k/2', 1000, { a: 'true' }); + const doc3 = doc('k/3', 1000, { b: true }); + + const pipeline = db + .pipeline() + .database() + .where(new BooleanExpr('not', [field('a')])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('where_errorProducingFunction_returnsEmpty', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: true }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: '42' }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 0 }); + + const pipeline = db + .pipeline() + .database() + .where(eq(divide(constant('100'), constant('50')), constant(2))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/inequality.test.ts b/packages/firestore/test/unit/core/pipeline/inequality.test.ts new file mode 100644 index 00000000000..e3a09e03788 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/inequality.test.ts @@ -0,0 +1,743 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Inequality Queries', () => { + it('greaterThan', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('score'), constant(90))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('greaterThanOrEqual', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gte(field('score'), constant(90))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('lessThan', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(lt(field('score'), constant(90))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + }); + + it('lessThanOrEqual', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(lte(field('score'), constant(90))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc1 + ]); + }); + + it('notEqual', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(neq(field('score'), constant(90))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc3 + ]); + }); + + it('notEqual_returnsMixedTypes', () => { + const doc1 = doc('users/alice', 1000, { score: 90 }); + const doc2 = doc('users/boc', 1000, { score: true }); + const doc3 = doc('users/charlie', 1000, { score: 42.0 }); + const doc4 = doc('users/drew', 1000, { score: 'abc' }); + const doc5 = doc('users/eric', 1000, { score: new Date(2000) }); // Assuming Timestamps are represented as Dates + const doc6 = doc('users/francis', 1000, { score: { lat: 0, lng: 0 } }); // Assuming LatLng is represented as an object + const doc7 = doc('users/george', 1000, { score: [42] }); + const doc8 = doc('users/hope', 1000, { score: { foo: 42 } }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(neq(field('score'), constant(90))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc2, doc3, doc4, doc5, doc6, doc7, doc8]); + }); + + it('comparisonHasImplicitBound', () => { + const doc1 = doc('users/alice', 1000, { score: 42 }); + const doc2 = doc('users/boc', 1000, { score: 100.0 }); + const doc3 = doc('users/charlie', 1000, { score: true }); + const doc4 = doc('users/drew', 1000, { score: 'abc' }); + const doc5 = doc('users/eric', 1000, { score: new Date(2000) }); // Assuming Timestamps are represented as Dates + const doc6 = doc('users/francis', 1000, { score: { lat: 0, lng: 0 } }); // Assuming LatLng is represented as an object + const doc7 = doc('users/george', 1000, { score: [42] }); + const doc8 = doc('users/hope', 1000, { score: { foo: 42 } }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('score'), constant(42))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc2]); + }); + + it('not_comparison_returnsMixedType', () => { + const doc1 = doc('users/alice', 1000, { score: 42 }); + const doc2 = doc('users/boc', 1000, { score: 100.0 }); + const doc3 = doc('users/charlie', 1000, { score: true }); + const doc4 = doc('users/drew', 1000, { score: 'abc' }); + const doc5 = doc('users/eric', 1000, { score: new Date(2000) }); // Assuming Timestamps are represented as Dates + const doc6 = doc('users/francis', 1000, { score: { lat: 0, lng: 0 } }); // Assuming LatLng is represented as an object + const doc7 = doc('users/george', 1000, { score: [42] }); + const doc8 = doc('users/hope', 1000, { score: { foo: 42 } }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(gt(field('score'), constant(90)))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc1, doc3, doc4, doc5, doc6, doc7, doc8]); + }); + + it('inequality_withEquality_onDifferentField', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(eq(field('rank'), constant(2)), gt(field('score'), constant(80))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('inequality_withEquality_onSameField', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(eq(field('score'), constant(90)), gt(field('score'), constant(80))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('withSort_onSameField', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gte(field('score'), constant(90))) + .sort(field('score').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc3 + ]); + }); + + it('withSort_onDifferentFields', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gte(field('score'), constant(90))) + .sort(field('rank').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1 + ]); + }); + + it('withOr_onSingleField', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(gt(field('score'), constant(90)), lt(field('score'), constant(60))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc3 + ]); + }); + + it('withOr_onDifferentFields', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + or(gt(field('score'), constant(80)), lt(field('rank'), constant(2))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('withEqAny_onSingleField', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + gt(field('score'), constant(80)), + eqAny(field('score'), [constant(50), constant(80), constant(97)]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('withEqAny_onDifferentFields', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + lt(field('rank'), constant(3)), + eqAny(field('score'), [constant(50), constant(80), constant(97)]) + ) + ); + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('withNotEqAny_onSingleField', () => { + const doc1 = doc('users/bob', 1000, { notScore: 90 }); + const doc2 = doc('users/alice', 1000, { score: 90 }); + const doc3 = doc('users/charlie', 1000, { score: 50 }); + const doc4 = doc('users/diane', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + gt(field('score'), constant(80)), + notEqAny(field('score'), [constant(90), constant(95)]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc4 + ]); + }); + + it('withNotEqAny_returnsMixedTypes', () => { + const doc1 = doc('users/bob', 1000, { notScore: 90 }); + const doc2 = doc('users/alice', 1000, { score: 90 }); + const doc3 = doc('users/charlie', 1000, { score: true }); + const doc4 = doc('users/diane', 1000, { score: 42.0 }); + const doc5 = doc('users/eric', 1000, { score: NaN }); + const doc6 = doc('users/francis', 1000, { score: 'abc' }); + const doc7 = doc('users/george', 1000, { score: new Date(2000) }); // Assuming Timestamps are represented as Dates + const doc8 = doc('users/hope', 1000, { score: { lat: 0, lng: 0 } }); // Assuming LatLng is represented as an object + const doc9 = doc('users/isla', 1000, { score: [42] }); + const doc10 = doc('users/jack', 1000, { score: { foo: 42 } }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + notEqAny(field('score'), [ + constant('foo'), + constant(90), + constant(false) + ]) + ); + + expect( + runPipeline(pipeline, [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8, + doc9, + doc10 + ]) + ).to.deep.equal([doc3, doc4, doc5, doc6, doc7, doc8, doc9, doc10]); + }); + + it('withNotEqAny_onDifferentFields', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + lt(field('rank'), constant(3)), + notEqAny(field('score'), [constant(90), constant(95)]) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('sortByEquality', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 4 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + const doc4 = doc('users/david', 1000, { score: 91, rank: 2 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(eq(field('rank'), constant(2)), gt(field('score'), constant(80))) + ) + .sort(field('rank').ascending(), field('score').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc1, doc4]); + }); + + it('withEqAny_sortByEquality', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 3 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 4 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + const doc4 = doc('users/david', 1000, { score: 91, rank: 2 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + eqAny(field('rank'), [constant(2), constant(3), constant(4)]), + gt(field('score'), constant(80)) + ) + ) + .sort(field('rank').ascending(), field('score').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc4, doc1]); + }); + + it('withArray', () => { + const doc1 = doc('users/bob', 1000, { + scores: [80, 85, 90], + rounds: [1, 2, 3] + }); + const doc2 = doc('users/alice', 1000, { + scores: [50, 65], + rounds: [1, 2] + }); + const doc3 = doc('users/charlie', 1000, { + scores: [90, 95, 97], + rounds: [1, 2, 4] + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + lte(field('scores'), constantArray([90, 90, 90])), + gt(field('rounds'), constantArray([1, 2])) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('withArrayContainsAny', () => { + const doc1 = doc('users/bob', 1000, { + scores: [80, 85, 90], + rounds: [1, 2, 3] + }); + const doc2 = doc('users/alice', 1000, { + scores: [50, 65], + rounds: [1, 2] + }); + const doc3 = doc('users/charlie', 1000, { + scores: [90, 95, 97], + rounds: [1, 2, 4] + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and( + lte(field('scores'), constantArray([90, 90, 90])), + arrayContains(field('rounds'), constant(3)) as BooleanExpr + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('withSortAndLimit', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 3 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 4 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + const doc4 = doc('users/david', 1000, { score: 91, rank: 2 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('score'), constant(80))) + .sort(field('rank').ascending()) + .limit(2); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc3, doc4]); + }); + + it('withSortAndOffset', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 3 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 4 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + const doc4 = doc('users/david', 1000, { score: 91, rank: 2 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('score'), constant(80))) + .sort(field('rank').ascending()) + .offset(1); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc4, doc1]); + }); + + it('multipleInequalities_onSingleField', () => { + const doc1 = doc('users/bob', 1000, { score: 90 }); + const doc2 = doc('users/alice', 1000, { score: 50 }); + const doc3 = doc('users/charlie', 1000, { score: 97 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(gt(field('score'), constant(90)), lt(field('score'), constant(100))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('multipleInequalities_onDifferentFields_singleMatch', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(gt(field('score'), constant(90)), lt(field('rank'), constant(2))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('multipleInequalities_onDifferentFields_multipleMatch', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(gt(field('score'), constant(80)), lt(field('rank'), constant(3))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('multipleInequalities_onDifferentFields_allMatch', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(gt(field('score'), constant(40)), lt(field('rank'), constant(4))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc1, + doc3 + ]); + }); + + it('multipleInequalities_onDifferentFields_noMatch', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(lt(field('score'), constant(90)), gt(field('rank'), constant(3))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('multipleInequalities_withBoundedRanges', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 4 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + const doc4 = doc('users/david', 1000, { score: 80, rank: 3 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + apiAnd( + gt(field('rank'), constant(0)), + lt(field('rank'), constant(4)), + gt(field('score'), constant(80)), + lt(field('score'), constant(95)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1 + ]); + }); + + it('multipleInequalities_withSingleSortAsc', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(lt(field('rank'), constant(3)), gt(field('score'), constant(80))) + ) + .sort(field('rank').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1 + ]); + }); + + it('multipleInequalities_withSingleSortDesc', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(lt(field('rank'), constant(3)), gt(field('score'), constant(80))) + ) + .sort(field('rank').descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc3 + ]); + }); + + it('multipleInequalities_withMultipleSortAsc', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(lt(field('rank'), constant(3)), gt(field('score'), constant(80))) + ) + .sort(field('rank').ascending(), field('score').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1 + ]); + }); + + it('multipleInequalities_withMultipleSortDesc', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(lt(field('rank'), constant(3)), gt(field('score'), constant(80))) + ) + .sort(field('rank').descending(), field('score').descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc3 + ]); + }); + + it('multipleInequalities_withMultipleSortDesc_onReverseIndex', () => { + const doc1 = doc('users/bob', 1000, { score: 90, rank: 2 }); + const doc2 = doc('users/alice', 1000, { score: 50, rank: 3 }); + const doc3 = doc('users/charlie', 1000, { score: 97, rank: 1 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + and(lt(field('rank'), constant(3)), gt(field('score'), constant(80))) + ) + .sort(field('score').descending(), field('rank').descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc1 + ]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/limit.test.ts b/packages/firestore/test/unit/core/pipeline/limit.test.ts new file mode 100644 index 00000000000..f49754b757d --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/limit.test.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Limit Queries', () => { + it('limit_zero', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(0); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('limit_zero_duplicated', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(0).limit(0).limit(0); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('limit_one', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(1); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(1); + }); + + it('limit_one_duplicated', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(1).limit(1).limit(1); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(1); + }); + + it('limit_two', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(2); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(2); + }); + + it('limit_two_duplicated', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(2).limit(2).limit(2); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(2); + }); + + it('limit_three', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(3); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(3); + }); + + it('limit_three_duplicated', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(3).limit(3).limit(3); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(3); + }); + + it('limit_four', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(4); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(4); + }); + + it('limit_four_duplicated', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(4).limit(4).limit(4); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(4); + }); + + it('limit_five', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(5); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(4); + }); + + it('limit_five_duplicated', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db.pipeline().collection('/k').limit(5).limit(5).limit(5); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(4); + }); + + it('limit_max', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db + .pipeline() + .collection('/k') + .limit(Number.MAX_SAFE_INTEGER); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(4); + }); + + it('limit_max_duplicated', () => { + const doc1 = doc('k/a', 1000, { a: 1, b: 2 }); + const doc2 = doc('k/b', 1000, { a: 3, b: 4 }); + const doc3 = doc('k/c', 1000, { a: 5, b: 6 }); + const doc4 = doc('k/d', 1000, { a: 7, b: 8 }); + + const pipeline = db + .pipeline() + .collection('/k') + .limit(Number.MAX_SAFE_INTEGER) + .limit(Number.MAX_SAFE_INTEGER) + .limit(Number.MAX_SAFE_INTEGER); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.have.lengthOf(4); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/nested_properties.test.ts b/packages/firestore/test/unit/core/pipeline/nested_properties.test.ts new file mode 100644 index 00000000000..06cf2be28b8 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/nested_properties.test.ts @@ -0,0 +1,483 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Nested Properties', () => { + it('where_equality_deeplyNested', () => { + const doc1 = doc('users/a', 1000, { + a: { + b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 42 } } } } } } } } } + } + }); + const doc2 = doc('users/b', 1000, { + a: { + b: { c: { d: { e: { f: { g: { h: { i: { j: { k: '42' } } } } } } } } } + } + }); + const doc3 = doc('users/c', 1000, { + a: { + b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 0 } } } } } } } } } + } + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('a.b.c.d.e.f.g.h.i.j.k'), constant(42))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('where_inequality_deeplyNested', () => { + const doc1 = doc('users/a', 1000, { + a: { + b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 42 } } } } } } } } } + } + }); + const doc2 = doc('users/b', 1000, { + a: { + b: { c: { d: { e: { f: { g: { h: { i: { j: { k: '42' } } } } } } } } } + } + }); + const doc3 = doc('users/c', 1000, { + a: { + b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 0 } } } } } } } } } + } + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gte(field('a.b.c.d.e.f.g.h.i.j.k'), constant(0))) + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_equality', () => { + const doc1 = doc('users/a', 1000, { + address: { city: 'San Francisco', state: 'CA', zip: 94105 } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('address.street'), constant('76'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2 + ]); + }); + + it('multipleFilters', () => { + const doc1 = doc('users/a', 1000, { + address: { city: 'San Francisco', state: 'CA', zip: 94105 } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('address.city'), constant('San Francisco'))) + .where(gt(field('address.zip'), constant(90000))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1 + ]); + }); + + it('multipleFilters_redundant', () => { + const doc1 = doc('users/a', 1000, { + address: { city: 'San Francisco', state: 'CA', zip: 94105 } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + eq( + field('address'), + constantMap({ city: 'San Francisco', state: 'CA', zip: 94105 }) + ) + ) + .where(gt(field('address.zip'), constant(90000))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1 + ]); + }); + + it('multipleFilters_withCompositeIndex', async () => { + // Assuming a similar setup for creating composite indexes in your environment. + // This part will need adaptation based on your specific index creation mechanism. + + const doc1 = doc('users/a', 1000, { + address: { city: 'San Francisco', state: 'CA', zip: 94105 } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('address.city'), constant('San Francisco'))) + .where(gt(field('address.zip'), constant(90000))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1 + ]); + }); + + // it('multipleFilters_redundant_withCompositeIndex', async () => { + // const doc1 = doc('users/a', 1000, { + // address: { city: 'San Francisco', state: 'CA', zip: 94105 }, + // }); + // const doc2 = doc('users/b', 1000, { + // address: { street: '76', city: 'New York', state: 'NY', zip: 10011 }, + // }); + // const doc3 = doc('users/c', 1000, { + // address: { city: 'Mountain View', state: 'CA', zip: 94043 }, + // }); + // const doc4 = doc('users/d', 1000, {}); + // + // const pipeline = db.pipeline().collection('/users') + // .where(eq(field('address'), constant({ city: 'San Francisco', state: 'CA', zip: 94105 }))) + // .where(gt(field('address.zip'), constant(90000))); + // + // expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([doc1]); + // }); + + // it('multipleFilters_redundant_withCompositeIndex_nestedPropertyFirst', async () => { + // const doc1 = doc('users/a', 1000, { + // address: { city: 'San Francisco', state: 'CA', zip: 94105 }, + // }); + // const doc2 = doc('users/b', 1000, { + // address: { street: '76', city: 'New York', state: 'NY', zip: 10011 }, + // }); + // const doc3 = doc('users/c', 1000, { + // address: { city: 'Mountain View', state: 'CA', zip: 94043 }, + // }); + // const doc4 = doc('users/d', 1000, {}); + // + // const pipeline = db.pipeline().collection('/users') + // .where(eq(field('address'), constant({ city: 'San Francisco', state: 'CA', zip: 94105 }))) + // .where(gt(field('address.zip'), constant(90000))); + // + // expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([doc1]); + // }); + + it('where_inequality', () => { + const doc1 = doc('users/a', 1000, { + address: { city: 'San Francisco', state: 'CA', zip: 94105 } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline1 = db + .pipeline() + .collection('/users') + .where(gt(field('address.zip'), constant(90000))); + expect(runPipeline(pipeline1, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1, + doc3 + ]); + + const pipeline2 = db + .pipeline() + .collection('/users') + .where(lt(field('address.zip'), constant(90000))); + expect(runPipeline(pipeline2, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2 + ]); + + const pipeline3 = db + .pipeline() + .collection('/users') + .where(lt(field('address.zip'), constant(0))); + expect(runPipeline(pipeline3, [doc1, doc2, doc3, doc4])).to.be.empty; + + const pipeline4 = db + .pipeline() + .collection('/users') + .where(neq(field('address.zip'), constant(10011))); + expect(runPipeline(pipeline4, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_exists', () => { + const doc1 = doc('users/a', 1000, { + address: { city: 'San Francisco', state: 'CA', zip: 94105 } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('address.street'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc2 + ]); + }); + + it('where_notExists', () => { + const doc1 = doc('users/a', 1000, { + address: { city: 'San Francisco', state: 'CA', zip: 94105 } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(exists(field('address.street')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1, + doc3, + doc4 + ]); + }); + + it('where_isNull', () => { + const doc1 = doc('users/a', 1000, { + address: { + city: 'San Francisco', + state: 'CA', + zip: 94105, + street: null + } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(field('address.street').isNull()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('where_isNotNull', () => { + const doc1 = doc('users/a', 1000, { + address: { + city: 'San Francisco', + state: 'CA', + zip: 94105, + street: null + } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(field('address.street').isNull())); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + }); + + it('sort_withExists', () => { + const doc1 = doc('users/a', 1000, { + address: { + street: '41', + city: 'San Francisco', + state: 'CA', + zip: 94105 + } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('address.street'))) + .sort(field('address.street').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4]) + ).to.have.ordered.members([doc1, doc2]); + }); + + it('sort_withoutExists', () => { + const doc1 = doc('users/a', 1000, { + address: { + street: '41', + city: 'San Francisco', + state: 'CA', + zip: 94105 + } + }); + const doc2 = doc('users/b', 1000, { + address: { street: '76', city: 'New York', state: 'NY', zip: 10011 } + }); + const doc3 = doc('users/c', 1000, { + address: { city: 'Mountain View', state: 'CA', zip: 94043 } + }); + const doc4 = doc('users/d', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('address.street').ascending()); + + const results = runPipeline(pipeline, [doc1, doc2, doc3, doc4]); + expect(results).to.have.lengthOf(4); + expect(results[2]).to.deep.equal(doc1); + expect(results[3]).to.deep.equal(doc2); + }); + + it('quotedNestedProperty_filterNested', () => { + const doc1 = doc('users/a', 1000, { 'address.city': 'San Francisco' }); + const doc2 = doc('users/b', 1000, { address: { city: 'San Francisco' } }); + const doc3 = doc('users/c', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('address.city'), constant('San Francisco'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + }); + + it('quotedNestedProperty_filterQuotedNested', () => { + const doc1 = doc('users/a', 1000, { 'address.city': 'San Francisco' }); + const doc2 = doc('users/b', 1000, { address: { city: 'San Francisco' } }); + const doc3 = doc('users/c', 1000, {}); + + const pipeline = db + .pipeline() + .collection('/users') + // TODO(pipeline): Replace below with field('`address.city`') once we support it. + .where( + eq( + new Field(new FieldPath(['address.city'])), + constant('San Francisco') + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/null_semantics.test.ts b/packages/firestore/test/unit/core/pipeline/null_semantics.test.ts new file mode 100644 index 00000000000..6203f7b2167 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/null_semantics.test.ts @@ -0,0 +1,1126 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Null Semantics', () => { + // =================================================================== + // Where Tests + // =================================================================== + it('where_isNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: [null] }); + const doc4 = doc('users/4', 1000, { score: {} }); + const doc5 = doc('users/5', 1000, { score: 42 }); + const doc6 = doc('users/6', 1000, { score: NaN }); + const doc7 = doc('users/7', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(isNull(field('score'))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7]) + ).to.deep.equal([doc1]); + }); + + it('where_isNotNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: [null] }); + const doc4 = doc('users/4', 1000, { score: {} }); + const doc5 = doc('users/5', 1000, { score: 42 }); + const doc6 = doc('users/6', 1000, { score: NaN }); + const doc7 = doc('users/7', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(not(isNull(field('score')))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7]) + ).to.deep.equal([doc2, doc3, doc4, doc5, doc6]); + }); + + it('where_isNullAndIsNotNull_empty', () => { + const doc1 = doc('users/a', 1000, { score: null }); + const doc2 = doc('users/b', 1000, { score: [null] }); + const doc3 = doc('users/c', 1000, { score: 42 }); + const doc4 = doc('users/d', 1000, { bar: 42 }); + + const pipeline = db + .pipeline() + .database() + .where(and(isNull(field('score')), not(isNull(field('score'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('where_eq_constantAsNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: 42 }); + const doc3 = doc('users/3', 1000, { score: NaN }); + const doc4 = doc('users/4', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('score'), constant(null))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('where_eq_fieldAsNull', () => { + const doc1 = doc('users/1', 1000, { score: null, rank: null }); + const doc2 = doc('users/2', 1000, { score: 42, rank: null }); + const doc3 = doc('users/3', 1000, { score: null, rank: 42 }); + const doc4 = doc('users/4', 1000, { score: null }); + const doc5 = doc('users/5', 1000, { rank: null }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('score'), field('rank'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('where_eq_segmentField', () => { + const doc1 = doc('users/1', 1000, { score: { bonus: null } }); + const doc2 = doc('users/2', 1000, { score: { bonus: 42 } }); + const doc3 = doc('users/3', 1000, { score: { bonus: NaN } }); + const doc4 = doc('users/4', 1000, { score: { 'not-bonus': 42 } }); + const doc5 = doc('users/5', 1000, { score: 'foo-bar' }); + const doc6 = doc('users/6', 1000, { 'not-score': { bonus: 42 } }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('score.bonus'), constant(null))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6])).to.be + .empty; + }); + + it('where_eq_singleFieldAndSegmentField', () => { + const doc1 = doc('users/1', 1000, { score: { bonus: null }, rank: null }); + const doc2 = doc('users/2', 1000, { score: { bonus: 42 }, rank: null }); + const doc3 = doc('users/3', 1000, { score: { bonus: NaN }, rank: null }); + const doc4 = doc('users/4', 1000, { + score: { 'not-bonus': 42 }, + rank: null + }); + const doc5 = doc('users/5', 1000, { score: 'foo-bar' }); + const doc6 = doc('users/6', 1000, { + 'not-score': { bonus: 42 }, + rank: null + }); + + const pipeline = db + .pipeline() + .database() + .where( + and( + eq(field('score.bonus'), constant(null)), + eq(field('rank'), constant(null)) + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6])).to.be + .empty; + }); + + it('where_eq_null_inArray', () => { + const doc1 = doc('k/1', 1000, { foo: [null] }); + const doc2 = doc('k/2', 1000, { foo: [1.0, null] }); + const doc3 = doc('k/3', 1000, { foo: [null, NaN] }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantArray([null]))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('where_eq_null_other_inArray', () => { + const doc1 = doc('k/1', 1000, { foo: [null] }); + const doc2 = doc('k/2', 1000, { foo: [1.0, null] }); + const doc3 = doc('k/3', 1000, { foo: [1, null] }); // Note: 1L becomes 1 + const doc4 = doc('k/4', 1000, { foo: [null, NaN] }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantArray([1, null]))); // Note: 1L becomes 1 + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('where_eq_null_nan_inArray', () => { + const doc1 = doc('k/1', 1000, { foo: [null] }); + const doc2 = doc('k/2', 1000, { foo: [1.0, null] }); + const doc3 = doc('k/3', 1000, { foo: [null, NaN] }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantArray([null, NaN]))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('where_eq_null_inMap', () => { + const doc1 = doc('k/1', 1000, { foo: { a: null } }); + const doc2 = doc('k/2', 1000, { foo: { a: 1.0, b: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: null, b: NaN } }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantMap({ a: null }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('where_eq_null_other_inMap', () => { + const doc1 = doc('k/1', 1000, { foo: { a: null } }); + const doc2 = doc('k/2', 1000, { foo: { a: 1.0, b: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: 1, b: null } }); // Note: 1L becomes 1 + const doc4 = doc('k/4', 1000, { foo: { a: null, b: NaN } }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantMap({ a: 1.0, b: null }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('where_eq_null_nan_inMap', () => { + const doc1 = doc('k/1', 1000, { foo: { a: null } }); + const doc2 = doc('k/2', 1000, { foo: { a: 1.0, b: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: null, b: NaN } }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantMap({ a: null, b: NaN }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('where_eq_map_withNullArray', () => { + const doc1 = doc('k/1', 1000, { foo: { a: [null] } }); + const doc2 = doc('k/2', 1000, { foo: { a: [1.0, null] } }); + const doc3 = doc('k/3', 1000, { foo: { a: [null, NaN] } }); + const doc4 = doc('k/4', 1000, { foo: { a: [] } }); + const doc5 = doc('k/5', 1000, { foo: { a: [1.0] } }); + const doc6 = doc('k/6', 1000, { foo: { a: [null, 1.0] } }); + const doc7 = doc('k/7', 1000, { foo: { 'not-a': [null] } }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantMap({ a: [null] }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7])).to + .be.empty; + }); + + it('where_eq_map_withNullOtherArray', () => { + const doc1 = doc('k/1', 1000, { foo: { a: [null] } }); + const doc2 = doc('k/2', 1000, { foo: { a: [1.0, null] } }); + const doc3 = doc('k/3', 1000, { foo: { a: [1, null] } }); // Note: 1L becomes 1 + const doc4 = doc('k/4', 1000, { foo: { a: [null, NaN] } }); + const doc5 = doc('k/5', 1000, { foo: { a: [] } }); + const doc6 = doc('k/6', 1000, { foo: { a: [1.0] } }); + const doc7 = doc('k/7', 1000, { foo: { a: [null, 1.0] } }); + const doc8 = doc('k/8', 1000, { foo: { 'not-a': [null] } }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantMap({ a: [1.0, null] }))); // Note: 1L becomes 1.0 + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.be.empty; + }); + + it('where_eq_map_withNullNanArray', () => { + const doc1 = doc('k/1', 1000, { foo: { a: [null] } }); + const doc2 = doc('k/2', 1000, { foo: { a: [1.0, null] } }); + const doc3 = doc('k/3', 1000, { foo: { a: [null, NaN] } }); + const doc4 = doc('k/4', 1000, { foo: { a: [] } }); + const doc5 = doc('k/5', 1000, { foo: { a: [1.0] } }); + const doc6 = doc('k/6', 1000, { foo: { a: [null, 1.0] } }); + const doc7 = doc('k/7', 1000, { foo: { 'not-a': [null] } }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantMap({ a: [null, NaN] }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7])).to + .be.empty; + }); + + it('where_compositeCondition_withNull', () => { + const doc1 = doc('users/a', 1000, { score: 42, rank: null }); + const doc2 = doc('users/b', 1000, { score: 42, rank: 42 }); + + const pipeline = db + .pipeline() + .database() + .where( + and(eq(field('score'), constant(42)), eq(field('rank'), constant(null))) + ); + + expect(runPipeline(pipeline, [doc1, doc2])).to.be.empty; + }); + + it('where_eqAny_nullOnly', () => { + const doc1 = doc('users/a', 1000, { score: null }); + const doc2 = doc('users/b', 1000, { score: 42 }); + const doc3 = doc('users/c', 1000, { rank: 42 }); + + const pipeline = db + .pipeline() + .database() + .where(eqAny(field('score'), [constant(null)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + // TODO(pipeline): Support constructing nested array constants + it.skip('where_eqAny_null_inArray', () => { + const doc1 = doc('k/1', 1000, { foo: null }); + const doc2 = doc('k/2', 1000, { foo: [null] }); + const doc3 = doc('k/3', 1000, { foo: [1.0, null] }); + const doc4 = doc('k/4', 1000, { foo: [null, NaN] }); + + const pipeline = db + .pipeline() + .database() + .where(eqAny(field('foo'), constantArray([[null]]))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('where_eqAny_partialNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: 25 }); + const doc4 = doc('users/4', 1000, { score: 100 }); + const doc5 = doc('users/5', 1000, { 'not-score': 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('score'), [constant(null), constant(100)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc4] + ); + }); + + it('where_arrayContains_null', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: [null] }); + const doc4 = doc('users/4', 1000, { score: [null, 42] }); + const doc5 = doc('users/5', 1000, { score: [101, null] }); + const doc6 = doc('users/6', 1000, { score: ['foo', 'bar'] }); + const doc7 = doc('users/7', 1000, { 'not-score': ['foo', 'bar'] }); + const doc8 = doc('users/8', 1000, { 'not-score': ['foo', null] }); + const doc9 = doc('users/9', 1000, { 'not-score': [null, 'foo'] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContains(field('score'), constant(null)) as BooleanExpr); + + expect( + runPipeline(pipeline, [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8, + doc9 + ]) + ).to.be.empty; + }); + + it('where_arrayContainsAny_onlyNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: [null] }); + const doc4 = doc('users/4', 1000, { score: [null, 42] }); + const doc5 = doc('users/5', 1000, { score: [101, null] }); + const doc6 = doc('users/6', 1000, { score: ['foo', 'bar'] }); + const doc7 = doc('users/7', 1000, { 'not-score': ['foo', 'bar'] }); + const doc8 = doc('users/8', 1000, { 'not-score': ['foo', null] }); + const doc9 = doc('users/9', 1000, { 'not-score': [null, 'foo'] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContainsAny(field('score'), [constant(null)])); + + expect( + runPipeline(pipeline, [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8, + doc9 + ]) + ).to.be.empty; + }); + + it('where_arrayContainsAny_partialNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: [null] }); + const doc4 = doc('users/4', 1000, { score: [null, 42] }); + const doc5 = doc('users/5', 1000, { score: [101, null] }); + const doc6 = doc('users/6', 1000, { score: ['foo', 'bar'] }); + const doc7 = doc('users/7', 1000, { 'not-score': ['foo', 'bar'] }); + const doc8 = doc('users/8', 1000, { 'not-score': ['foo', null] }); + const doc9 = doc('users/9', 1000, { 'not-score': [null, 'foo'] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where( + arrayContainsAny(field('score'), [constant(null), constant('foo')]) + ); + + expect( + runPipeline(pipeline, [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8, + doc9 + ]) + ).to.deep.equal([doc6]); + }); + + it('where_arrayContainsAll_onlyNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: [null] }); + const doc4 = doc('users/4', 1000, { score: [null, 42] }); + const doc5 = doc('users/5', 1000, { score: [101, null] }); + const doc6 = doc('users/6', 1000, { score: ['foo', 'bar'] }); + const doc7 = doc('users/7', 1000, { 'not-score': ['foo', 'bar'] }); + const doc8 = doc('users/8', 1000, { 'not-score': ['foo', null] }); + const doc9 = doc('users/9', 1000, { 'not-score': [null, 'foo'] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContainsAny(field('score'), [constant(null)])); // Note: arrayContainsAll not directly available, using Any for now + + expect( + runPipeline(pipeline, [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8, + doc9 + ]) + ).to.be.empty; // Assuming arrayContainsAll would be empty + }); + + it('where_arrayContainsAll_partialNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: [] }); + const doc3 = doc('users/3', 1000, { score: [null] }); + const doc4 = doc('users/4', 1000, { score: [null, 42] }); + const doc5 = doc('users/5', 1000, { score: [101, null] }); + const doc6 = doc('users/6', 1000, { score: ['foo', 'bar'] }); + const doc7 = doc('users/7', 1000, { 'not-score': ['foo', 'bar'] }); + const doc8 = doc('users/8', 1000, { 'not-score': ['foo', null] }); + const doc9 = doc('users/9', 1000, { 'not-score': [null, 'foo'] }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContainsAll(field('score'), [constant(null), constant(42)])); + + expect( + runPipeline(pipeline, [ + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8, + doc9 + ]) + ).to.be.empty; + }); + + it('where_neq_constantAsNull', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: 42 }); + const doc3 = doc('users/3', 1000, { score: NaN }); + const doc4 = doc('users/4', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('score'), constant(null))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.be.empty; + }); + + it('where_neq_fieldAsNull', () => { + const doc1 = doc('users/1', 1000, { score: null, rank: null }); + const doc2 = doc('users/2', 1000, { score: 42, rank: null }); + const doc3 = doc('users/3', 1000, { score: null, rank: 42 }); + const doc4 = doc('users/4', 1000, { score: null }); + const doc5 = doc('users/5', 1000, { rank: null }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('score'), field('rank'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('where_neq_null_inArray', () => { + const doc1 = doc('k/1', 1000, { foo: [null] }); + const doc2 = doc('k/2', 1000, { foo: [1.0, null] }); + const doc3 = doc('k/3', 1000, { foo: [null, NaN] }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('foo'), constantArray([null]))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc3 + ]); + }); + + it('where_neq_null_other_inArray', () => { + const doc1 = doc('k/1', 1000, { foo: [null] }); + const doc2 = doc('k/2', 1000, { foo: [1.0, null] }); + const doc3 = doc('k/3', 1000, { foo: [1, null] }); // Note: 1L becomes 1 + const doc4 = doc('k/4', 1000, { foo: [null, NaN] }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('foo'), constantArray([1, null]))); // Note: 1L becomes 1 + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1 + ]); + }); + + it('where_neq_null_nan_inArray', () => { + const doc1 = doc('k/1', 1000, { foo: [null] }); + const doc2 = doc('k/2', 1000, { foo: [1.0, null] }); + const doc3 = doc('k/3', 1000, { foo: [null, NaN] }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('foo'), constantArray([null, NaN]))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_neq_null_inMap', () => { + const doc1 = doc('k/1', 1000, { foo: { a: null } }); + const doc2 = doc('k/2', 1000, { foo: { a: 1.0, b: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: null, b: NaN } }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('foo'), constantMap({ a: null }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc3 + ]); + }); + + it('where_neq_null_other_inMap', () => { + const doc1 = doc('k/1', 1000, { foo: { a: null } }); + const doc2 = doc('k/2', 1000, { foo: { a: 1.0, b: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: 1, b: null } }); // Note: 1L becomes 1 + const doc4 = doc('k/4', 1000, { foo: { a: null, b: NaN } }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('foo'), constantMap({ a: 1.0, b: null }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc1 + ]); + }); + + it('where_neq_null_nan_inMap', () => { + const doc1 = doc('k/1', 1000, { foo: { a: null } }); + const doc2 = doc('k/2', 1000, { foo: { a: 1.0, b: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: null, b: NaN } }); + + const pipeline = db + .pipeline() + .database() + .where(neq(field('foo'), constantMap({ a: null, b: NaN }))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc3 + ]); + }); + + it('where_notEqAny_withNull', () => { + const doc1 = doc('users/a', 1000, { score: null }); + const doc2 = doc('users/b', 1000, { score: 42 }); + + const pipeline = db + .pipeline() + .database() + .where(notEqAny(field('score'), [constant(null)])); + + expect(runPipeline(pipeline, [doc1, doc2])).to.be.empty; + }); + + it('where_gt', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: 42 }); + const doc3 = doc('users/3', 1000, { score: 'hello world' }); + const doc4 = doc('users/4', 1000, { score: NaN }); + const doc5 = doc('users/5', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(gt(field('score'), constant(null))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('where_gte', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: 42 }); + const doc3 = doc('users/3', 1000, { score: 'hello world' }); + const doc4 = doc('users/4', 1000, { score: NaN }); + const doc5 = doc('users/5', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(gte(field('score'), constant(null))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('where_lt', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: 42 }); + const doc3 = doc('users/3', 1000, { score: 'hello world' }); + const doc4 = doc('users/4', 1000, { score: NaN }); + const doc5 = doc('users/5', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(lt(field('score'), constant(null))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('where_lte', () => { + const doc1 = doc('users/1', 1000, { score: null }); + const doc2 = doc('users/2', 1000, { score: 42 }); + const doc3 = doc('users/3', 1000, { score: 'hello world' }); + const doc4 = doc('users/4', 1000, { score: NaN }); + const doc5 = doc('users/5', 1000, { 'not-score': 42 }); + + const pipeline = db + .pipeline() + .database() + .where(lte(field('score'), constant(null))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('where_and', () => { + const doc1 = doc('k/1', 1000, { a: true, b: null }); + const doc2 = doc('k/2', 1000, { a: false, b: null }); + const doc3 = doc('k/3', 1000, { a: null, b: null }); + const doc4 = doc('k/4', 1000, { a: true, b: true }); + + const pipeline = db + .pipeline() + .database() + .where( + and( + field('a') as unknown as BooleanExpr, + field('b') as unknown as BooleanExpr + ) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc4 + ]); + }); + + it('where_isNull_and', () => { + const doc1 = doc('k/1', 1000, { a: null, b: null }); + const doc2 = doc('k/2', 1000, { a: null }); + const doc3 = doc('k/3', 1000, { a: null, b: true }); + const doc4 = doc('k/4', 1000, { a: null, b: false }); + const doc5 = doc('k/5', 1000, { b: null }); + const doc6 = doc('k/6', 1000, { a: true, b: null }); + const doc7 = doc('k/7', 1000, { a: false, b: null }); + const doc8 = doc('k/8', 1000, { 'not-a': true, 'not-b': true }); + + const pipeline = db + .pipeline() + .database() + .where( + isNull( + and( + field('a') as unknown as BooleanExpr, + field('b') as unknown as BooleanExpr + ) + ) + ); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc1, doc3, doc6]); + }); + + it('where_isError_and', () => { + const doc1 = doc('k/1', 1000, { a: null, b: null }); + const doc2 = doc('k/2', 1000, { a: null }); + const doc3 = doc('k/3', 1000, { a: null, b: true }); + const doc4 = doc('k/4', 1000, { a: null, b: false }); + const doc5 = doc('k/5', 1000, { b: null }); + const doc6 = doc('k/6', 1000, { a: true, b: null }); + const doc7 = doc('k/7', 1000, { a: false, b: null }); + const doc8 = doc('k/8', 1000, { 'not-a': true, 'not-b': true }); + + // isError is not directly available, using isNull as a placeholder for structure + const pipeline = db + .pipeline() + .database() + .where(isNull(and(field('a'), field('b')))); // Placeholder + + // Expected result based on Java's isError + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc1, doc3, doc6]); // This needs adjustment based on actual isError implementation + // Java expected: [doc2, doc5, doc8] + }); + + it('where_or', () => { + const doc1 = doc('k/1', 1000, { a: true, b: null }); + const doc2 = doc('k/2', 1000, { a: false, b: null }); + const doc3 = doc('k/3', 1000, { a: null, b: null }); + + const pipeline = db + .pipeline() + .database() + .where(or(field('a'), field('b'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('where_isNull_or', () => { + const doc1 = doc('k/1', 1000, { a: null, b: null }); + const doc2 = doc('k/2', 1000, { a: null }); + const doc3 = doc('k/3', 1000, { a: null, b: true }); + const doc4 = doc('k/4', 1000, { a: null, b: false }); + const doc5 = doc('k/5', 1000, { b: null }); + const doc6 = doc('k/6', 1000, { a: true, b: null }); + const doc7 = doc('k/7', 1000, { a: false, b: null }); + const doc8 = doc('k/8', 1000, { 'not-a': true, 'not-b': true }); + + const pipeline = db + .pipeline() + .database() + .where(isNull(or(field('a'), field('b')))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc1, doc4, doc7]); + }); + + it('where_isError_or', () => { + const doc1 = doc('k/1', 1000, { a: null, b: null }); + const doc2 = doc('k/2', 1000, { a: null }); + const doc3 = doc('k/3', 1000, { a: null, b: true }); + const doc4 = doc('k/4', 1000, { a: null, b: false }); + const doc5 = doc('k/5', 1000, { b: null }); + const doc6 = doc('k/6', 1000, { a: true, b: null }); + const doc7 = doc('k/7', 1000, { a: false, b: null }); + const doc8 = doc('k/8', 1000, { 'not-a': true, 'not-b': true }); + + // isError is not directly available, using isNull as a placeholder for structure + const pipeline = db + .pipeline() + .database() + .where(isNull(or(field('a'), field('b')))); // Placeholder + + // Expected result based on Java's isError + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc1, doc4, doc7]); // This needs adjustment based on actual isError implementation + // Java expected: [doc2, doc5, doc8] + }); + + it('where_xor', () => { + const doc1 = doc('k/1', 1000, { a: true, b: null }); + const doc2 = doc('k/2', 1000, { a: false, b: null }); + const doc3 = doc('k/3', 1000, { a: null, b: null }); + const doc4 = doc('k/4', 1000, { a: true, b: false }); + + const pipeline = db + .pipeline() + .database() + .where(xor(field('a'), field('b'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4])).to.deep.equal([ + doc4 + ]); + }); + + it('where_isNull_xor', () => { + const doc1 = doc('k/1', 1000, { a: null, b: null }); + const doc2 = doc('k/2', 1000, { a: null }); + const doc3 = doc('k/3', 1000, { a: null, b: true }); + const doc4 = doc('k/4', 1000, { a: null, b: false }); + const doc5 = doc('k/5', 1000, { b: null }); + const doc6 = doc('k/6', 1000, { a: true, b: null }); + const doc7 = doc('k/7', 1000, { a: false, b: null }); + const doc8 = doc('k/8', 1000, { 'not-a': true, 'not-b': true }); + + const pipeline = db + .pipeline() + .database() + .where(isNull(xor(field('a'), field('b')))); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc1, doc3, doc4, doc6, doc7]); + }); + + it('where_isError_xor', () => { + const doc1 = doc('k/1', 1000, { a: null, b: null }); + const doc2 = doc('k/2', 1000, { a: null }); + const doc3 = doc('k/3', 1000, { a: null, b: true }); + const doc4 = doc('k/4', 1000, { a: null, b: false }); + const doc5 = doc('k/5', 1000, { b: null }); + const doc6 = doc('k/6', 1000, { a: true, b: null }); + const doc7 = doc('k/7', 1000, { a: false, b: null }); + const doc8 = doc('k/8', 1000, { 'not-a': true, 'not-b': true }); + + // isError is not directly available, using isNull as a placeholder for structure + const pipeline = db + .pipeline() + .database() + .where(isNull(xor(field('a'), field('b')))); // Placeholder + + // Expected result based on Java's isError + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5, doc6, doc7, doc8]) + ).to.deep.equal([doc1, doc3, doc4, doc6, doc7]); // This needs adjustment based on actual isError implementation + // Java expected: [doc2, doc5, doc8] + }); + + it('where_not', () => { + const doc1 = doc('k/1', 1000, { a: true }); + const doc2 = doc('k/2', 1000, { a: false }); + const doc3 = doc('k/3', 1000, { a: null }); + + const pipeline = db + .pipeline() + .database() + .where(not(eq(field('a'), true))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + }); + + it('where_isNull_not', () => { + const doc1 = doc('k/1', 1000, { a: true }); + const doc2 = doc('k/2', 1000, { a: false }); + const doc3 = doc('k/3', 1000, { a: null }); + + const pipeline = db + .pipeline() + .database() + .where(isNull(not(field('a').eq(true)))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('where_isError_not', () => { + const doc1 = doc('k/1', 1000, { a: true }); + const doc2 = doc('k/2', 1000, { a: false }); + const doc3 = doc('k/3', 1000, { a: null }); + + // isError is not directly available, using isNull as a placeholder for structure + const pipeline = db + .pipeline() + .database() + .where(isNull(not(field('a').eq(true)))); // Placeholder + + // Expected result based on Java's isError + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); // This needs adjustment based on actual isError implementation + // Java expected: [] + }); + + // =================================================================== + // Sort Tests + // =================================================================== + it('sort_null_inArray_ascending', () => { + const doc0 = doc('k/0', 1000, { 'not-foo': [] }); + const doc1 = doc('k/1', 1000, { foo: [] }); + const doc2 = doc('k/2', 1000, { foo: [null] }); + const doc3 = doc('k/3', 1000, { foo: [null, null] }); + const doc4 = doc('k/4', 1000, { foo: [null, 1] }); + const doc5 = doc('k/5', 1000, { foo: [null, 2] }); + const doc6 = doc('k/6', 1000, { foo: [1, null] }); + const doc7 = doc('k/7', 1000, { foo: [2, null] }); + const doc8 = doc('k/8', 1000, { foo: [2, 1] }); + + const pipeline = db.pipeline().database().sort(field('foo').ascending()); + + expect( + runPipeline(pipeline, [ + doc0, + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8 + ]) + ).to.have.ordered.members([ + doc0, + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8 + ]); + }); + + it('sort_null_inArray_descending', () => { + const doc0 = doc('k/0', 1000, { 'not-foo': [] }); + const doc1 = doc('k/1', 1000, { foo: [] }); + const doc2 = doc('k/2', 1000, { foo: [null] }); + const doc3 = doc('k/3', 1000, { foo: [null, null] }); + const doc4 = doc('k/4', 1000, { foo: [null, 1] }); + const doc5 = doc('k/5', 1000, { foo: [null, 2] }); + const doc6 = doc('k/6', 1000, { foo: [1, null] }); + const doc7 = doc('k/7', 1000, { foo: [2, null] }); + const doc8 = doc('k/8', 1000, { foo: [2, 1] }); + + const pipeline = db.pipeline().database().sort(field('foo').descending()); + + expect( + runPipeline(pipeline, [ + doc0, + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8 + ]) + ).to.have.ordered.members([ + doc8, + doc7, + doc6, + doc5, + doc4, + doc3, + doc2, + doc1, + doc0 + ]); + }); + + it('sort_null_inMap_ascending', () => { + const doc0 = doc('k/0', 1000, { 'not-foo': {} }); + const doc1 = doc('k/1', 1000, { foo: {} }); + const doc2 = doc('k/2', 1000, { foo: { a: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: null, b: null } }); + const doc4 = doc('k/4', 1000, { foo: { a: null, b: 1 } }); + const doc5 = doc('k/5', 1000, { foo: { a: null, b: 2 } }); + const doc6 = doc('k/6', 1000, { foo: { a: 1, b: null } }); + const doc7 = doc('k/7', 1000, { foo: { a: 2, b: null } }); + const doc8 = doc('k/8', 1000, { foo: { a: 2, b: 1 } }); + + const pipeline = db.pipeline().database().sort(field('foo').ascending()); + + expect( + runPipeline(pipeline, [ + doc0, + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8 + ]) + ).to.have.ordered.members([ + doc0, + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8 + ]); + }); + + it('sort_null_inMap_descending', () => { + const doc0 = doc('k/0', 1000, { 'not-foo': {} }); + const doc1 = doc('k/1', 1000, { foo: {} }); + const doc2 = doc('k/2', 1000, { foo: { a: null } }); + const doc3 = doc('k/3', 1000, { foo: { a: null, b: null } }); + const doc4 = doc('k/4', 1000, { foo: { a: null, b: 1 } }); + const doc5 = doc('k/5', 1000, { foo: { a: null, b: 2 } }); + const doc6 = doc('k/6', 1000, { foo: { a: 1, b: null } }); + const doc7 = doc('k/7', 1000, { foo: { a: 2, b: null } }); + const doc8 = doc('k/8', 1000, { foo: { a: 2, b: 1 } }); + + const pipeline = db.pipeline().database().sort(field('foo').descending()); + + expect( + runPipeline(pipeline, [ + doc0, + doc1, + doc2, + doc3, + doc4, + doc5, + doc6, + doc7, + doc8 + ]) + ).to.have.ordered.members([ + doc8, + doc7, + doc6, + doc5, + doc4, + doc3, + doc2, + doc1, + doc0 + ]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/number_semantics.test.ts b/packages/firestore/test/unit/core/pipeline/number_semantics.test.ts new file mode 100644 index 00000000000..6cb1b90b04c --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/number_semantics.test.ts @@ -0,0 +1,323 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Number Semantics', () => { + it('zero_negativeDoubleZero', () => { + const doc1 = doc('users/a', 1000, { score: 0 }); + const doc2 = doc('users/b', 1000, { score: -0 }); + const doc3 = doc('users/c', 1000, { score: 0.0 }); + const doc4 = doc('users/d', 1000, { score: -0.0 }); + const doc5 = doc('users/e', 1000, { score: 1 }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('score'), constant(-0.0))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4] + ); + }); + + it('zero_negativeIntegerZero', () => { + const doc1 = doc('users/a', 1000, { score: 0 }); + const doc2 = doc('users/b', 1000, { score: -0 }); + const doc3 = doc('users/c', 1000, { score: 0.0 }); + const doc4 = doc('users/d', 1000, { score: -0.0 }); + const doc5 = doc('users/e', 1000, { score: 1 }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('score'), constant(-0))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4] + ); + }); + + it('zero_positiveDoubleZero', () => { + const doc1 = doc('users/a', 1000, { score: 0 }); + const doc2 = doc('users/b', 1000, { score: -0 }); + const doc3 = doc('users/c', 1000, { score: 0.0 }); + const doc4 = doc('users/d', 1000, { score: -0.0 }); + const doc5 = doc('users/e', 1000, { score: 1 }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('score'), constant(0.0))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4] + ); + }); + + it('zero_positiveIntegerZero', () => { + const doc1 = doc('users/a', 1000, { score: 0 }); + const doc2 = doc('users/b', 1000, { score: -0 }); + const doc3 = doc('users/c', 1000, { score: 0.0 }); + const doc4 = doc('users/d', 1000, { score: -0.0 }); + const doc5 = doc('users/e', 1000, { score: 1 }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('score'), constant(0))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4] + ); + }); + + it('equalNan', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('age'), constant(NaN))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('lessThanNan', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: null }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(lt(field('age'), constant(NaN))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('lessThanEqualNan', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: null }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(lte(field('age'), constant(NaN))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('greaterThanEqualNan', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 100 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gte(field('age'), constant(NaN))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('greaterThanNan', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 100 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('age'), constant(NaN))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('notEqualNan', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(neq(field('age'), constant(NaN))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc2, + doc3 + ]); + }); + + it('eqAny_containsNan', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('name'), [constant(NaN), constant('alice')])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc1]); + }); + + it('eqAny_containsNanOnly_isEmpty', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eqAny(field('age'), [constant(NaN)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('arrayContains_nanOnly_isEmpty', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: NaN }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(arrayContains(field('age'), constant(NaN)) as BooleanExpr); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('arrayContainsAny_withNaN', () => { + const doc1 = doc('users/a', 1000, { field: [NaN] }); + const doc2 = doc('users/b', 1000, { field: [NaN, 42] }); + const doc3 = doc('users/c', 1000, { field: ['foo', 42] }); + + const pipeline = db + .pipeline() + .database() + .where( + arrayContainsAny(field('field'), [constant(NaN), constant('foo')]) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc3]); + }); + + it('notEqAny_containsNan', () => { + const doc1 = doc('users/a', 1000, { age: 42 }); + const doc2 = doc('users/b', 1000, { age: NaN }); + const doc3 = doc('users/c', 1000, { age: 25 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(notEqAny(field('age'), [constant(NaN), constant(42)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc2, + doc3 + ]); + }); + + it('notEqAny_containsNanOnly_isEmpty', () => { + const doc1 = doc('users/a', 1000, { age: 42 }); + const doc2 = doc('users/b', 1000, { age: NaN }); + const doc3 = doc('users/c', 1000, { age: 25 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(notEqAny(field('age'), [constant(NaN)])); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([ + doc1, + doc2, + doc3 + ]); + }); + + it('array_withNan', () => { + const doc1 = doc('k/a', 1000, { foo: [NaN] }); + const doc2 = doc('k/b', 1000, { foo: [42] }); + + const pipeline = db + .pipeline() + .database() + .where(eq(field('foo'), constantArray([NaN]))); + + expect(runPipeline(pipeline, [doc1, doc2])).to.be.empty; + }); + + // it('map_withNan', () => { + // const doc1 = doc('k/a', 1000, { foo: { a: NaN } }); + // const doc2 = doc('k/b', 1000, { foo: { a: 42 } }); + // + // const pipeline = db.pipeline().database().where(eq(field('foo'), constant({ a: NaN }))); + // + // expect(runPipeline(pipeline, [doc1, doc2])).to.be.empty; + // }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/sort.test.ts b/packages/firestore/test/unit/core/pipeline/sort.test.ts new file mode 100644 index 00000000000..f547a4a6ed1 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/sort.test.ts @@ -0,0 +1,751 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Sort Tests', () => { + it('empty_ascending', () => { + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [])).to.be.empty; + }); + + it('empty_descending', () => { + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').descending()); + + expect(runPipeline(pipeline, [])).to.be.empty; + }); + + it('singleResult_ascending', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1])).to.deep.equal([doc1]); + }); + + it('singleResult_ascending_explicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('age'))) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1])).to.deep.equal([doc1]); + }); + + it('singleResult_ascending_explicitNotExists_empty', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(exists(field('age')))) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1])).to.be.empty; + }); + + it('singleResult_ascending_implicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('age'), constant(10))) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1])).to.deep.equal([doc1]); + }); + + it('singleResult_descending', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').descending()); + + expect(runPipeline(pipeline, [doc1])).to.deep.equal([doc1]); + }); + + it('singleResult_descending_explicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('age'))) + .sort(field('age').descending()); + + expect(runPipeline(pipeline, [doc1])).to.deep.equal([doc1]); + }); + + it('singleResult_descending_implicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('age'), constant(10))) + .sort(field('age').descending()); + + expect(runPipeline(pipeline, [doc1])).to.deep.equal([doc1]); + }); + + it('multipleResults_ambiguousOrder', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('multipleResults_ambiguousOrder_explicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('age'))) + .sort(field('age').descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('multipleResults_ambiguousOrder_implicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('age'), constant(0))) + .sort(field('age').descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('multipleResults_fullOrder', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').descending(), field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('multipleResults_fullOrder_explicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('age'))) + .where(exists(field('name'))) + .sort(field('age').descending(), field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('multipleResults_fullOrder_explicitNotExists_empty', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob' }); + const doc3 = doc('users/c', 1000, { age: 100 }); + const doc4 = doc('users/d', 1000, { other_name: 'diane' }); + const doc5 = doc('users/e', 1000, { other_age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(exists(field('age')))) + .where(not(exists(field('name')))) + .sort(field('age').descending(), field('name').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.contain( + doc4 + ); + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.contain( + doc5 + ); + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.lengthOf(2); + }); + + it('multipleResults_fullOrder_implicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('age'), field('age'))) + .where(regexMatch(field('name'), constant('.*'))) + .sort(field('age').descending(), field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('multipleResults_fullOrder_partialExplicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('name'))) + .sort(field('age').descending(), field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('multipleResults_fullOrder_partialExplicitNotExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { age: 25 }); + const doc3 = doc('users/c', 1000, { age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(exists(field('name')))) + .sort(field('age').descending(), field('name').descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc2]); + }); + + it('multipleResults_fullOrder_partialExplicitNotExists_sortOnNonExistFieldFirst', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { age: 25 }); + const doc3 = doc('users/c', 1000, { age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(exists(field('name')))) + .sort(field('name').descending(), field('age').descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc2]); + }); + + it('multipleResults_fullOrder_partialImplicitExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(regexMatch(field('name'), constant('.*'))) + .sort(field('age').descending(), field('name').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc1, doc2, doc4, doc5]); + }); + + it('missingField_allFields', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('not_age').descending()); + + // Any order is acceptable. + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.deep.members([doc1, doc2, doc3, doc4, doc5]); + }); + + it('missingField_withExist_empty', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('not_age'))) + .sort(field('not_age').descending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('missingField_partialFields', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob' }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').ascending()); + + // Any order is acceptable. + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.deep.members([doc5, doc1, doc3, doc2, doc4]); + }); + + it('missingField_partialFields_withExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob' }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('age'))) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc5, doc1, doc3]); + }); + + it('missingField_partialFields_withNotExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob' }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(exists(field('age')))) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc2, doc4]); + }); + + it('limit_afterSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').ascending()) + .limit(2); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc5]); + }); + + it('limit_afterSort_withExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field('age'))) + .sort(field('age').ascending()) + .limit(2); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc5, doc2]); + }); + + it('limit_afterSort_withNotExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(not(exists(field('age')))) + .sort(field('age').ascending()) + .limit(2); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc5]); + }); + + it('limit_zero_afterSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collection('/users') + .sort(field('age').ascending()) + .limit(0); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('limit_beforeSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .limit(1) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.lengthOf(1); + }); + + it('limit_beforeSort_withExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(exists(field('age'))) + .limit(1) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.lengthOf(1); + }); + + it('limit_beforeSort_withNotExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric' }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(not(exists(field('age')))) + .limit(1) + .sort(field('age').ascending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.lengthOf(1); + }); + + it('limit_beforeNotExistFilter', () => { + const doc1 = doc('users/a', 1000, { age: 75.5 }); + const doc2 = doc('users/b', 1000, { age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric' }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .limit(2) + .where(not(exists(field('age')))) + .sort(field('age').ascending()); + + // The right sematics would accept [], [doc4], [doc5], [doc4, doc5] [doc5, doc4]. + // We only test the first possibility here because of the implied order limit + // is applied for offline evaluation. + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('limit_zero_beforeSort', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .limit(0) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.be.empty; + }); + + it('sort_expression', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 30 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 50 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 40 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 20 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .sort(add(field('age'), constant(10)).descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc4, doc2, doc5, doc1]); + }); + + it('sort_expression_withExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + const doc2 = doc('users/b', 1000, { age: 30 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 50 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 20 }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(exists(field('age'))) + .sort(add(field('age'), constant(10)).descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc3, doc2, doc5, doc1]); + }); + + it('sort_expression_withNotExist', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 10 }); + const doc2 = doc('users/b', 1000, { age: 30 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 50 }); + const doc4 = doc('users/d', 1000, { name: 'diane' }); + const doc5 = doc('users/e', 1000, { name: 'eric' }); + + const pipeline = db + .pipeline() + .collectionGroup('users') + .where(not(exists(field('age')))) + .sort(add(field('age'), constant(10)).descending()); + + expect( + runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5]) + ).to.have.ordered.members([doc4, doc5]); + }); + + it('sortOnPathAndOtherField_onDifferentStages', () => { + const doc1 = doc('users/1', 1000, { name: 'alice', age: 40 }); + const doc2 = doc('users/2', 1000, { name: 'bob', age: 30 }); + const doc3 = doc('users/3', 1000, { name: 'charlie', age: 50 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field(DOCUMENT_KEY_NAME))) + .sort(field(DOCUMENT_KEY_NAME).ascending()) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1, + doc3 + ]); + }); + + it('sortOnOtherFieldAndPath_onDifferentStages', () => { + const doc1 = doc('users/1', 1000, { name: 'alice', age: 40 }); + const doc2 = doc('users/2', 1000, { name: 'bob', age: 30 }); + const doc3 = doc('users/3', 1000, { name: 'charlie', age: 50 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field(DOCUMENT_KEY_NAME))) + .sort(field('age').ascending()) + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc2, + doc3 + ]); + }); + + it('sortOnKeyAndOtherField_onMultipleStages', () => { + const doc1 = doc('users/1', 1000, { name: 'alice', age: 40 }); + const doc2 = doc('users/2', 1000, { name: 'bob', age: 30 }); + const doc3 = doc('users/3', 1000, { name: 'charlie', age: 50 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field(DOCUMENT_KEY_NAME))) + .sort(field(DOCUMENT_KEY_NAME).ascending()) + .sort(field('age').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1, + doc3 + ]); + }); + + it('sortOnOtherFieldAndKey_onMultipleStages', () => { + const doc1 = doc('users/1', 1000, { name: 'alice', age: 40 }); + const doc2 = doc('users/2', 1000, { name: 'bob', age: 30 }); + const doc3 = doc('users/3', 1000, { name: 'charlie', age: 50 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(exists(field(DOCUMENT_KEY_NAME))) + .sort(field('age').ascending()) + .sort(field(DOCUMENT_KEY_NAME).ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc2, + doc3 + ]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/unicode.test.ts b/packages/firestore/test/unit/core/pipeline/unicode.test.ts new file mode 100644 index 00000000000..46d25971f91 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/unicode.test.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Unicode Tests', () => { + it('basicUnicode', () => { + const doc1 = doc('🐵/Łukasiewicz', 1000, { Ł: 'Jan Łukasiewicz' }); + const doc2 = doc('🐵/Sierpiński', 1000, { Ł: 'Wacław Sierpiński' }); + const doc3 = doc('🐵/iwasawa', 1000, { Ł: '岩澤' }); + + const pipeline = db + .pipeline() + .collection('/🐵') + .sort(field('Ł').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc2, + doc3 + ]); + }); + + // TODO(pipeline): SDK's surrogates ordering has always been incompatible with + // backends, which comes from ICU4J. We need to replicate the semantics of that. + // Skipping below tests until then. + it('unicodeSurrogates', () => { + const doc1 = doc('users/a', 1000, { str: '🄟' }); + const doc2 = doc('users/b', 1000, { str: 'P' }); + const doc3 = doc('users/c', 1000, { str: '︒' }); + + const pipeline = db + .pipeline() + .database() + .where( + and(lte(field('str'), constant('🄟')), gte(field('str'), constant('P'))) + ) + .sort(field('str').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc2, + doc1 + ]); + }); + + it.skip('unicodeSurrogatesInArray', () => { + const doc1 = doc('users/a', 1000, { foo: ['🄟'] }); + const doc2 = doc('users/b', 1000, { foo: ['P'] }); + const doc3 = doc('users/c', 1000, { foo: ['︒'] }); + + const pipeline = db.pipeline().database().sort(field('foo').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc3, + doc2, + doc1 + ]); + }); + + it.skip('unicodeSurrogatesInMapKeys', () => { + const doc1 = doc('users/a', 1000, { map: { '︒': true, z: true } }); + const doc2 = doc('users/b', 1000, { map: { '🄟': true, '︒': true } }); + const doc3 = doc('users/c', 1000, { map: { 'P': true, '︒': true } }); + + const pipeline = db.pipeline().database().sort(field('map').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc3, + doc2 + ]); + }); + + it.skip('unicodeSurrogatesInMapValues', () => { + const doc1 = doc('users/a', 1000, { map: { foo: '︒' } }); + const doc2 = doc('users/b', 1000, { map: { foo: '🄟' } }); + const doc3 = doc('users/c', 1000, { map: { foo: 'P' } }); + + const pipeline = db.pipeline().database().sort(field('map').ascending()); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.have.ordered.members([ + doc1, + doc3, + doc2 + ]); + }); +}); diff --git a/packages/firestore/test/unit/core/pipeline/util.ts b/packages/firestore/test/unit/core/pipeline/util.ts new file mode 100644 index 00000000000..ba6d9d0a720 --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/util.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + or as apiOr, + not as apiNot, + xor as ApiXor, + BooleanExpr, + Expr +} from '../../../../lite/pipelines/pipelines'; + +export function and(expr1: Expr, expr2: Expr): BooleanExpr { + return apiAnd(expr1 as BooleanExpr, expr2 as BooleanExpr); +} + +export function or(expr1: Expr, expr2: Expr): BooleanExpr { + return apiOr(expr1 as BooleanExpr, expr2 as BooleanExpr); +} + +export function not(expr: Expr): BooleanExpr { + return apiNot(expr as BooleanExpr); +} + +export function xor(expr1: Expr, expr2: Expr): BooleanExpr { + return ApiXor(expr1 as BooleanExpr, expr2 as BooleanExpr); +} diff --git a/packages/firestore/test/unit/core/pipeline/where.test.ts b/packages/firestore/test/unit/core/pipeline/where.test.ts new file mode 100644 index 00000000000..5ffc4fbf34a --- /dev/null +++ b/packages/firestore/test/unit/core/pipeline/where.test.ts @@ -0,0 +1,600 @@ +/** + * @license + * Copyright 2025 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 { + and as apiAnd, + eq, + Field, + gt, + gte, + isNan, + like, + lt, + lte, + neq, + notEqAny, + arrayContainsAny, + add, + constant, + field, + or as apiOr, + not as apiNot, + divide, + BooleanExpr, + exists, + regexMatch, + eqAny, + xor as ApiXor, + arrayContains, + Expr, + arrayContainsAll +} from '../../../../lite/pipelines/pipelines'; +import { doc as docRef } from '../../../../src'; +import { isNull } from '../../../../src/lite-api/expressions'; +import { MutableDocument } from '../../../../src/model/document'; +import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path'; +import { newTestFirestore } from '../../../util/api_helpers'; +import { doc } from '../../../util/helpers'; +import { + canonifyPipeline, + constantArray, + constantMap, + pipelineEq, + runPipeline +} from '../../../util/pipelines'; +import { and, or, not, xor } from './util'; + +const db = newTestFirestore(); + +describe('Where Stage', () => { + it('emptyDatabase_returnsNoResults', () => { + expect( + runPipeline( + db + .pipeline() + .database() + .where(gte(field('age'), constant(10))), + [] + ) + ).to.be.empty; + }); + + it('duplicateConditions', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .database() + .where( + and(gte(field('age'), constant(10)), gte(field('age'), constant(20))) + ); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3] + ); + }); + + it('logicalEquivalentCondition_equal', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline1 = db + .pipeline() + .database() + .where(eq(field('age'), constant(25))); + const pipeline2 = db + .pipeline() + .database() + .where(eq(constant(25), field('age'))); + + const result1 = runPipeline(pipeline1, [doc1, doc2, doc3]); + const result2 = runPipeline(pipeline2, [doc1, doc2, doc3]); + + expect(result1).to.deep.equal([doc2]); + expect(result1).to.deep.equal(result2); + }); + + it('logicalEquivalentCondition_and', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline1 = db + .pipeline() + .database() + .where( + and(gt(field('age'), constant(10)), lt(field('age'), constant(70))) + ); + const pipeline2 = db + .pipeline() + .database() + .where( + and(lt(field('age'), constant(70)), gt(field('age'), constant(10))) + ); + + const result1 = runPipeline(pipeline1, [doc1, doc2, doc3]); + const result2 = runPipeline(pipeline2, [doc1, doc2, doc3]); + + expect(result1).to.deep.equal([doc2]); + expect(result1).to.deep.equal(result2); + }); + + it('logicalEquivalentCondition_or', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline1 = db + .pipeline() + .database() + .where( + or(lt(field('age'), constant(10)), gt(field('age'), constant(80))) + ); + const pipeline2 = db + .pipeline() + .database() + .where( + or(gt(field('age'), constant(80)), lt(field('age'), constant(10))) + ); + + const result1 = runPipeline(pipeline1, [doc1, doc2, doc3]); + const result2 = runPipeline(pipeline2, [doc1, doc2, doc3]); + + expect(result1).to.deep.equal([doc3]); + expect(result1).to.deep.equal(result2); + }); + + it('logicalEquivalentCondition_in', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + + const pipeline1 = db + .pipeline() + .database() + .where( + eqAny(field('name'), [ + constant('alice'), + constant('matthew'), + constant('joe') + ]) + ); + const pipeline2 = db + .pipeline() + .database() + .where( + arrayContainsAny(constantArray(['alice', 'matthew', 'joe']), [ + field('name') + ]) + ); + + const result1 = runPipeline(pipeline1, [doc1, doc2, doc3]); + const result2 = runPipeline(pipeline2, [doc1, doc2, doc3]); + + expect(result1).to.deep.equal([doc1]); + expect(result1).to.deep.equal(result2); + }); + + it('repeatedStages', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 100 }); + const doc4 = doc('users/d', 1000, { name: 'diane', age: 10 }); + const doc5 = doc('users/e', 1000, { name: 'eric', age: 10 }); + + const pipeline = db + .pipeline() + .database() + .where(gte(field('age'), constant(10))) + .where(gte(field('age'), constant(20))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3] + ); + }); + + it('composite_equalities', () => { + const doc1 = doc('users/a', 1000, { height: 60, age: 75 }); + const doc2 = doc('users/b', 1000, { height: 55, age: 50 }); + const doc3 = doc('users/c', 1000, { height: 55.0, age: 75 }); + const doc4 = doc('users/d', 1000, { height: 50, age: 41 }); + const doc5 = doc('users/e', 1000, { height: 80, age: 75 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('age'), constant(75))) + .where(eq(field('height'), constant(55))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3] + ); + }); + + it('composite_inequalities', () => { + const doc1 = doc('users/a', 1000, { height: 60, age: 75 }); + const doc2 = doc('users/b', 1000, { height: 55, age: 50 }); + const doc3 = doc('users/c', 1000, { height: 55.0, age: 75 }); + const doc4 = doc('users/d', 1000, { height: 50, age: 41 }); + const doc5 = doc('users/e', 1000, { height: 80, age: 75 }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(gt(field('age'), constant(50))) + .where(lt(field('height'), constant(75))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc3] + ); + }); + + it('composite_nonSeekable', () => { + const doc1 = doc('users/a', 1000, { first: 'alice', last: 'smith' }); + const doc2 = doc('users/b', 1000, { first: 'bob', last: 'smith' }); + const doc3 = doc('users/c', 1000, { first: 'charlie', last: 'baker' }); + const doc4 = doc('users/d', 1000, { first: 'diane', last: 'miller' }); + const doc5 = doc('users/e', 1000, { first: 'eric', last: 'davis' }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(like(field('first'), constant('%a%'))) + .where(like(field('last'), constant('%er'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc4] + ); + }); + + it('composite_mixed', () => { + const doc1 = doc('users/a', 1000, { + first: 'alice', + last: 'smith', + age: 75, + height: 40 + }); + const doc2 = doc('users/b', 1000, { + first: 'bob', + last: 'smith', + age: 75, + height: 50 + }); + const doc3 = doc('users/c', 1000, { + first: 'charlie', + last: 'baker', + age: 75, + height: 50 + }); + const doc4 = doc('users/d', 1000, { + first: 'diane', + last: 'miller', + age: 75, + height: 50 + }); + const doc5 = doc('users/e', 1000, { + first: 'eric', + last: 'davis', + age: 80, + height: 50 + }); + + const pipeline = db + .pipeline() + .collection('/users') + .where(eq(field('age'), constant(75))) + .where(gt(field('height'), constant(45))) + .where(like(field('last'), constant('%er'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc4] + ); + }); + + it('exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(exists(field('name'))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3] + ); + }); + + it('not_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(not(exists(field('name')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc4, doc5] + ); + }); + + it('not_not_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(not(not(exists(field('name'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3] + ); + }); + + it('exists_and_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(and(exists(field('name')), exists(field('age')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2] + ); + }); + + it('exists_or_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(or(exists(field('name')), exists(field('age')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc3, doc4] + ); + }); + + it('not_exists_and_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(not(and(exists(field('name')), exists(field('age'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc4, doc5] + ); + }); + + it('not_exists_or_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(not(or(exists(field('name')), exists(field('age'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc5] + ); + }); + + it('not_exists_xor_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(not(xor(exists(field('name')), exists(field('age'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc5] + ); + }); + + it('and_notExists_notExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(and(not(exists(field('name'))), not(exists(field('age'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc5] + ); + }); + + it('or_notExists_notExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(or(not(exists(field('name'))), not(exists(field('age'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc4, doc5] + ); + }); + + it('xor_notExists_notExists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(xor(not(exists(field('name'))), not(exists(field('age'))))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc3, doc4] + ); + }); + + it('and_notExists_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(and(not(exists(field('name'))), exists(field('age')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc4] + ); + }); + + it('or_notExists_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(or(not(exists(field('name'))), exists(field('age')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc4, doc5] + ); + }); + + it('xor_notExists_exists', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: 75.5 }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: 25 }); + const doc3 = doc('users/c', 1000, { name: 'charlie' }); + const doc4 = doc('users/d', 1000, { age: 30 }); + const doc5 = doc('users/e', 1000, { other: true }); + + const pipeline = db + .pipeline() + .database() + .where(xor(not(exists(field('name'))), exists(field('age')))); + + expect(runPipeline(pipeline, [doc1, doc2, doc3, doc4, doc5])).to.deep.equal( + [doc1, doc2, doc5] + ); + }); + + it('whereExpressionIsNotBooleanYielding', () => { + const doc1 = doc('users/a', 1000, { name: 'alice', age: true }); + const doc2 = doc('users/b', 1000, { name: 'bob', age: '42' }); + const doc3 = doc('users/c', 1000, { name: 'charlie', age: 0 }); + + const pipeline = db + .pipeline() + .database() + .where(divide(constant('100'), constant('50')) as unknown as BooleanExpr); + + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.be.empty; + }); + + it('andExpression_logicallyEquivalent_toSeparatedStages', () => { + const doc1 = doc('users/a', 1000, { a: 1, b: 1 }); + const doc2 = doc('users/b', 1000, { a: 1, b: 2 }); + const doc3 = doc('users/c', 1000, { a: 2, b: 2 }); + + const equalityArgument1 = eq(field('a'), constant(1)); + const equalityArgument2 = eq(field('b'), constant(2)); + + let pipeline = db + .pipeline() + .database() + .where(and(equalityArgument1, equalityArgument2)); + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + + pipeline = db + .pipeline() + .database() + .where(and(equalityArgument2, equalityArgument1)); + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + + pipeline = db + .pipeline() + .database() + .where(equalityArgument1) + .where(equalityArgument2); + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + + pipeline = db + .pipeline() + .database() + .where(equalityArgument2) + .where(equalityArgument1); + expect(runPipeline(pipeline, [doc1, doc2, doc3])).to.deep.equal([doc2]); + }); +}); diff --git a/packages/firestore/test/util/api_helpers.ts b/packages/firestore/test/util/api_helpers.ts index 517167be323..752fe3d7e36 100644 --- a/packages/firestore/test/util/api_helpers.ts +++ b/packages/firestore/test/util/api_helpers.ts @@ -56,11 +56,14 @@ export function firestore(): Firestore { return FIRESTORE; } -export function newTestFirestore(projectId = 'new-project'): Firestore { +export function newTestFirestore( + projectId = 'new-project', + databaseId: string | undefined = undefined +): Firestore { return new Firestore( new EmptyAuthCredentialsProvider(), new EmptyAppCheckTokenProvider(), - new DatabaseId(projectId) + new DatabaseId(databaseId ?? projectId) ); } diff --git a/packages/firestore/test/util/pipelines.ts b/packages/firestore/test/util/pipelines.ts new file mode 100644 index 00000000000..23cf5dbc60a --- /dev/null +++ b/packages/firestore/test/util/pipelines.ts @@ -0,0 +1,57 @@ +import { + canonifyPipeline as canonifyCorePipeline, + pipelineEq as corePipelineEq, + toCorePipeline +} from '../../src/core/pipeline-util'; +import { + PipelineInputOutput, + runPipeline as runCorePipeline +} from '../../src/core/pipeline_run'; +import { Constant } from '../../src/lite-api/expressions'; +import { Pipeline as LitePipeline } from '../../src/lite-api/pipeline'; +import { newUserDataReader } from '../../src/lite-api/user_data_reader'; + +import { firestore, newTestFirestore } from './api_helpers'; +import { RealtimePipeline } from '../../src/api/realtime_pipeline'; +import { Stage } from '../../src/lite-api/stage'; +import { PipelineSource } from '../../src/lite-api/pipeline-source'; +import { ExpUserDataWriter } from '../../src/api/user_data_writer'; + +export function canonifyPipeline(p: LitePipeline): string { + return canonifyCorePipeline(toCorePipeline(p)); +} + +export function pipelineEq(p1: LitePipeline, p2: LitePipeline): boolean { + return corePipelineEq(toCorePipeline(p1), toCorePipeline(p2)); +} + +export function runPipeline( + p: LitePipeline, + inputs: PipelineInputOutput[] +): PipelineInputOutput[] { + return runCorePipeline(toCorePipeline(p), inputs); +} + +const db = newTestFirestore(); + +export function constantArray(values: unknown[]): Constant { + const result = new Constant(values); + result._readUserData(newUserDataReader(db)); + return result; +} + +export function constantMap(values: Record): Constant { + const result = new Constant(values); + result._readUserData(newUserDataReader(db)); + return result; +} + +export function pipelineFromStages(stages: Stage[]): RealtimePipeline { + const db = firestore(); + return new RealtimePipeline( + db, + newUserDataReader(db), + new ExpUserDataWriter(db), + stages + ); +}