Skip to content

Commit 1583bd0

Browse files
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
1 parent c2b92a6 commit 1583bd0

11 files changed

Lines changed: 296 additions & 10 deletions

File tree

.github/scripts/compare-types/configs/firestore-pipelines.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,6 @@ import type { PackageConfig } from '../src/types';
2323
const config: PackageConfig = {
2424
nameMapping: {},
2525
missingInRN: [
26-
{
27-
name: 'arrayFirst',
28-
reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.',
29-
},
30-
{
31-
name: 'arrayFirstN',
32-
reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.',
33-
},
3426
{
3527
name: 'arrayIndexOf',
3628
reason: 'Newer firebase-js-sdk array expression helper not yet exposed by RN Firebase pipelines.',

packages/firestore/__tests__/pipelines-parity.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,18 @@ const ANDROID_EXECUTOR_PATH = join(
1313
ROOT,
1414
'packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java',
1515
);
16+
const ANDROID_NODE_BUILDER_PATH = join(
17+
ROOT,
18+
'packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java',
19+
);
1620
const IOS_EXECUTOR_PATH = join(
1721
ROOT,
1822
'packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift',
1923
);
24+
const IOS_NODE_BUILDER_PATH = join(
25+
ROOT,
26+
'packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift',
27+
);
2028

2129
function extractQuotedList(source: string, marker: string, endMarker: string): string[] {
2230
const markerIndex = source.indexOf(marker);
@@ -79,4 +87,14 @@ describe('Firestore pipeline native parity', function () {
7987
expect(iosSource).toContain('does not support options.rawOptions on iOS');
8088
expect(iosSource).toContain('does not support pipeline.source.rawOptions');
8189
});
90+
91+
it('keeps arrayFirst and arrayFirstN on native lowering paths', function () {
92+
const androidSource = readFileSync(ANDROID_NODE_BUILDER_PATH, 'utf8');
93+
const iosSource = readFileSync(IOS_NODE_BUILDER_PATH, 'utf8');
94+
95+
expect(androidSource).toContain('currentExpression.arrayFirst()');
96+
expect(androidSource).toContain('arrayExpr.arrayFirstN');
97+
expect(iosSource).toContain('"array_first"');
98+
expect(iosSource).toContain('"array_first_n"');
99+
});
82100
});

packages/firestore/__tests__/pipelines.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, it, jest } from '@jest/globals';
22
import { firebase } from '../lib';
33
import {
44
arrayFilter,
5+
arrayFirst,
6+
arrayFirstN,
57
arrayGet,
68
and,
79
conditional,
@@ -205,6 +207,78 @@ describe('Firestore pipelines runtime', function () {
205207
});
206208
});
207209

