diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java index f27d5fd2a42b..9aeb352a9785 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java @@ -274,8 +274,49 @@ Expression parseExpression(@NonNull Map expressionMap) { case "array_sum": return Expression.arraySum(parseChild(args, "expression")); case "array_slice": - throw new UnsupportedOperationException( - "Expression type 'array_slice' is not supported on Android Firestore pipeline API"); + { + Expression array = parseChild(args, "expression"); + Expression offset = parseChild(args, "offset"); + Map lengthMap = (Map) args.get("length"); + if (lengthMap == null) { + return array.arraySliceToEnd(offset); + } + return array.arraySlice(offset, parseExpression(lengthMap)); + } + case "array_filter": + { + Expression array = parseChild(args, "expression"); + String alias = (String) args.get("alias"); + Map filterMap = (Map) args.get("filter"); + if (alias == null || filterMap == null) { + throw new IllegalArgumentException("array_filter requires alias and filter"); + } + return array.arrayFilter(alias, parseBooleanExpression(filterMap)); + } + case "array_transform": + { + Expression array = parseChild(args, "expression"); + String elementAlias = (String) args.get("element_alias"); + Map transformMap = (Map) args.get("transform"); + if (elementAlias == null || transformMap == null) { + throw new IllegalArgumentException( + "array_transform requires element_alias and transform"); + } + return array.arrayTransform(elementAlias, parseExpression(transformMap)); + } + case "array_transform_with_index": + { + Expression array = parseChild(args, "expression"); + String elementAlias = (String) args.get("element_alias"); + String indexAlias = (String) args.get("index_alias"); + Map transformMap = (Map) args.get("transform"); + if (elementAlias == null || indexAlias == null || transformMap == null) { + throw new IllegalArgumentException( + "array_transform_with_index requires element_alias, index_alias, and transform"); + } + return array.arrayTransformWithIndex( + elementAlias, indexAlias, parseExpression(transformMap)); + } case "if_absent": { Map exprMap = (Map) args.get("expression"); diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m index cc3d36f510dd..22449aaf96bf 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m @@ -486,6 +486,86 @@ - (FIRExprBridge *)parseExpression:(NSDictionary *)map error:(NS return FLTNewFunctionExprBridge(@"array_concat", all); } + // ------------------------------------------------------------------------- + // expression + offset (+ optional length): array_slice + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_slice"]) { + id exprMap = args[@"expression"]; + id offsetMap = args[@"offset"]; + id lengthMap = args[@"length"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![offsetMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"array_slice requires expression and offset"); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *offset = [self parseExpression:offsetMap error:error]; + if (!expr || !offset) return nil; + NSMutableArray *sliceArgs = + [NSMutableArray arrayWithObjects:expr, offset, nil]; + if ([lengthMap isKindOfClass:[NSDictionary class]]) { + FIRExprBridge *length = [self parseExpression:lengthMap error:error]; + if (!length) return nil; + [sliceArgs addObject:length]; + } + return FLTNewFunctionExprBridge(@"array_slice", sliceArgs); + } + + // ------------------------------------------------------------------------- + // expression + alias + filter: array_filter + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_filter"]) { + id exprMap = args[@"expression"]; + NSString *alias = args[@"alias"]; + id filterMap = args[@"filter"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || ![alias isKindOfClass:[NSString class]] || + ![filterMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"array_filter requires expression, alias, and filter"); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *filter = [self parseBooleanExpression:filterMap error:error]; + if (!expr || !filter) return nil; + return FLTNewFunctionExprBridge(@"array_filter", + @[ expr, [[FIRConstantBridge alloc] init:alias], filter ]); + } + + // ------------------------------------------------------------------------- + // expression + aliases + transform: array_transform / array_transform_with_index + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_transform"] || + [name isEqualToString:@"array_transform_with_index"]) { + id exprMap = args[@"expression"]; + NSString *elementAlias = args[@"element_alias"]; + NSString *indexAlias = args[@"index_alias"]; + id transformMap = args[@"transform"]; + BOOL withIndex = [name isEqualToString:@"array_transform_with_index"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![elementAlias isKindOfClass:[NSString class]] || + (withIndex && ![indexAlias isKindOfClass:[NSString class]]) || + ![transformMap isKindOfClass:[NSDictionary class]]) { + if (error) { + NSString *message = + withIndex + ? @"array_transform_with_index requires expression, element_alias, index_alias, " + @"and transform" + : @"array_transform requires expression, element_alias, and transform"; + *error = parseError(message); + } + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *transform = [self parseExpression:transformMap error:error]; + if (!expr || !transform) return nil; + NSMutableArray *transformArgs = + [NSMutableArray arrayWithObjects:expr, [[FIRConstantBridge alloc] init:elementAlias], nil]; + if (withIndex) { + [transformArgs addObject:[[FIRConstantBridge alloc] init:indexAlias]]; + } + [transformArgs addObject:transform]; + return FLTNewFunctionExprBridge(name, transformArgs); + } + // ------------------------------------------------------------------------- // elements[]: array (construct) — Expression.array([...]) from Dart // ------------------------------------------------------------------------- diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart index 4f8f85e49c1e..0690924f2011 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart @@ -701,6 +701,41 @@ abstract class Expression implements PipelineSerializable { return _ArrayIndexOfAllExpression(this, _toExpression(element)); } + /// Returns a slice of this array starting at [offset]. + /// + /// When [length] is provided, at most [length] elements are returned. + Expression arraySlice(Object? offset, [Object? length]) { + return _ArraySliceExpression( + this, + _toExpression(offset), + length == null ? null : _toExpression(length), + ); + } + + /// Filters this array by evaluating [filter] for each element bound to [alias]. + Expression arrayFilter(String alias, BooleanExpression filter) { + return _ArrayFilterExpression(this, alias, filter); + } + + /// Transforms each element of this array bound to [elementAlias]. + Expression arrayTransform(String elementAlias, Expression transform) { + return _ArrayTransformExpression(this, elementAlias, null, transform); + } + + /// Transforms each element of this array with both element and index aliases. + Expression arrayTransformWithIndex( + String elementAlias, + String indexAlias, + Expression transform, + ) { + return _ArrayTransformExpression( + this, + elementAlias, + indexAlias, + transform, + ); + } + // ============================================================================ // AGGREGATE FUNCTIONS // ============================================================================ @@ -1596,6 +1631,52 @@ abstract class Expression implements PipelineSerializable { ); } + /// Returns a slice of [array] starting at [offset]. + static Expression arraySliceStatic( + Expression array, + Object? offset, [ + Object? length, + ]) { + return _ArraySliceExpression( + array, + _toExpression(offset), + length == null ? null : _toExpression(length), + ); + } + + /// Filters [array] by evaluating [filter] for each element bound to [alias]. + static Expression arrayFilterStatic( + Expression array, + String alias, + BooleanExpression filter, + ) { + return _ArrayFilterExpression(array, alias, filter); + } + + /// Transforms each element of [array] bound to [elementAlias]. + static Expression arrayTransformStatic( + Expression array, + String elementAlias, + Expression transform, + ) { + return _ArrayTransformExpression(array, elementAlias, null, transform); + } + + /// Transforms each element of [array] with both element and index aliases. + static Expression arrayTransformWithIndexStatic( + Expression array, + String elementAlias, + String indexAlias, + Expression transform, + ) { + return _ArrayTransformExpression( + array, + elementAlias, + indexAlias, + transform, + ); + } + /// Creates a raw/custom function expression static Expression rawFunction( String name, @@ -2423,6 +2504,92 @@ class _ArraySumExpression extends FunctionExpression { } } +/// Represents an array slice expression. +class _ArraySliceExpression extends FunctionExpression { + final Expression expression; + final Expression offset; + final Expression? sliceLength; + + _ArraySliceExpression(this.expression, this.offset, this.sliceLength); + + @override + String get name => 'array_slice'; + + @override + Map toMap() { + final args = { + 'expression': expression.toMap(), + 'offset': offset.toMap(), + }; + if (sliceLength != null) { + args['length'] = sliceLength!.toMap(); + } + return { + 'name': name, + 'args': args, + }; + } +} + +/// Represents an array filter expression. +class _ArrayFilterExpression extends FunctionExpression { + final Expression expression; + final String elementAlias; + final BooleanExpression filter; + + _ArrayFilterExpression(this.expression, this.elementAlias, this.filter); + + @override + String get name => 'array_filter'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'alias': elementAlias, + 'filter': filter.toMap(), + }, + }; + } +} + +/// Represents an array transform expression. +class _ArrayTransformExpression extends FunctionExpression { + final Expression expression; + final String elementAlias; + final String? indexAlias; + final Expression transform; + + _ArrayTransformExpression( + this.expression, + this.elementAlias, + this.indexAlias, + this.transform, + ); + + @override + String get name => + indexAlias == null ? 'array_transform' : 'array_transform_with_index'; + + @override + Map toMap() { + final args = { + 'expression': expression.toMap(), + 'element_alias': elementAlias, + 'transform': transform.toMap(), + }; + if (indexAlias != null) { + args['index_alias'] = indexAlias; + } + return { + 'name': name, + 'args': args, + }; + } +} + // ============================================================================ // CONDITIONAL / LOGIC OPERATION EXPRESSION CLASSES // ============================================================================ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_expressions_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_expressions_e2e.dart index e67e2fbdf0f5..4835839efb89 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_expressions_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_expressions_e2e.dart @@ -820,6 +820,24 @@ void runPipelineExpressionsTests() { expect(snapshot.result[0].data()!['tags_rev'], ['q', 'p']); }); + test('addFields with arraySlice returns sliced array', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(50)) + .addFields(Expression.field('arr').arraySlice(1, 2).as('arr_slice')) + .limit(1) + .execute(); + + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + { + 'arr_slice': [4, 6], + }, + ]); + }); + test( 'arraySum addFields succeeds on Android', () async { diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart index dbd4f1729424..067efa4d1cb7 100644 --- a/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart @@ -660,6 +660,76 @@ void main() { expect(expr.toMap()['name'], 'array_reverse'); expect(expr.toMap()['args']['expression']['args']['field'], 'order'); }); + + test('arraySlice serializes correctly', () { + final expr = Field('items').arraySlice(1, Field('count')); + expect(expr.toMap(), { + 'name': 'array_slice', + 'args': { + 'expression': Field('items').toMap(), + 'offset': Constant(1).toMap(), + 'length': Field('count').toMap(), + }, + }); + }); + + test('arraySlice without length serializes correctly', () { + final expr = Field('items').arraySlice(1); + expect(expr.toMap(), { + 'name': 'array_slice', + 'args': { + 'expression': Field('items').toMap(), + 'offset': Constant(1).toMap(), + }, + }); + }); + + test('arrayFilter serializes correctly', () { + final expr = Field('scores').arrayFilter( + 'item', + Field('item').greaterThanValue(10), + ); + expect(expr.toMap(), { + 'name': 'array_filter', + 'args': { + 'expression': Field('scores').toMap(), + 'alias': 'item', + 'filter': Field('item').greaterThanValue(10).toMap(), + }, + }); + }); + + test('arrayTransform serializes correctly', () { + final expr = Field('scores').arrayTransform( + 'score', + Field('score').multiplyNumber(10), + ); + expect(expr.toMap(), { + 'name': 'array_transform', + 'args': { + 'expression': Field('scores').toMap(), + 'element_alias': 'score', + 'transform': Field('score').multiplyNumber(10).toMap(), + }, + }); + }); + + test('arrayTransformWithIndex serializes correctly', () { + final expr = Field('scores').arrayTransformWithIndex( + 'score', + 'i', + Field('score').add(Field('i')), + ); + expect(expr.toMap(), { + 'name': 'array_transform_with_index', + 'args': { + 'expression': Field('scores').toMap(), + 'element_alias': 'score', + 'index_alias': 'i', + 'transform': Field('score').add(Field('i')).toMap(), + }, + }); + }); }); group('Numeric expressions', () { diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart index a4069ebab335..cd4ee712c568 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart @@ -483,6 +483,12 @@ extension type ExpressionJsImpl._(JSObject _) implements JSObject { external ExpressionJsImpl arrayIndexOf(JSAny element); external ExpressionJsImpl arrayLastIndexOf(JSAny element); external ExpressionJsImpl arrayIndexOfAll(JSAny element); + external ExpressionJsImpl arraySlice(JSAny offset, [JSAny? length]); + external ExpressionJsImpl arrayFilter(JSString alias, JSAny filter); + external ExpressionJsImpl arrayTransform( + JSString elementAlias, JSAny transform); + external ExpressionJsImpl arrayTransformWithIndex( + JSString elementAlias, JSString indexAlias, JSAny transform); external ExpressionJsImpl mapSet(JSAny key, JSAny value); external ExpressionJsImpl mapEntries(); } diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart index 722c28095d02..3460778faba9 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart @@ -360,6 +360,41 @@ class PipelineExpressionParserWeb { case 'array_index_of_all': return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) .arrayIndexOfAll(_expr(argsMap, 'element')); + case 'array_slice': + { + final base = _expr(argsMap, _kExpression) as interop.ExpressionJsImpl; + final length = argsMap['length']; + if (length == null) { + return base.arraySlice(_expr(argsMap, 'offset')); + } + return base.arraySlice( + _expr(argsMap, 'offset'), + toExpression(length as Map), + ); + } + case 'array_filter': + { + final filter = + toBooleanExpression(argsMap['filter'] as Map); + if (filter == null) { + throw UnsupportedError('array_filter requires a boolean filter'); + } + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .arrayFilter((argsMap['alias'] as String).toJS, filter); + } + case 'array_transform': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .arrayTransform( + (argsMap['element_alias'] as String).toJS, + _expr(argsMap, 'transform'), + ); + case 'array_transform_with_index': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .arrayTransformWithIndex( + (argsMap['element_alias'] as String).toJS, + (argsMap['index_alias'] as String).toJS, + _expr(argsMap, 'transform'), + ); default: throw FirebaseException( plugin: 'cloud_firestore',