From 42b3c0510155cad49161971e5a2ad98a82e12ee6 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 11 May 2026 12:41:36 +0100 Subject: [PATCH 01/10] feat: arrayFilter API now implemented to the JS layer --- .../configs/firestore-pipelines.ts | 4 -- .../firestore/__tests__/pipelines.test.ts | 63 +++++++++++++++++++ packages/firestore/consumer-type-test.ts | 7 +++ packages/firestore/e2e/Pipeline.e2e.js | 10 +++ .../firestore/lib/pipelines/expressions.ts | 28 +++++++++ packages/firestore/lib/pipelines/index.ts | 1 + packages/firestore/type-test.ts | 7 +++ 7 files changed, 116 insertions(+), 4 deletions(-) diff --git a/.github/scripts/compare-types/configs/firestore-pipelines.ts b/.github/scripts/compare-types/configs/firestore-pipelines.ts index 1e924adf17..6f81037507 100644 --- a/.github/scripts/compare-types/configs/firestore-pipelines.ts +++ b/.github/scripts/compare-types/configs/firestore-pipelines.ts @@ -23,10 +23,6 @@ import type { PackageConfig } from '../src/types'; const config: PackageConfig = { nameMapping: {}, missingInRN: [ - { - name: 'arrayFilter', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, { name: 'arrayFirst', reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', diff --git a/packages/firestore/__tests__/pipelines.test.ts b/packages/firestore/__tests__/pipelines.test.ts index bdca431412..f721ea8e80 100644 --- a/packages/firestore/__tests__/pipelines.test.ts +++ b/packages/firestore/__tests__/pipelines.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest } from '@jest/globals'; import { firebase } from '../lib'; import { + arrayFilter, arrayGet, and, conditional, @@ -141,6 +142,68 @@ describe('Firestore pipelines runtime', function () { }); }); + it('serializes arrayFilter as a function expression helper and fluent method', function () { + const db: any = firebase.firestore(); + const serialized = db + .pipeline() + .collection('firestore') + .select( + arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))).as( + 'passingScores', + ), + field('scores').arrayFilter('score', greaterThan(constant(2), constant(1))).as( + 'topScores', + ), + ) + .serialize(); + + expect(serialized.stages[0]).toMatchObject({ + stage: 'select', + options: { + selections: [ + { + alias: 'passingScores', + expr: { + exprType: 'Function', + name: 'arrayFilter', + args: [ + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 'score' }, + { + exprType: 'Function', + name: 'greaterThan', + args: [ + { exprType: 'Constant', value: 1 }, + { exprType: 'Constant', value: 0 }, + ], + }, + ], + }, + }, + { + alias: 'topScores', + expr: { + exprType: 'Function', + name: 'arrayFilter', + args: [ + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 'score' }, + { + exprType: 'Function', + name: 'greaterThan', + args: [ + { exprType: 'Constant', value: 2 }, + { exprType: 'Constant', value: 1 }, + ], + }, + ], + }, + }, + ], + }, + }); + }); + it('enforces union guards and self-cycle serialization constraints', function () { const db: any = firebase.firestore(); const secondaryDb: any = firebase.app('secondaryFromNative').firestore(); diff --git a/packages/firestore/consumer-type-test.ts b/packages/firestore/consumer-type-test.ts index f8bbcbb209..3c9e58ac19 100644 --- a/packages/firestore/consumer-type-test.ts +++ b/packages/firestore/consumer-type-test.ts @@ -170,6 +170,7 @@ import { currentTimestamp, // array array, + arrayFilter, arrayConcat, arrayGet, arrayLength, @@ -1310,6 +1311,10 @@ void currentTimestamp(); // array void array([1, 2, 3]); void array([field('a'), constant(2)]); +// arrayFilter: (string, alias, BooleanExpression) | (Expression, alias, BooleanExpression) +void arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))); +void arrayFilter(field('scores'), 'score', greaterThan(constant(1), constant(0))); +void field('scores').arrayFilter('score', greaterThan(constant(1), constant(0))); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1689,6 +1694,8 @@ const pipelineArrayOps = xDb arrayGet('items', 0).as('firstItem2'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), arrayConcat('primaryTags', ['extra']).as('allTags2'), + arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))).as('passingScores'), + field('scores').arrayFilter('score', greaterThan(constant(2), constant(1))).as('topScores'), arraySum(field('scores')).as('totalScore'), arraySum('scores').as('totalScore2'), ); diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index e87927fa8b..feb19eb6c0 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -1519,8 +1519,10 @@ describe('FirestorePipeline', function () { arrayLength, arrayGet, arrayConcat, + arrayFilter, arraySum, and, + greaterThan, arrayContains, arrayContainsAny, arrayContainsAll, @@ -1563,6 +1565,9 @@ describe('FirestorePipeline', function () { arrayLength(field('tags')).as('tagCount'), arrayGet(field('items'), 0).as('firstItem'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), + arrayFilter(field('items'), 'item', greaterThan(constant(1), constant(0))).as( + 'filteredItems', + ), arraySum(field('scores')).as('totalScore'), ); @@ -1584,6 +1589,9 @@ describe('FirestorePipeline', function () { array([constant(1), constant(2), constant(3)]).as('fixedArr'), arrayLength(field('tags')).as('tagCount'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), + arrayFilter('items', 'item', greaterThan(constant(1), constant(0))).as( + 'filteredItems', + ), arraySum(field('scores')).as('totalScore'), ), ); @@ -1593,6 +1601,7 @@ describe('FirestorePipeline', function () { iosData.fixedArr.should.eql([1, 2, 3]); iosData.tagCount.should.equal(2); iosData.allTags.should.eql(['a', 'b', 'c', 'd']); + iosData.filteredItems.should.eql(['x', 'y', 'z']); iosData.totalScore.should.equal(60); return; } @@ -1605,6 +1614,7 @@ describe('FirestorePipeline', function () { data.tagCount.should.equal(2); data.firstItem.should.equal('x'); data.allTags.should.eql(['a', 'b', 'c', 'd']); + data.filteredItems.should.eql(['x', 'y', 'z']); data.totalScore.should.equal(60); }); }); diff --git a/packages/firestore/lib/pipelines/expressions.ts b/packages/firestore/lib/pipelines/expressions.ts index b5ff72f415..a16a88e44a 100644 --- a/packages/firestore/lib/pipelines/expressions.ts +++ b/packages/firestore/lib/pipelines/expressions.ts @@ -108,6 +108,7 @@ interface FluentExpressionMethods { sum(): AggregateFunction; arrayAgg(): AggregateFunction; arrayAggDistinct(): AggregateFunction; + arrayFilter(alias: string, filter: BooleanExpression): FunctionExpression; } export interface BooleanExpression extends Selectable, FluentExpressionMethods { @@ -379,6 +380,7 @@ const EXPRESSION_METHOD_NAMES = [ 'arrayContains', 'arrayContainsAny', 'arrayContainsAll', + 'arrayFilter', 'startsWith', 'endsWith', 'add', @@ -765,6 +767,7 @@ function normalizeGlobalArguments(name: string, args: unknown[]): RuntimeNode[] case 'arrayContains': case 'arrayContainsAny': case 'arrayContainsAll': + case 'arrayFilter': case 'sum': case 'count': case 'average': @@ -998,6 +1001,31 @@ export function arrayContainsAll( return callBooleanHelper('arrayContainsAll', arguments); } +/** + * @beta + * Filters an array using a provided alias and predicate expression. + * + * The alias is serialized for native/web SDKs that support variable-bound + * predicates; RN Firebase does not yet expose the `variable()` helper. + */ +export function arrayFilter( + _fieldName: string, + _alias: string, + _filter: BooleanExpression, +): FunctionExpression; +export function arrayFilter( + _arrayExpression: Expression, + _alias: string, + _filter: BooleanExpression, +): FunctionExpression; +export function arrayFilter( + _arrayOrField: string | Expression, + _alias: string, + _filter: BooleanExpression, +): FunctionExpression { + return callFunctionHelper('arrayFilter', arguments); +} + /** * @beta * Checks if a string starts with a prefix. diff --git a/packages/firestore/lib/pipelines/index.ts b/packages/firestore/lib/pipelines/index.ts index 6704ba2c87..9b6474eaaf 100644 --- a/packages/firestore/lib/pipelines/index.ts +++ b/packages/firestore/lib/pipelines/index.ts @@ -79,6 +79,7 @@ export { arrayContains, arrayContainsAny, arrayContainsAll, + arrayFilter, startsWith, endsWith, OrderingHelper as Ordering, diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index 6f6d06558b..df43bc1ab0 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -169,6 +169,7 @@ import { currentTimestamp, // array array, + arrayFilter, arrayConcat, arrayGet, arrayLength, @@ -1312,6 +1313,10 @@ void currentTimestamp(); // array void array([1, 2, 3]); void array([field('a'), constant(2)]); +// arrayFilter: (string, alias, BooleanExpression) | (Expression, alias, BooleanExpression) +void arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))); +void arrayFilter(field('scores'), 'score', greaterThan(constant(1), constant(0))); +void field('scores').arrayFilter('score', greaterThan(constant(1), constant(0))); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1691,6 +1696,8 @@ const pipelineArrayOps = xDb arrayGet('items', 0).as('firstItem2'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), arrayConcat('primaryTags', ['extra']).as('allTags2'), + arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))).as('passingScores'), + field('scores').arrayFilter('score', greaterThan(constant(2), constant(1))).as('topScores'), arraySum(field('scores')).as('totalScore'), arraySum('scores').as('totalScore2'), ); From d881c5f2a1b695c8415e2c33c9c79adb3a0757f7 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 12 May 2026 13:20:09 +0100 Subject: [PATCH 02/10] feat(firestore): variable TS implementation --- .../firestore/lib/pipelines/expressions.ts | 39 +++++++++++++++++-- packages/firestore/lib/pipelines/index.ts | 1 + packages/firestore/lib/types/internal.ts | 8 ++++ .../web/pipelines/pipeline_node_builder.ts | 6 +++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/firestore/lib/pipelines/expressions.ts b/packages/firestore/lib/pipelines/expressions.ts index a16a88e44a..d85a7fa9c5 100644 --- a/packages/firestore/lib/pipelines/expressions.ts +++ b/packages/firestore/lib/pipelines/expressions.ts @@ -26,6 +26,7 @@ import type { Bytes } from '../modular/Bytes'; export type ExpressionType = | 'Field' | 'Constant' + | 'Variable' | 'Function' | 'AggregateFunction' | 'ListOfExpressions' @@ -148,11 +149,25 @@ export interface ConstantExpression extends FluentExpressionMethods { readonly _brand?: 'ConstantExpression'; } +/** + * @beta + * Variable expression returned by `variable(...)`. + */ +export interface VariableExpression extends Selectable, FluentExpressionMethods { + readonly _brand?: 'VariableExpression'; +} + /** * @beta * Expression type for pipeline parameters (field refs, literals, function results). */ -export type Expression = Field | FunctionExpression | ConstantExpression | Selectable | string; +export type Expression = + | Field + | FunctionExpression + | ConstantExpression + | VariableExpression + | Selectable + | string; /** * @beta @@ -258,6 +273,7 @@ type BooleanExpressionNode = RuntimeExpressionNode & RuntimeExpressionMethods & type RuntimeExpressionFluentNode = | (RuntimeExpressionNode & RuntimeExpressionMethods & Field) | (RuntimeExpressionNode & RuntimeExpressionMethods & FunctionExpression) + | (RuntimeExpressionNode & RuntimeExpressionMethods & VariableExpression) | BooleanExpressionNode | ConstantExpressionNode; type FieldNode = RuntimeExpressionFluentNode & Field; @@ -550,6 +566,16 @@ function createConstant(value: unknown): ConstantExpressionNode { }); } +function createVariable(name: unknown): RuntimeExpressionFluentNode & VariableExpression { + return createNode(expressionProto, { + [RUNTIME_NODE_SYMBOL]: true, + __kind: 'expression', + exprType: 'Variable', + selectable: true, + name: String(name ?? ''), + }); +} + function normalizeMapLikeValue(value: Record): Record { const output: Record = {}; @@ -842,6 +868,14 @@ export function field(_path: string): Field { return createField(_path); } +/** + * @beta + * Returns a variable reference for alias-bound pipeline expressions. + */ +export function variable(_name: string): Expression { + return createVariable(_name); +} + /** * @beta * Logical AND of boolean expressions. @@ -1004,9 +1038,6 @@ export function arrayContainsAll( /** * @beta * Filters an array using a provided alias and predicate expression. - * - * The alias is serialized for native/web SDKs that support variable-bound - * predicates; RN Firebase does not yet expose the `variable()` helper. */ export function arrayFilter( _fieldName: string, diff --git a/packages/firestore/lib/pipelines/index.ts b/packages/firestore/lib/pipelines/index.ts index 9b6474eaaf..10d8fbda0f 100644 --- a/packages/firestore/lib/pipelines/index.ts +++ b/packages/firestore/lib/pipelines/index.ts @@ -67,6 +67,7 @@ export type { OneOf } from './types'; export { execute } from './pipeline_impl'; export { field, + variable, and, or, greaterThan, diff --git a/packages/firestore/lib/types/internal.ts b/packages/firestore/lib/types/internal.ts index 46dde1f175..0f6ba6a39e 100644 --- a/packages/firestore/lib/types/internal.ts +++ b/packages/firestore/lib/types/internal.ts @@ -110,6 +110,13 @@ export interface FirestorePipelineConstantExpressionInternal { value: FirestorePipelineSerializedValueInternal; } +/** Serialized variable expression node passed to native pipeline execute bridge. */ +export interface FirestorePipelineVariableExpressionInternal { + __kind?: 'expression'; + exprType: 'Variable'; + name: string; +} + /** Serialized function expression node passed to native pipeline execute bridge. */ export interface FirestorePipelineFunctionExpressionInternal { __kind?: 'expression'; @@ -160,6 +167,7 @@ export interface FirestorePipelineAliasedAggregateInternal { export type FirestorePipelineExpressionInternal = | FirestorePipelineFieldExpressionInternal | FirestorePipelineConstantExpressionInternal + | FirestorePipelineVariableExpressionInternal | FirestorePipelineFunctionExpressionInternal; export type FirestorePipelineSelectableInternal = diff --git a/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts b/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts index a8a2dd0964..d78c5001ee 100644 --- a/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts +++ b/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts @@ -57,6 +57,7 @@ function isExpressionNode(value: Record): boolean { value.__kind === 'expression' || value.exprType === 'Field' || value.exprType === 'Constant' || + value.exprType === 'Variable' || value.exprType === 'Function' ); } @@ -168,6 +169,11 @@ function rebuildExpressionNode( return; } + if (node.exprType === 'Variable' && typeof node.name === 'string') { + resolve(getPipelineHelper('variable')(node.name) as Expression); + return; + } + if (typeof node.name === 'string') { const helperName = node.name; const args = Array.isArray(node.args) ? node.args : []; From d93f9689abd42dd066e5f3cf92f22caefadb5d10 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 12 May 2026 13:20:39 +0100 Subject: [PATCH 03/10] feat(firestore, android): variable implementation --- ...eFirebaseFirestorePipelineNodeBuilder.java | 44 ++++++++++++++++++- ...NativeFirebaseFirestorePipelineParser.java | 17 +++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index fbd26607cd..e83b311e30 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -994,6 +994,22 @@ private void processObjectLoweringStack(ArrayDeque stack) break; } + Object exprType = map.get("exprType"); + if (exprType instanceof String + && "variable".equals(((String) exprType).toLowerCase(Locale.ROOT))) { + Object nameValue = map.get("name"); + if (!(nameValue instanceof String) || ((String) nameValue).isEmpty()) { + throw new ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException( + "pipelineExecute() expected " + + currentFieldName + + ".name to be a non-empty string."); + } + enterFrame.box.value = + applyPendingUnaryExpressionFunctions( + Expression.variable((String) nameValue), pendingUnaryFunctions); + break; + } + Object name = map.get("name"); if (name instanceof String) { String functionName = (String) name; @@ -1022,7 +1038,7 @@ private void processObjectLoweringStack(ArrayDeque stack) break; } - Object exprType = map.get("exprType"); + exprType = map.get("exprType"); if (exprType instanceof String) { String normalizedType = ((String) exprType).toLowerCase(Locale.ROOT); if ("field".equals(normalizedType)) { @@ -3287,6 +3303,19 @@ private Object serializeExpressionNode( continue; } + if (expression + instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedVariableExpressionNode) { + Map output = new LinkedHashMap<>(); + output.put("__kind", "expression"); + output.put("exprType", "Variable"); + output.put( + "name", + ((ReactNativeFirebaseFirestorePipelineParser.ParsedVariableExpressionNode) expression) + .name); + enterFrame.box.value = output; + continue; + } + ReactNativeFirebaseFirestorePipelineParser.ParsedFunctionExpressionNode function = (ReactNativeFirebaseFirestorePipelineParser.ParsedFunctionExpressionNode) expression; List argBoxes = new ArrayList<>(function.args.size()); @@ -3532,6 +3561,19 @@ private Object serializeValueNode( continue; } + if (expression + instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedVariableExpressionNode) { + Map output = new LinkedHashMap<>(); + output.put("__kind", "expression"); + output.put("exprType", "Variable"); + output.put( + "name", + ((ReactNativeFirebaseFirestorePipelineParser.ParsedVariableExpressionNode) expression) + .name); + enterFrame.box.value = output; + continue; + } + ReactNativeFirebaseFirestorePipelineParser.ParsedFunctionExpressionNode function = (ReactNativeFirebaseFirestorePipelineParser.ParsedFunctionExpressionNode) expression; List argBoxes = new ArrayList<>(function.args.size()); diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java index da0b30ae1c..acd69f2c24 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java @@ -943,6 +943,15 @@ private static void parseExpressionValueTree(ExpressionValueParseFrame initialFr stack.push(new ValueEnterFrame(map.get("value"), valueBox, fieldName + ".value")); continue; } + if ("variable".equals(normalizedType)) { + Object nameValue = map.get("name"); + if (!(nameValue instanceof String) || ((String) nameValue).isEmpty()) { + throw new ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException( + "pipelineExecute() expected " + fieldName + ".name to be a non-empty string."); + } + enterFrame.box.value = new ParsedVariableExpressionNode((String) nameValue); + continue; + } } if (map.containsKey("name")) { @@ -1636,6 +1645,14 @@ static final class ParsedConstantExpressionNode extends ParsedExpressionNode { } } + static final class ParsedVariableExpressionNode extends ParsedExpressionNode { + final String name; + + ParsedVariableExpressionNode(String name) { + this.name = name; + } + } + static final class ParsedFunctionExpressionNode extends ParsedExpressionNode { final String name; final List args; From bfc227e304f7b4faf390d9035baf0cf5c3123d2b Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 12 May 2026 13:20:51 +0100 Subject: [PATCH 04/10] feat(firestore, ios): variable implementation --- .../RNFBFirestorePipelineBridgeFactory.swift | 6 ++++ .../RNFBFirestorePipelineNodeBuilder.swift | 28 +++++++++++++++++++ .../RNFBFirestorePipelineParser.swift | 8 ++++++ 3 files changed, 42 insertions(+) diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift index 92091a7520..1a7e601886 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift @@ -353,6 +353,12 @@ final class RNFBFirestorePipelineBridgeFactory { let childBox = QuerySourceValueBox() stack.append(.expressionConstantExit(box, childBox)) stack.append(.value(value, childBox)) + case let .variable(name): + box.value = [ + "__kind": "expression", + "exprType": "Variable", + "name": name, + ] case let .function(name, args): let childBoxes = args.map { _ in QuerySourceValueBox() } stack.append(.expressionFunctionExit(name, box, childBoxes)) diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift index 8052b7cd45..69937fbd0e 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift @@ -1044,6 +1044,14 @@ final class RNFBFirestorePipelineNodeBuilder { break expressionLoop } + if let kind = (map["exprType"] as? String)?.lowercased(), kind == "variable" { + guard let name = map["name"] as? String, !name.isEmpty else { + throw PipelineValidationError("pipelineExecute() expected \(currentField).name to be a non-empty string.") + } + box.value = VariableBridge(name: name) + break expressionLoop + } + if let name = map["name"] as? String { let rawArgs: [Any] if let args = map["args"] as? [Any] { @@ -1237,6 +1245,14 @@ final class RNFBFirestorePipelineNodeBuilder { break expressionLoop } + if let kind = (map["exprType"] as? String)?.lowercased(), kind == "variable" { + guard let name = map["name"] as? String, !name.isEmpty else { + throw PipelineValidationError("pipelineExecute() expected \(currentField).name to be a non-empty string.") + } + box.value = VariableBridge(name: name) + break expressionLoop + } + throw PipelineValidationError( "pipelineExecute() could not convert \(currentField) into a pipeline expression.") } @@ -1357,6 +1373,12 @@ final class RNFBFirestorePipelineNodeBuilder { let valueBox = SerializedValueBox() stack.append(.expressionConstantExit(box, valueBox)) stack.append(.valueEnter(constantValue, valueBox)) + case let .variable(name): + box.value = [ + "__kind": "expression", + "exprType": "Variable", + "name": name, + ] case let .function(name, args): let argBoxes = args.map { _ in SerializedValueBox() } stack.append(.expressionFunctionExit(box, name, argBoxes)) @@ -1463,6 +1485,12 @@ final class RNFBFirestorePipelineNodeBuilder { let valueBox = SerializedValueBox() stack.append(.expressionConstantExit(box, valueBox)) stack.append(.valueEnter(constantValue, valueBox)) + case let .variable(name): + box.value = [ + "__kind": "expression", + "exprType": "Variable", + "name": name, + ] case let .function(name, args): let argBoxes = args.map { _ in SerializedValueBox() } stack.append(.expressionFunctionExit(box, name, argBoxes)) diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift index cfbefc5ce6..1b9a215cb3 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift @@ -41,6 +41,7 @@ struct RNFBFirestoreParsedQuerySource { indirect enum RNFBFirestoreParsedExpressionNode { case field(path: String) case constant(RNFBFirestoreParsedValueNode) + case variable(name: String) case function(name: String, args: [RNFBFirestoreParsedValueNode]) } @@ -1191,6 +1192,13 @@ enum RNFBFirestorePipelineParser { stack.append(.valueEnter(map["value"] as Any, valueBox, "\(fieldName).value")) continue } + if normalizedType == "variable" { + guard let name = map["name"] as? String, !name.isEmpty else { + throw PipelineValidationError("pipelineExecute() expected \(fieldName).name to be a non-empty string.") + } + box.value = .variable(name: name) + continue + } } if map["name"] != nil { From 7da0bea01bea28a234ed053db6fbec06dda3f97e Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 12 May 2026 13:21:11 +0100 Subject: [PATCH 05/10] test(firestore): variable implementation --- .../configs/firestore-pipelines.ts | 6 +----- .../firestore/__tests__/pipelines-web.test.ts | 4 +++- packages/firestore/__tests__/pipelines.test.ts | 13 +++++++------ packages/firestore/e2e/Pipeline.e2e.js | 9 +++++---- packages/firestore/type-test.ts | 17 ++++++++++++----- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/.github/scripts/compare-types/configs/firestore-pipelines.ts b/.github/scripts/compare-types/configs/firestore-pipelines.ts index 6f81037507..fe5185b56a 100644 --- a/.github/scripts/compare-types/configs/firestore-pipelines.ts +++ b/.github/scripts/compare-types/configs/firestore-pipelines.ts @@ -123,10 +123,6 @@ const config: PackageConfig = { name: 'timestampExtract', reason: 'Newer firebase-js-sdk timestamp expression helper not yet exposed by RN Firebase pipelines.', }, - { - name: 'variable', - reason: 'Newer firebase-js-sdk variable expression helper not yet exposed by RN Firebase pipelines.', - }, { name: 'DefineStageOptions', reason: 'Newer firebase-js-sdk stage options type not yet exposed by RN Firebase pipelines.', @@ -165,7 +161,7 @@ const config: PackageConfig = { }, { name: 'ExpressionType', - reason: 'RN Firebase has not yet exposed the newer firebase-js-sdk `Variable` and `PipelineValue` expression kinds.', + reason: 'RN Firebase has not yet exposed the newer firebase-js-sdk `PipelineValue` expression kind.', }, { name: 'StageOptions', diff --git a/packages/firestore/__tests__/pipelines-web.test.ts b/packages/firestore/__tests__/pipelines-web.test.ts index 126aec5c04..f593ee87d7 100644 --- a/packages/firestore/__tests__/pipelines-web.test.ts +++ b/packages/firestore/__tests__/pipelines-web.test.ts @@ -15,6 +15,7 @@ jest.mock('@react-native-firebase/app/dist/module/internal/web/firebaseFirestore conditional: jest.fn(actual.conditional as (...args: unknown[]) => unknown), isType: jest.fn(actual.isType as (...args: unknown[]) => unknown), mapGet: jest.fn(actual.mapGet as (...args: unknown[]) => unknown), + variable: jest.fn(actual.variable as (...args: unknown[]) => unknown), euclideanDistance: jest.fn(actual.euclideanDistance as (...args: unknown[]) => unknown), }; }); @@ -70,7 +71,7 @@ describe('Firestore web pipeline bridge', function () { exprType: 'Function', name: 'greaterThan', args: [ - { __kind: 'expression', exprType: 'Field', path: 'rating' }, + { __kind: 'expression', exprType: 'Variable', name: 'score' }, { __kind: 'expression', exprType: 'Constant', value: 3 }, ], }, @@ -124,6 +125,7 @@ describe('Firestore web pipeline bridge', function () { const whereArg = (pipelineInstance.where as jest.Mock).mock.calls[0][0] as any; expect(whereArg).toBeDefined(); expect(whereArg.__kind).toBeUndefined(); + expect(firebaseFirestorePipelines.variable).toHaveBeenCalledWith('score'); const selectArg = (pipelineInstance.select as jest.Mock).mock.calls[0][0] as any; expect(selectArg).toBeDefined(); diff --git a/packages/firestore/__tests__/pipelines.test.ts b/packages/firestore/__tests__/pipelines.test.ts index f721ea8e80..d368043784 100644 --- a/packages/firestore/__tests__/pipelines.test.ts +++ b/packages/firestore/__tests__/pipelines.test.ts @@ -17,6 +17,7 @@ import { timestampAdd, timestampSubtract, trunc, + variable, } from '../lib/pipelines'; import '../lib/pipelines'; import { ConstantExpression } from '../lib/pipelines/expressions'; @@ -148,10 +149,10 @@ describe('Firestore pipelines runtime', function () { .pipeline() .collection('firestore') .select( - arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))).as( + arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( 'passingScores', ), - field('scores').arrayFilter('score', greaterThan(constant(2), constant(1))).as( + field('scores').arrayFilter('score', greaterThan(variable('score'), constant(20))).as( 'topScores', ), ) @@ -173,8 +174,8 @@ describe('Firestore pipelines runtime', function () { exprType: 'Function', name: 'greaterThan', args: [ - { exprType: 'Constant', value: 1 }, - { exprType: 'Constant', value: 0 }, + { exprType: 'Variable', name: 'score' }, + { exprType: 'Constant', value: 15 }, ], }, ], @@ -192,8 +193,8 @@ describe('Firestore pipelines runtime', function () { exprType: 'Function', name: 'greaterThan', args: [ - { exprType: 'Constant', value: 2 }, - { exprType: 'Constant', value: 1 }, + { exprType: 'Variable', name: 'score' }, + { exprType: 'Constant', value: 20 }, ], }, ], diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index feb19eb6c0..106ac57d99 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -1521,6 +1521,7 @@ describe('FirestorePipeline', function () { arrayConcat, arrayFilter, arraySum, + variable, and, greaterThan, arrayContains, @@ -1565,7 +1566,7 @@ describe('FirestorePipeline', function () { arrayLength(field('tags')).as('tagCount'), arrayGet(field('items'), 0).as('firstItem'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), - arrayFilter(field('items'), 'item', greaterThan(constant(1), constant(0))).as( + arrayFilter(field('scores'), 'score', greaterThan(variable('score'), 15)).as( 'filteredItems', ), arraySum(field('scores')).as('totalScore'), @@ -1589,7 +1590,7 @@ describe('FirestorePipeline', function () { array([constant(1), constant(2), constant(3)]).as('fixedArr'), arrayLength(field('tags')).as('tagCount'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), - arrayFilter('items', 'item', greaterThan(constant(1), constant(0))).as( + arrayFilter('scores', 'score', greaterThan(variable('score'), 15)).as( 'filteredItems', ), arraySum(field('scores')).as('totalScore'), @@ -1601,7 +1602,7 @@ describe('FirestorePipeline', function () { iosData.fixedArr.should.eql([1, 2, 3]); iosData.tagCount.should.equal(2); iosData.allTags.should.eql(['a', 'b', 'c', 'd']); - iosData.filteredItems.should.eql(['x', 'y', 'z']); + iosData.filteredItems.should.eql([20, 30]); iosData.totalScore.should.equal(60); return; } @@ -1614,7 +1615,7 @@ describe('FirestorePipeline', function () { data.tagCount.should.equal(2); data.firstItem.should.equal('x'); data.allTags.should.eql(['a', 'b', 'c', 'd']); - data.filteredItems.should.eql(['x', 'y', 'z']); + data.filteredItems.should.eql([20, 30]); data.totalScore.should.equal(60); }); }); diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index df43bc1ab0..f459dd0903 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -167,6 +167,7 @@ import { collectionId, type as pipelineType, currentTimestamp, + variable, // array array, arrayFilter, @@ -1313,10 +1314,12 @@ void currentTimestamp(); // array void array([1, 2, 3]); void array([field('a'), constant(2)]); +// variable: (string) => Expression +void variable('score'); // arrayFilter: (string, alias, BooleanExpression) | (Expression, alias, BooleanExpression) -void arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))); -void arrayFilter(field('scores'), 'score', greaterThan(constant(1), constant(0))); -void field('scores').arrayFilter('score', greaterThan(constant(1), constant(0))); +void arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))); +void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); +void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1696,8 +1699,12 @@ const pipelineArrayOps = xDb arrayGet('items', 0).as('firstItem2'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), arrayConcat('primaryTags', ['extra']).as('allTags2'), - arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))).as('passingScores'), - field('scores').arrayFilter('score', greaterThan(constant(2), constant(1))).as('topScores'), + arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( + 'passingScores', + ), + field('scores').arrayFilter('score', greaterThan(variable('score'), constant(20))).as( + 'topScores', + ), arraySum(field('scores')).as('totalScore'), arraySum('scores').as('totalScore2'), ); From c2b92a686718560dc48cd35fb7bc4e9c8eee0b72 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 12 May 2026 13:21:35 +0100 Subject: [PATCH 06/10] test(firestore): variable implementation in consumer api test --- packages/firestore/consumer-type-test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/firestore/consumer-type-test.ts b/packages/firestore/consumer-type-test.ts index 3c9e58ac19..78e17604d0 100644 --- a/packages/firestore/consumer-type-test.ts +++ b/packages/firestore/consumer-type-test.ts @@ -168,6 +168,7 @@ import { collectionId, type as pipelineType, currentTimestamp, + variable, // array array, arrayFilter, @@ -1311,10 +1312,12 @@ void currentTimestamp(); // array void array([1, 2, 3]); void array([field('a'), constant(2)]); +// variable: (string) => Expression +void variable('score'); // arrayFilter: (string, alias, BooleanExpression) | (Expression, alias, BooleanExpression) -void arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))); -void arrayFilter(field('scores'), 'score', greaterThan(constant(1), constant(0))); -void field('scores').arrayFilter('score', greaterThan(constant(1), constant(0))); +void arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))); +void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); +void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1694,8 +1697,12 @@ const pipelineArrayOps = xDb arrayGet('items', 0).as('firstItem2'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), arrayConcat('primaryTags', ['extra']).as('allTags2'), - arrayFilter('scores', 'score', greaterThan(constant(1), constant(0))).as('passingScores'), - field('scores').arrayFilter('score', greaterThan(constant(2), constant(1))).as('topScores'), + arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( + 'passingScores', + ), + field('scores').arrayFilter('score', greaterThan(variable('score'), constant(20))).as( + 'topScores', + ), arraySum(field('scores')).as('totalScore'), arraySum('scores').as('totalScore2'), ); From 1583bd0b841a70fb635275bbac94bcf2ff858cae Mon Sep 17 00:00:00 2001 From: Russell Wheatley Date: Wed, 13 May 2026 14:34:27 +0100 Subject: [PATCH 07/10] feat(firestore): arrayFirst and arrayFirstN API features (#9019) * feat(firestore): arrayFirst and arrayFirstN API features * feat(firestore, ios): arrayFirst and arrayFirstN API features * feat(firestore, android): arrayFirst and arrayFirstN API features * test(firestore): arrayFirst and arrayFirstN API features * chore(firestore): arrayFirst and arrayFirstN API removed from config * chore: iOS does not support arrayFirst and arrayFirstN yet --- .../configs/firestore-pipelines.ts | 8 -- .../__tests__/pipelines-parity.test.ts | 18 ++++ .../firestore/__tests__/pipelines.test.ts | 80 +++++++++++++++++ ...eFirebaseFirestorePipelineNodeBuilder.java | 87 +++++++++++++++++++ packages/firestore/consumer-type-test.ts | 17 ++++ packages/firestore/e2e/Pipeline.e2e.js | 14 ++- .../RNFBFirestorePipelineNodeBuilder.swift | 29 +++++++ .../firestore/lib/pipelines/expressions.ts | 32 +++++++ packages/firestore/lib/pipelines/index.ts | 2 + .../lib/pipelines/pipeline_support.ts | 2 + packages/firestore/type-test.ts | 17 ++++ 11 files changed, 296 insertions(+), 10 deletions(-) diff --git a/.github/scripts/compare-types/configs/firestore-pipelines.ts b/.github/scripts/compare-types/configs/firestore-pipelines.ts index fe5185b56a..28ff9fe5a2 100644 --- a/.github/scripts/compare-types/configs/firestore-pipelines.ts +++ b/.github/scripts/compare-types/configs/firestore-pipelines.ts @@ -23,14 +23,6 @@ import type { PackageConfig } from '../src/types'; const config: PackageConfig = { nameMapping: {}, missingInRN: [ - { - name: 'arrayFirst', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayFirstN', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, { name: 'arrayIndexOf', reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', diff --git a/packages/firestore/__tests__/pipelines-parity.test.ts b/packages/firestore/__tests__/pipelines-parity.test.ts index ca749cdbcb..1402f681a1 100644 --- a/packages/firestore/__tests__/pipelines-parity.test.ts +++ b/packages/firestore/__tests__/pipelines-parity.test.ts @@ -13,10 +13,18 @@ const ANDROID_EXECUTOR_PATH = join( ROOT, 'packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java', ); +const ANDROID_NODE_BUILDER_PATH = join( + ROOT, + 'packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java', +); const IOS_EXECUTOR_PATH = join( ROOT, 'packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift', ); +const IOS_NODE_BUILDER_PATH = join( + ROOT, + 'packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift', +); function extractQuotedList(source: string, marker: string, endMarker: string): string[] { const markerIndex = source.indexOf(marker); @@ -79,4 +87,14 @@ describe('Firestore pipeline native parity', function () { expect(iosSource).toContain('does not support options.rawOptions on iOS'); expect(iosSource).toContain('does not support pipeline.source.rawOptions'); }); + + it('keeps arrayFirst and arrayFirstN on native lowering paths', function () { + const androidSource = readFileSync(ANDROID_NODE_BUILDER_PATH, 'utf8'); + const iosSource = readFileSync(IOS_NODE_BUILDER_PATH, 'utf8'); + + expect(androidSource).toContain('currentExpression.arrayFirst()'); + expect(androidSource).toContain('arrayExpr.arrayFirstN'); + expect(iosSource).toContain('"array_first"'); + expect(iosSource).toContain('"array_first_n"'); + }); }); diff --git a/packages/firestore/__tests__/pipelines.test.ts b/packages/firestore/__tests__/pipelines.test.ts index d368043784..3980744fa1 100644 --- a/packages/firestore/__tests__/pipelines.test.ts +++ b/packages/firestore/__tests__/pipelines.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, jest } from '@jest/globals'; import { firebase } from '../lib'; import { arrayFilter, + arrayFirst, + arrayFirstN, arrayGet, and, conditional, @@ -205,6 +207,78 @@ describe('Firestore pipelines runtime', function () { }); }); + it('serializes arrayFirst and arrayFirstN as function expression helpers', function () { + const db: any = firebase.firestore(); + const serialized = db + .pipeline() + .collection('firestore') + .select( + arrayFirst('items').as('firstItem'), + arrayFirstN(field('items'), 2).as('firstTwoItems'), + arrayFirstN('items', field('count')).as('dynamicFirstItems'), + field('items').arrayFirst().as('fluentFirstItem'), + field('items').arrayFirstN(2).as('fluentFirstTwoItems'), + ) + .serialize(); + + expect(serialized.stages[0]).toMatchObject({ + stage: 'select', + options: { + selections: [ + { + alias: 'firstItem', + expr: { + exprType: 'Function', + name: 'arrayFirst', + args: [{ exprType: 'Field', path: 'items' }], + }, + }, + { + alias: 'firstTwoItems', + expr: { + exprType: 'Function', + name: 'arrayFirstN', + args: [ + { exprType: 'Field', path: 'items' }, + { exprType: 'Constant', value: 2 }, + ], + }, + }, + { + alias: 'dynamicFirstItems', + expr: { + exprType: 'Function', + name: 'arrayFirstN', + args: [ + { exprType: 'Field', path: 'items' }, + { exprType: 'Field', path: 'count' }, + ], + }, + }, + { + alias: 'fluentFirstItem', + expr: { + exprType: 'Function', + name: 'arrayFirst', + args: [{ exprType: 'Field', path: 'items' }], + }, + }, + { + alias: 'fluentFirstTwoItems', + expr: { + exprType: 'Function', + name: 'arrayFirstN', + args: [ + { exprType: 'Field', path: 'items' }, + { exprType: 'Constant', value: 2 }, + ], + }, + }, + ], + }, + }); + }); + it('enforces union guards and self-cycle serialization constraints', function () { const db: any = firebase.firestore(); const secondaryDb: any = firebase.app('secondaryFromNative').firestore(); @@ -452,6 +526,10 @@ describe('Firestore pipelines runtime', function () { .pipeline() .documents(['firestore/a']) .select( + arrayFirst(field('items')).as('firstArrayItem'), + arrayFirstN(field('items'), 2).as('firstArrayItems'), + field('items').arrayFirst().as('fluentFirstArrayItem'), + field('items').arrayFirstN(2).as('fluentFirstArrayItems'), arrayGet(field('items'), 0).as('firstItem'), conditional( field('value').greaterThan(0), @@ -468,6 +546,8 @@ describe('Firestore pipelines runtime', function () { .serialize(); expect(getIOSUnsupportedPipelineFunctions(serialized)).toEqual([ + 'arrayFirst', + 'arrayFirstN', 'arrayGet', 'conditional', 'round', diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index e83b311e30..c8ee251d88 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -409,6 +409,27 @@ private static final class ExitReceiverArrayGetFrame implements ObjectLoweringFr } } + private static final class ExitReceiverArrayFirstNFrame implements ObjectLoweringFrame { + final LoweredExpressionBox box; + final List pendingOperations; + final int nextIndex; + final Expression currentExpression; + final LoweredExpressionBox countBox; + + ExitReceiverArrayFirstNFrame( + LoweredExpressionBox box, + List pendingOperations, + int nextIndex, + Expression currentExpression, + LoweredExpressionBox countBox) { + this.box = box; + this.pendingOperations = pendingOperations; + this.nextIndex = nextIndex; + this.currentExpression = currentExpression; + this.countBox = countBox; + } + } + private static final class ExitReceiverArrayConcatFrame implements ObjectLoweringFrame { final LoweredExpressionBox box; final List pendingOperations; @@ -1556,6 +1577,36 @@ private void processObjectLoweringStack(ArrayDeque stack) indexArg, operationFieldName + ".args[1]", indexBox)); continue; } + case "arrayfirstn": + { + Object countArg = args.get(1); + if (!containsLowerableExpression(countArg)) { + Object countValue = resolveConstantValue(countArg, operationFieldName + ".args[1]"); + if (countValue instanceof Number) { + stack.push( + new ContinueReceiverExpressionChainFrame( + continueFrame.box, + null, + continueFrame.pendingOperations, + nextIndex, + currentExpression.arrayFirstN(((Number) countValue).intValue()))); + continue; + } + } + + LoweredExpressionBox countBox = new LoweredExpressionBox(); + stack.push( + new ExitReceiverArrayFirstNFrame( + continueFrame.box, + continueFrame.pendingOperations, + nextIndex, + currentExpression, + countBox)); + stack.push( + new EnterObjectExpressionValueFrame( + countArg, operationFieldName + ".args[1]", countBox)); + continue; + } case "arrayconcat": { if (args.size() < 2) { @@ -1787,6 +1838,18 @@ private void processObjectLoweringStack(ArrayDeque stack) continue; } + if (frame instanceof ExitReceiverArrayFirstNFrame) { + ExitReceiverArrayFirstNFrame exitFrame = (ExitReceiverArrayFirstNFrame) frame; + stack.push( + new ContinueReceiverExpressionChainFrame( + exitFrame.box, + null, + exitFrame.pendingOperations, + exitFrame.nextIndex, + exitFrame.currentExpression.arrayFirstN(exitFrame.countBox.value))); + continue; + } + if (frame instanceof ExitReceiverArrayConcatFrame) { ExitReceiverArrayConcatFrame exitFrame = (ExitReceiverArrayConcatFrame) frame; Object secondValue = exitFrame.childBoxes.get(0).value; @@ -2571,6 +2634,7 @@ private boolean isDeferredUnaryExpressionFunction(String normalizedFunctionName) return "type".equals(normalizedFunctionName) || "collectionid".equals(normalizedFunctionName) || "documentid".equals(normalizedFunctionName) + || "arrayfirst".equals(normalizedFunctionName) || "arraylength".equals(normalizedFunctionName) || "arraysum".equals(normalizedFunctionName) || "vectorlength".equals(normalizedFunctionName) @@ -2588,6 +2652,7 @@ private boolean isDeferredReceiverExpressionFunction(String normalizedFunctionNa || "mapget".equals(normalizedFunctionName) || "mapmerge".equals(normalizedFunctionName) || "arrayget".equals(normalizedFunctionName) + || "arrayfirstn".equals(normalizedFunctionName) || "arrayconcat".equals(normalizedFunctionName) || "cosinedistance".equals(normalizedFunctionName) || "dotproduct".equals(normalizedFunctionName) @@ -2612,6 +2677,9 @@ private Expression applyPendingUnaryExpressionFunctions( case "documentid": currentExpression = currentExpression.documentId(); break; + case "arrayfirst": + currentExpression = currentExpression.arrayFirst(); + break; case "arraylength": currentExpression = currentExpression.arrayLength(); break; @@ -2758,6 +2826,12 @@ private Expression buildSpecialParsedExpressionFunction( case "arrayget": requireParsedArgumentCount(args, 2, functionName, fieldName); return buildParsedArrayGetExpression(args, fieldName); + case "arrayfirst": + requireParsedArgumentCount(args, 1, functionName, fieldName); + return coerceExpressionValueNode(args.get(0), fieldName + ".args[0]").arrayFirst(); + case "arrayfirstn": + requireParsedArgumentCount(args, 2, functionName, fieldName); + return buildParsedArrayFirstNExpression(args, fieldName); case "arrayconcat": return buildParsedArrayConcatExpression(args, functionName, fieldName); case "arraysum": @@ -2975,6 +3049,19 @@ private Expression buildParsedArrayGetExpression( return arrayExpr.arrayGet(coerceExpressionValueNode(args.get(1), fieldName + ".args[1]")); } + private Expression buildParsedArrayFirstNExpression( + List args, String fieldName) + throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { + Expression arrayExpr = coerceExpressionValueNode(args.get(0), fieldName + ".args[0]"); + if (!containsParsedExpression(args.get(1))) { + Object countValue = resolveValueNode(args.get(1), fieldName + ".args[1]"); + if (countValue instanceof Number) { + return arrayExpr.arrayFirstN(((Number) countValue).intValue()); + } + } + return arrayExpr.arrayFirstN(coerceExpressionValueNode(args.get(1), fieldName + ".args[1]")); + } + private Expression buildParsedArrayConcatExpression( List args, String functionName, diff --git a/packages/firestore/consumer-type-test.ts b/packages/firestore/consumer-type-test.ts index 78e17604d0..010fed6862 100644 --- a/packages/firestore/consumer-type-test.ts +++ b/packages/firestore/consumer-type-test.ts @@ -172,6 +172,8 @@ import { // array array, arrayFilter, + arrayFirst, + arrayFirstN, arrayConcat, arrayGet, arrayLength, @@ -1318,6 +1320,17 @@ void variable('score'); void arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))); void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); +// arrayFirst: (string) | (Expression) +void arrayFirst('items'); +void arrayFirst(field('items')); +// arrayFirstN: 4 overloads +void arrayFirstN('items', 2); +void arrayFirstN('items', field('limit')); +void arrayFirstN(field('items'), 2); +void arrayFirstN(field('items'), field('limit')); +void field('items').arrayFirst(); +void field('items').arrayFirstN(2); +void field('items').arrayFirstN(field('limit')); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1692,6 +1705,10 @@ const pipelineArrayOps = xDb array([constant(1), constant(2), constant(3)]).as('fixedArr'), arrayLength(field('comments')).as('commentCount'), arrayLength('comments').as('commentCount2'), + arrayFirst(field('items')).as('firstItemByHelper'), + arrayFirst('items').as('firstItemByField'), + arrayFirstN(field('items'), 2).as('firstItems'), + arrayFirstN('items', field('limit')).as('dynamicFirstItems'), arrayGet(field('items'), 0).as('firstItem'), arrayGet(field('items'), field('idx')).as('dynamicItem'), arrayGet('items', 0).as('firstItem2'), diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 106ac57d99..bb51500627 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -1510,13 +1510,15 @@ describe('FirestorePipeline', function () { }); describe('array operators', function () { - it('evaluates array, arrayLength, arrayGet, arrayConcat, arraySum and array predicates', async function () { + it('evaluates array helpers and array predicates', async function () { const { execute, field, constant, array, arrayLength, + arrayFirst, + arrayFirstN, arrayGet, arrayConcat, arrayFilter, @@ -1564,6 +1566,8 @@ describe('FirestorePipeline', function () { .select( array([constant(1), constant(2), constant(3)]).as('fixedArr'), arrayLength(field('tags')).as('tagCount'), + arrayFirst(field('items')).as('firstItemByHelper'), + arrayFirstN(field('items'), 2).as('firstTwoItems'), arrayGet(field('items'), 0).as('firstItem'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), arrayFilter(field('scores'), 'score', greaterThan(variable('score'), 15)).as( @@ -1573,7 +1577,11 @@ describe('FirestorePipeline', function () { ); if (Platform.ios) { - await expectIOSUnsupportedFunctions(() => execute(pipeline), ['arrayGet']); + await expectIOSUnsupportedFunctions(() => execute(pipeline), [ + 'arrayFirst', + 'arrayFirstN', + 'arrayGet', + ]); const iosSnapshot = await execute( db @@ -1613,6 +1621,8 @@ describe('FirestorePipeline', function () { const data = snapshot.results[0].data(); data.fixedArr.should.eql([1, 2, 3]); data.tagCount.should.equal(2); + data.firstItemByHelper.should.equal('x'); + data.firstTwoItems.should.eql(['x', 'y']); data.firstItem.should.equal('x'); data.allTags.should.eql(['a', 'b', 'c', 'd']); data.filteredItems.should.eql([20, 30]); diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift index 69937fbd0e..49ba8fa27f 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift @@ -747,6 +747,10 @@ final class RNFBFirestorePipelineNodeBuilder { return "array_contains_any" case "arraycontainsall": return "array_contains_all" + case "arrayfirst": + return "array_first" + case "arrayfirstn": + return "array_first_n" case "charlength", "characterlength": return "char_length" case "bytelength": @@ -1142,6 +1146,31 @@ final class RNFBFirestorePipelineNodeBuilder { break expressionLoop } + if normalized == "arrayfirst" { + guard rawArgs.count == 1 else { + throw PipelineValidationError( + "pipelineExecute() expected \(currentField).\(name) to include exactly 1 argument.") + } + + let argBoxes = rawArgs.map { _ in ExprBridgeBox() } + stack.append(.functionExit(box, "array_first", argBoxes, currentField)) + stack.append(.enter(rawArgs[0], "\(currentField).args[0]", .expressionValue, argBoxes[0])) + break expressionLoop + } + + if normalized == "arrayfirstn" { + guard rawArgs.count == 2 else { + throw PipelineValidationError( + "pipelineExecute() expected \(currentField).\(name) to include exactly 2 arguments.") + } + + let argBoxes = rawArgs.map { _ in ExprBridgeBox() } + stack.append(.functionExit(box, "array_first_n", argBoxes, currentField)) + stack.append(.enter(rawArgs[1], "\(currentField).args[1]", .expressionValue, argBoxes[1])) + stack.append(.enter(rawArgs[0], "\(currentField).args[0]", .expressionValue, argBoxes[0])) + break expressionLoop + } + if normalized == "conditional" { guard rawArgs.count == 3 else { throw PipelineValidationError( diff --git a/packages/firestore/lib/pipelines/expressions.ts b/packages/firestore/lib/pipelines/expressions.ts index d85a7fa9c5..a10c029ea6 100644 --- a/packages/firestore/lib/pipelines/expressions.ts +++ b/packages/firestore/lib/pipelines/expressions.ts @@ -110,6 +110,9 @@ interface FluentExpressionMethods { arrayAgg(): AggregateFunction; arrayAggDistinct(): AggregateFunction; arrayFilter(alias: string, filter: BooleanExpression): FunctionExpression; + arrayFirst(): FunctionExpression; + arrayFirstN(n: number): FunctionExpression; + arrayFirstN(n: Expression): FunctionExpression; } export interface BooleanExpression extends Selectable, FluentExpressionMethods { @@ -397,6 +400,8 @@ const EXPRESSION_METHOD_NAMES = [ 'arrayContainsAny', 'arrayContainsAll', 'arrayFilter', + 'arrayFirst', + 'arrayFirstN', 'startsWith', 'endsWith', 'add', @@ -719,6 +724,8 @@ function normalizeGlobalArguments(name: string, args: unknown[]): RuntimeNode[] case 'toUpper': case 'trim': case 'substring': + case 'arrayFirst': + case 'arrayFirstN': case 'arrayGet': case 'arrayLength': case 'arraySum': @@ -1057,6 +1064,31 @@ export function arrayFilter( return callFunctionHelper('arrayFilter', arguments); } +/** + * @beta + * Gets the first element in an array field or expression. + */ +export function arrayFirst(_arrayField: string): FunctionExpression; +export function arrayFirst(_arrayExpression: Expression): FunctionExpression; +export function arrayFirst(_arrayOrField: string | Expression): FunctionExpression { + return callFunctionHelper('arrayFirst', arguments); +} + +/** + * @beta + * Gets the first `n` elements in an array field or expression. + */ +export function arrayFirstN(_arrayField: string, _n: number): FunctionExpression; +export function arrayFirstN(_arrayField: string, _n: Expression): FunctionExpression; +export function arrayFirstN(_arrayExpression: Expression, _n: number): FunctionExpression; +export function arrayFirstN(_arrayExpression: Expression, _n: Expression): FunctionExpression; +export function arrayFirstN( + _arrayOrField: string | Expression, + _n: number | Expression, +): FunctionExpression { + return callFunctionHelper('arrayFirstN', arguments); +} + /** * @beta * Checks if a string starts with a prefix. diff --git a/packages/firestore/lib/pipelines/index.ts b/packages/firestore/lib/pipelines/index.ts index 10d8fbda0f..eaefca109a 100644 --- a/packages/firestore/lib/pipelines/index.ts +++ b/packages/firestore/lib/pipelines/index.ts @@ -81,6 +81,8 @@ export { arrayContainsAny, arrayContainsAll, arrayFilter, + arrayFirst, + arrayFirstN, startsWith, endsWith, OrderingHelper as Ordering, diff --git a/packages/firestore/lib/pipelines/pipeline_support.ts b/packages/firestore/lib/pipelines/pipeline_support.ts index 06e13abb0a..8b4a5afa2b 100644 --- a/packages/firestore/lib/pipelines/pipeline_support.ts +++ b/packages/firestore/lib/pipelines/pipeline_support.ts @@ -53,6 +53,8 @@ export const PIPELINE_UNSUPPORTED_BASE_MESSAGE = // `RNFBFirestorePipelineNodeBuilder.swift`. // Remove entries once the iOS node builder/runtime path supports them. const IOS_UNSUPPORTED_FUNCTION_NAMES = new Set([ + 'arrayFirst', + 'arrayFirstN', 'arrayGet', 'conditional', 'round', diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index f459dd0903..5e255682b6 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -171,6 +171,8 @@ import { // array array, arrayFilter, + arrayFirst, + arrayFirstN, arrayConcat, arrayGet, arrayLength, @@ -1320,6 +1322,17 @@ void variable('score'); void arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))); void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); +// arrayFirst: (string) | (Expression) +void arrayFirst('items'); +void arrayFirst(field('items')); +// arrayFirstN: 4 overloads +void arrayFirstN('items', 2); +void arrayFirstN('items', field('limit')); +void arrayFirstN(field('items'), 2); +void arrayFirstN(field('items'), field('limit')); +void field('items').arrayFirst(); +void field('items').arrayFirstN(2); +void field('items').arrayFirstN(field('limit')); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1694,6 +1707,10 @@ const pipelineArrayOps = xDb array([constant(1), constant(2), constant(3)]).as('fixedArr'), arrayLength(field('comments')).as('commentCount'), arrayLength('comments').as('commentCount2'), + arrayFirst(field('items')).as('firstItemByHelper'), + arrayFirst('items').as('firstItemByField'), + arrayFirstN(field('items'), 2).as('firstItems'), + arrayFirstN('items', field('limit')).as('dynamicFirstItems'), arrayGet(field('items'), 0).as('firstItem'), arrayGet(field('items'), field('idx')).as('dynamicItem'), arrayGet('items', 0).as('firstItem2'), From a1f59fab82dfb2242403169707d13ec264d89324 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 13 May 2026 15:38:57 +0100 Subject: [PATCH 08/10] feat: support array pipeline expression helpers Co-authored-by: Cursor --- .../configs/firestore-pipelines.ts | 103 +++---- packages/firestore/consumer-type-test.ts | 202 +++++++++----- .../firestore/lib/pipelines/expressions.ts | 264 ++++++++++++++++-- packages/firestore/lib/pipelines/index.ts | 16 +- 4 files changed, 419 insertions(+), 166 deletions(-) diff --git a/.github/scripts/compare-types/configs/firestore-pipelines.ts b/.github/scripts/compare-types/configs/firestore-pipelines.ts index 28ff9fe5a2..b00d3d00dc 100644 --- a/.github/scripts/compare-types/configs/firestore-pipelines.ts +++ b/.github/scripts/compare-types/configs/firestore-pipelines.ts @@ -19,101 +19,62 @@ import type { PackageConfig } from '../src/types'; - const config: PackageConfig = { nameMapping: {}, missingInRN: [ - { - name: 'arrayIndexOf', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayIndexOfAll', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayLast', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayLastIndexOf', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayLastN', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayMaximum', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayMaximumN', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayMinimum', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayMinimumN', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arraySlice', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayTransform', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, - { - name: 'arrayTransformWithIndex', - reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.', - }, { name: 'coalesce', reason: 'Newer firebase-js-sdk expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'currentDocument', - reason: 'Newer firebase-js-sdk document expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk document expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'documentMatches', - reason: 'Newer firebase-js-sdk document expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk document expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'geoDistance', - reason: 'Newer firebase-js-sdk geospatial expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk geospatial expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'ifNull', - reason: 'Newer firebase-js-sdk null-handling expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk null-handling expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'nor', - reason: 'Newer firebase-js-sdk boolean expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk boolean expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'score', - reason: 'Newer firebase-js-sdk search score expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk search score expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'subcollection', - reason: 'Newer firebase-js-sdk subcollection stage helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk subcollection stage helper not yet exposed by RN Firebase pipelines.', }, { name: 'switchOn', - reason: 'Newer firebase-js-sdk conditional expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk conditional expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'timestampDiff', - reason: 'Newer firebase-js-sdk timestamp expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk timestamp expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'timestampExtract', - reason: 'Newer firebase-js-sdk timestamp expression helper not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk timestamp expression helper not yet exposed by RN Firebase pipelines.', }, { name: 'DefineStageOptions', @@ -125,15 +86,18 @@ const config: PackageConfig = { }, { name: 'SearchStageOptions', - reason: 'Newer firebase-js-sdk search stage options type not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk search stage options type not yet exposed by RN Firebase pipelines.', }, { name: 'SubcollectionStageOptions', - reason: 'Newer firebase-js-sdk subcollection stage options type not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk subcollection stage options type not yet exposed by RN Firebase pipelines.', }, { name: 'TimePart', - reason: 'Newer firebase-js-sdk timestamp extraction type not yet exposed by RN Firebase pipelines.', + reason: + 'Newer firebase-js-sdk timestamp extraction type not yet exposed by RN Firebase pipelines.', }, { name: 'TimeUnit', @@ -143,25 +107,30 @@ const config: PackageConfig = { extraInRN: [ { name: 'Type', - reason: 'RN Firebase exposes a local type discriminator alias for pipeline expression helpers.', + reason: + 'RN Firebase exposes a local type discriminator alias for pipeline expression helpers.', }, ], differentShape: [ { name: 'isType', - reason: 'RN Firebase accepts its local `Type` alias where the firebase-js-sdk declaration accepts a string.', + reason: + 'RN Firebase accepts its local `Type` alias where the firebase-js-sdk declaration accepts a string.', }, { name: 'ExpressionType', - reason: 'RN Firebase has not yet exposed the newer firebase-js-sdk `PipelineValue` expression kind.', + reason: + 'RN Firebase has not yet exposed the newer firebase-js-sdk `PipelineValue` expression kind.', }, { name: 'StageOptions', - reason: 'Declaration formatting differs for the raw options object, but the public shape is equivalent.', + reason: + 'Declaration formatting differs for the raw options object, but the public shape is equivalent.', }, { name: 'TimeGranularity', - reason: 'RN Firebase uses the existing `isoWeek` and `isoYear` casing while the firebase-js-sdk declaration includes lowercase variants.', + reason: + 'RN Firebase uses the existing `isoWeek` and `isoYear` casing while the firebase-js-sdk declaration includes lowercase variants.', }, ], }; diff --git a/packages/firestore/consumer-type-test.ts b/packages/firestore/consumer-type-test.ts index 010fed6862..7f982d0156 100644 --- a/packages/firestore/consumer-type-test.ts +++ b/packages/firestore/consumer-type-test.ts @@ -174,6 +174,18 @@ import { arrayFilter, arrayFirst, arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLast, + arrayLastIndexOf, + arrayLastN, + arrayMaximum, + arrayMaximumN, + arrayMinimum, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arrayConcat, arrayGet, arrayLength, @@ -318,9 +330,7 @@ const nsDocRef = nsColl.doc('alice'); const nsQuery = nsColl.where('name', '==', 'test'); nsDocRef.set({ name: 'Alice', count: 1 }).then(() => {}); -nsDocRef - .set({ name: 'Alice' }, { merge: true }) - .then(() => {}); +nsDocRef.set({ name: 'Alice' }, { merge: true }).then(() => {}); nsDocRef.update({ count: 2 }).then(() => {}); nsDocRef.update('count', 3).then(() => {}); @@ -405,13 +415,15 @@ void nsLoadTask.then(() => {}); const nsNamed = nsFirestore.namedQuery('my-query'); void nsNamed; -nsFirestore.runTransaction(async (tx: FirebaseFirestoreTypes.Transaction) => { - const snap = await tx.get(nsDocRef); - if (snap.exists()) { - tx.update(nsDocRef, { count: ((snap.data() as { count?: number })?.count ?? 0) + 1 }); - } - return null; -}).then(() => {}); +nsFirestore + .runTransaction(async (tx: FirebaseFirestoreTypes.Transaction) => { + const snap = await tx.get(nsDocRef); + if (snap.exists()) { + tx.update(nsDocRef, { count: ((snap.data() as { count?: number })?.count ?? 0) + 1 }); + } + return null; + }) + .then(() => {}); // ----- Firestore instance: persistence and network ----- nsFirestore.clearPersistence().then(() => {}); @@ -456,13 +468,15 @@ const nsArrayRemove = firebase.firestore.FieldValue.arrayRemove(1); void nsArrayRemove; const nsIncrement = firebase.firestore.FieldValue.increment(1); -nsDocRef.set({ - name: 'x', - deleted: nsDelete, - ts: nsServerTs, - arr: nsArrayUnion, - cnt: nsIncrement, -}).then(() => {}); +nsDocRef + .set({ + name: 'x', + deleted: nsDelete, + ts: nsServerTs, + arr: nsArrayUnion, + cnt: nsIncrement, + }) + .then(() => {}); // ----- withConverter (namespaced) ----- interface User { @@ -485,7 +499,6 @@ nsDocWithConv.get().then((snap: FirebaseFirestoreTypes.DocumentSnapshot) = if (u) void [u.name, u.age]; }); - // ----- getFirestore ----- const modFirestore1 = getFirestore(); void modFirestore1.app.name; @@ -974,10 +987,7 @@ const pipelineUnion = pipelineDb .collection('cities/sf/restaurants') .where(field('type').equal('Chinese')) .union( - pipelineDb - .pipeline() - .collection('cities/ny/restaurants') - .where(field('type').equal('Italian')), + pipelineDb.pipeline().collection('cities/ny/restaurants').where(field('type').equal('Italian')), ) .where(field('rating').greaterThanOrEqual(4.5)) .sort(field('__name__').descending()); @@ -988,10 +998,7 @@ const pipelineWithTransforms = pipelineDb .collection('books') .where( pipelineOr( - pipelineAnd( - field('rating').greaterThan(4), - lessThan(field('price'), constant(10)), - ), + pipelineAnd(field('rating').greaterThan(4), lessThan(field('price'), constant(10))), field('genre').equal('Fantasy'), ), ) @@ -1000,9 +1007,7 @@ const pipelineWithTransforms = pipelineDb .select( field('fullTitle'), field('rating').greaterThan(4).as('isTopRated'), - arrayContainsAny(field('genre'), ['Fantasy', constant('Sci-Fi')]).as( - 'matchesGenre', - ), + arrayContainsAny(field('genre'), ['Fantasy', constant('Sci-Fi')]).as('matchesGenre'), ) .sort(Ordering.of(field('rating')).descending(), field('__name__').ascending()) .offset(1) @@ -1019,22 +1024,22 @@ const pipelineAggregateDistinct = pipelineDb pipelineAverage('population').as('populationAvg'), maximum('population').as('populationMax'), ], - groups: [ - field('country').as('country'), - toLower(field('state')).as('normalizedState'), - ], + groups: [field('country').as('country'), toLower(field('state')).as('normalizedState')], }) .where(field('populationTotal').greaterThan(1000)) .distinct(field('normalizedState'), 'country'); void pipelineAggregateDistinct; -const pipelineFindNearest = pipelineDb.pipeline().collection('cities').findNearest({ - field: 'embedding', - vectorValue: [1.5, 2.345], - distanceMeasure: 'COSINE', - distanceField: 'computedDistance', - limit: 10, -}); +const pipelineFindNearest = pipelineDb + .pipeline() + .collection('cities') + .findNearest({ + field: 'embedding', + vectorValue: [1.5, 2.345], + distanceMeasure: 'COSINE', + distanceField: 'computedDistance', + limit: 10, + }); void pipelineFindNearest; const pipelineSampleAndUnnest = pipelineDb @@ -1117,7 +1122,11 @@ const _cStr: Expression = constant('hello'); const _cBool: BooleanExpression = constant(true); const _cNull: Expression = constant(null); const _cUnknown: Expression = constant({ nested: true }); -void _cNum; void _cStr; void _cBool; void _cNull; void _cUnknown; +void _cNum; +void _cStr; +void _cBool; +void _cNull; +void _cUnknown; // ----- Comparison: standalone overloads ----- // greaterThan(Expression, Expression) | greaterThan(Expression, value) @@ -1320,17 +1329,62 @@ void variable('score'); void arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))); void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); -// arrayFirst: (string) | (Expression) -void arrayFirst('items'); -void arrayFirst(field('items')); -// arrayFirstN: 4 overloads -void arrayFirstN('items', 2); -void arrayFirstN('items', field('limit')); -void arrayFirstN(field('items'), 2); -void arrayFirstN(field('items'), field('limit')); -void field('items').arrayFirst(); -void field('items').arrayFirstN(2); -void field('items').arrayFirstN(field('limit')); +// newer array helpers +void arrayTransform('scores', 'score', add(variable('score'), 1)); +void arrayTransform(field('scores'), 'score', add(variable('score'), 1)); +void field('scores').arrayTransform('score', add(variable('score'), 1)); +void arrayTransformWithIndex('scores', 'score', 'index', add(variable('score'), variable('index'))); +void arrayTransformWithIndex( + field('scores'), + 'score', + 'index', + add(variable('score'), variable('index')), +); +void field('scores').arrayTransformWithIndex( + 'score', + 'index', + add(variable('score'), variable('index')), +); +void arraySlice('scores', 1, 2); +void arraySlice(field('scores'), field('offset'), field('length')); +void field('scores').arraySlice(1, 2); +void arrayFirst('scores'); +void arrayFirst(field('scores')); +void field('scores').arrayFirst(); +void arrayFirstN('scores', 2); +void arrayFirstN('scores', field('limit')); +void arrayFirstN(field('scores'), field('limit')); +void field('scores').arrayFirstN(field('limit')); +void arrayLast('scores'); +void arrayLast(field('scores')); +void field('scores').arrayLast(); +void arrayLastN('scores', 2); +void arrayLastN('scores', field('limit')); +void arrayLastN(field('scores'), field('limit')); +void field('scores').arrayLastN(field('limit')); +void arrayMaximum('scores'); +void arrayMaximum(field('scores')); +void field('scores').arrayMaximum(); +void arrayMaximumN('scores', 2); +void arrayMaximumN('scores', field('limit')); +void arrayMaximumN(field('scores'), field('limit')); +void field('scores').arrayMaximumN(field('limit')); +void arrayMinimum('scores'); +void arrayMinimum(field('scores')); +void field('scores').arrayMinimum(); +void arrayMinimumN('scores', 2); +void arrayMinimumN('scores', field('limit')); +void arrayMinimumN(field('scores'), field('limit')); +void field('scores').arrayMinimumN(field('limit')); +void arrayIndexOf('scores', 20); +void arrayIndexOf(field('scores'), field('needle')); +void field('scores').arrayIndexOf(20); +void arrayLastIndexOf('scores', 20); +void arrayLastIndexOf(field('scores'), field('needle')); +void field('scores').arrayLastIndexOf(20); +void arrayIndexOfAll('scores', 20); +void arrayIndexOfAll(field('scores'), field('needle')); +void field('scores').arrayIndexOfAll(20); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1557,11 +1611,9 @@ const pipelineComparisonOps = xDb ) .select( field('sku'), - conditional( - field('stock').greaterThan(0), - constant('in-stock'), - constant('out-of-stock'), - ).as('availability'), + conditional(field('stock').greaterThan(0), constant('in-stock'), constant('out-of-stock')).as( + 'availability', + ), isType(field('value'), 'string').as('isString'), logicalMaximum(field('bidA'), field('bidB')).as('topBid'), logicalMinimum(field('askA'), field('askB')).as('bottomAsk'), @@ -1616,10 +1668,7 @@ const pipelineStringOps = xDb stringContains(field('bio'), 'developer'), like('role', 'eng%'), regexContains(field('phone'), '^\\+1'), - xor( - field('isPublic').equal(true), - field('isVerified').equal(true), - ), + xor(field('isPublic').equal(true), field('isVerified').equal(true)), ), ) .addFields( @@ -1717,9 +1766,29 @@ const pipelineArrayOps = xDb arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( 'passingScores', ), - field('scores').arrayFilter('score', greaterThan(variable('score'), constant(20))).as( - 'topScores', - ), + field('scores') + .arrayFilter('score', greaterThan(variable('score'), constant(20))) + .as('topScores'), + arrayFirst('scores').as('firstScore'), + arrayFirstN('scores', 2).as('firstTwoScores'), + field('scores').arrayLast().as('lastScore'), + field('scores').arrayLastN(2).as('lastTwoScores'), + arraySlice('scores', 1, 2).as('middleScores'), + arrayTransform('scores', 'score', add(variable('score'), 1)).as('incrementedScores'), + arrayTransformWithIndex( + 'scores', + 'score', + 'index', + add(variable('score'), variable('index')), + ).as('indexedScores'), + arrayMaximum('scores').as('maxScore'), + arrayMaximumN('scores', 2).as('topTwoScores'), + arrayMinimum('scores').as('minScore'), + arrayMinimumN('scores', 2).as('bottomTwoScores'), + arrayIndexOf('scores', 20).as('firstTwentyIndex'), + field('scores').arrayIndexOf(20).as('fluentFirstTwentyIndex'), + arrayLastIndexOf('scores', 20).as('lastTwentyIndex'), + arrayIndexOfAll('scores', 20).as('allTwentyIndexes'), arraySum(field('scores')).as('totalScore'), arraySum('scores').as('totalScore2'), ); @@ -1753,10 +1822,7 @@ const pipelineAllAggregates = xDb arrayAggDistinct(field('category')).as('distinctCategories'), arrayAggDistinct('category').as('distinctCategories2'), ], - groups: [ - field('country').as('country'), - toLower(field('state')).as('normalizedState'), - ], + groups: [field('country').as('country'), toLower(field('state')).as('normalizedState')], }); void pipelineAllAggregates; diff --git a/packages/firestore/lib/pipelines/expressions.ts b/packages/firestore/lib/pipelines/expressions.ts index a10c029ea6..ed1a280469 100644 --- a/packages/firestore/lib/pipelines/expressions.ts +++ b/packages/firestore/lib/pipelines/expressions.ts @@ -110,9 +110,24 @@ interface FluentExpressionMethods { arrayAgg(): AggregateFunction; arrayAggDistinct(): AggregateFunction; arrayFilter(alias: string, filter: BooleanExpression): FunctionExpression; + arrayTransform(elementAlias: string, transform: Expression): FunctionExpression; + arrayTransformWithIndex( + elementAlias: string, + indexAlias: string, + transform: Expression, + ): FunctionExpression; + arraySlice(offset: number | Expression, length?: number | Expression): FunctionExpression; arrayFirst(): FunctionExpression; - arrayFirstN(n: number): FunctionExpression; - arrayFirstN(n: Expression): FunctionExpression; + arrayFirstN(n: number | Expression): FunctionExpression; + arrayLast(): FunctionExpression; + arrayLastN(n: number | Expression): FunctionExpression; + arrayMaximum(): FunctionExpression; + arrayMaximumN(n: number | Expression): FunctionExpression; + arrayMinimum(): FunctionExpression; + arrayMinimumN(n: number | Expression): FunctionExpression; + arrayIndexOf(search: unknown): FunctionExpression; + arrayLastIndexOf(search: unknown): FunctionExpression; + arrayIndexOfAll(search: unknown): FunctionExpression; } export interface BooleanExpression extends Selectable, FluentExpressionMethods { @@ -400,8 +415,6 @@ const EXPRESSION_METHOD_NAMES = [ 'arrayContainsAny', 'arrayContainsAll', 'arrayFilter', - 'arrayFirst', - 'arrayFirstN', 'startsWith', 'endsWith', 'add', @@ -433,6 +446,20 @@ const EXPRESSION_METHOD_NAMES = [ 'trim', 'substring', 'arrayAggDistinct', + 'arrayTransform', + 'arrayTransformWithIndex', + 'arraySlice', + 'arrayFirst', + 'arrayFirstN', + 'arrayLast', + 'arrayLastN', + 'arrayMaximum', + 'arrayMaximumN', + 'arrayMinimum', + 'arrayMinimumN', + 'arrayIndexOf', + 'arrayLastIndexOf', + 'arrayIndexOfAll', 'arrayConcat', 'arrayGet', 'arrayLength', @@ -724,8 +751,20 @@ function normalizeGlobalArguments(name: string, args: unknown[]): RuntimeNode[] case 'toUpper': case 'trim': case 'substring': + case 'arrayTransform': + case 'arrayTransformWithIndex': + case 'arraySlice': case 'arrayFirst': case 'arrayFirstN': + case 'arrayLast': + case 'arrayLastN': + case 'arrayMaximum': + case 'arrayMaximumN': + case 'arrayMinimum': + case 'arrayMinimumN': + case 'arrayIndexOf': + case 'arrayLastIndexOf': + case 'arrayIndexOfAll': case 'arrayGet': case 'arrayLength': case 'arraySum': @@ -831,6 +870,14 @@ function createMethodResult( return createAggregate(canonicalName, [base, ...rawArgs.map(arg => toExpressionArgument(arg))]); } + if (canonicalName === 'arrayIndexOf' || canonicalName === 'arrayLastIndexOf') { + return createFunctionExpression(canonicalName, [ + base, + toExpressionArgument(rawArgs[0]), + createConstant(canonicalName === 'arrayIndexOf' ? 'first' : 'last'), + ]); + } + return createFunctionExpression(canonicalName, [ base, ...rawArgs.map(arg => toExpressionArgument(arg)), @@ -1064,31 +1111,6 @@ export function arrayFilter( return callFunctionHelper('arrayFilter', arguments); } -/** - * @beta - * Gets the first element in an array field or expression. - */ -export function arrayFirst(_arrayField: string): FunctionExpression; -export function arrayFirst(_arrayExpression: Expression): FunctionExpression; -export function arrayFirst(_arrayOrField: string | Expression): FunctionExpression { - return callFunctionHelper('arrayFirst', arguments); -} - -/** - * @beta - * Gets the first `n` elements in an array field or expression. - */ -export function arrayFirstN(_arrayField: string, _n: number): FunctionExpression; -export function arrayFirstN(_arrayField: string, _n: Expression): FunctionExpression; -export function arrayFirstN(_arrayExpression: Expression, _n: number): FunctionExpression; -export function arrayFirstN(_arrayExpression: Expression, _n: Expression): FunctionExpression; -export function arrayFirstN( - _arrayOrField: string | Expression, - _n: number | Expression, -): FunctionExpression { - return callFunctionHelper('arrayFirstN', arguments); -} - /** * @beta * Checks if a string starts with a prefix. @@ -1700,6 +1722,190 @@ export function arrayGet( return callFunctionHelper('arrayGet', arguments); } +export function arrayTransform( + _arrayExpression: Expression, + _elementAlias: string, + _transform: Expression, +): FunctionExpression; +export function arrayTransform( + _arrayField: string, + _elementAlias: string, + _transform: Expression, +): FunctionExpression; +export function arrayTransform( + _arrayOrField: string | Expression, + _elementAlias: string, + _transform: Expression, +): FunctionExpression { + return callFunctionHelper('arrayTransform', arguments); +} + +export function arrayTransformWithIndex( + _arrayExpression: Expression, + _elementAlias: string, + _indexAlias: string, + _transform: Expression, +): FunctionExpression; +export function arrayTransformWithIndex( + _arrayField: string, + _elementAlias: string, + _indexAlias: string, + _transform: Expression, +): FunctionExpression; +export function arrayTransformWithIndex( + _arrayOrField: string | Expression, + _elementAlias: string, + _indexAlias: string, + _transform: Expression, +): FunctionExpression { + return callFunctionHelper('arrayTransformWithIndex', arguments); +} + +export function arraySlice( + _arrayField: string, + _offset: number | Expression, + _length?: number | Expression, +): FunctionExpression; +export function arraySlice( + _arrayExpression: Expression, + _offset: number | Expression, + _length?: number | Expression, +): FunctionExpression; +export function arraySlice( + _arrayOrField: string | Expression, + _offset: number | Expression, + _length?: number | Expression, +): FunctionExpression { + return callFunctionHelper('arraySlice', arguments); +} + +export function arrayFirst(_arrayField: string): FunctionExpression; +export function arrayFirst(_arrayExpression: Expression): FunctionExpression; +export function arrayFirst(_arrayOrField: string | Expression): FunctionExpression { + return callFunctionHelper('arrayFirst', arguments); +} + +export function arrayFirstN(_arrayField: string, _n: number): FunctionExpression; +export function arrayFirstN(_arrayField: string, _n: Expression): FunctionExpression; +export function arrayFirstN( + _arrayExpression: Expression, + _n: number | Expression, +): FunctionExpression; +export function arrayFirstN( + _arrayOrField: string | Expression, + _n: number | Expression, +): FunctionExpression { + return callFunctionHelper('arrayFirstN', arguments); +} + +export function arrayLast(_arrayField: string): FunctionExpression; +export function arrayLast(_arrayExpression: Expression): FunctionExpression; +export function arrayLast(_arrayOrField: string | Expression): FunctionExpression { + return callFunctionHelper('arrayLast', arguments); +} + +export function arrayLastN(_arrayField: string, _n: number): FunctionExpression; +export function arrayLastN(_arrayField: string, _n: Expression): FunctionExpression; +export function arrayLastN( + _arrayExpression: Expression, + _n: number | Expression, +): FunctionExpression; +export function arrayLastN( + _arrayOrField: string | Expression, + _n: number | Expression, +): FunctionExpression { + return callFunctionHelper('arrayLastN', arguments); +} + +export function arrayMaximum(_arrayField: string): FunctionExpression; +export function arrayMaximum(_arrayExpression: Expression): FunctionExpression; +export function arrayMaximum(_arrayOrField: string | Expression): FunctionExpression { + return callFunctionHelper('arrayMaximum', arguments); +} + +export function arrayMaximumN(_arrayField: string, _n: number): FunctionExpression; +export function arrayMaximumN(_arrayField: string, _n: Expression): FunctionExpression; +export function arrayMaximumN( + _arrayExpression: Expression, + _n: number | Expression, +): FunctionExpression; +export function arrayMaximumN( + _arrayOrField: string | Expression, + _n: number | Expression, +): FunctionExpression { + return callFunctionHelper('arrayMaximumN', arguments); +} + +export function arrayMinimum(_arrayField: string): FunctionExpression; +export function arrayMinimum(_arrayExpression: Expression): FunctionExpression; +export function arrayMinimum(_arrayOrField: string | Expression): FunctionExpression { + return callFunctionHelper('arrayMinimum', arguments); +} + +export function arrayMinimumN(_arrayField: string, _n: number): FunctionExpression; +export function arrayMinimumN(_arrayField: string, _n: Expression): FunctionExpression; +export function arrayMinimumN( + _arrayExpression: Expression, + _n: number | Expression, +): FunctionExpression; +export function arrayMinimumN( + _arrayOrField: string | Expression, + _n: number | Expression, +): FunctionExpression { + return callFunctionHelper('arrayMinimumN', arguments); +} + +export function arrayIndexOf( + _arrayField: string, + _search: unknown | Expression, +): FunctionExpression; +export function arrayIndexOf( + _arrayExpression: Expression, + _search: unknown | Expression, +): FunctionExpression; +export function arrayIndexOf( + _arrayOrField: string | Expression, + _search: unknown, +): FunctionExpression { + return createFunctionExpression('arrayIndexOf', [ + ...normalizeGlobalArguments('arrayIndexOf', Array.from(arguments).slice(0, 2)), + createConstant('first'), + ]); +} + +export function arrayLastIndexOf( + _arrayField: string, + _search: unknown | Expression, +): FunctionExpression; +export function arrayLastIndexOf( + _arrayExpression: Expression, + _search: unknown | Expression, +): FunctionExpression; +export function arrayLastIndexOf( + _arrayOrField: string | Expression, + _search: unknown, +): FunctionExpression { + return createFunctionExpression('arrayLastIndexOf', [ + ...normalizeGlobalArguments('arrayLastIndexOf', Array.from(arguments).slice(0, 2)), + createConstant('last'), + ]); +} + +export function arrayIndexOfAll( + _arrayField: string, + _search: unknown | Expression, +): FunctionExpression; +export function arrayIndexOfAll( + _arrayExpression: Expression, + _search: unknown | Expression, +): FunctionExpression; +export function arrayIndexOfAll( + _arrayOrField: string | Expression, + _search: unknown, +): FunctionExpression { + return callFunctionHelper('arrayIndexOfAll', arguments); +} + /** * @beta * Length of an array (field or expression). diff --git a/packages/firestore/lib/pipelines/index.ts b/packages/firestore/lib/pipelines/index.ts index eaefca109a..28c942f2da 100644 --- a/packages/firestore/lib/pipelines/index.ts +++ b/packages/firestore/lib/pipelines/index.ts @@ -81,8 +81,6 @@ export { arrayContainsAny, arrayContainsAll, arrayFilter, - arrayFirst, - arrayFirstN, startsWith, endsWith, OrderingHelper as Ordering, @@ -121,6 +119,20 @@ export { trim, substring, arrayAggDistinct, + arrayFirst, + arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLast, + arrayLastIndexOf, + arrayLastN, + arrayMaximum, + arrayMaximumN, + arrayMinimum, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arrayConcat, arrayGet, arrayLength, From 910b62554ee2331106cc2f6403f0aedeba603c01 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 13 May 2026 15:38:57 +0100 Subject: [PATCH 09/10] feat(firestore): add native array pipeline lowering Co-authored-by: Cursor --- ...ctNativeFirebaseFirestorePipelineNodeBuilder.java | 12 ++++++++++++ .../RNFBFirestorePipelineNodeBuilder.swift | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index c8ee251d88..27e7376c23 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -3724,6 +3724,18 @@ private String normalizeExpressionFunctionName(String name) { switch (canonicalizeExpressionFunctionName(name)) { case "conditional": return "cond"; + case "arraytransformwithindex": + return "array_transform"; + case "arraylastindexof": + return "array_index_of"; + case "arraymaximum": + return "maximum"; + case "arraymaximumn": + return "maximum_n"; + case "arrayminimum": + return "minimum"; + case "arrayminimumn": + return "minimum_n"; case "logicalmaximum": return "logical_max"; case "logicalminimum": diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift index 49ba8fa27f..0a1595653b 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift @@ -721,10 +721,18 @@ final class RNFBFirestorePipelineNodeBuilder { switch normalized { case "conditional": return "cond" + case "arraytransformwithindex": + return "array_transform" + case "arraylastindexof": + return "array_index_of" case "logicalmaximum", "arraymaximum": return "maximum" + case "arraymaximumn": + return "maximum_n" case "logicalminimum", "arrayminimum": return "minimum" + case "arrayminimumn": + return "minimum_n" case "arraysum": return "sum" case "lower", "tolower": From 7fedf7725f4129f0d484b6d557454fdeae6f10e1 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 13 May 2026 15:38:58 +0100 Subject: [PATCH 10/10] test(firestore): cover array pipeline helpers Co-authored-by: Cursor --- .../firestore/__tests__/pipelines.test.ts | 94 ++++++---- packages/firestore/e2e/Pipeline.e2e.js | 109 ++++++++++-- packages/firestore/type-test.ts | 166 ++++++++++++------ 3 files changed, 275 insertions(+), 94 deletions(-) diff --git a/packages/firestore/__tests__/pipelines.test.ts b/packages/firestore/__tests__/pipelines.test.ts index 3980744fa1..548516ee10 100644 --- a/packages/firestore/__tests__/pipelines.test.ts +++ b/packages/firestore/__tests__/pipelines.test.ts @@ -4,7 +4,13 @@ import { arrayFilter, arrayFirst, arrayFirstN, + arrayIndexOf, arrayGet, + arrayLastIndexOf, + arrayMaximum, + arrayMaximumN, + arrayMinimumN, + arrayTransform, and, conditional, constant, @@ -154,9 +160,9 @@ describe('Firestore pipelines runtime', function () { arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( 'passingScores', ), - field('scores').arrayFilter('score', greaterThan(variable('score'), constant(20))).as( - 'topScores', - ), + field('scores') + .arrayFilter('score', greaterThan(variable('score'), constant(20))) + .as('topScores'), ) .serialize(); @@ -207,17 +213,30 @@ describe('Firestore pipelines runtime', function () { }); }); - it('serializes arrayFirst and arrayFirstN as function expression helpers', function () { + it('serializes newer array expression helpers with SDK-compatible arguments', function () { const db: any = firebase.firestore(); const serialized = db .pipeline() .collection('firestore') .select( - arrayFirst('items').as('firstItem'), - arrayFirstN(field('items'), 2).as('firstTwoItems'), - arrayFirstN('items', field('count')).as('dynamicFirstItems'), - field('items').arrayFirst().as('fluentFirstItem'), - field('items').arrayFirstN(2).as('fluentFirstTwoItems'), + arrayFirst('scores').as('firstScore'), + arrayFirstN('scores', 2).as('firstTwoScores'), + arrayFirstN('scores', field('limit')).as('dynamicFirstScores'), + field('scores').arrayLast().as('lastScore'), + field('scores').arrayLastN(2).as('lastTwoScores'), + field('scores').arraySlice(1, 3).as('middleScores'), + arrayTransform('scores', 'score', variable('score')).as('transformedScores'), + field('scores') + .arrayTransformWithIndex('score', 'index', variable('index')) + .as('indexedScores'), + arrayMaximum('scores').as('maxScore'), + arrayMaximumN(field('scores'), 3).as('topScores'), + field('scores').arrayMinimum().as('minScore'), + arrayMinimumN(field('scores'), 3).as('bottomScores'), + arrayIndexOf('scores', 10).as('firstIndex'), + field('scores').arrayIndexOf(10).as('fluentFirstIndex'), + arrayLastIndexOf(field('scores'), 10).as('lastIndex'), + field('scores').arrayIndexOfAll(10).as('allIndexes'), ) .serialize(); @@ -225,55 +244,68 @@ describe('Firestore pipelines runtime', function () { stage: 'select', options: { selections: [ + { alias: 'firstScore', expr: { exprType: 'Function', name: 'arrayFirst' } }, + { alias: 'firstTwoScores', expr: { exprType: 'Function', name: 'arrayFirstN' } }, { - alias: 'firstItem', - expr: { - exprType: 'Function', - name: 'arrayFirst', - args: [{ exprType: 'Field', path: 'items' }], - }, - }, - { - alias: 'firstTwoItems', + alias: 'dynamicFirstScores', expr: { exprType: 'Function', name: 'arrayFirstN', args: [ - { exprType: 'Field', path: 'items' }, - { exprType: 'Constant', value: 2 }, + { exprType: 'Field', path: 'scores' }, + { exprType: 'Field', path: 'limit' }, ], }, }, + { alias: 'lastScore', expr: { exprType: 'Function', name: 'arrayLast' } }, + { alias: 'lastTwoScores', expr: { exprType: 'Function', name: 'arrayLastN' } }, + { alias: 'middleScores', expr: { exprType: 'Function', name: 'arraySlice' } }, + { alias: 'transformedScores', expr: { exprType: 'Function', name: 'arrayTransform' } }, + { + alias: 'indexedScores', + expr: { exprType: 'Function', name: 'arrayTransformWithIndex' }, + }, + { alias: 'maxScore', expr: { exprType: 'Function', name: 'arrayMaximum' } }, + { alias: 'topScores', expr: { exprType: 'Function', name: 'arrayMaximumN' } }, + { alias: 'minScore', expr: { exprType: 'Function', name: 'arrayMinimum' } }, + { alias: 'bottomScores', expr: { exprType: 'Function', name: 'arrayMinimumN' } }, { - alias: 'dynamicFirstItems', + alias: 'firstIndex', expr: { exprType: 'Function', - name: 'arrayFirstN', + name: 'arrayIndexOf', args: [ - { exprType: 'Field', path: 'items' }, - { exprType: 'Field', path: 'count' }, + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 10 }, + { exprType: 'Constant', value: 'first' }, ], }, }, { - alias: 'fluentFirstItem', + alias: 'fluentFirstIndex', expr: { exprType: 'Function', - name: 'arrayFirst', - args: [{ exprType: 'Field', path: 'items' }], + name: 'arrayIndexOf', + args: [ + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 10 }, + { exprType: 'Constant', value: 'first' }, + ], }, }, { - alias: 'fluentFirstTwoItems', + alias: 'lastIndex', expr: { exprType: 'Function', - name: 'arrayFirstN', + name: 'arrayLastIndexOf', args: [ - { exprType: 'Field', path: 'items' }, - { exprType: 'Constant', value: 2 }, + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 10 }, + { exprType: 'Constant', value: 'last' }, ], }, }, + { alias: 'allIndexes', expr: { exprType: 'Function', name: 'arrayIndexOfAll' } }, ], }, }); diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index bb51500627..8feaaca5cc 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -1517,14 +1517,24 @@ describe('FirestorePipeline', function () { constant, array, arrayLength, - arrayFirst, - arrayFirstN, arrayGet, arrayConcat, arrayFilter, + arrayFirst, + arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLastIndexOf, + arrayMaximum, + arrayMaximumN, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arraySum, variable, and, + add, greaterThan, arrayContains, arrayContainsAny, @@ -1540,7 +1550,7 @@ describe('FirestorePipeline', function () { permissions: ['read', 'write'], primaryTags: ['a', 'b'], secondaryTags: ['c', 'd'], - scores: [10, 20, 30], + scores: [10, 20, 30, 20], items: ['x', 'y', 'z'], }), setDoc(doc(coll, 'p2'), { @@ -1573,15 +1583,35 @@ describe('FirestorePipeline', function () { arrayFilter(field('scores'), 'score', greaterThan(variable('score'), 15)).as( 'filteredItems', ), + arrayFirst('scores').as('firstScore'), + arrayFirstN('scores', 2).as('firstTwoScores'), + field('scores').arrayLast().as('lastScore'), + field('scores').arrayLastN(2).as('lastTwoScores'), + arraySlice('scores', 1, 2).as('middleScores'), + arrayTransform('scores', 'score', add(variable('score'), 1)).as('incrementedScores'), + arrayTransformWithIndex( + 'scores', + 'score', + 'index', + add(variable('score'), variable('index')), + ).as('indexedScores'), + arrayMaximum('scores').as('maxScore'), + arrayMaximumN('scores', 2).as('topTwoScores'), + field('scores').arrayMinimum().as('minScore'), + arrayMinimumN('scores', 2).as('bottomTwoScores'), + arrayIndexOf('scores', 20).as('firstTwentyIndex'), + field('scores').arrayIndexOf(20).as('fluentFirstTwentyIndex'), + arrayLastIndexOf('scores', 20).as('lastTwentyIndex'), + field('scores').arrayLastIndexOf(20).as('fluentLastTwentyIndex'), + arrayIndexOfAll('scores', 20).as('allTwentyIndexes'), arraySum(field('scores')).as('totalScore'), ); if (Platform.ios) { - await expectIOSUnsupportedFunctions(() => execute(pipeline), [ - 'arrayFirst', - 'arrayFirstN', - 'arrayGet', - ]); + await expectIOSUnsupportedFunctions( + () => execute(pipeline), + ['arrayFirst', 'arrayFirstN', 'arrayGet'], + ); const iosSnapshot = await execute( db @@ -1601,6 +1631,29 @@ describe('FirestorePipeline', function () { arrayFilter('scores', 'score', greaterThan(variable('score'), 15)).as( 'filteredItems', ), + arrayFirst('scores').as('firstScore'), + arrayFirstN('scores', 2).as('firstTwoScores'), + field('scores').arrayLast().as('lastScore'), + field('scores').arrayLastN(2).as('lastTwoScores'), + arraySlice('scores', 1, 2).as('middleScores'), + arrayTransform('scores', 'score', add(variable('score'), 1)).as( + 'incrementedScores', + ), + arrayTransformWithIndex( + 'scores', + 'score', + 'index', + add(variable('score'), variable('index')), + ).as('indexedScores'), + arrayMaximum('scores').as('maxScore'), + arrayMaximumN('scores', 2).as('topTwoScores'), + field('scores').arrayMinimum().as('minScore'), + arrayMinimumN('scores', 2).as('bottomTwoScores'), + arrayIndexOf('scores', 20).as('firstTwentyIndex'), + field('scores').arrayIndexOf(20).as('fluentFirstTwentyIndex'), + arrayLastIndexOf('scores', 20).as('lastTwentyIndex'), + field('scores').arrayLastIndexOf(20).as('fluentLastTwentyIndex'), + arrayIndexOfAll('scores', 20).as('allTwentyIndexes'), arraySum(field('scores')).as('totalScore'), ), ); @@ -1610,8 +1663,24 @@ describe('FirestorePipeline', function () { iosData.fixedArr.should.eql([1, 2, 3]); iosData.tagCount.should.equal(2); iosData.allTags.should.eql(['a', 'b', 'c', 'd']); - iosData.filteredItems.should.eql([20, 30]); - iosData.totalScore.should.equal(60); + iosData.filteredItems.should.eql([20, 30, 20]); + iosData.firstScore.should.equal(10); + iosData.firstTwoScores.should.eql([10, 20]); + iosData.lastScore.should.equal(20); + iosData.lastTwoScores.should.eql([30, 20]); + iosData.middleScores.should.eql([20, 30]); + iosData.incrementedScores.should.eql([11, 21, 31, 21]); + iosData.indexedScores.should.eql([10, 21, 32, 23]); + iosData.maxScore.should.equal(30); + [...iosData.topTwoScores].sort((a, b) => a - b).should.eql([20, 30]); + iosData.minScore.should.equal(10); + [...iosData.bottomTwoScores].sort((a, b) => a - b).should.eql([10, 20]); + iosData.firstTwentyIndex.should.equal(1); + iosData.fluentFirstTwentyIndex.should.equal(1); + iosData.lastTwentyIndex.should.equal(3); + iosData.fluentLastTwentyIndex.should.equal(3); + iosData.allTwentyIndexes.should.eql([1, 3]); + iosData.totalScore.should.equal(80); return; } @@ -1625,8 +1694,24 @@ describe('FirestorePipeline', function () { data.firstTwoItems.should.eql(['x', 'y']); data.firstItem.should.equal('x'); data.allTags.should.eql(['a', 'b', 'c', 'd']); - data.filteredItems.should.eql([20, 30]); - data.totalScore.should.equal(60); + data.filteredItems.should.eql([20, 30, 20]); + data.firstScore.should.equal(10); + data.firstTwoScores.should.eql([10, 20]); + data.lastScore.should.equal(20); + data.lastTwoScores.should.eql([30, 20]); + data.middleScores.should.eql([20, 30]); + data.incrementedScores.should.eql([11, 21, 31, 21]); + data.indexedScores.should.eql([10, 21, 32, 23]); + data.maxScore.should.equal(30); + [...data.topTwoScores].sort((a, b) => a - b).should.eql([20, 30]); + data.minScore.should.equal(10); + [...data.bottomTwoScores].sort((a, b) => a - b).should.eql([10, 20]); + data.firstTwentyIndex.should.equal(1); + data.fluentFirstTwentyIndex.should.equal(1); + data.lastTwentyIndex.should.equal(3); + data.fluentLastTwentyIndex.should.equal(3); + data.allTwentyIndexes.should.eql([1, 3]); + data.totalScore.should.equal(80); }); }); diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index 5e255682b6..4593b7017b 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -173,6 +173,18 @@ import { arrayFilter, arrayFirst, arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLast, + arrayLastIndexOf, + arrayLastN, + arrayMaximum, + arrayMaximumN, + arrayMinimum, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arrayConcat, arrayGet, arrayLength, @@ -493,7 +505,6 @@ nsDocWithConv.get().then((snap: FirebaseFirestoreTypes.DocumentSnapshot) = if (u) console.log(u.name, u.age); }); - // ----- getFirestore ----- const modFirestore1 = getFirestore(); console.log(modFirestore1.app.name); @@ -976,10 +987,7 @@ const pipelineUnion = pipelineDb .collection('cities/sf/restaurants') .where(field('type').equal('Chinese')) .union( - pipelineDb - .pipeline() - .collection('cities/ny/restaurants') - .where(field('type').equal('Italian')), + pipelineDb.pipeline().collection('cities/ny/restaurants').where(field('type').equal('Italian')), ) .where(field('rating').greaterThanOrEqual(4.5)) .sort(field('__name__').descending()); @@ -990,10 +998,7 @@ const pipelineWithTransforms = pipelineDb .collection('books') .where( pipelineOr( - pipelineAnd( - field('rating').greaterThan(4), - lessThan(field('price'), constant(10)), - ), + pipelineAnd(field('rating').greaterThan(4), lessThan(field('price'), constant(10))), field('genre').equal('Fantasy'), ), ) @@ -1002,9 +1007,7 @@ const pipelineWithTransforms = pipelineDb .select( field('fullTitle'), field('rating').greaterThan(4).as('isTopRated'), - arrayContainsAny(field('genre'), ['Fantasy', constant('Sci-Fi')]).as( - 'matchesGenre', - ), + arrayContainsAny(field('genre'), ['Fantasy', constant('Sci-Fi')]).as('matchesGenre'), ) .sort(Ordering.of(field('rating')).descending(), field('__name__').ascending()) .offset(1) @@ -1021,22 +1024,22 @@ const pipelineAggregateDistinct = pipelineDb pipelineAverage('population').as('populationAvg'), maximum('population').as('populationMax'), ], - groups: [ - field('country').as('country'), - toLower(field('state')).as('normalizedState'), - ], + groups: [field('country').as('country'), toLower(field('state')).as('normalizedState')], }) .where(field('populationTotal').greaterThan(1000)) .distinct(field('normalizedState'), 'country'); void pipelineAggregateDistinct; -const pipelineFindNearest = pipelineDb.pipeline().collection('cities').findNearest({ - field: 'embedding', - vectorValue: [1.5, 2.345], - distanceMeasure: 'COSINE', - distanceField: 'computedDistance', - limit: 10, -}); +const pipelineFindNearest = pipelineDb + .pipeline() + .collection('cities') + .findNearest({ + field: 'embedding', + vectorValue: [1.5, 2.345], + distanceMeasure: 'COSINE', + distanceField: 'computedDistance', + limit: 10, + }); void pipelineFindNearest; const pipelineSampleAndUnnest = pipelineDb @@ -1119,7 +1122,11 @@ const _cStr: Expression = constant('hello'); const _cBool: BooleanExpression = constant(true); const _cNull: Expression = constant(null); const _cUnknown: Expression = constant({ nested: true }); -void _cNum; void _cStr; void _cBool; void _cNull; void _cUnknown; +void _cNum; +void _cStr; +void _cBool; +void _cNull; +void _cUnknown; // ----- Comparison: standalone overloads ----- // greaterThan(Expression, Expression) | greaterThan(Expression, value) @@ -1322,17 +1329,62 @@ void variable('score'); void arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))); void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); -// arrayFirst: (string) | (Expression) -void arrayFirst('items'); -void arrayFirst(field('items')); -// arrayFirstN: 4 overloads -void arrayFirstN('items', 2); -void arrayFirstN('items', field('limit')); -void arrayFirstN(field('items'), 2); -void arrayFirstN(field('items'), field('limit')); -void field('items').arrayFirst(); -void field('items').arrayFirstN(2); -void field('items').arrayFirstN(field('limit')); +// newer array helpers +void arrayTransform('scores', 'score', add(variable('score'), 1)); +void arrayTransform(field('scores'), 'score', add(variable('score'), 1)); +void field('scores').arrayTransform('score', add(variable('score'), 1)); +void arrayTransformWithIndex('scores', 'score', 'index', add(variable('score'), variable('index'))); +void arrayTransformWithIndex( + field('scores'), + 'score', + 'index', + add(variable('score'), variable('index')), +); +void field('scores').arrayTransformWithIndex( + 'score', + 'index', + add(variable('score'), variable('index')), +); +void arraySlice('scores', 1, 2); +void arraySlice(field('scores'), field('offset'), field('length')); +void field('scores').arraySlice(1, 2); +void arrayFirst('scores'); +void arrayFirst(field('scores')); +void field('scores').arrayFirst(); +void arrayFirstN('scores', 2); +void arrayFirstN('scores', field('limit')); +void arrayFirstN(field('scores'), field('limit')); +void field('scores').arrayFirstN(field('limit')); +void arrayLast('scores'); +void arrayLast(field('scores')); +void field('scores').arrayLast(); +void arrayLastN('scores', 2); +void arrayLastN('scores', field('limit')); +void arrayLastN(field('scores'), field('limit')); +void field('scores').arrayLastN(field('limit')); +void arrayMaximum('scores'); +void arrayMaximum(field('scores')); +void field('scores').arrayMaximum(); +void arrayMaximumN('scores', 2); +void arrayMaximumN('scores', field('limit')); +void arrayMaximumN(field('scores'), field('limit')); +void field('scores').arrayMaximumN(field('limit')); +void arrayMinimum('scores'); +void arrayMinimum(field('scores')); +void field('scores').arrayMinimum(); +void arrayMinimumN('scores', 2); +void arrayMinimumN('scores', field('limit')); +void arrayMinimumN(field('scores'), field('limit')); +void field('scores').arrayMinimumN(field('limit')); +void arrayIndexOf('scores', 20); +void arrayIndexOf(field('scores'), field('needle')); +void field('scores').arrayIndexOf(20); +void arrayLastIndexOf('scores', 20); +void arrayLastIndexOf(field('scores'), field('needle')); +void field('scores').arrayLastIndexOf(20); +void arrayIndexOfAll('scores', 20); +void arrayIndexOfAll(field('scores'), field('needle')); +void field('scores').arrayIndexOfAll(20); // arrayConcat: (Expression, ...) | (string, ...) void arrayConcat(field('tags'), field('moreTags')); void arrayConcat(field('tags'), ['extra']); @@ -1559,11 +1611,9 @@ const pipelineComparisonOps = xDb ) .select( field('sku'), - conditional( - field('stock').greaterThan(0), - constant('in-stock'), - constant('out-of-stock'), - ).as('availability'), + conditional(field('stock').greaterThan(0), constant('in-stock'), constant('out-of-stock')).as( + 'availability', + ), isType(field('value'), 'string').as('isString'), logicalMaximum(field('bidA'), field('bidB')).as('topBid'), logicalMinimum(field('askA'), field('askB')).as('bottomAsk'), @@ -1618,10 +1668,7 @@ const pipelineStringOps = xDb stringContains(field('bio'), 'developer'), like('role', 'eng%'), regexContains(field('phone'), '^\\+1'), - xor( - field('isPublic').equal(true), - field('isVerified').equal(true), - ), + xor(field('isPublic').equal(true), field('isVerified').equal(true)), ), ) .addFields( @@ -1719,9 +1766,29 @@ const pipelineArrayOps = xDb arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( 'passingScores', ), - field('scores').arrayFilter('score', greaterThan(variable('score'), constant(20))).as( - 'topScores', - ), + field('scores') + .arrayFilter('score', greaterThan(variable('score'), constant(20))) + .as('topScores'), + arrayFirst('scores').as('firstScore'), + arrayFirstN('scores', 2).as('firstTwoScores'), + field('scores').arrayLast().as('lastScore'), + field('scores').arrayLastN(2).as('lastTwoScores'), + arraySlice('scores', 1, 2).as('middleScores'), + arrayTransform('scores', 'score', add(variable('score'), 1)).as('incrementedScores'), + arrayTransformWithIndex( + 'scores', + 'score', + 'index', + add(variable('score'), variable('index')), + ).as('indexedScores'), + arrayMaximum('scores').as('maxScore'), + arrayMaximumN('scores', 2).as('topTwoScores'), + arrayMinimum('scores').as('minScore'), + arrayMinimumN('scores', 2).as('bottomTwoScores'), + arrayIndexOf('scores', 20).as('firstTwentyIndex'), + field('scores').arrayIndexOf(20).as('fluentFirstTwentyIndex'), + arrayLastIndexOf('scores', 20).as('lastTwentyIndex'), + arrayIndexOfAll('scores', 20).as('allTwentyIndexes'), arraySum(field('scores')).as('totalScore'), arraySum('scores').as('totalScore2'), ); @@ -1755,10 +1822,7 @@ const pipelineAllAggregates = xDb arrayAggDistinct(field('category')).as('distinctCategories'), arrayAggDistinct('category').as('distinctCategories2'), ], - groups: [ - field('country').as('country'), - toLower(field('state')).as('normalizedState'), - ], + groups: [field('country').as('country'), toLower(field('state')).as('normalizedState')], }); void pipelineAllAggregates;