210+
it('serializes arrayFirst and arrayFirstN as function expression helpers', function () {
211+
const db: any = firebase.firestore();
212+
const serialized = db
213+
.pipeline()
214+
.collection('firestore')
215+
.select(
216+
arrayFirst('items').as('firstItem'),
217+
arrayFirstN(field('items'), 2).as('firstTwoItems'),
218+
arrayFirstN('items', field('count')).as('dynamicFirstItems'),
219+
field('items').arrayFirst().as('fluentFirstItem'),
220+
field('items').arrayFirstN(2).as('fluentFirstTwoItems'),
221+
)
222+
.serialize();
223+
224+
expect(serialized.stages[0]).toMatchObject({
225+
stage: 'select',
226+
options: {
227+
selections: [
228+
{
229+
alias: 'firstItem',
230+
expr: {
231+
exprType: 'Function',
232+
name: 'arrayFirst',
233+
args: [{ exprType: 'Field', path: 'items' }],
234+
},
235+
},
236+
{
237+
alias: 'firstTwoItems',
238+
expr: {
239+
exprType: 'Function',
240+
name: 'arrayFirstN',
241+
args: [
242+
{ exprType: 'Field', path: 'items' },
243+
{ exprType: 'Constant', value: 2 },
244+
],
245+
},
246+
},
247+
{
248+
alias: 'dynamicFirstItems',
249+
expr: {
250+
exprType: 'Function',
251+
name: 'arrayFirstN',
252+
args: [
253+
{ exprType: 'Field', path: 'items' },
254+
{ exprType: 'Field', path: 'count' },
255+
],
256+
},
257+
},
258+
{
259+
alias: 'fluentFirstItem',
260+
expr: {
261+
exprType: 'Function',
262+
name: 'arrayFirst',
263+
args: [{ exprType: 'Field', path: 'items' }],
264+
},
265+
},
266+
{
267+
alias: 'fluentFirstTwoItems',
268+
expr: {
269+
exprType: 'Function',
270+
name: 'arrayFirstN',
271+
args: [
272+
{ exprType: 'Field', path: 'items' },
273+
{ exprType: 'Constant', value: 2 },
274+
],
275+
},
276+
},
277+
],
278+
},
279+
});
280+
});
281+
208282
it('enforces union guards and self-cycle serialization constraints', function () {
209283
const db: any = firebase.firestore();
210284
const secondaryDb: any = firebase.app('secondaryFromNative').firestore();
@@ -452,6 +526,10 @@ describe('Firestore pipelines runtime', function () {
452526
.pipeline()
453527
.documents(['firestore/a'])
454528
.select(
529+
arrayFirst(field('items')).as('firstArrayItem'),
530+
arrayFirstN(field('items'), 2).as('firstArrayItems'),
531+
field('items').arrayFirst().as('fluentFirstArrayItem'),
532+
field('items').arrayFirstN(2).as('fluentFirstArrayItems'),
455533
arrayGet(field('items'), 0).as('firstItem'),
456534
conditional(
457535
field('value').greaterThan(0),
@@ -468,6 +546,8 @@ describe('Firestore pipelines runtime', function () {
468546
.serialize();
469547

470548
expect(getIOSUnsupportedPipelineFunctions(serialized)).toEqual([
549+
'arrayFirst',
550+
'arrayFirstN',
471551
'arrayGet',
472552
'conditional',
473553
'round',

packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,27 @@ private static final class ExitReceiverArrayGetFrame implements ObjectLoweringFr
409409
}
410410
}
411411

412+
private static final class ExitReceiverArrayFirstNFrame implements ObjectLoweringFrame {
413+
final LoweredExpressionBox box;
414+
final List<PendingReceiverOperation> pendingOperations;
415+
final int nextIndex;
416+
final Expression currentExpression;
417+
final LoweredExpressionBox countBox;
418+
419+
ExitReceiverArrayFirstNFrame(
420+
LoweredExpressionBox box,
421+
List<PendingReceiverOperation> pendingOperations,
422+
int nextIndex,
423+
Expression currentExpression,
424+
LoweredExpressionBox countBox) {
425+
this.box = box;
426+
this.pendingOperations = pendingOperations;
427+
this.nextIndex = nextIndex;
428+
this.currentExpression = currentExpression;
429+
this.countBox = countBox;
430+
}
431+
}
432+
412433
private static final class ExitReceiverArrayConcatFrame implements ObjectLoweringFrame {
413434
final LoweredExpressionBox box;
414435
final List<PendingReceiverOperation> pendingOperations;
@@ -1556,6 +1577,36 @@ private void processObjectLoweringStack(ArrayDeque<ObjectLoweringFrame> stack)
15561577
indexArg, operationFieldName + ".args[1]", indexBox));
15571578
continue;
15581579
}
1580+
case "arrayfirstn":
1581+
{
1582+
Object countArg = args.get(1);
1583+
if (!containsLowerableExpression(countArg)) {
1584+
Object countValue = resolveConstantValue(countArg, operationFieldName + ".args[1]");
1585+
if (countValue instanceof Number) {
1586+
stack.push(
1587+
new ContinueReceiverExpressionChainFrame(
1588+
continueFrame.box,
1589+
null,
1590+
continueFrame.pendingOperations,
1591+
nextIndex,
1592+
currentExpression.arrayFirstN(((Number) countValue).intValue())));
1593+
continue;
1594+
}
1595+
}
1596+
1597+
LoweredExpressionBox countBox = new LoweredExpressionBox();
1598+
stack.push(
1599+
new ExitReceiverArrayFirstNFrame(
1600+
continueFrame.box,
1601+
continueFrame.pendingOperations,
1602+
nextIndex,
1603+
currentExpression,
1604+
countBox));
1605+
stack.push(
1606+
new EnterObjectExpressionValueFrame(
1607+
countArg, operationFieldName + ".args[1]", countBox));
1608+
continue;
1609+
}
15591610
case "arrayconcat":
15601611
{
15611612
if (args.size() < 2) {
@@ -1787,6 +1838,18 @@ private void processObjectLoweringStack(ArrayDeque<ObjectLoweringFrame> stack)
17871838
continue;
17881839
}
17891840

1841+
if (frame instanceof ExitReceiverArrayFirstNFrame) {
1842+
ExitReceiverArrayFirstNFrame exitFrame = (ExitReceiverArrayFirstNFrame) frame;
1843+
stack.push(
1844+
new ContinueReceiverExpressionChainFrame(
1845+
exitFrame.box,
1846+
null,
1847+
exitFrame.pendingOperations,
1848+
exitFrame.nextIndex,
1849+
exitFrame.currentExpression.arrayFirstN(exitFrame.countBox.value)));
1850+
continue;
1851+
}
1852+
17901853
if (frame instanceof ExitReceiverArrayConcatFrame) {
17911854
ExitReceiverArrayConcatFrame exitFrame = (ExitReceiverArrayConcatFrame) frame;
17921855
Object secondValue = exitFrame.childBoxes.get(0).value;
@@ -2571,6 +2634,7 @@ private boolean isDeferredUnaryExpressionFunction(String normalizedFunctionName)
25712634
return "type".equals(normalizedFunctionName)
25722635
|| "collectionid".equals(normalizedFunctionName)
25732636
|| "documentid".equals(normalizedFunctionName)
2637+
|| "arrayfirst".equals(normalizedFunctionName)
25742638
|| "arraylength".equals(normalizedFunctionName)
25752639
|| "arraysum".equals(normalizedFunctionName)
25762640
|| "vectorlength".equals(normalizedFunctionName)
@@ -2588,6 +2652,7 @@ private boolean isDeferredReceiverExpressionFunction(String normalizedFunctionNa
25882652
|| "mapget".equals(normalizedFunctionName)
25892653
|| "mapmerge".equals(normalizedFunctionName)
25902654
|| "arrayget".equals(normalizedFunctionName)
2655+
|| "arrayfirstn".equals(normalizedFunctionName)
25912656
|| "arrayconcat".equals(normalizedFunctionName)
25922657
|| "cosinedistance".equals(normalizedFunctionName)
25932658
|| "dotproduct".equals(normalizedFunctionName)
@@ -2612,6 +2677,9 @@ private Expression applyPendingUnaryExpressionFunctions(
26122677
case "documentid":
26132678
currentExpression = currentExpression.documentId();
26142679
break;
2680+
case "arrayfirst":
2681+
currentExpression = currentExpression.arrayFirst();
2682+
break;
26152683
case "arraylength":
26162684
currentExpression = currentExpression.arrayLength();
26172685
break;
@@ -2758,6 +2826,12 @@ private Expression buildSpecialParsedExpressionFunction(
27582826
case "arrayget":
27592827
requireParsedArgumentCount(args, 2, functionName, fieldName);
27602828
return buildParsedArrayGetExpression(args, fieldName);
2829+
case "arrayfirst":
2830+
requireParsedArgumentCount(args, 1, functionName, fieldName);
2831+
return coerceExpressionValueNode(args.get(0), fieldName + ".args[0]").arrayFirst();
2832+
case "arrayfirstn":
2833+
requireParsedArgumentCount(args, 2, functionName, fieldName);
2834+
return buildParsedArrayFirstNExpression(args, fieldName);
27612835
case "arrayconcat":
27622836
return buildParsedArrayConcatExpression(args, functionName, fieldName);
27632837
case "arraysum":
@@ -2975,6 +3049,19 @@ private Expression buildParsedArrayGetExpression(
29753049
return arrayExpr.arrayGet(coerceExpressionValueNode(args.get(1), fieldName + ".args[1]"));
29763050
}
29773051

3052+
private Expression buildParsedArrayFirstNExpression(
3053+
List<ReactNativeFirebaseFirestorePipelineParser.ParsedValueNode> args, String fieldName)
3054+
throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException {
3055+
Expression arrayExpr = coerceExpressionValueNode(args.get(0), fieldName + ".args[0]");
3056+
if (!containsParsedExpression(args.get(1))) {
3057+
Object countValue = resolveValueNode(args.get(1), fieldName + ".args[1]");
3058+
if (countValue instanceof Number) {
3059+
return arrayExpr.arrayFirstN(((Number) countValue).intValue());
3060+
}
3061+
}
3062+
return arrayExpr.arrayFirstN(coerceExpressionValueNode(args.get(1), fieldName + ".args[1]"));
3063+
}
3064+
29783065
private Expression buildParsedArrayConcatExpression(
29793066
List<ReactNativeFirebaseFirestorePipelineParser.ParsedValueNode> args,
29803067
String functionName,

packages/firestore/consumer-type-test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ import {
172172
// array
173173
array,
174174
arrayFilter,
175+
arrayFirst,
176+
arrayFirstN,
175177
arrayConcat,
176178
arrayGet,
177179
arrayLength,
@@ -1318,6 +1320,17 @@ void variable('score');
13181320
void arrayFilter('scores', 'score', greaterThan(variable('score'), constant(15)));
13191321
void arrayFilter(field('scores'), 'score', greaterThan(variable('score'), constant(15)));
13201322
void field('scores').arrayFilter('score', greaterThan(variable('score'), constant(15)));
1323+
// arrayFirst: (string) | (Expression)
1324+
void arrayFirst('items');
1325+
void arrayFirst(field('items'));
1326+
// arrayFirstN: 4 overloads
1327+
void arrayFirstN('items', 2);
1328+
void arrayFirstN('items', field('limit'));
1329+
void arrayFirstN(field('items'), 2);
1330+
void arrayFirstN(field('items'), field('limit'));
1331+
void field('items').arrayFirst();
1332+
void field('items').arrayFirstN(2);
1333+
void field('items').arrayFirstN(field('limit'));
13211334
// arrayConcat: (Expression, ...) | (string, ...)
13221335
void arrayConcat(field('tags'), field('moreTags'));
13231336
void arrayConcat(field('tags'), ['extra']);
@@ -1692,6 +1705,10 @@ const pipelineArrayOps = xDb
16921705
array([constant(1), constant(2), constant(3)]).as('fixedArr'),
16931706
arrayLength(field('comments')).as('commentCount'),
16941707
arrayLength('comments').as('commentCount2'),
1708+
arrayFirst(field('items')).as('firstItemByHelper'),
1709+
arrayFirst('items').as('firstItemByField'),
1710+
arrayFirstN(field('items'), 2).as('firstItems'),
1711+
arrayFirstN('items', field('limit')).as('dynamicFirstItems'),
16951712
arrayGet(field('items'), 0).as('firstItem'),
16961713
arrayGet(field('items'), field('idx')).as('dynamicItem'),
16971714
arrayGet('items', 0).as('firstItem2'),

packages/firestore/e2e/Pipeline.e2e.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,13 +1510,15 @@ describe('FirestorePipeline', function () {
15101510
});
15111511

15121512
describe('array operators', function () {
1513-
it('evaluates array, arrayLength, arrayGet, arrayConcat, arraySum and array predicates', async function () {
1513+
it('evaluates array helpers and array predicates', async function () {
15141514
const {
15151515
execute,
15161516
field,
15171517
constant,
15181518
array,
15191519
arrayLength,
1520+
arrayFirst,
1521+
arrayFirstN,
15201522
arrayGet,
15211523
arrayConcat,
15221524
arrayFilter,
@@ -1564,6 +1566,8 @@ describe('FirestorePipeline', function () {
15641566
.select(
15651567
array([constant(1), constant(2), constant(3)]).as('fixedArr'),
15661568
arrayLength(field('tags')).as('tagCount'),
1569+
arrayFirst(field('items')).as('firstItemByHelper'),
1570+
arrayFirstN(field('items'), 2).as('firstTwoItems'),
15671571
arrayGet(field('items'), 0).as('firstItem'),
15681572
arrayConcat(field('primaryTags'), field('secondaryTags')).as('allTags'),
15691573
arrayFilter(field('scores'), 'score', greaterThan(variable('score'), 15)).as(
@@ -1573,7 +1577,11 @@ describe('FirestorePipeline', function () {
15731577
);
15741578

15751579
if (Platform.ios) {
1576-
await expectIOSUnsupportedFunctions(() => execute(pipeline), ['arrayGet']);
1580+
await expectIOSUnsupportedFunctions(() => execute(pipeline), [
1581+
'arrayFirst',
1582+
'arrayFirstN',
1583+
'arrayGet',
1584+
]);
15771585

15781586
const iosSnapshot = await execute(
15791587
db
@@ -1613,6 +1621,8 @@ describe('FirestorePipeline', function () {
16131621
const data = snapshot.results[0].data();
16141622
data.fixedArr.should.eql([1, 2, 3]);
16151623
data.tagCount.should.equal(2);
1624+
data.firstItemByHelper.should.equal('x');
1625+
data.firstTwoItems.should.eql(['x', 'y']);
16161626
data.firstItem.should.equal('x');
16171627
data.allTags.should.eql(['a', 'b', 'c', 'd']);
16181628
data.filteredItems.should.eql([20, 30]);

0 commit comments

Comments
 (0)