diff --git a/.github/scripts/compare-types/configs/firestore-pipelines.ts b/.github/scripts/compare-types/configs/firestore-pipelines.ts index 1e924adf17..b00d3d00dc 100644 --- a/.github/scripts/compare-types/configs/firestore-pipelines.ts +++ b/.github/scripts/compare-types/configs/firestore-pipelines.ts @@ -19,117 +19,62 @@ 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.', - }, - { - 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.', - }, - { - 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.', - }, - { - name: 'variable', - reason: 'Newer firebase-js-sdk variable 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', @@ -141,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', @@ -159,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 `Variable` and `PipelineValue` expression kinds.', + 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/__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-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 bdca431412..548516ee10 100644 --- a/packages/firestore/__tests__/pipelines.test.ts +++ b/packages/firestore/__tests__/pipelines.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it, jest } from '@jest/globals'; import { firebase } from '../lib'; import { + arrayFilter, + arrayFirst, + arrayFirstN, + arrayIndexOf, arrayGet, + arrayLastIndexOf, + arrayMaximum, + arrayMaximumN, + arrayMinimumN, + arrayTransform, and, conditional, constant, @@ -16,6 +25,7 @@ import { timestampAdd, timestampSubtract, trunc, + variable, } from '../lib/pipelines'; import '../lib/pipelines'; import { ConstantExpression } from '../lib/pipelines/expressions'; @@ -141,6 +151,166 @@ 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(variable('score'), constant(15))).as( + 'passingScores', + ), + field('scores') + .arrayFilter('score', greaterThan(variable('score'), constant(20))) + .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: 'Variable', name: 'score' }, + { exprType: 'Constant', value: 15 }, + ], + }, + ], + }, + }, + { + alias: 'topScores', + expr: { + exprType: 'Function', + name: 'arrayFilter', + args: [ + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 'score' }, + { + exprType: 'Function', + name: 'greaterThan', + args: [ + { exprType: 'Variable', name: 'score' }, + { exprType: 'Constant', value: 20 }, + ], + }, + ], + }, + }, + ], + }, + }); + }); + + it('serializes newer array expression helpers with SDK-compatible arguments', function () { + const db: any = firebase.firestore(); + const serialized = db + .pipeline() + .collection('firestore') + .select( + 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(); + + expect(serialized.stages[0]).toMatchObject({ + stage: 'select', + options: { + selections: [ + { alias: 'firstScore', expr: { exprType: 'Function', name: 'arrayFirst' } }, + { alias: 'firstTwoScores', expr: { exprType: 'Function', name: 'arrayFirstN' } }, + { + alias: 'dynamicFirstScores', + expr: { + exprType: 'Function', + name: 'arrayFirstN', + args: [ + { 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: 'firstIndex', + expr: { + exprType: 'Function', + name: 'arrayIndexOf', + args: [ + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 10 }, + { exprType: 'Constant', value: 'first' }, + ], + }, + }, + { + alias: 'fluentFirstIndex', + expr: { + exprType: 'Function', + name: 'arrayIndexOf', + args: [ + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 10 }, + { exprType: 'Constant', value: 'first' }, + ], + }, + }, + { + alias: 'lastIndex', + expr: { + exprType: 'Function', + name: 'arrayLastIndexOf', + args: [ + { exprType: 'Field', path: 'scores' }, + { exprType: 'Constant', value: 10 }, + { exprType: 'Constant', value: 'last' }, + ], + }, + }, + { alias: 'allIndexes', expr: { exprType: 'Function', name: 'arrayIndexOfAll' } }, + ], + }, + }); + }); + it('enforces union guards and self-cycle serialization constraints', function () { const db: any = firebase.firestore(); const secondaryDb: any = firebase.app('secondaryFromNative').firestore(); @@ -388,6 +558,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), @@ -404,6 +578,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 fbd26607cd..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 @@ -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; @@ -994,6 +1015,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 +1059,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)) { @@ -1540,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) { @@ -1771,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; @@ -2555,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) @@ -2572,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) @@ -2596,6 +2677,9 @@ private Expression applyPendingUnaryExpressionFunctions( case "documentid": currentExpression = currentExpression.documentId(); break; + case "arrayfirst": + currentExpression = currentExpression.arrayFirst(); + break; case "arraylength": currentExpression = currentExpression.arrayLength(); break; @@ -2742,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": @@ -2959,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, @@ -3287,6 +3390,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 +3648,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()); @@ -3595,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/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; diff --git a/packages/firestore/consumer-type-test.ts b/packages/firestore/consumer-type-test.ts index f8bbcbb209..7f982d0156 100644 --- a/packages/firestore/consumer-type-test.ts +++ b/packages/firestore/consumer-type-test.ts @@ -168,8 +168,24 @@ import { collectionId, type as pipelineType, currentTimestamp, + variable, // array array, + arrayFilter, + arrayFirst, + arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLast, + arrayLastIndexOf, + arrayLastN, + arrayMaximum, + arrayMaximumN, + arrayMinimum, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arrayConcat, arrayGet, arrayLength, @@ -314,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(() => {}); @@ -401,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(() => {}); @@ -452,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 { @@ -481,7 +499,6 @@ nsDocWithConv.get().then((snap: FirebaseFirestoreTypes.DocumentSnapshot) = if (u) void [u.name, u.age]; }); - // ----- getFirestore ----- const modFirestore1 = getFirestore(); void modFirestore1.app.name; @@ -970,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()); @@ -984,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'), ), ) @@ -996,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) @@ -1015,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 @@ -1113,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) @@ -1310,6 +1323,68 @@ 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(variable('score'), constant(15))); +void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); +void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); +// 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']); @@ -1536,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'), @@ -1595,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( @@ -1684,11 +1754,41 @@ 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'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), arrayConcat('primaryTags', ['extra']).as('allTags2'), + arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( + 'passingScores', + ), + 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'), ); @@ -1722,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/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index e87927fa8b..8feaaca5cc 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -1510,7 +1510,7 @@ 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, @@ -1519,8 +1519,23 @@ describe('FirestorePipeline', function () { arrayLength, arrayGet, arrayConcat, + arrayFilter, + arrayFirst, + arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLastIndexOf, + arrayMaximum, + arrayMaximumN, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arraySum, + variable, and, + add, + greaterThan, arrayContains, arrayContainsAny, arrayContainsAll, @@ -1535,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'), { @@ -1561,13 +1576,42 @@ 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( + '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), ['arrayGet']); + await expectIOSUnsupportedFunctions( + () => execute(pipeline), + ['arrayFirst', 'arrayFirstN', 'arrayGet'], + ); const iosSnapshot = await execute( db @@ -1584,6 +1628,32 @@ 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('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'), ), ); @@ -1593,7 +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.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; } @@ -1603,9 +1690,28 @@ 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.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/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..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": @@ -747,6 +755,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": @@ -1044,6 +1056,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] { @@ -1134,6 +1154,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( @@ -1237,6 +1282,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 +1410,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 +1522,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 { diff --git a/packages/firestore/lib/pipelines/expressions.ts b/packages/firestore/lib/pipelines/expressions.ts index b5ff72f415..ed1a280469 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' @@ -108,6 +109,25 @@ interface FluentExpressionMethods { sum(): AggregateFunction; 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 | 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 { @@ -147,11 +167,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 @@ -257,6 +291,7 @@ type BooleanExpressionNode = RuntimeExpressionNode & RuntimeExpressionMethods & type RuntimeExpressionFluentNode = | (RuntimeExpressionNode & RuntimeExpressionMethods & Field) | (RuntimeExpressionNode & RuntimeExpressionMethods & FunctionExpression) + | (RuntimeExpressionNode & RuntimeExpressionMethods & VariableExpression) | BooleanExpressionNode | ConstantExpressionNode; type FieldNode = RuntimeExpressionFluentNode & Field; @@ -379,6 +414,7 @@ const EXPRESSION_METHOD_NAMES = [ 'arrayContains', 'arrayContainsAny', 'arrayContainsAll', + 'arrayFilter', 'startsWith', 'endsWith', 'add', @@ -410,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', @@ -548,6 +598,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 = {}; @@ -691,6 +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': @@ -765,6 +839,7 @@ function normalizeGlobalArguments(name: string, args: unknown[]): RuntimeNode[] case 'arrayContains': case 'arrayContainsAny': case 'arrayContainsAll': + case 'arrayFilter': case 'sum': case 'count': case 'average': @@ -795,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)), @@ -839,6 +922,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. @@ -998,6 +1089,28 @@ export function arrayContainsAll( return callBooleanHelper('arrayContainsAll', arguments); } +/** + * @beta + * Filters an array using a provided alias and predicate expression. + */ +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. @@ -1609,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 6704ba2c87..28c942f2da 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, @@ -79,6 +80,7 @@ export { arrayContains, arrayContainsAny, arrayContainsAll, + arrayFilter, startsWith, endsWith, OrderingHelper as Ordering, @@ -117,6 +119,20 @@ export { trim, substring, arrayAggDistinct, + arrayFirst, + arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLast, + arrayLastIndexOf, + arrayLastN, + arrayMaximum, + arrayMaximumN, + arrayMinimum, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arrayConcat, arrayGet, arrayLength, 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/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 : []; diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index 6f6d06558b..4593b7017b 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -167,8 +167,24 @@ import { collectionId, type as pipelineType, currentTimestamp, + variable, // array array, + arrayFilter, + arrayFirst, + arrayFirstN, + arrayIndexOf, + arrayIndexOfAll, + arrayLast, + arrayLastIndexOf, + arrayLastN, + arrayMaximum, + arrayMaximumN, + arrayMinimum, + arrayMinimumN, + arraySlice, + arrayTransform, + arrayTransformWithIndex, arrayConcat, arrayGet, arrayLength, @@ -489,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); @@ -972,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()); @@ -986,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'), ), ) @@ -998,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) @@ -1017,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 @@ -1115,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) @@ -1312,6 +1323,68 @@ 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(variable('score'), constant(15))); +void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15))); +void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15))); +// 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']); @@ -1538,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'), @@ -1597,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( @@ -1686,11 +1754,41 @@ 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'), arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'), arrayConcat('primaryTags', ['extra']).as('allTags2'), + arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15))).as( + 'passingScores', + ), + 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'), ); @@ -1724,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;