From 6f0d91add30115c32b4d55ed6a7728fe231e3a78 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Thu, 16 Apr 2026 13:41:53 +0000 Subject: [PATCH 1/8] feat(firestore): add support for new expressions including nor, coalesce, switch_on, timestamp_diff, and timestamp_extract --- .../firestore/utils/ExpressionHelpers.java | 78 + .../firestore/utils/ExpressionParsers.java | 72 + .../cloud_firestore/FLTPipelineParser.m | 171 +- .../lib/src/pipeline_expression.dart | 416 +++++ .../pipeline/pipeline_expressions_e2e.dart | 171 +- .../pipeline/pipeline_filter_sort_e2e.dart | 5 +- .../pipeline/pipeline_seed.dart | 3 + .../pipeline/pipeline_unnest_union_e2e.dart | 5 +- .../pipeline_example/lib/main.dart | 1518 ++++++++++------- .../test/pipeline_expression_test.dart | 90 + .../lib/src/interop/firestore_interop.dart | 9 + .../src/pipeline_expression_parser_web.dart | 89 +- 12 files changed, 1945 insertions(+), 682 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java index 86c0877a902c..b1a4e83e9d35 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java @@ -97,6 +97,84 @@ static BooleanExpression parseXorExpression( return Expression.xor(first, rest); } + /** + * Parses a "nor" expression from a list of expression maps. Uses Expression.nor() with varargs + * signature. + */ + @SuppressWarnings("unchecked") + static BooleanExpression parseNorExpression( + @NonNull List> exprMaps, @NonNull ExpressionParsers parser) { + if (exprMaps == null || exprMaps.isEmpty()) { + throw new IllegalArgumentException("'nor' requires at least one expression"); + } + + BooleanExpression first = parser.parseBooleanExpression(exprMaps.get(0)); + if (exprMaps.size() == 1) { + return first; + } + + BooleanExpression[] rest = new BooleanExpression[exprMaps.size() - 1]; + for (int i = 1; i < exprMaps.size(); i++) { + rest[i - 1] = parser.parseBooleanExpression(exprMaps.get(i)); + } + return Expression.nor(first, rest); + } + + /** + * Parses a "coalesce" expression from a list of expression maps. Uses Expression.coalesce() with + * varargs. + */ + @SuppressWarnings("unchecked") + static Expression parseCoalesceExpression( + @NonNull List> exprMaps, @NonNull ExpressionParsers parser) { + if (exprMaps == null || exprMaps.size() < 2) { + throw new IllegalArgumentException("'coalesce' requires at least two expressions"); + } + + Expression first = parser.parseExpression(exprMaps.get(0)); + Expression second = parser.parseExpression(exprMaps.get(1)); + if (exprMaps.size() == 2) { + return Expression.coalesce(first, second); + } + + Object[] rest = new Object[exprMaps.size() - 2]; + for (int i = 2; i < exprMaps.size(); i++) { + rest[i - 2] = parser.parseExpression(exprMaps.get(i)); + } + return Expression.coalesce(first, second, rest); + } + + /** + * Parses a "switch_on" expression: alternating BooleanExpression condition and Expression result, + * with an optional trailing default Expression when the list length is odd. + */ + @SuppressWarnings("unchecked") + static Expression parseSwitchOnExpression( + @NonNull List> exprMaps, @NonNull ExpressionParsers parser) { + int n = exprMaps.size(); + if (n < 2) { + throw new IllegalArgumentException("'switch_on' requires at least two expressions"); + } + + BooleanExpression first = parser.parseBooleanExpression(exprMaps.get(0)); + Expression second = parser.parseExpression(exprMaps.get(1)); + if (n == 2) { + return Expression.switchOn(first, second); + } + + Object[] tail = new Object[n - 2]; + for (int i = 2; i < n; i++) { + if (n % 2 == 1 && i == n - 1) { + tail[i - 2] = parser.parseExpression(exprMaps.get(i)); + } else if (i % 2 == 0) { + tail[i - 2] = parser.parseBooleanExpression(exprMaps.get(i)); + } else { + tail[i - 2] = parser.parseExpression(exprMaps.get(i)); + } + } + return Expression.switchOn(first, second, tail); + } + /** * Parses a constant value based on its type to match Android SDK constant() overloads. Valid * types: String, Number, Boolean, Date, Timestamp, GeoPoint, byte[], Blob, DocumentReference, 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..de9127e6fa79 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 @@ -185,6 +185,11 @@ Expression parseExpression(@NonNull Map expressionMap) { List> exprMaps = (List>) args.get("expressions"); return ExpressionHelpers.parseXorExpression(exprMaps, this); } + case "nor": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseNorExpression(exprMaps, this); + } case "not": { Map exprMap = (Map) args.get("expression"); @@ -351,6 +356,68 @@ Expression parseExpression(@NonNull Map expressionMap) { } return Expression.timestampTruncate(parseExpression(timestampMap), unit); } + case "timestamp_diff": + { + Map endMap = (Map) args.get("end"); + Map startMap = (Map) args.get("start"); + Object unitObj = args.get("unit"); + Expression endExpr = parseExpression(endMap); + Expression startExpr = parseExpression(startMap); + if (unitObj instanceof String) { + return Expression.timestampDiff(endExpr, startExpr, (String) unitObj); + } + @SuppressWarnings("unchecked") + Map unitMap = (Map) unitObj; + return Expression.timestampDiff(endExpr, startExpr, parseExpression(unitMap)); + } + case "timestamp_extract": + { + Map timestampMap = (Map) args.get("timestamp"); + Map partMap = (Map) args.get("part"); + Expression tsExpr = parseExpression(timestampMap); + Expression partExpr = parseExpression(partMap); + if (!args.containsKey("timezone") || args.get("timezone") == null) { + return Expression.timestampExtract(tsExpr, partExpr); + } + Object tzObj = args.get("timezone"); + if (tzObj instanceof String) { + return Expression.timestampExtractWithTimezone(tsExpr, partExpr, (String) tzObj); + } + @SuppressWarnings("unchecked") + Map tzMap = (Map) tzObj; + return Expression.timestampExtractWithTimezone(tsExpr, partExpr, parseExpression(tzMap)); + } + case "parent": + { + if (args.containsKey("doc_ref")) { + String path = (String) args.get("doc_ref"); + if (path == null) { + throw new IllegalArgumentException("parent requires 'doc_ref' argument"); + } + return Expression.parent(firestore.document(path)); + } + return Expression.parent(parseChild(args, "expression")); + } + case "if_null": + { + Map exprMap = (Map) args.get("expression"); + Map replacementMap = (Map) args.get("replacement"); + return Expression.ifNull(parseExpression(exprMap), parseExpression(replacementMap)); + } + case "coalesce": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseCoalesceExpression(exprMaps, this); + } + case "switch_on": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseSwitchOnExpression(exprMaps, this); + } + case "map_keys": + return Expression.mapKeys(parseChild(args, "expression")); + case "map_values": + return Expression.mapValues(parseChild(args, "expression")); case "array": { List elements = (List) args.get("elements"); @@ -518,6 +585,11 @@ BooleanExpression parseBooleanExpression(@NonNull Map expression List> exprMaps = (List>) args.get("expressions"); return ExpressionHelpers.parseXorExpression(exprMaps, this); } + case "nor": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseNorExpression(exprMaps, this); + } case "not": { 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..238b88a7ffe2 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 @@ -247,10 +247,10 @@ - (FIRExprBridge *)parseExpression:(NSDictionary *)map error:(NS } // ------------------------------------------------------------------------- - // N-ary logical (expressions array): and, or, xor + // N-ary logical (expressions array): and, or, xor, nor // ------------------------------------------------------------------------- if ([name isEqualToString:@"and"] || [name isEqualToString:@"or"] || - [name isEqualToString:@"xor"]) { + [name isEqualToString:@"xor"] || [name isEqualToString:@"nor"]) { NSArray *exprMaps = args[@"expressions"]; if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count == 0) { if (error) @@ -651,6 +651,173 @@ - (FIRExprBridge *)parseExpression:(NSDictionary *)map error:(NS return FLTNewFunctionExprBridge(@"timestamp_trunc", @[ timestampExpr, unitExpr ]); } + // ------------------------------------------------------------------------- + // map_keys, map_values (unary on map expression) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"map_keys"] || [name isEqualToString:@"map_values"]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError([NSString stringWithFormat:@"%@ requires expression", name]); + return nil; + } + FIRExprBridge *expr = [self parseExpression:(NSDictionary *)exprMap error:error]; + if (!expr) return nil; + return FLTNewFunctionExprBridge(name, @[ expr ]); + } + + // ------------------------------------------------------------------------- + // parent: doc_ref path or expression + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"parent"]) { + NSString *docPath = args[@"doc_ref"]; + if ([docPath isKindOfClass:[NSString class]] && docPath.length > 0) { + FIRDocumentReference *ref = [self.firestore documentWithPath:docPath]; + FIRExprBridge *refExpr = [[FIRConstantBridge alloc] init:ref]; + return FLTNewFunctionExprBridge(@"parent", @[ refExpr ]); + } + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"parent requires expression or doc_ref"); + return nil; + } + FIRExprBridge *expr = [self parseExpression:(NSDictionary *)exprMap error:error]; + if (!expr) return nil; + return FLTNewFunctionExprBridge(@"parent", @[ expr ]); + } + + // ------------------------------------------------------------------------- + // timestamp_diff: end, start, unit (unit = string or expression map) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"timestamp_diff"]) { + id endMap = args[@"end"]; + id startMap = args[@"start"]; + id unitObj = args[@"unit"]; + if (![endMap isKindOfClass:[NSDictionary class]] || + ![startMap isKindOfClass:[NSDictionary class]] || !unitObj) { + if (error) *error = parseError(@"timestamp_diff requires end, start, and unit"); + return nil; + } + FIRExprBridge *endExpr = [self parseExpression:(NSDictionary *)endMap error:error]; + FIRExprBridge *startExpr = [self parseExpression:(NSDictionary *)startMap error:error]; + if (!endExpr || !startExpr) return nil; + FIRExprBridge *unitExpr = nil; + if ([unitObj isKindOfClass:[NSString class]]) { + unitExpr = [[FIRConstantBridge alloc] init:unitObj]; + } else if ([unitObj isKindOfClass:[NSDictionary class]]) { + unitExpr = [self parseExpression:(NSDictionary *)unitObj error:error]; + } else { + if (error) *error = parseError(@"timestamp_diff unit must be string or expression"); + return nil; + } + if (!unitExpr) return nil; + return FLTNewFunctionExprBridge(@"timestamp_diff", @[ endExpr, startExpr, unitExpr ]); + } + + // ------------------------------------------------------------------------- + // timestamp_extract: timestamp, part; optional timezone (string or expression) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"timestamp_extract"]) { + id timestampMap = args[@"timestamp"]; + id partMap = args[@"part"]; + if (![timestampMap isKindOfClass:[NSDictionary class]] || + ![partMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"timestamp_extract requires timestamp and part"); + return nil; + } + FIRExprBridge *tsExpr = [self parseExpression:(NSDictionary *)timestampMap error:error]; + FIRExprBridge *partExpr = [self parseExpression:(NSDictionary *)partMap error:error]; + if (!tsExpr || !partExpr) return nil; + id tzRaw = args[@"timezone"]; + if (tzRaw == nil) { + return FLTNewFunctionExprBridge(@"timestamp_extract", @[ tsExpr, partExpr ]); + } + FIRExprBridge *tzExpr = nil; + if ([tzRaw isKindOfClass:[NSString class]]) { + tzExpr = [[FIRConstantBridge alloc] init:tzRaw]; + } else if ([tzRaw isKindOfClass:[NSDictionary class]]) { + tzExpr = [self parseExpression:(NSDictionary *)tzRaw error:error]; + } else { + if (error) *error = parseError(@"timestamp_extract timezone must be string or expression"); + return nil; + } + if (!tzExpr) return nil; + return FLTNewFunctionExprBridge(@"timestamp_extract", @[ tsExpr, partExpr, tzExpr ]); + } + + // ------------------------------------------------------------------------- + // if_null: expression + replacement + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"if_null"]) { + id exprMap = args[@"expression"]; + id replMap = args[@"replacement"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![replMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"if_null requires expression and replacement"); + return nil; + } + FIRExprBridge *ifExpr = [self parseExpression:(NSDictionary *)exprMap error:error]; + FIRExprBridge *replExpr = [self parseExpression:(NSDictionary *)replMap error:error]; + if (!ifExpr || !replExpr) return nil; + return FLTNewFunctionExprBridge(@"if_null", @[ ifExpr, replExpr ]); + } + + // ------------------------------------------------------------------------- + // coalesce: expressions[] (>= 2) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"coalesce"]) { + NSArray *exprMaps = args[@"expressions"]; + if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count < 2) { + if (error) *error = parseError(@"coalesce requires at least two expressions"); + return nil; + } + NSMutableArray *exprs = [NSMutableArray array]; + for (id em in exprMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *e = [self parseExpression:(NSDictionary *)em error:error]; + if (!e) return nil; + [exprs addObject:e]; + } + if (exprs.count < 2) { + if (error) *error = parseError(@"coalesce requires at least two expressions"); + return nil; + } + return FLTNewFunctionExprBridge(@"coalesce", exprs); + } + + // ------------------------------------------------------------------------- + // switch_on: alternating condition (bool), result (expr), optional default + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"switch_on"]) { + NSArray *exprMaps = args[@"expressions"]; + if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count < 2) { + if (error) *error = parseError(@"switch_on requires at least two expressions"); + return nil; + } + NSUInteger n = exprMaps.count; + FIRExprBridge *first = [self parseBooleanExpression:exprMaps[0] error:error]; + FIRExprBridge *second = [self parseExpression:exprMaps[1] error:error]; + if (!first || !second) return nil; + if (n == 2) { + return FLTNewFunctionExprBridge(@"switch_on", @[ first, second ]); + } + NSMutableArray *rest = [NSMutableArray array]; + for (NSUInteger i = 2; i < n; i++) { + FIRExprBridge *e = nil; + if ((n % 2 == 1) && (i == n - 1)) { + e = [self parseExpression:exprMaps[i] error:error]; + } else if (i % 2 == 0) { + e = [self parseBooleanExpression:exprMaps[i] error:error]; + } else { + e = [self parseExpression:exprMaps[i] error:error]; + } + if (!e) return nil; + [rest addObject:e]; + } + NSMutableArray *all = [NSMutableArray arrayWithObjects:first, second, nil]; + [all addObjectsFromArray:rest]; + return FLTNewFunctionExprBridge(@"switch_on", all); + } + // ------------------------------------------------------------------------- // PipelineFilter (name "filter"): operator-based (and/or) or field-based // ------------------------------------------------------------------------- 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..7bca91635cb0 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart @@ -39,6 +39,63 @@ void _validateTimestampUnit(String unit) { } } +/// Validates and normalizes [Expression.switchOn] arguments. +List _parseSwitchOnParts(List parts) { + final n = parts.length; + if (n < 2) { + throw ArgumentError.value( + parts, + 'parts', + 'switchOn requires at least a condition and a result', + ); + } + if (n.isEven) { + final out = []; + for (var i = 0; i < n; i += 2) { + final c = parts[i]; + final r = parts[i + 1]; + if (c is! BooleanExpression) { + throw ArgumentError( + 'switchOn position $i: expected BooleanExpression, got ${c.runtimeType}', + ); + } + if (r is! Expression) { + throw ArgumentError( + 'switchOn position ${i + 1}: expected Expression, got ${r.runtimeType}', + ); + } + out.add(c); + out.add(r); + } + return out; + } + final out = []; + for (var i = 0; i < n - 1; i += 2) { + final c = parts[i]; + final r = parts[i + 1]; + if (c is! BooleanExpression) { + throw ArgumentError( + 'switchOn position $i: expected BooleanExpression, got ${c.runtimeType}', + ); + } + if (r is! Expression) { + throw ArgumentError( + 'switchOn position ${i + 1}: expected Expression, got ${r.runtimeType}', + ); + } + out.add(c); + out.add(r); + } + final d = parts[n - 1]; + if (d is! Expression) { + throw ArgumentError( + 'switchOn default: expected Expression, got ${d.runtimeType}', + ); + } + out.add(d); + return out; +} + /// Value types for [Expression.isType] and [Expression.isTypeStatic]. enum Type { /// `null` @@ -293,6 +350,49 @@ abstract class Expression implements PipelineSerializable { return _MapEntriesExpression(this); } + /// Returns an array of keys for this map expression. + // ignore: use_to_and_as_if_applicable + Expression mapKeys() { + return _MapKeysExpression(this); + } + + /// Returns an array of values for this map expression. + // ignore: use_to_and_as_if_applicable + Expression mapValues() { + return _MapValuesExpression(this); + } + + /// Parent collection or document reference for this document reference expression. + // ignore: use_to_and_as_if_applicable + Expression parent() { + return _ParentExpression(this); + } + + /// Difference between this timestamp ([end]) and [start], in [unit] (a unit string + /// or an expression). + Expression timestampDiff(Expression start, Object unit) { + return _TimestampDiffExpression(this, start, _toExpression(unit)); + } + + /// Extracts [part] (string or expression) from this timestamp; optional [timezone]. + Expression timestampExtract(Object part, [Object? timezone]) { + return _TimestampExtractExpression( + this, + _toExpression(part), + timezone == null ? null : _toExpression(timezone), + ); + } + + /// If this expression is null, evaluates to [replacement]. + Expression ifNull(Expression replacement) { + return _IfNullExpression(this, replacement); + } + + /// If this expression is null, evaluates to [replacement]. + Expression ifNullValue(Object? replacement) { + return _IfNullExpression(this, _toExpression(replacement)); + } + // ============================================================================ // ALIASING // ============================================================================ @@ -949,11 +1049,48 @@ abstract class Expression implements PipelineSerializable { return _TimestampTruncateExpression(timestamp, unit); } + /// Difference between [end] and [start] timestamps in [unit] (string or expression). + static Expression timestampDiffStatic( + Expression end, + Expression start, + Object unit, + ) { + return end.timestampDiff(start, unit); + } + /// Creates a document ID expression from a DocumentReference static Expression documentIdFromRef(DocumentReference docRef) { return _DocumentIdFromRefExpression(docRef); } + /// Parent collection or document reference of a constant [docRef]. + static Expression parentFromRef(DocumentReference docRef) { + return _ParentFromDocumentRefExpression(docRef); + } + + /// First non-null argument among operands (short-circuit). + static Expression coalesce( + Expression first, + Object second, [ + List? more, + ]) { + final expressions = [first, _toExpression(second)]; + if (more != null) { + for (final o in more) { + expressions.add(_toExpression(o)); + } + } + return _CoalesceExpression(expressions); + } + + /// Switch: first matching [BooleanExpression] condition wins. + /// + /// [parts] alternates condition, result, ... If [parts] has odd length, the last + /// value is a default [Expression] when no condition matches. + static Expression switchOn(List parts) { + return _SwitchOnExpression(_parseSwitchOnParts(parts)); + } + /// Checks if a value is in a list (IN operator) static BooleanExpression equalAny( Expression value, @@ -1217,6 +1354,72 @@ abstract class Expression implements PipelineSerializable { return _OrExpression(expressions); } + /// Combines boolean expressions with a logical NOR + static BooleanExpression nor( + BooleanExpression expression1, [ + BooleanExpression? expression2, + BooleanExpression? expression3, + BooleanExpression? expression4, + BooleanExpression? expression5, + BooleanExpression? expression6, + BooleanExpression? expression7, + BooleanExpression? expression8, + BooleanExpression? expression9, + BooleanExpression? expression10, + BooleanExpression? expression11, + BooleanExpression? expression12, + BooleanExpression? expression13, + BooleanExpression? expression14, + BooleanExpression? expression15, + BooleanExpression? expression16, + BooleanExpression? expression17, + BooleanExpression? expression18, + BooleanExpression? expression19, + BooleanExpression? expression20, + BooleanExpression? expression21, + BooleanExpression? expression22, + BooleanExpression? expression23, + BooleanExpression? expression24, + BooleanExpression? expression25, + BooleanExpression? expression26, + BooleanExpression? expression27, + BooleanExpression? expression28, + BooleanExpression? expression29, + BooleanExpression? expression30, + ]) { + final expressions = [expression1]; + if (expression2 != null) expressions.add(expression2); + if (expression3 != null) expressions.add(expression3); + if (expression4 != null) expressions.add(expression4); + if (expression5 != null) expressions.add(expression5); + if (expression6 != null) expressions.add(expression6); + if (expression7 != null) expressions.add(expression7); + if (expression8 != null) expressions.add(expression8); + if (expression9 != null) expressions.add(expression9); + if (expression10 != null) expressions.add(expression10); + if (expression11 != null) expressions.add(expression11); + if (expression12 != null) expressions.add(expression12); + if (expression13 != null) expressions.add(expression13); + if (expression14 != null) expressions.add(expression14); + if (expression15 != null) expressions.add(expression15); + if (expression16 != null) expressions.add(expression16); + if (expression17 != null) expressions.add(expression17); + if (expression18 != null) expressions.add(expression18); + if (expression19 != null) expressions.add(expression19); + if (expression20 != null) expressions.add(expression20); + if (expression21 != null) expressions.add(expression21); + if (expression22 != null) expressions.add(expression22); + if (expression23 != null) expressions.add(expression23); + if (expression24 != null) expressions.add(expression24); + if (expression25 != null) expressions.add(expression25); + if (expression26 != null) expressions.add(expression26); + if (expression27 != null) expressions.add(expression27); + if (expression28 != null) expressions.add(expression28); + if (expression29 != null) expressions.add(expression29); + if (expression30 != null) expressions.add(expression30); + return _NorExpression(expressions); + } + /// Joins array elements with a delimiter static Expression joinStatic( Expression arrayExpression, @@ -3106,6 +3309,219 @@ class _MapEntriesExpression extends FunctionExpression { } } +/// Serialized pipeline function `map_keys`. +class _MapKeysExpression extends FunctionExpression { + final Expression expression; + + _MapKeysExpression(this.expression); + + @override + String get name => 'map_keys'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Serialized pipeline function `map_values`. +class _MapValuesExpression extends FunctionExpression { + final Expression expression; + + _MapValuesExpression(this.expression); + + @override + String get name => 'map_values'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Serialized pipeline function `parent` (expression operand). +class _ParentExpression extends FunctionExpression { + final Expression expression; + + _ParentExpression(this.expression); + + @override + String get name => 'parent'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Serialized pipeline function `parent` ([DocumentReference] constant). +class _ParentFromDocumentRefExpression extends FunctionExpression { + final DocumentReference docRef; + + _ParentFromDocumentRefExpression(this.docRef); + + @override + String get name => 'parent'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'doc_ref': docRef.path, + }, + }; + } +} + +/// Serialized pipeline function `timestamp_diff`. +class _TimestampDiffExpression extends FunctionExpression { + final Expression end; + final Expression start; + final Expression unit; + + _TimestampDiffExpression(this.end, this.start, this.unit); + + @override + String get name => 'timestamp_diff'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'end': end.toMap(), + 'start': start.toMap(), + 'unit': unit.toMap(), + }, + }; + } +} + +/// Serialized pipeline function `timestamp_extract`. +class _TimestampExtractExpression extends FunctionExpression { + final Expression timestamp; + final Expression part; + final Expression? timezone; + + _TimestampExtractExpression(this.timestamp, this.part, this.timezone); + + @override + String get name => 'timestamp_extract'; + + @override + Map toMap() { + final args = { + 'timestamp': timestamp.toMap(), + 'part': part.toMap(), + }; + final tz = timezone; + if (tz != null) { + args['timezone'] = tz.toMap(); + } + return { + 'name': name, + 'args': args, + }; + } +} + +/// Serialized pipeline function `if_null`. +class _IfNullExpression extends FunctionExpression { + final Expression expression; + final Expression replacement; + + _IfNullExpression(this.expression, this.replacement); + + @override + String get name => 'if_null'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'replacement': replacement.toMap(), + }, + }; + } +} + +class _NorExpression extends BooleanExpression { + final List expressions; + + _NorExpression(this.expressions); + + @override + String get name => 'nor'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': expressions.map((e) => e.toMap()).toList(), + }, + }; + } +} + +/// Serialized pipeline function `switch_on`. +class _SwitchOnExpression extends FunctionExpression { + final List parts; + + _SwitchOnExpression(this.parts); + + @override + String get name => 'switch_on'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': parts.map((p) => (p as Expression).toMap()).toList(), + }, + }; + } +} + +/// Serialized pipeline function `coalesce`. +class _CoalesceExpression extends FunctionExpression { + final List expressions; + + _CoalesceExpression(this.expressions); + + @override + String get name => 'coalesce'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': expressions.map((e) => e.toMap()).toList(), + }, + }; + } +} + /// Serialized pipeline function `regex_find`. class _RegexFindExpression extends FunctionExpression { final Expression expression; 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..c6249cfed9df 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 @@ -106,9 +106,8 @@ void runPipelineExpressionsTests() { .limit(5) .execute(); expectResultCount(snapshot, 5); - final withTags = snapshot.result - .where((r) => r.data()!['tags_len'] == 2) - .toList(); + final withTags = + snapshot.result.where((r) => r.data()!['tags_len'] == 2).toList(); expect(withTags.length, 1); expect(withTags.first.data()!['score'], 50); }); @@ -594,12 +593,10 @@ void runPipelineExpressionsTests() { test('addFields arrayConcatMultiple', () async { final snapshot = await expressionsDocScore50( - Expression.field('arr') - .arrayConcatMultiple([ - [10], - [11], - ]) - .as('arr_concat_multi'), + Expression.field('arr').arrayConcatMultiple([ + [10], + [11], + ]).as('arr_concat_multi'), ); expectResultCount(snapshot, 1); expect(snapshot.result[0].data()!['arr_concat_multi'], [2, 4, 6, 10, 11]); @@ -854,8 +851,7 @@ void runPipelineExpressionsTests() { expect(e.message!, contains('Unsupported expression')); } }, - skip: - defaultTargetPlatform != TargetPlatform.iOS && + skip: defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.macOS, ); @@ -1084,5 +1080,158 @@ void runPipelineExpressionsTests() { .execute(); expectResultCount(snapshot, 2); }, skip: !kIsWeb); + + // map_keys, timestamp_diff, nor, switchOn, if_null, coalesce, parent — expressions seed row score 60 (see pipeline_seed). + test( + 'addFields map_keys and map_values on map field (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('m').mapKeys().as('mk'), + Expression.field('m').mapValues().as('mv'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + final data = snapshot.result.first.data()!; + expect((data['mk'] as List).map((e) => e as String).toSet(), {'x', 'y'}); + expect((data['mv'] as List).map((e) => e as int).toSet(), {10, 20}); + }); + + test( + 'addFields timestamp_diff between t_end and t_start (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('t_end') + .timestampDiff(Expression.field('t_start'), 'day') + .as('diff_days'), + Expression.timestampDiffStatic( + Expression.field('t_end'), + Expression.field('t_start'), + 'hour', + ).as('diff_hours'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'diff_days': 2, 'diff_hours': 48}, + ]); + }); + + test('addFields timestamp_extract on t_end (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('t_end').timestampExtract('month').as('end_month'), + Expression.field('t_end').timestampExtract('day').as('end_day'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'end_month': 1, 'end_day': 3}, + ]); + }); + + test('addFields nor — both arms false on seed doc (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.nor( + Expression.field('score').lessThanValue(0), + Expression.field('a').equalValue(9999), + ).as('nor_ok'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result.first.data()!['nor_ok'], true); + }); + + test('addFields switchOn — a=1 yields low (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.switchOn([ + Expression.field('a').greaterThanValue(50), + Expression.constant('high'), + Expression.field('a').greaterThanValue(5), + Expression.constant('mid'), + Expression.constant('low'), + ]).as('bucket'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result.first.data()!['bucket'], 'low'); + }); + + test('addFields if_null and coalesce (expressions score 60)', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('nope').ifNullValue(-1).as('if_null_n'), + Expression.coalesce( + Expression.field('title'), + Expression.field('missing'), + [Expression.constant('fb')], + ).as('coalesce_title'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'if_null_n': -1, 'coalesce_title': 'Expressions seed doc'}, + ]); + }); + + test( + 'addFields parentFromRef and Constant(ref).parent() (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.parentFromRef( + firestore.collection('pipeline-e2e').doc('e2e_parent_demo'), + ).as('parent_from_ref'), + Constant( + firestore.collection('pipeline-e2e').doc('e2e_parent_demo'), + ).parent().as('parent_of_const'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + final data = snapshot.result.first.data()!; + expect(data['parent_from_ref'], isNotNull); + expect(data['parent_of_const'], isNotNull); + }); }); } diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart index 95e52f52e5b4..b031040767ed 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart @@ -80,9 +80,8 @@ void runPipelineFilterSortTests() { .limit(10) .execute(); expectResultCount(snapshot, 3); - final categories = snapshot.result - .map((r) => r.data()!['category']) - .toList(); + final categories = + snapshot.result.map((r) => r.data()!['category']).toList(); expect(categories..sort(), ['a', 'b', 'c']); }); }); diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart index 7df0783a15c4..ad03e59221de 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart @@ -105,6 +105,9 @@ Future seedPipelineE2ECollections(FirebaseFirestore firestore) async { 'b': 2, 's': ' AbC ', 'm': {'x': 10, 'y': 20}, + 'title': 'Expressions seed doc', + 't_start': Timestamp.fromDate(DateTime.utc(2025, 1, 1, 12)), + 't_end': Timestamp.fromDate(DateTime.utc(2025, 1, 3, 12)), 'email': 'demo@example.com', 'pi': 3.14159, 'dup_tags': ['a', 'b', 'a'], diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart index dac760d19043..4d3d6aed7834 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart @@ -29,9 +29,8 @@ void runPipelineUnnestUnionTests() { .limit(10) .execute(); expectResultCount(snapshot, 5); - final tags = snapshot.result - .map((r) => r.data()!['tag'] as String) - .toList(); + final tags = + snapshot.result.map((r) => r.data()!['tag'] as String).toList(); expect(tags..sort(), [ 'dart', 'dart', diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart index 1875e5da6ece..74a40bc93218 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart @@ -124,6 +124,9 @@ class _PipelineExamplePageState extends State { 'b': 2, 's': ' AbC ', 'm': {'x': 10, 'y': 20}, + 'title': 'Expressions seed doc', + 't_start': Timestamp.fromDate(DateTime.utc(2025, 1, 1, 12)), + 't_end': Timestamp.fromDate(DateTime.utc(2025, 1, 3, 12)), }, { 'test': 'expressions', @@ -228,6 +231,9 @@ class _PipelineExamplePageState extends State { 'b': 2, 's': ' AbC ', 'm': {'x': 10, 'y': 20}, + 'title': 'Expressions seed doc', + 't_start': Timestamp.fromDate(DateTime.utc(2025, 1, 1, 12)), + 't_end': Timestamp.fromDate(DateTime.utc(2025, 1, 3, 12)), }, { 'test': 'expressions', @@ -304,162 +310,168 @@ class _PipelineExamplePageState extends State { // 1: where + limit Future _runPipeline1() => _runPipeline( - 'Pipeline 1: collection → where(score > 10) → limit(3)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('score').greaterThan(Expression.constant(10))) - .limit(3) - .execute(), - ); + 'Pipeline 1: collection → where(score > 10) → limit(3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.field('score').greaterThan(Expression.constant(10))) + .limit(3) + .execute(), + ); // 1b: execute with ExecuteOptions (indexMode: recommended) Future _runPipelineExecuteOptions() => _runPipeline( - 'Pipeline 1b: same as 1 but execute(options: ExecuteOptions(indexMode: recommended))', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('score').greaterThan(Expression.constant(10))) - .limit(3) - .execute( - options: const ExecuteOptions(indexMode: IndexMode.recommended), - ), - ); + 'Pipeline 1b: same as 1 but execute(options: ExecuteOptions(indexMode: recommended))', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.field('score').greaterThan(Expression.constant(10))) + .limit(3) + .execute( + options: const ExecuteOptions(indexMode: IndexMode.recommended), + ), + ); // 2: select Future _runPipeline2() => _runPipeline( - 'Pipeline 2: collection → where(year > 2022) → select(title, score, year) → limit(4)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('year').greaterThan(Expression.constant(2022))) - .select( - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ) - .limit(4) - .execute(), - ); + 'Pipeline 2: collection → where(year > 2022) → select(title, score, year) → limit(4)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.field('year').greaterThan(Expression.constant(2022))) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(4) + .execute(), + ); // 3: aggregate Future _runPipeline3() => _runPipeline( - 'Pipeline 3: collection → aggregate(sum, avg, count_all)', - () => _firestore - .pipeline() - .collection(_collectionId) - .aggregate( - Expression.field('score').sum().as('total_score'), - Expression.field('score').average().as('avg_score'), - CountAll().as('doc_count'), - ) - .execute(), - ); + 'Pipeline 3: collection → aggregate(sum, avg, count_all)', + () => _firestore + .pipeline() + .collection(_collectionId) + .aggregate( + Expression.field('score').sum().as('total_score'), + Expression.field('score').average().as('avg_score'), + CountAll().as('doc_count'), + ) + .execute(), + ); // 4: addFields Future _runPipeline4() => _runPipeline( - 'Pipeline 4: collection → addFields(score+100 as bonus) → limit(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'score', - ).add(Expression.constant(100)).as('bonus_score'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 4: collection → addFields(score+100 as bonus) → limit(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'score', + ).add(Expression.constant(100)).as('bonus_score'), + ) + .limit(2) + .execute(), + ); // 5: distinct Future _runPipeline5() => _runPipeline( - 'Pipeline 5: collection → distinct(category) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .distinct(Expression.field('category')) - .limit(5) - .execute(), - ); + 'Pipeline 5: collection → distinct(category) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .distinct(Expression.field('category')) + .limit(5) + .execute(), + ); // 6: offset Future _runPipeline6() => _runPipeline( - 'Pipeline 6: collection → limit(4) → offset(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(4) - .offset(2) - .execute(), - ); + 'Pipeline 6: collection → limit(4) → offset(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(4) + .offset(2) + .execute(), + ); // 7: removeFields Future _runPipeline7() => _runPipeline( - 'Pipeline 7: collection → removeFields(category) → limit(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .removeFields('category') - .limit(2) - .execute(), - ); + 'Pipeline 7: collection → removeFields(category) → limit(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .removeFields('category') + .limit(2) + .execute(), + ); // 8: replaceWith Future _runPipeline8() => _runPipeline( - 'Pipeline 8: collection → replaceWith(constant) → limit(1)', - () => _firestore - .pipeline() - .collection(_collectionId) - .replaceWith(Expression.field('items')) - // .limit(1) - .execute(), - ); + 'Pipeline 8: collection → replaceWith(constant) → limit(1)', + () => _firestore + .pipeline() + .collection(_collectionId) + .replaceWith(Expression.field('items')) + // .limit(1) + .execute(), + ); // 9: sample Future _runPipeline9() => _runPipeline( - 'Pipeline 9: collection → sample(size: 3)', - () => _firestore - .pipeline() - .collection(_collectionId) - .sample(PipelineSample.withSize(3)) - .execute(), - ); + 'Pipeline 9: collection → sample(size: 3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .sample(PipelineSample.withSize(3)) + .execute(), + ); // 10: sort Future _runPipeline10() => _runPipeline( - 'Pipeline 10: collection → sort(score desc) → limit(3)', - () => _firestore - .pipeline() - .collection(_collectionId) - .sort(Expression.field('score').descending()) - .limit(3) - .execute(), - ); + 'Pipeline 10: collection → sort(score desc) → limit(3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .sort(Expression.field('score').descending()) + .limit(3) + .execute(), + ); // 11: aggregateStage with groups Future _runPipeline11() => _runPipeline( - 'Pipeline 11: collection → aggregateStage(groups: category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .aggregateWithOptions( - AggregateStageOptions( - accumulators: [ - Expression.field('score').sum().as('total'), - CountAll().as('count'), - ], - groups: [Expression.field('category')], - ), - ) - .execute(), - ); + 'Pipeline 11: collection → aggregateStage(groups: category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [ + Expression.field('score').sum().as('total'), + CountAll().as('count'), + ], + groups: [Expression.field('category')], + ), + ) + .execute(), + ); // 12: collectionGroup Future _runPipeline12() => _runPipeline( - 'Pipeline 12: collectionGroup → limit(2)', - () => - _firestore.pipeline().collectionGroup(_collectionId).limit(2).execute(), - ); + 'Pipeline 12: collectionGroup → limit(2)', + () => _firestore + .pipeline() + .collectionGroup(_collectionId) + .limit(2) + .execute(), + ); // 13: documents Future _runPipeline13() async { @@ -481,54 +493,54 @@ class _PipelineExamplePageState extends State { // 14: database Future _runPipeline14() => _runPipeline( - 'Pipeline 14: database() → limit(2)', - () => _firestore.pipeline().database().execute(), - ); + 'Pipeline 14: database() → limit(2)', + () => _firestore.pipeline().database().execute(), + ); // 15: findNearest (may fail without vector index) Future _runPipeline15() => _runPipeline( - 'Pipeline 15: collection → findNearest (needs vector index)', - () => _firestore - .pipeline() - .collection(_collectionId) - .findNearest( - Field('embedding'), - [0.1, 0.2, 0.3], - DistanceMeasure.cosine, - limit: 2, - ) - .execute(), - ); + 'Pipeline 15: collection → findNearest (needs vector index)', + () => _firestore + .pipeline() + .collection(_collectionId) + .findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + limit: 2, + ) + .execute(), + ); // 16: unnest Future _runPipeline16() => _runPipeline( - 'Pipeline 16: collection → where(has tags) → unnest(tags) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .unnest(Expression.field('tags'), 'index') - .limit(5) - .execute(), - ); + 'Pipeline 16: collection → where(has tags) → unnest(tags) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .unnest(Expression.field('tags'), 'index') + .limit(5) + .execute(), + ); // 17: union Future _runPipeline17() => _runPipeline( - 'Pipeline 17: collection limit 2 → union(collection offset 2 limit 2)', - () { - final p2 = _firestore - .pipeline() - .collection(_collectionId) - .offset(2) - .limit(2); - return _firestore - .pipeline() - .collection(_collectionId) - .limit(2) - .union(p2) - .execute(); - }, - ); + 'Pipeline 17: collection limit 2 → union(collection offset 2 limit 2)', + () { + final p2 = _firestore + .pipeline() + .collection(_collectionId) + .offset(2) + .limit(2); + return _firestore + .pipeline() + .collection(_collectionId) + .limit(2) + .union(p2) + .execute(); + }, + ); // 18: Constant — one addFields field per supported constant type Future _runPipeline18() { @@ -573,144 +585,147 @@ class _PipelineExamplePageState extends State { // 19: Expression.and Future _runPipeline19() => _runPipeline( - 'Pipeline 19: collection → where(and(score > 50, year >= 2022)) → select(title, score, year) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.and( - Expression.field('score').greaterThan(Expression.constant(20)), - Expression.field( - 'year', - ).greaterThanOrEqual(Expression.constant(2022)), - ), - ) - .select( - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 19: collection → where(and(score > 50, year >= 2022)) → select(title, score, year) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.and( + Expression.field('score').greaterThan(Expression.constant(20)), + Expression.field( + 'year', + ).greaterThanOrEqual(Expression.constant(2022)), + ), + ) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(5) + .execute(), + ); // 20: Expression.or Future _runPipeline20() => _runPipeline( - 'Pipeline 20: collection → where(or(score > 80, year < 2021)) → select(title, score, year) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.or( - Expression.field('score').greaterThan(Expression.constant(30)), - Expression.field('year').lessThan(Expression.constant(2022)), - ), - ) - .select( - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 20: collection → where(or(score > 80, year < 2021)) → select(title, score, year) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.or( + Expression.field('score').greaterThan(Expression.constant(30)), + Expression.field('year').lessThan(Expression.constant(2022)), + ), + ) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(5) + .execute(), + ); // 20b: Expression.not (same pattern as pipeline_expressions_e2e "where with not") Future _runPipeline20b() => _runPipeline( - 'Pipeline 20b: where(test=expressions) + NOT(score>=60) + sort(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where( - Expression.not( - Expression.field( - 'score', - ).greaterThanOrEqual(Expression.constant(60)), - ), - ) - .sort(Expression.field('score').ascending()) - .execute(), - ); + 'Pipeline 20b: where(test=expressions) + NOT(score>=60) + sort(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.not( + Expression.field( + 'score', + ).greaterThanOrEqual(Expression.constant(60)), + ), + ) + .sort(Expression.field('score').ascending()) + .execute(), + ); // 21: arrayContainsAny Future _runPipeline21() => _runPipeline( - 'Pipeline 21: collection → where(tags arrayContainsAny [x, z]) → select(title, tags) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').arrayContainsAny(['x', 'z'])) - .select(Expression.field('title'), Expression.field('tags')) - .limit(5) - .execute(), - ); + 'Pipeline 21: collection → where(tags arrayContainsAny [x, z]) → select(title, tags) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').arrayContainsAny(['x', 'z'])) + .select(Expression.field('title'), Expression.field('tags')) + .limit(5) + .execute(), + ); // ── New expression examples (22+) ───────────────────────────────────── // 22: concat Future _runPipeline22() => _runPipeline( - 'Pipeline 22: addFields concat(title, " | ", category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'title', - ).concat([' | ', Expression.field('category')]).as('title_category'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 22: addFields concat(title, " | ", category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'title', + ).concat([' | ', Expression.field('category')]).as( + 'title_category'), + ) + .limit(3) + .execute(), + ); // 23: length (string) Future _runPipeline23() => _runPipeline( - 'Pipeline 23: addFields title.length()', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('title').length().as('title_len')) - .limit(4) - .execute(), - ); + 'Pipeline 23: addFields title.length()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('title').length().as('title_len')) + .limit(4) + .execute(), + ); // 24: toLowerCase / toUpperCase Future _runPipeline24() => _runPipeline( - 'Pipeline 24: addFields toLowerCase(title), toUpperCase(category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('title').toLowerCase().as('title_lower'), - Expression.field('category').toUpperCase().as('category_upper'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 24: addFields toLowerCase(title), toUpperCase(category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').toLowerCase().as('title_lower'), + Expression.field('category').toUpperCase().as('category_upper'), + ) + .limit(3) + .execute(), + ); // 25: trim Future _runPipeline25() => _runPipeline( - 'Pipeline 25: where(has title) → addFields trim(title)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').exists()) - .addFields(Expression.field('title').trim().as('title_trimmed')) - .limit(5) - .execute(), - ); + 'Pipeline 25: where(has title) → addFields trim(title)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').exists()) + .addFields(Expression.field('title').trim().as('title_trimmed')) + .limit(5) + .execute(), + ); // 26: substring Future _runPipeline26() => _runPipeline( - 'Pipeline 26: addFields substring(title, 0, 5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('title').substringLiteral(0, 5).as('title_prefix'), - ) - .limit(4) - .execute(), - ); + 'Pipeline 26: addFields substring(title, 0, 5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title') + .substringLiteral(0, 5) + .as('title_prefix'), + ) + .limit(4) + .execute(), + ); // 27: stringReplaceAll // Future _runPipeline27() => _runPipeline( @@ -729,487 +744,660 @@ class _PipelineExamplePageState extends State { // 28: split Future _runPipeline28() => _runPipeline( - 'Pipeline 28: addFields split(title, " ")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('title').splitLiteral(' ').as('title_parts'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 28: addFields split(title, " ")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').splitLiteral(' ').as('title_parts'), + ) + .limit(3) + .execute(), + ); // 29: join Future _runPipeline29() => _runPipeline( - 'Pipeline 29: where(has tags) → addFields join(tags, "-")', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .addFields(Expression.field('tags').joinLiteral('-').as('tags_joined')) - .limit(3) - .execute(), - ); + 'Pipeline 29: where(has tags) → addFields join(tags, "-")', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields( + Expression.field('tags').joinLiteral('-').as('tags_joined')) + .limit(3) + .execute(), + ); // 30: if_absent Future _runPipeline30() => _runPipeline( - 'Pipeline 30: addFields if_absent(optional_field, "default")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'optional_field', - ).ifAbsentValue('default').as('opt_or_default'), - ) - .limit(4) - .execute(), - ); + 'Pipeline 30: addFields if_absent(optional_field, "default")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'optional_field', + ).ifAbsentValue('default').as('opt_or_default'), + ) + .limit(4) + .execute(), + ); // 30b: if_error (e.g. safe divide) Future _runPipeline30b() => _runPipeline( - 'Pipeline 30b: addFields score/0 with ifError("N/A")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'score', - ).divide(Expression.constant(0)).ifErrorValue('N/A').as('safe_ratio'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 30b: addFields score/0 with ifError("N/A")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'score', + ) + .divide(Expression.constant(0)) + .ifErrorValue('N/A') + .as('safe_ratio'), + ) + .limit(2) + .execute(), + ); // 31: conditional Future _runPipeline31() => _runPipeline( - 'Pipeline 31: addFields conditional(score > 20, "high", "low")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.conditionalValues( - Expression.field('score').greaterThan(Expression.constant(20)), - 'high', - 'low', - ).as('score_tier'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 31: addFields conditional(score > 20, "high", "low")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.conditionalValues( + Expression.field('score').greaterThan(Expression.constant(20)), + 'high', + 'low', + ).as('score_tier'), + ) + .limit(5) + .execute(), + ); // 32: document_id (current document ID) Future _runPipeline32() => _runPipeline( - 'Pipeline 32: addFields documentId()', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('__path__').documentId().as('doc_id')) - .limit(3) - .execute(), - ); + 'Pipeline 32: addFields documentId()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('__path__').documentId().as('doc_id')) + .limit(3) + .execute(), + ); // 33: collection_id (current collection ID) Future _runPipeline33() => _runPipeline( - 'Pipeline 33: addFields collectionId()', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('__path__').collectionId().as('coll_id')) - .limit(2) - .execute(), - ); + 'Pipeline 33: addFields collectionId()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('__path__').collectionId().as('coll_id')) + .limit(2) + .execute(), + ); // 34: map_get, map_keys, map_values Future _runPipeline34() => _runPipeline( - 'Pipeline 34: where(has items) → addFields mapGet, mapKeys, mapValues', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('items').exists()) - .addFields(Expression.field('items').mapGetLiteral('a').as('items_a')) - .limit(2) - .execute(), - ); + 'Pipeline 34: where(has items) → mapGet, mapKeys, mapValues', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('items').exists()) + .addFields( + Expression.field('items').mapGetLiteral('a').as('items_a'), + Expression.field('items').mapKeys().as('items_keys'), + Expression.field('items').mapValues().as('items_vals'), + ) + .limit(2) + .execute(), + ); // 35: current_timestamp, timestamp_add, timestamp_subtract, timestamp_truncate Future _runPipeline35() => _runPipeline( - 'Pipeline 35: addFields currentTimestamp, timestampAdd(1 day)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.currentTimestamp().as('now'), - Expression.timestampAddLiteral( - Expression.currentTimestamp(), - 'day', - 1, - ).as('tomorrow'), - ) - .limit(1) - .execute(), - ); + 'Pipeline 35: addFields currentTimestamp, timestampAdd(1 day)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.currentTimestamp().as('now'), + Expression.timestampAddLiteral( + Expression.currentTimestamp(), + 'day', + 1, + ).as('tomorrow'), + ) + .limit(1) + .execute(), + ); // 36: abs Future _runPipeline36() => _runPipeline( - 'Pipeline 36: addFields abs(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('score').abs().as('score_abs')) - .limit(5) - .execute(), - ); + 'Pipeline 36: addFields abs(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').abs().as('score_abs')) + .limit(5) + .execute(), + ); // 37: array_length Future _runPipeline37() => _runPipeline( - 'Pipeline 37: where(has tags) → addFields arrayLength(tags)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .addFields(Expression.field('tags').arrayLength().as('tags_len')) - .limit(5) - .execute(), - ); + 'Pipeline 37: where(has tags) → addFields arrayLength(tags)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields(Expression.field('tags').arrayLength().as('tags_len')) + .limit(5) + .execute(), + ); Future _runPipeline37b() => _runPipeline( - 'Pipeline 37b: where(has scores) → addFields arraySum(scores)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('scores').exists()) - .addFields(Expression.field('scores').arraySum().as('scores_total')) - .limit(3) - .execute(), - ); + 'Pipeline 37b: where(has scores) → addFields arraySum(scores)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('scores').exists()) + .addFields(Expression.field('scores').arraySum().as('scores_total')) + .limit(3) + .execute(), + ); // 38: array_concat Future _runPipeline38() => _runPipeline( - 'Pipeline 38: where(has tags) → addFields arrayConcat(tags, [extra])', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .addFields( - Expression.field('tags').arrayConcat(['extra']).as('tags_extended'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 38: where(has tags) → addFields arrayConcat(tags, [extra])', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields( + Expression.field('tags') + .arrayConcat(['extra']).as('tags_extended'), + ) + .limit(2) + .execute(), + ); // 40: array (construct) Future _runPipeline40() => _runPipeline( - 'Pipeline 40: addFields array([title, score, year])', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.array([ - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ]).as('tuple'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 40: addFields array([title, score, year])', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.array([ + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ]).as('tuple'), + ) + .limit(2) + .execute(), + ); // 41: map (construct) Future _runPipeline41() => _runPipeline( - 'Pipeline 41: addFields map({ t: title, s: score })', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.map({ - 't': Expression.field('title'), - 's': Expression.field('score'), - }).as('mini_map'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 41: addFields map({ t: title, s: score })', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.map({ + 't': Expression.field('title'), + 's': Expression.field('score'), + }).as('mini_map'), + ) + .limit(2) + .execute(), + ); // 42: array_contains_all (values list) Future _runPipeline42() => _runPipeline( - 'Pipeline 42: where(tags arrayContainsAll [x, y]) → select title, tags', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').arrayContainsAll(['x', 'y'])) - .select(Expression.field('title'), Expression.field('tags')) - .limit(5) - .execute(), - ); + 'Pipeline 42: where(tags arrayContainsAll [x, y]) → select title, tags', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').arrayContainsAll(['x', 'y'])) + .select(Expression.field('title'), Expression.field('tags')) + .limit(5) + .execute(), + ); // 43: equal_any (IN) Future _runPipeline43() => _runPipeline( - 'Pipeline 43: where(score equalAny [10, 25, 40]) → select title, score', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.equalAny(Expression.field('score'), [10, 25, 40])) - .select(Expression.field('title'), Expression.field('score')) - .limit(5) - .execute(), - ); + 'Pipeline 43: where(score equalAny [10, 25, 40]) → select title, score', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.equalAny(Expression.field('score'), [10, 25, 40])) + .select(Expression.field('title'), Expression.field('score')) + .limit(5) + .execute(), + ); // 44: not_equal_any (NOT IN) Future _runPipeline44() => _runPipeline( - 'Pipeline 44: where(category notEqualAny [news]) → select title, category', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.notEqualAny(Expression.field('category'), ['news'])) - .select(Expression.field('title'), Expression.field('category')) - .limit(5) - .execute(), - ); + 'Pipeline 44: where(category notEqualAny [news]) → select title, category', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.notEqualAny(Expression.field('category'), ['news'])) + .select(Expression.field('title'), Expression.field('category')) + .limit(5) + .execute(), + ); // 45: asBoolean (coerce numeric to boolean) Future _runPipeline45() => _runPipeline( - 'Pipeline 45: addFields asBoolean(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('score').asBoolean().as('score_bool')) - .limit(4) - .execute(), - ); + 'Pipeline 45: addFields asBoolean(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').asBoolean().as('score_bool')) + .limit(4) + .execute(), + ); // 46: isError (missing field vs divide-by-zero) Future _runPipeline46() => _runPipeline( - 'Pipeline 46: addFields isError(missing field), isError(score/0)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('missing_field').isError().as('missing_is_err'), - Expression.field( - 'score', - ).divide(Expression.constant(0)).isError().as('div0_is_err'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 46: addFields isError(missing field), isError(score/0)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('missing_field').isError().as('missing_is_err'), + Expression.field( + 'score', + ).divide(Expression.constant(0)).isError().as('div0_is_err'), + ) + .limit(3) + .execute(), + ); // ── New pipeline expressions (regex, map, string, array, agg) ─────────── Future _runPipeline47() => _runPipeline( - 'Pipeline 47: where(has email) → addFields regexFind(@.+)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('email').exists()) - .addFields(Expression.field('email').regexFind('@.+').as('at_domain')) - .limit(5) - .execute(), - ); + 'Pipeline 47: where(has email) → addFields regexFind(@.+)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('email').exists()) + .addFields( + Expression.field('email').regexFind('@.+').as('at_domain')) + .limit(5) + .execute(), + ); Future _runPipeline48() => _runPipeline( - 'Pipeline 48: where(has email) → addFields regexFindAll([a-z]+)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('email').exists()) - .addFields( - Expression.field('email').regexFindAll('[a-z]+').as('word_chunks'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 48: where(has email) → addFields regexFindAll([a-z]+)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('email').exists()) + .addFields( + Expression.field('email') + .regexFindAll('[a-z]+') + .as('word_chunks'), + ) + .limit(5) + .execute(), + ); Future _runPipeline49() => _runPipeline( - 'Pipeline 49: test=expressions + has s → stringReplaceOne, stringIndexOf, stringRepeat', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.and( - Expression.field('test').equalValue('expressions'), - Expression.field('s').exists(), - ), - ) - .addFields( - Expression.field( - 's', - ).stringReplaceOneLiteral('A', 'Z').as('s_replace_one'), - Expression.field('s').stringIndexOf('y').as('idx_y'), - Expression.field('s').stringRepeat(2).as('s_twice'), - ) - .limit(8) - .execute(), - ); + 'Pipeline 49: test=expressions + has s → stringReplaceOne, stringIndexOf, stringRepeat', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.and( + Expression.field('test').equalValue('expressions'), + Expression.field('s').exists(), + ), + ) + .addFields( + Expression.field( + 's', + ).stringReplaceOneLiteral('A', 'Z').as('s_replace_one'), + Expression.field('s').stringIndexOf('y').as('idx_y'), + Expression.field('s').stringRepeat(2).as('s_twice'), + ) + .limit(8) + .execute(), + ); Future _runPipeline50() => _runPipeline( - 'Pipeline 50: title " Padded " → ltrim, rtrim', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue(' Padded ')) - .addFields( - Expression.field('title').ltrim().as('lt'), - Expression.field('title').rtrim().as('rt'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 50: title " Padded " → ltrim, rtrim', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue(' Padded ')) + .addFields( + Expression.field('title').ltrim().as('lt'), + Expression.field('title').rtrim().as('rt'), + ) + .limit(3) + .execute(), + ); Future _runPipeline51() => _runPipeline( - 'Pipeline 51: where(has items) → mapSet(z), mapEntries()', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('items').exists()) - .addFields( - Expression.field('items').mapSet('z', 'added').as('items_plus'), - Expression.field('items').mapEntries().as('items_entries'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 51: where(has items) → mapSet(z), mapEntries()', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('items').exists()) + .addFields( + Expression.field('items').mapSet('z', 'added').as('items_plus'), + Expression.field('items').mapEntries().as('items_entries'), + ) + .limit(3) + .execute(), + ); Future _runPipeline52() => _runPipeline( - 'Pipeline 52: addFields type(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('score').type().as('score_type')) - .limit(6) - .execute(), - ); + 'Pipeline 52: addFields type(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').type().as('score_type')) + .limit(6) + .execute(), + ); Future _runPipeline53() => _runPipeline( - 'Pipeline 53: where(score isType int64) → select title, score', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('score').isType(Type.int64)) - .select(Expression.field('title'), Expression.field('score')) - .limit(8) - .execute(), - ); + 'Pipeline 53: where(score isType int64) → select title, score', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('score').isType(Type.int64)) + .select(Expression.field('title'), Expression.field('score')) + .limit(8) + .execute(), + ); Future _runPipeline54() => _runPipeline( - 'Pipeline 54: where(has pi) → trunc(pi), trunc(2dp), rand()', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('pi').exists()) - .addFields( - Expression.field('pi').trunc().as('pi_trunc'), - Expression.field('pi').trunc(Expression.constant(2)).as('pi_2'), - Expression.rand().as('rnd'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 54: where(has pi) → trunc(pi), trunc(2dp), rand()', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('pi').exists()) + .addFields( + Expression.field('pi').trunc().as('pi_trunc'), + Expression.field('pi').trunc(Expression.constant(2)).as('pi_2'), + Expression.rand().as('rnd'), + ) + .limit(3) + .execute(), + ); Future _runPipeline55() => _runPipeline( - 'Pipeline 55: title Item G → arrayFirst, arrayLast(tags)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue('Item G')) - .addFields( - Expression.field('tags').arrayFirst().as('tag_first'), - Expression.field('tags').arrayLast().as('tag_last'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 55: title Item G → arrayFirst, arrayLast(tags)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue('Item G')) + .addFields( + Expression.field('tags').arrayFirst().as('tag_first'), + Expression.field('tags').arrayLast().as('tag_last'), + ) + .limit(3) + .execute(), + ); Future _runPipeline56() => _runPipeline( - 'Pipeline 56: Item G → arrayFirstN(2), arrayLastN(2)(tags)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue('Item G')) - .addFields( - Expression.field('tags').arrayFirstN(2).as('tags_head'), - Expression.field('tags').arrayLastN(2).as('tags_tail'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 56: Item G → arrayFirstN(2), arrayLastN(2)(tags)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue('Item G')) + .addFields( + Expression.field('tags').arrayFirstN(2).as('tags_head'), + Expression.field('tags').arrayLastN(2).as('tags_tail'), + ) + .limit(3) + .execute(), + ); Future _runPipeline57() => _runPipeline( - 'Pipeline 57: where(has scores) → arrayMaximum, arrayMinimum', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('scores').exists()) - .addFields( - Expression.field('scores').arrayMaximum().as('smax'), - Expression.field('scores').arrayMinimum().as('smin'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 57: where(has scores) → arrayMaximum, arrayMinimum', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('scores').exists()) + .addFields( + Expression.field('scores').arrayMaximum().as('smax'), + Expression.field('scores').arrayMinimum().as('smin'), + ) + .limit(3) + .execute(), + ); Future _runPipeline58() => _runPipeline( - 'Pipeline 58: where(has scores) → arrayMaximumN(2), arrayMinimumN(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('scores').exists()) - .addFields( - Expression.field('scores').arrayMaximumN(2).as('top2'), - Expression.field('scores').arrayMinimumN(2).as('bottom2'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 58: where(has scores) → arrayMaximumN(2), arrayMinimumN(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('scores').exists()) + .addFields( + Expression.field('scores').arrayMaximumN(2).as('top2'), + Expression.field('scores').arrayMinimumN(2).as('bottom2'), + ) + .limit(3) + .execute(), + ); Future _runPipeline59() => _runPipeline( - 'Pipeline 59: Dup Tags → arrayIndexOf(a), arrayLastIndexOf(a), arrayIndexOfAll(a)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue('Dup Tags')) - .addFields( - Expression.field('tags').arrayIndexOf('a').as('idx_first_a'), - Expression.field('tags').arrayLastIndexOf('a').as('idx_last_a'), - Expression.field('tags').arrayIndexOfAll('a').as('all_a'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 59: Dup Tags → arrayIndexOf(a), arrayLastIndexOf(a), arrayIndexOfAll(a)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue('Dup Tags')) + .addFields( + Expression.field('tags').arrayIndexOf('a').as('idx_first_a'), + Expression.field('tags').arrayLastIndexOf('a').as('idx_last_a'), + Expression.field('tags').arrayIndexOfAll('a').as('all_a'), + ) + .limit(3) + .execute(), + ); Future _runPipeline60() => _runPipeline( - 'Pipeline 60: aggregate first(score), last(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(50) - .aggregate( - Expression.field('score').first().as('first_score'), - Expression.field('score').last().as('last_score'), - ) - .execute(), - ); + 'Pipeline 60: aggregate first(score), last(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(50) + .aggregate( + Expression.field('score').first().as('first_score'), + Expression.field('score').last().as('last_score'), + ) + .execute(), + ); Future _runPipeline61() => _runPipeline( - 'Pipeline 61: limit 25 → aggregate array_agg(title)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(25) - .aggregate(Expression.field('title').arrayAgg().as('all_titles')) - .execute(), - ); + 'Pipeline 61: limit 25 → aggregate array_agg(title)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(25) + .aggregate(Expression.field('title').arrayAgg().as('all_titles')) + .execute(), + ); Future _runPipeline62() => _runPipeline( - 'Pipeline 62: limit 25 → aggregate array_agg_distinct(category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(25) - .aggregate(Expression.field('category').arrayAggDistinct().as('cats')) - .execute(), - ); + 'Pipeline 62: limit 25 → aggregate array_agg_distinct(category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(25) + .aggregate( + Expression.field('category').arrayAggDistinct().as('cats')) + .execute(), + ); + + /// map_keys, timestamp_diff, nor, switchOn, if_null, coalesce, parent — expressions row score 60. + Future _runPipeline63() => _runPipeline( + 'Pipeline 63: test=expressions score=60 → maps, ts diff/extract, nor, switchOn, ifNull, coalesce, parent', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('m').mapKeys().as('m_keys'), + Expression.field('m').mapValues().as('m_vals'), + Expression.field('t_end') + .timestampDiff(Expression.field('t_start'), 'day') + .as('diff_days_field'), + Expression.timestampDiffStatic( + Expression.field('t_end'), + Expression.field('t_start'), + 'hour', + ).as('diff_hours_static'), + Expression.field('t_end') + .timestampExtract('month') + .as('end_month'), + Expression.currentTimestamp() + .timestampExtract('year') + .as('now_year'), + Expression.currentTimestamp() + .timestampExtract('hour', 'UTC') + .as('now_hour_utc'), + Expression.field('nope').ifNullValue(-1).as('if_null_n'), + Expression.coalesce( + Expression.field('title'), + Expression.field('missing'), + [Expression.constant('fb')], + ).as('coalesce_title'), + Expression.nor( + Expression.field('score').lessThanValue(0), + Expression.field('a').equalValue(9999), + ).as('nor_ok'), + Expression.switchOn([ + Expression.field('a').greaterThanValue(50), + Expression.constant('high'), + Expression.field('a').greaterThanValue(5), + Expression.constant('mid'), + Expression.constant('low'), + ]).as('bucket'), + Expression.parentFromRef( + _firestore.collection(_collectionId).doc('demo_parent'), + ).as('parent_from_ref'), + Constant( + _firestore.collection(_collectionId).doc('demo_parent'), + ).parent().as('parent_of_const_ref'), + ) + .limit(5) + .execute(), + ); + + Future _runPipeline64() => _runPipeline( + 'Pipeline 64: test=expressions score=60 → mapKeys, mapValues only', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('m').mapKeys().as('m_keys'), + Expression.field('m').mapValues().as('m_vals'), + ) + .limit(3) + .execute(), + ); + + Future _runPipeline65() => _runPipeline( + 'Pipeline 65: test=expressions score=60 → timestampDiff + timestampExtract', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('t_end') + .timestampDiff(Expression.field('t_start'), 'day') + .as('diff_days'), + Expression.timestampDiffStatic( + Expression.field('t_end'), + Expression.field('t_start'), + 'hour', + ).as('diff_hours'), + Expression.field('t_end').timestampExtract('day').as('end_day'), + Expression.currentTimestamp() + .timestampExtract('year') + .as('now_year'), + ) + .limit(3) + .execute(), + ); + + Future _runPipeline66() => _runPipeline( + 'Pipeline 66: test=expressions score=60 → Expression.nor only (two falsey arms → true)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.nor( + Expression.field('score').lessThanValue(0), + Expression.field('a').equalValue(9999), + ).as('nor_ok'), + ) + .limit(3) + .execute(), + ); + + Future _runPipeline67() => _runPipeline( + 'Pipeline 67: test=expressions score=60 → Expression.switchOn only (a=1 → low)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.switchOn([ + Expression.field('a').greaterThanValue(50), + Expression.constant('high'), + Expression.field('a').greaterThanValue(5), + Expression.constant('mid'), + Expression.constant('low'), + ]).as('bucket'), + ) + .limit(3) + .execute(), + ); + + Future _runPipeline68() => _runPipeline( + 'Pipeline 68: test=expressions score=60 → ifNull, coalesce, parentFromRef, parent(expr)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('nope').ifNullValue(-1).as('if_null_n'), + Expression.coalesce( + Expression.field('title'), + Expression.field('missing'), + [Expression.constant('fb')], + ).as('coalesce_title'), + Expression.parentFromRef( + _firestore.collection(_collectionId).doc('demo_parent'), + ).as('parent_from_ref'), + Constant( + _firestore.collection(_collectionId).doc('demo_parent'), + ).parent().as('parent_of_const_ref'), + ) + .limit(3) + .execute(), + ); @override Widget build(BuildContext context) { @@ -1325,6 +1513,12 @@ class _PipelineExamplePageState extends State { _btn('60: agg first/last', _runPipeline60), _btn('61: agg array_agg', _runPipeline61), _btn('62: agg array_agg_dist', _runPipeline62), + _btn('63: Option A (all)', _runPipeline63), + _btn('64: OA map keys/vals', _runPipeline64), + _btn('65: OA timestamps', _runPipeline65), + _btn('66: OA nor', _runPipeline66), + _btn('67: OA switchOn', _runPipeline67), + _btn('68: OA ifNull/parent', _runPipeline68), ], ), ), 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..bbe5fe486adb 100644 --- a/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart @@ -1004,6 +1004,96 @@ void main() { }); }); + group('Option A: extended pipeline expressions', () { + test('mapKeys / mapValues serialize correctly', () { + expect(Field('m').mapKeys().toMap()['name'], 'map_keys'); + expect(Field('m').mapValues().toMap()['name'], 'map_values'); + }); + + test('parent() serializes correctly', () { + expect(Field('ref').parent().toMap(), { + 'name': 'parent', + 'args': {'expression': Field('ref').toMap()}, + }); + }); + + test('parentFromRef serializes correctly', () { + final ref = firestore.collection('c').doc('d'); + expect(Expression.parentFromRef(ref).toMap(), { + 'name': 'parent', + 'args': {'doc_ref': 'c/d'}, + }); + }); + + test('timestampDiffStatic serializes correctly', () { + final expr = Expression.timestampDiffStatic( + Field('end'), + Field('start'), + 'day', + ); + expect(expr.toMap(), { + 'name': 'timestamp_diff', + 'args': { + 'end': Field('end').toMap(), + 'start': Field('start').toMap(), + 'unit': Constant('day').toMap(), + }, + }); + }); + + test('timestampExtract serializes correctly', () { + final expr = Field('created').timestampExtract('year'); + expect(expr.toMap()['name'], 'timestamp_extract'); + expect(expr.toMap()['args']['part'], Constant('year').toMap()); + }); + + test('timestampExtract with timezone serializes correctly', () { + final expr = Field('created').timestampExtract('hour', 'UTC'); + expect(expr.toMap()['args']['timezone'], Constant('UTC').toMap()); + }); + + test('if_null serializes correctly', () { + final expr = Field('x').ifNullValue('fallback'); + expect(expr.toMap(), { + 'name': 'if_null', + 'args': { + 'expression': Field('x').toMap(), + 'replacement': Constant('fallback').toMap(), + }, + }); + }); + + test('nor serializes correctly', () { + final expr = Expression.nor( + Field('a').equalValue(1), + Field('b').equalValue(2), + ); + expect(expr.toMap()['name'], 'nor'); + }); + + test('coalesce serializes correctly', () { + final expr = Expression.coalesce(Field('a'), Field('b'), [Constant('c')]); + expect(expr.toMap()['name'], 'coalesce'); + expect((expr.toMap()['args']['expressions'] as List).length, 3); + }); + + test('switchOn serializes correctly', () { + final expr = Expression.switchOn([ + Field('x').greaterThanValue(0), + Constant('pos'), + Constant('zero'), + ]); + expect(expr.toMap()['name'], 'switch_on'); + }); + + test('switchOn rejects invalid parts', () { + expect( + () => Expression.switchOn([Field('x')]), + throwsA(isA()), + ); + }); + }); + group('Expression aggregate helpers', () { test('first() returns First aggregate', () { expect(Field('s').first().toMap()['name'], 'first'); 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..57a954f96beb 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 @@ -404,6 +404,15 @@ extension type PipelinesJsImpl._(JSObject _) implements JSObject { JSAny timestamp, JSString unit, JSAny amount); external ExpressionJsImpl timestampTruncate(JSAny timestamp, JSString unit, [JSString? timezone]); + external ExpressionJsImpl timestampDiff(JSAny end, JSAny start, JSAny unit); + external ExpressionJsImpl timestampExtract(JSAny timestamp, JSAny part, + [JSAny? timezone]); + external ExpressionJsImpl parent(JSAny documentRefOrExpression); + external ExpressionJsImpl ifNull(JSAny ifExpr, JSAny elseExpr); + external ExpressionJsImpl coalesce( + JSAny first, JSAny second, JSArray more); + external ExpressionJsImpl switchOn( + JSAny condition, JSAny result, JSArray others); external ExpressionJsImpl abs(JSAny expr); external ExpressionJsImpl arrayLength(JSAny array); external ExpressionJsImpl arraySum(JSAny expression); 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..337d083cb889 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,59 @@ class PipelineExpressionParserWeb { case 'array_index_of_all': return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) .arrayIndexOfAll(_expr(argsMap, 'element')); + case 'timestamp_diff': + return _pipelines.timestampDiff( + _expr(argsMap, 'end'), + _expr(argsMap, 'start'), + _expr(argsMap, 'unit'), + ); + case 'timestamp_extract': + { + final ts = _expr(argsMap, 'timestamp'); + final part = _expr(argsMap, 'part'); + final tzRaw = argsMap['timezone']; + if (tzRaw == null) { + return _pipelines.timestampExtract(ts, part); + } + final tzJs = tzRaw is String + ? tzRaw.toJS + : toExpression(tzRaw as Map); + return _pipelines.timestampExtract(ts, part, tzJs); + } + case 'parent': + { + final docRefPath = argsMap['doc_ref'] as String?; + if (docRefPath != null && docRefPath.isNotEmpty) { + final docRef = interop.doc(_jsFirestore as JSAny, docRefPath.toJS); + return _pipelines.parent(docRef); + } + return _pipelines.parent(_expr(argsMap, _kExpression)); + } + case 'if_null': + return _pipelines.ifNull( + _expr(argsMap, _kExpression), + _expr(argsMap, 'replacement'), + ); + case 'coalesce': + { + final exprMaps = argsMap['expressions'] as List?; + if (exprMaps == null || exprMaps.length < 2) { + throw UnsupportedError( + 'coalesce requires at least two expressions'); + } + final first = toExpression(exprMaps[0] as Map); + final second = toExpression(exprMaps[1] as Map); + if (exprMaps.length == 2) { + return _pipelines.coalesce(first, second, [].toJS); + } + final more = []; + for (var i = 2; i < exprMaps.length; i++) { + more.add(toExpression(exprMaps[i] as Map)); + } + return _pipelines.coalesce(first, second, more.toJS); + } + case 'switch_on': + return _switchOnToExpression(argsMap); default: throw FirebaseException( plugin: 'cloud_firestore', @@ -371,6 +424,37 @@ class PipelineExpressionParserWeb { } } + interop.ExpressionJsImpl _switchOnToExpression(Map argsMap) { + final exprMaps = argsMap['expressions'] as List?; + if (exprMaps == null || exprMaps.length < 2) { + throw UnsupportedError('switch_on requires at least two expressions'); + } + final n = exprMaps.length; + final first = toBooleanExpression(exprMaps[0] as Map); + if (first == null) { + throw UnsupportedError('switch_on requires a boolean condition'); + } + final second = toExpression(exprMaps[1] as Map); + if (n == 2) { + return _pipelines.switchOn(first, second, [].toJS); + } + final tail = []; + for (var i = 2; i < n; i++) { + if (n.isOdd && i == n - 1) { + tail.add(toExpression(exprMaps[i] as Map)); + } else if (i.isEven) { + final b = toBooleanExpression(exprMaps[i] as Map); + if (b == null) { + throw UnsupportedError('switch_on expected a boolean expression'); + } + tail.add(b); + } else { + tail.add(toExpression(exprMaps[i] as Map)); + } + } + return _pipelines.switchOn(first, second, tail.toJS); + } + // ── Boolean expressions ─────────────────────────────────────────────────── /// Converts a serialized boolean expression to a JS BooleanExpression. @@ -401,6 +485,7 @@ class PipelineExpressionParserWeb { case 'and': case 'or': case 'xor': + case 'nor': final exprMaps = argsMap['expressions'] as List?; if (exprMaps == null || exprMaps.isEmpty) return null; final exprs = exprMaps @@ -414,8 +499,10 @@ class PipelineExpressionParserWeb { result = _pipelines.and(result, exprs[i]); } else if (name == 'or') { result = _pipelines.or(result, exprs[i]); - } else { + } else if (name == 'xor') { result = _pipelines.xor(result, exprs[i]); + } else { + result = _pipelines.nor(result, exprs[i]); } } return result; From 699abd7f048498dac7e8adc59fd33eabad9a1c75 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Thu, 16 Apr 2026 14:12:03 +0000 Subject: [PATCH 2/8] fix --- .../lib/src/interop/firestore_interop.dart | 1 + .../src/pipeline_expression_parser_web.dart | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) 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 57a954f96beb..89e95292edb4 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 @@ -365,6 +365,7 @@ extension type PipelinesJsImpl._(JSObject _) implements JSObject { external JSAny and(JSAny a, JSAny b); external JSAny or(JSAny a, JSAny b); external JSAny xor(JSAny a, JSAny b); + external JSAny nor(JSAny a, JSAny b); external JSAny not(JSAny expr); // --- Existence / type checks --- 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 337d083cb889..6ffdf37f2f3a 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 @@ -413,6 +413,15 @@ class PipelineExpressionParserWeb { } case 'switch_on': return _switchOnToExpression(argsMap); + // Boolean combinators / `not` — used as value expressions in add_fields, + // select, etc. (e.g. `Expression.nor(...).as('x')`). Those stages call + // [toExpression] only; [where] uses [toBooleanExpression] directly. + case 'and': + case 'or': + case 'xor': + case 'nor': + case 'not': + return _expressionFromBooleanMap(map); default: throw FirebaseException( plugin: 'cloud_firestore', @@ -424,6 +433,24 @@ class PipelineExpressionParserWeb { } } + /// Builds a JS value [Expression] from a serialized boolean expression map. + /// + /// The Firebase JS pipeline API represents boolean expressions as values + /// where needed (e.g. aliased add_fields); [toBooleanExpression] already + /// constructs the correct interop objects. + interop.ExpressionJsImpl _expressionFromBooleanMap( + Map map, + ) { + final boolExpr = toBooleanExpression(map); + if (boolExpr == null) { + final n = map[_kName] as String? ?? '?'; + throw UnsupportedError( + 'Boolean expression $n requires at least one valid sub-expression', + ); + } + return boolExpr as interop.ExpressionJsImpl; + } + interop.ExpressionJsImpl _switchOnToExpression(Map argsMap) { final exprMaps = argsMap['expressions'] as List?; if (exprMaps == null || exprMaps.length < 2) { From 3edb90bddb1d7b69318c6c521db6571dc688cf52 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Fri, 17 Apr 2026 09:06:22 +0000 Subject: [PATCH 3/8] format --- .../pipeline/pipeline_expressions_e2e.dart | 274 ++++++++++-------- 1 file changed, 146 insertions(+), 128 deletions(-) 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 c6249cfed9df..a9e8c4f6b586 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 @@ -106,8 +106,9 @@ void runPipelineExpressionsTests() { .limit(5) .execute(); expectResultCount(snapshot, 5); - final withTags = - snapshot.result.where((r) => r.data()!['tags_len'] == 2).toList(); + final withTags = snapshot.result + .where((r) => r.data()!['tags_len'] == 2) + .toList(); expect(withTags.length, 1); expect(withTags.first.data()!['score'], 50); }); @@ -593,10 +594,12 @@ void runPipelineExpressionsTests() { test('addFields arrayConcatMultiple', () async { final snapshot = await expressionsDocScore50( - Expression.field('arr').arrayConcatMultiple([ - [10], - [11], - ]).as('arr_concat_multi'), + Expression.field('arr') + .arrayConcatMultiple([ + [10], + [11], + ]) + .as('arr_concat_multi'), ); expectResultCount(snapshot, 1); expect(snapshot.result[0].data()!['arr_concat_multi'], [2, 4, 6, 10, 11]); @@ -851,7 +854,8 @@ void runPipelineExpressionsTests() { expect(e.message!, contains('Unsupported expression')); } }, - skip: defaultTargetPlatform != TargetPlatform.iOS && + skip: + defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.macOS, ); @@ -1083,110 +1087,123 @@ void runPipelineExpressionsTests() { // map_keys, timestamp_diff, nor, switchOn, if_null, coalesce, parent — expressions seed row score 60 (see pipeline_seed). test( - 'addFields map_keys and map_values on map field (expressions score 60)', - () async { - final snapshot = await firestore - .pipeline() - .collection('pipeline-e2e') - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.field('m').mapKeys().as('mk'), - Expression.field('m').mapValues().as('mv'), - ) - .limit(1) - .execute(); - expectResultCount(snapshot, 1); - final data = snapshot.result.first.data()!; - expect((data['mk'] as List).map((e) => e as String).toSet(), {'x', 'y'}); - expect((data['mv'] as List).map((e) => e as int).toSet(), {10, 20}); - }); + 'addFields map_keys and map_values on map field (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('m').mapKeys().as('mk'), + Expression.field('m').mapValues().as('mv'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + final data = snapshot.result.first.data()!; + expect((data['mk'] as List).map((e) => e as String).toSet(), { + 'x', + 'y', + }); + expect((data['mv'] as List).map((e) => e as int).toSet(), {10, 20}); + }, + ); test( - 'addFields timestamp_diff between t_end and t_start (expressions score 60)', - () async { - final snapshot = await firestore - .pipeline() - .collection('pipeline-e2e') - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.field('t_end') - .timestampDiff(Expression.field('t_start'), 'day') - .as('diff_days'), - Expression.timestampDiffStatic( - Expression.field('t_end'), - Expression.field('t_start'), - 'hour', - ).as('diff_hours'), - ) - .limit(1) - .execute(); - expectResultCount(snapshot, 1); - expectResultsData(snapshot, [ - {'diff_days': 2, 'diff_hours': 48}, - ]); - }); + 'addFields timestamp_diff between t_end and t_start (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('t_end') + .timestampDiff(Expression.field('t_start'), 'day') + .as('diff_days'), + Expression.timestampDiffStatic( + Expression.field('t_end'), + Expression.field('t_start'), + 'hour', + ).as('diff_hours'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'diff_days': 2, 'diff_hours': 48}, + ]); + }, + ); - test('addFields timestamp_extract on t_end (expressions score 60)', - () async { - final snapshot = await firestore - .pipeline() - .collection('pipeline-e2e') - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.field('t_end').timestampExtract('month').as('end_month'), - Expression.field('t_end').timestampExtract('day').as('end_day'), - ) - .limit(1) - .execute(); - expectResultCount(snapshot, 1); - expectResultsData(snapshot, [ - {'end_month': 1, 'end_day': 3}, - ]); - }); + test( + 'addFields timestamp_extract on t_end (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field( + 't_end', + ).timestampExtract('month').as('end_month'), + Expression.field('t_end').timestampExtract('day').as('end_day'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'end_month': 1, 'end_day': 3}, + ]); + }, + ); - test('addFields nor — both arms false on seed doc (expressions score 60)', - () async { - final snapshot = await firestore - .pipeline() - .collection('pipeline-e2e') - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.nor( - Expression.field('score').lessThanValue(0), - Expression.field('a').equalValue(9999), - ).as('nor_ok'), - ) - .limit(1) - .execute(); - expectResultCount(snapshot, 1); - expect(snapshot.result.first.data()!['nor_ok'], true); - }); + test( + 'addFields nor — both arms false on seed doc (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.nor( + Expression.field('score').lessThanValue(0), + Expression.field('a').equalValue(9999), + ).as('nor_ok'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result.first.data()!['nor_ok'], true); + }, + ); - test('addFields switchOn — a=1 yields low (expressions score 60)', - () async { - final snapshot = await firestore - .pipeline() - .collection('pipeline-e2e') - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.switchOn([ - Expression.field('a').greaterThanValue(50), - Expression.constant('high'), - Expression.field('a').greaterThanValue(5), - Expression.constant('mid'), - Expression.constant('low'), - ]).as('bucket'), - ) - .limit(1) - .execute(); - expectResultCount(snapshot, 1); - expect(snapshot.result.first.data()!['bucket'], 'low'); - }); + test( + 'addFields switchOn — a=1 yields low (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.switchOn([ + Expression.field('a').greaterThanValue(50), + Expression.constant('high'), + Expression.field('a').greaterThanValue(5), + Expression.constant('mid'), + Expression.constant('low'), + ]).as('bucket'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result.first.data()!['bucket'], 'low'); + }, + ); test('addFields if_null and coalesce (expressions score 60)', () async { final snapshot = await firestore @@ -1211,27 +1228,28 @@ void runPipelineExpressionsTests() { }); test( - 'addFields parentFromRef and Constant(ref).parent() (expressions score 60)', - () async { - final snapshot = await firestore - .pipeline() - .collection('pipeline-e2e') - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.parentFromRef( - firestore.collection('pipeline-e2e').doc('e2e_parent_demo'), - ).as('parent_from_ref'), - Constant( - firestore.collection('pipeline-e2e').doc('e2e_parent_demo'), - ).parent().as('parent_of_const'), - ) - .limit(1) - .execute(); - expectResultCount(snapshot, 1); - final data = snapshot.result.first.data()!; - expect(data['parent_from_ref'], isNotNull); - expect(data['parent_of_const'], isNotNull); - }); + 'addFields parentFromRef and Constant(ref).parent() (expressions score 60)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.parentFromRef( + firestore.collection('pipeline-e2e').doc('e2e_parent_demo'), + ).as('parent_from_ref'), + Constant( + firestore.collection('pipeline-e2e').doc('e2e_parent_demo'), + ).parent().as('parent_of_const'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + final data = snapshot.result.first.data()!; + expect(data['parent_from_ref'], isNotNull); + expect(data['parent_of_const'], isNotNull); + }, + ); }); } From 4c8a02968f484090473fce5bfe08b982b7177d36 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Fri, 17 Apr 2026 13:49:51 +0000 Subject: [PATCH 4/8] fix formatting --- .../integration_test/pipeline/pipeline_filter_sort_e2e.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart index b031040767ed..95e52f52e5b4 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart @@ -80,8 +80,9 @@ void runPipelineFilterSortTests() { .limit(10) .execute(); expectResultCount(snapshot, 3); - final categories = - snapshot.result.map((r) => r.data()!['category']).toList(); + final categories = snapshot.result + .map((r) => r.data()!['category']) + .toList(); expect(categories..sort(), ['a', 'b', 'c']); }); }); From e7f2e34bf2c832ecc587183b48e7bf4e815a30f0 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Fri, 17 Apr 2026 14:07:45 +0000 Subject: [PATCH 5/8] fix formatting --- .../integration_test/pipeline/pipeline_unnest_union_e2e.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart index 4d3d6aed7834..dac760d19043 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart @@ -29,8 +29,9 @@ void runPipelineUnnestUnionTests() { .limit(10) .execute(); expectResultCount(snapshot, 5); - final tags = - snapshot.result.map((r) => r.data()!['tag'] as String).toList(); + final tags = snapshot.result + .map((r) => r.data()!['tag'] as String) + .toList(); expect(tags..sort(), [ 'dart', 'dart', From e08d85c91547de210b5039079be32d2e2f0db95e Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Fri, 17 Apr 2026 14:27:43 +0000 Subject: [PATCH 6/8] fix formatting --- .../pipeline_example/lib/main.dart | 1636 ++++++++--------- 1 file changed, 805 insertions(+), 831 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart index 74a40bc93218..b153cc335ddc 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart @@ -310,168 +310,162 @@ class _PipelineExamplePageState extends State { // 1: where + limit Future _runPipeline1() => _runPipeline( - 'Pipeline 1: collection → where(score > 10) → limit(3)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.field('score').greaterThan(Expression.constant(10))) - .limit(3) - .execute(), - ); + 'Pipeline 1: collection → where(score > 10) → limit(3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('score').greaterThan(Expression.constant(10))) + .limit(3) + .execute(), + ); // 1b: execute with ExecuteOptions (indexMode: recommended) Future _runPipelineExecuteOptions() => _runPipeline( - 'Pipeline 1b: same as 1 but execute(options: ExecuteOptions(indexMode: recommended))', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.field('score').greaterThan(Expression.constant(10))) - .limit(3) - .execute( - options: const ExecuteOptions(indexMode: IndexMode.recommended), - ), - ); + 'Pipeline 1b: same as 1 but execute(options: ExecuteOptions(indexMode: recommended))', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('score').greaterThan(Expression.constant(10))) + .limit(3) + .execute( + options: const ExecuteOptions(indexMode: IndexMode.recommended), + ), + ); // 2: select Future _runPipeline2() => _runPipeline( - 'Pipeline 2: collection → where(year > 2022) → select(title, score, year) → limit(4)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.field('year').greaterThan(Expression.constant(2022))) - .select( - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ) - .limit(4) - .execute(), - ); + 'Pipeline 2: collection → where(year > 2022) → select(title, score, year) → limit(4)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('year').greaterThan(Expression.constant(2022))) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(4) + .execute(), + ); // 3: aggregate Future _runPipeline3() => _runPipeline( - 'Pipeline 3: collection → aggregate(sum, avg, count_all)', - () => _firestore - .pipeline() - .collection(_collectionId) - .aggregate( - Expression.field('score').sum().as('total_score'), - Expression.field('score').average().as('avg_score'), - CountAll().as('doc_count'), - ) - .execute(), - ); + 'Pipeline 3: collection → aggregate(sum, avg, count_all)', + () => _firestore + .pipeline() + .collection(_collectionId) + .aggregate( + Expression.field('score').sum().as('total_score'), + Expression.field('score').average().as('avg_score'), + CountAll().as('doc_count'), + ) + .execute(), + ); // 4: addFields Future _runPipeline4() => _runPipeline( - 'Pipeline 4: collection → addFields(score+100 as bonus) → limit(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'score', - ).add(Expression.constant(100)).as('bonus_score'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 4: collection → addFields(score+100 as bonus) → limit(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'score', + ).add(Expression.constant(100)).as('bonus_score'), + ) + .limit(2) + .execute(), + ); // 5: distinct Future _runPipeline5() => _runPipeline( - 'Pipeline 5: collection → distinct(category) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .distinct(Expression.field('category')) - .limit(5) - .execute(), - ); + 'Pipeline 5: collection → distinct(category) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .distinct(Expression.field('category')) + .limit(5) + .execute(), + ); // 6: offset Future _runPipeline6() => _runPipeline( - 'Pipeline 6: collection → limit(4) → offset(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(4) - .offset(2) - .execute(), - ); + 'Pipeline 6: collection → limit(4) → offset(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(4) + .offset(2) + .execute(), + ); // 7: removeFields Future _runPipeline7() => _runPipeline( - 'Pipeline 7: collection → removeFields(category) → limit(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .removeFields('category') - .limit(2) - .execute(), - ); + 'Pipeline 7: collection → removeFields(category) → limit(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .removeFields('category') + .limit(2) + .execute(), + ); // 8: replaceWith Future _runPipeline8() => _runPipeline( - 'Pipeline 8: collection → replaceWith(constant) → limit(1)', - () => _firestore - .pipeline() - .collection(_collectionId) - .replaceWith(Expression.field('items')) - // .limit(1) - .execute(), - ); + 'Pipeline 8: collection → replaceWith(constant) → limit(1)', + () => _firestore + .pipeline() + .collection(_collectionId) + .replaceWith(Expression.field('items')) + // .limit(1) + .execute(), + ); // 9: sample Future _runPipeline9() => _runPipeline( - 'Pipeline 9: collection → sample(size: 3)', - () => _firestore - .pipeline() - .collection(_collectionId) - .sample(PipelineSample.withSize(3)) - .execute(), - ); + 'Pipeline 9: collection → sample(size: 3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .sample(PipelineSample.withSize(3)) + .execute(), + ); // 10: sort Future _runPipeline10() => _runPipeline( - 'Pipeline 10: collection → sort(score desc) → limit(3)', - () => _firestore - .pipeline() - .collection(_collectionId) - .sort(Expression.field('score').descending()) - .limit(3) - .execute(), - ); + 'Pipeline 10: collection → sort(score desc) → limit(3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .sort(Expression.field('score').descending()) + .limit(3) + .execute(), + ); // 11: aggregateStage with groups Future _runPipeline11() => _runPipeline( - 'Pipeline 11: collection → aggregateStage(groups: category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .aggregateWithOptions( - AggregateStageOptions( - accumulators: [ - Expression.field('score').sum().as('total'), - CountAll().as('count'), - ], - groups: [Expression.field('category')], - ), - ) - .execute(), - ); + 'Pipeline 11: collection → aggregateStage(groups: category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [ + Expression.field('score').sum().as('total'), + CountAll().as('count'), + ], + groups: [Expression.field('category')], + ), + ) + .execute(), + ); // 12: collectionGroup Future _runPipeline12() => _runPipeline( - 'Pipeline 12: collectionGroup → limit(2)', - () => _firestore - .pipeline() - .collectionGroup(_collectionId) - .limit(2) - .execute(), - ); + 'Pipeline 12: collectionGroup → limit(2)', + () => + _firestore.pipeline().collectionGroup(_collectionId).limit(2).execute(), + ); // 13: documents Future _runPipeline13() async { @@ -493,54 +487,54 @@ class _PipelineExamplePageState extends State { // 14: database Future _runPipeline14() => _runPipeline( - 'Pipeline 14: database() → limit(2)', - () => _firestore.pipeline().database().execute(), - ); + 'Pipeline 14: database() → limit(2)', + () => _firestore.pipeline().database().execute(), + ); // 15: findNearest (may fail without vector index) Future _runPipeline15() => _runPipeline( - 'Pipeline 15: collection → findNearest (needs vector index)', - () => _firestore - .pipeline() - .collection(_collectionId) - .findNearest( - Field('embedding'), - [0.1, 0.2, 0.3], - DistanceMeasure.cosine, - limit: 2, - ) - .execute(), - ); + 'Pipeline 15: collection → findNearest (needs vector index)', + () => _firestore + .pipeline() + .collection(_collectionId) + .findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + limit: 2, + ) + .execute(), + ); // 16: unnest Future _runPipeline16() => _runPipeline( - 'Pipeline 16: collection → where(has tags) → unnest(tags) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .unnest(Expression.field('tags'), 'index') - .limit(5) - .execute(), - ); + 'Pipeline 16: collection → where(has tags) → unnest(tags) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .unnest(Expression.field('tags'), 'index') + .limit(5) + .execute(), + ); // 17: union Future _runPipeline17() => _runPipeline( - 'Pipeline 17: collection limit 2 → union(collection offset 2 limit 2)', - () { - final p2 = _firestore - .pipeline() - .collection(_collectionId) - .offset(2) - .limit(2); - return _firestore - .pipeline() - .collection(_collectionId) - .limit(2) - .union(p2) - .execute(); - }, - ); + 'Pipeline 17: collection limit 2 → union(collection offset 2 limit 2)', + () { + final p2 = _firestore + .pipeline() + .collection(_collectionId) + .offset(2) + .limit(2); + return _firestore + .pipeline() + .collection(_collectionId) + .limit(2) + .union(p2) + .execute(); + }, + ); // 18: Constant — one addFields field per supported constant type Future _runPipeline18() { @@ -585,147 +579,144 @@ class _PipelineExamplePageState extends State { // 19: Expression.and Future _runPipeline19() => _runPipeline( - 'Pipeline 19: collection → where(and(score > 50, year >= 2022)) → select(title, score, year) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.and( - Expression.field('score').greaterThan(Expression.constant(20)), - Expression.field( - 'year', - ).greaterThanOrEqual(Expression.constant(2022)), - ), - ) - .select( - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 19: collection → where(and(score > 50, year >= 2022)) → select(title, score, year) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.and( + Expression.field('score').greaterThan(Expression.constant(20)), + Expression.field( + 'year', + ).greaterThanOrEqual(Expression.constant(2022)), + ), + ) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(5) + .execute(), + ); // 20: Expression.or Future _runPipeline20() => _runPipeline( - 'Pipeline 20: collection → where(or(score > 80, year < 2021)) → select(title, score, year) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.or( - Expression.field('score').greaterThan(Expression.constant(30)), - Expression.field('year').lessThan(Expression.constant(2022)), - ), - ) - .select( - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 20: collection → where(or(score > 80, year < 2021)) → select(title, score, year) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.or( + Expression.field('score').greaterThan(Expression.constant(30)), + Expression.field('year').lessThan(Expression.constant(2022)), + ), + ) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(5) + .execute(), + ); // 20b: Expression.not (same pattern as pipeline_expressions_e2e "where with not") Future _runPipeline20b() => _runPipeline( - 'Pipeline 20b: where(test=expressions) + NOT(score>=60) + sort(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where( - Expression.not( - Expression.field( - 'score', - ).greaterThanOrEqual(Expression.constant(60)), - ), - ) - .sort(Expression.field('score').ascending()) - .execute(), - ); + 'Pipeline 20b: where(test=expressions) + NOT(score>=60) + sort(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.not( + Expression.field( + 'score', + ).greaterThanOrEqual(Expression.constant(60)), + ), + ) + .sort(Expression.field('score').ascending()) + .execute(), + ); // 21: arrayContainsAny Future _runPipeline21() => _runPipeline( - 'Pipeline 21: collection → where(tags arrayContainsAny [x, z]) → select(title, tags) → limit(5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').arrayContainsAny(['x', 'z'])) - .select(Expression.field('title'), Expression.field('tags')) - .limit(5) - .execute(), - ); + 'Pipeline 21: collection → where(tags arrayContainsAny [x, z]) → select(title, tags) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').arrayContainsAny(['x', 'z'])) + .select(Expression.field('title'), Expression.field('tags')) + .limit(5) + .execute(), + ); // ── New expression examples (22+) ───────────────────────────────────── // 22: concat Future _runPipeline22() => _runPipeline( - 'Pipeline 22: addFields concat(title, " | ", category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'title', - ).concat([' | ', Expression.field('category')]).as( - 'title_category'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 22: addFields concat(title, " | ", category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'title', + ).concat([' | ', Expression.field('category')]).as('title_category'), + ) + .limit(3) + .execute(), + ); // 23: length (string) Future _runPipeline23() => _runPipeline( - 'Pipeline 23: addFields title.length()', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('title').length().as('title_len')) - .limit(4) - .execute(), - ); + 'Pipeline 23: addFields title.length()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('title').length().as('title_len')) + .limit(4) + .execute(), + ); // 24: toLowerCase / toUpperCase Future _runPipeline24() => _runPipeline( - 'Pipeline 24: addFields toLowerCase(title), toUpperCase(category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('title').toLowerCase().as('title_lower'), - Expression.field('category').toUpperCase().as('category_upper'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 24: addFields toLowerCase(title), toUpperCase(category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').toLowerCase().as('title_lower'), + Expression.field('category').toUpperCase().as('category_upper'), + ) + .limit(3) + .execute(), + ); // 25: trim Future _runPipeline25() => _runPipeline( - 'Pipeline 25: where(has title) → addFields trim(title)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').exists()) - .addFields(Expression.field('title').trim().as('title_trimmed')) - .limit(5) - .execute(), - ); + 'Pipeline 25: where(has title) → addFields trim(title)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').exists()) + .addFields(Expression.field('title').trim().as('title_trimmed')) + .limit(5) + .execute(), + ); // 26: substring Future _runPipeline26() => _runPipeline( - 'Pipeline 26: addFields substring(title, 0, 5)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('title') - .substringLiteral(0, 5) - .as('title_prefix'), - ) - .limit(4) - .execute(), - ); + 'Pipeline 26: addFields substring(title, 0, 5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').substringLiteral(0, 5).as('title_prefix'), + ) + .limit(4) + .execute(), + ); // 27: stringReplaceAll // Future _runPipeline27() => _runPipeline( @@ -744,660 +735,643 @@ class _PipelineExamplePageState extends State { // 28: split Future _runPipeline28() => _runPipeline( - 'Pipeline 28: addFields split(title, " ")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('title').splitLiteral(' ').as('title_parts'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 28: addFields split(title, " ")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').splitLiteral(' ').as('title_parts'), + ) + .limit(3) + .execute(), + ); // 29: join Future _runPipeline29() => _runPipeline( - 'Pipeline 29: where(has tags) → addFields join(tags, "-")', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .addFields( - Expression.field('tags').joinLiteral('-').as('tags_joined')) - .limit(3) - .execute(), - ); + 'Pipeline 29: where(has tags) → addFields join(tags, "-")', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields(Expression.field('tags').joinLiteral('-').as('tags_joined')) + .limit(3) + .execute(), + ); // 30: if_absent Future _runPipeline30() => _runPipeline( - 'Pipeline 30: addFields if_absent(optional_field, "default")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'optional_field', - ).ifAbsentValue('default').as('opt_or_default'), - ) - .limit(4) - .execute(), - ); + 'Pipeline 30: addFields if_absent(optional_field, "default")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'optional_field', + ).ifAbsentValue('default').as('opt_or_default'), + ) + .limit(4) + .execute(), + ); // 30b: if_error (e.g. safe divide) Future _runPipeline30b() => _runPipeline( - 'Pipeline 30b: addFields score/0 with ifError("N/A")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field( - 'score', - ) - .divide(Expression.constant(0)) - .ifErrorValue('N/A') - .as('safe_ratio'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 30b: addFields score/0 with ifError("N/A")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'score', + ).divide(Expression.constant(0)).ifErrorValue('N/A').as('safe_ratio'), + ) + .limit(2) + .execute(), + ); // 31: conditional Future _runPipeline31() => _runPipeline( - 'Pipeline 31: addFields conditional(score > 20, "high", "low")', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.conditionalValues( - Expression.field('score').greaterThan(Expression.constant(20)), - 'high', - 'low', - ).as('score_tier'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 31: addFields conditional(score > 20, "high", "low")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.conditionalValues( + Expression.field('score').greaterThan(Expression.constant(20)), + 'high', + 'low', + ).as('score_tier'), + ) + .limit(5) + .execute(), + ); // 32: document_id (current document ID) Future _runPipeline32() => _runPipeline( - 'Pipeline 32: addFields documentId()', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('__path__').documentId().as('doc_id')) - .limit(3) - .execute(), - ); + 'Pipeline 32: addFields documentId()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('__path__').documentId().as('doc_id')) + .limit(3) + .execute(), + ); // 33: collection_id (current collection ID) Future _runPipeline33() => _runPipeline( - 'Pipeline 33: addFields collectionId()', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('__path__').collectionId().as('coll_id')) - .limit(2) - .execute(), - ); + 'Pipeline 33: addFields collectionId()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('__path__').collectionId().as('coll_id')) + .limit(2) + .execute(), + ); // 34: map_get, map_keys, map_values Future _runPipeline34() => _runPipeline( - 'Pipeline 34: where(has items) → mapGet, mapKeys, mapValues', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('items').exists()) - .addFields( - Expression.field('items').mapGetLiteral('a').as('items_a'), - Expression.field('items').mapKeys().as('items_keys'), - Expression.field('items').mapValues().as('items_vals'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 34: where(has items) → mapGet, mapKeys, mapValues', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('items').exists()) + .addFields( + Expression.field('items').mapGetLiteral('a').as('items_a'), + Expression.field('items').mapKeys().as('items_keys'), + Expression.field('items').mapValues().as('items_vals'), + ) + .limit(2) + .execute(), + ); // 35: current_timestamp, timestamp_add, timestamp_subtract, timestamp_truncate Future _runPipeline35() => _runPipeline( - 'Pipeline 35: addFields currentTimestamp, timestampAdd(1 day)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.currentTimestamp().as('now'), - Expression.timestampAddLiteral( - Expression.currentTimestamp(), - 'day', - 1, - ).as('tomorrow'), - ) - .limit(1) - .execute(), - ); + 'Pipeline 35: addFields currentTimestamp, timestampAdd(1 day)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.currentTimestamp().as('now'), + Expression.timestampAddLiteral( + Expression.currentTimestamp(), + 'day', + 1, + ).as('tomorrow'), + ) + .limit(1) + .execute(), + ); // 36: abs Future _runPipeline36() => _runPipeline( - 'Pipeline 36: addFields abs(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('score').abs().as('score_abs')) - .limit(5) - .execute(), - ); + 'Pipeline 36: addFields abs(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').abs().as('score_abs')) + .limit(5) + .execute(), + ); // 37: array_length Future _runPipeline37() => _runPipeline( - 'Pipeline 37: where(has tags) → addFields arrayLength(tags)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .addFields(Expression.field('tags').arrayLength().as('tags_len')) - .limit(5) - .execute(), - ); + 'Pipeline 37: where(has tags) → addFields arrayLength(tags)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields(Expression.field('tags').arrayLength().as('tags_len')) + .limit(5) + .execute(), + ); Future _runPipeline37b() => _runPipeline( - 'Pipeline 37b: where(has scores) → addFields arraySum(scores)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('scores').exists()) - .addFields(Expression.field('scores').arraySum().as('scores_total')) - .limit(3) - .execute(), - ); + 'Pipeline 37b: where(has scores) → addFields arraySum(scores)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('scores').exists()) + .addFields(Expression.field('scores').arraySum().as('scores_total')) + .limit(3) + .execute(), + ); // 38: array_concat Future _runPipeline38() => _runPipeline( - 'Pipeline 38: where(has tags) → addFields arrayConcat(tags, [extra])', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').exists()) - .addFields( - Expression.field('tags') - .arrayConcat(['extra']).as('tags_extended'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 38: where(has tags) → addFields arrayConcat(tags, [extra])', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields( + Expression.field('tags').arrayConcat(['extra']).as('tags_extended'), + ) + .limit(2) + .execute(), + ); // 40: array (construct) Future _runPipeline40() => _runPipeline( - 'Pipeline 40: addFields array([title, score, year])', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.array([ - Expression.field('title'), - Expression.field('score'), - Expression.field('year'), - ]).as('tuple'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 40: addFields array([title, score, year])', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.array([ + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ]).as('tuple'), + ) + .limit(2) + .execute(), + ); // 41: map (construct) Future _runPipeline41() => _runPipeline( - 'Pipeline 41: addFields map({ t: title, s: score })', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.map({ - 't': Expression.field('title'), - 's': Expression.field('score'), - }).as('mini_map'), - ) - .limit(2) - .execute(), - ); + 'Pipeline 41: addFields map({ t: title, s: score })', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.map({ + 't': Expression.field('title'), + 's': Expression.field('score'), + }).as('mini_map'), + ) + .limit(2) + .execute(), + ); // 42: array_contains_all (values list) Future _runPipeline42() => _runPipeline( - 'Pipeline 42: where(tags arrayContainsAll [x, y]) → select title, tags', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('tags').arrayContainsAll(['x', 'y'])) - .select(Expression.field('title'), Expression.field('tags')) - .limit(5) - .execute(), - ); + 'Pipeline 42: where(tags arrayContainsAll [x, y]) → select title, tags', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').arrayContainsAll(['x', 'y'])) + .select(Expression.field('title'), Expression.field('tags')) + .limit(5) + .execute(), + ); // 43: equal_any (IN) Future _runPipeline43() => _runPipeline( - 'Pipeline 43: where(score equalAny [10, 25, 40]) → select title, score', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.equalAny(Expression.field('score'), [10, 25, 40])) - .select(Expression.field('title'), Expression.field('score')) - .limit(5) - .execute(), - ); + 'Pipeline 43: where(score equalAny [10, 25, 40]) → select title, score', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.equalAny(Expression.field('score'), [10, 25, 40])) + .select(Expression.field('title'), Expression.field('score')) + .limit(5) + .execute(), + ); // 44: not_equal_any (NOT IN) Future _runPipeline44() => _runPipeline( - 'Pipeline 44: where(category notEqualAny [news]) → select title, category', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.notEqualAny(Expression.field('category'), ['news'])) - .select(Expression.field('title'), Expression.field('category')) - .limit(5) - .execute(), - ); + 'Pipeline 44: where(category notEqualAny [news]) → select title, category', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.notEqualAny(Expression.field('category'), ['news'])) + .select(Expression.field('title'), Expression.field('category')) + .limit(5) + .execute(), + ); // 45: asBoolean (coerce numeric to boolean) Future _runPipeline45() => _runPipeline( - 'Pipeline 45: addFields asBoolean(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('score').asBoolean().as('score_bool')) - .limit(4) - .execute(), - ); + 'Pipeline 45: addFields asBoolean(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').asBoolean().as('score_bool')) + .limit(4) + .execute(), + ); // 46: isError (missing field vs divide-by-zero) Future _runPipeline46() => _runPipeline( - 'Pipeline 46: addFields isError(missing field), isError(score/0)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields( - Expression.field('missing_field').isError().as('missing_is_err'), - Expression.field( - 'score', - ).divide(Expression.constant(0)).isError().as('div0_is_err'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 46: addFields isError(missing field), isError(score/0)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('missing_field').isError().as('missing_is_err'), + Expression.field( + 'score', + ).divide(Expression.constant(0)).isError().as('div0_is_err'), + ) + .limit(3) + .execute(), + ); // ── New pipeline expressions (regex, map, string, array, agg) ─────────── Future _runPipeline47() => _runPipeline( - 'Pipeline 47: where(has email) → addFields regexFind(@.+)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('email').exists()) - .addFields( - Expression.field('email').regexFind('@.+').as('at_domain')) - .limit(5) - .execute(), - ); + 'Pipeline 47: where(has email) → addFields regexFind(@.+)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('email').exists()) + .addFields(Expression.field('email').regexFind('@.+').as('at_domain')) + .limit(5) + .execute(), + ); Future _runPipeline48() => _runPipeline( - 'Pipeline 48: where(has email) → addFields regexFindAll([a-z]+)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('email').exists()) - .addFields( - Expression.field('email') - .regexFindAll('[a-z]+') - .as('word_chunks'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 48: where(has email) → addFields regexFindAll([a-z]+)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('email').exists()) + .addFields( + Expression.field('email').regexFindAll('[a-z]+').as('word_chunks'), + ) + .limit(5) + .execute(), + ); Future _runPipeline49() => _runPipeline( - 'Pipeline 49: test=expressions + has s → stringReplaceOne, stringIndexOf, stringRepeat', - () => _firestore - .pipeline() - .collection(_collectionId) - .where( - Expression.and( - Expression.field('test').equalValue('expressions'), - Expression.field('s').exists(), - ), - ) - .addFields( - Expression.field( - 's', - ).stringReplaceOneLiteral('A', 'Z').as('s_replace_one'), - Expression.field('s').stringIndexOf('y').as('idx_y'), - Expression.field('s').stringRepeat(2).as('s_twice'), - ) - .limit(8) - .execute(), - ); + 'Pipeline 49: test=expressions + has s → stringReplaceOne, stringIndexOf, stringRepeat', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.and( + Expression.field('test').equalValue('expressions'), + Expression.field('s').exists(), + ), + ) + .addFields( + Expression.field( + 's', + ).stringReplaceOneLiteral('A', 'Z').as('s_replace_one'), + Expression.field('s').stringIndexOf('y').as('idx_y'), + Expression.field('s').stringRepeat(2).as('s_twice'), + ) + .limit(8) + .execute(), + ); Future _runPipeline50() => _runPipeline( - 'Pipeline 50: title " Padded " → ltrim, rtrim', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue(' Padded ')) - .addFields( - Expression.field('title').ltrim().as('lt'), - Expression.field('title').rtrim().as('rt'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 50: title " Padded " → ltrim, rtrim', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue(' Padded ')) + .addFields( + Expression.field('title').ltrim().as('lt'), + Expression.field('title').rtrim().as('rt'), + ) + .limit(3) + .execute(), + ); Future _runPipeline51() => _runPipeline( - 'Pipeline 51: where(has items) → mapSet(z), mapEntries()', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('items').exists()) - .addFields( - Expression.field('items').mapSet('z', 'added').as('items_plus'), - Expression.field('items').mapEntries().as('items_entries'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 51: where(has items) → mapSet(z), mapEntries()', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('items').exists()) + .addFields( + Expression.field('items').mapSet('z', 'added').as('items_plus'), + Expression.field('items').mapEntries().as('items_entries'), + ) + .limit(3) + .execute(), + ); Future _runPipeline52() => _runPipeline( - 'Pipeline 52: addFields type(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .addFields(Expression.field('score').type().as('score_type')) - .limit(6) - .execute(), - ); + 'Pipeline 52: addFields type(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').type().as('score_type')) + .limit(6) + .execute(), + ); Future _runPipeline53() => _runPipeline( - 'Pipeline 53: where(score isType int64) → select title, score', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('score').isType(Type.int64)) - .select(Expression.field('title'), Expression.field('score')) - .limit(8) - .execute(), - ); + 'Pipeline 53: where(score isType int64) → select title, score', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('score').isType(Type.int64)) + .select(Expression.field('title'), Expression.field('score')) + .limit(8) + .execute(), + ); Future _runPipeline54() => _runPipeline( - 'Pipeline 54: where(has pi) → trunc(pi), trunc(2dp), rand()', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('pi').exists()) - .addFields( - Expression.field('pi').trunc().as('pi_trunc'), - Expression.field('pi').trunc(Expression.constant(2)).as('pi_2'), - Expression.rand().as('rnd'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 54: where(has pi) → trunc(pi), trunc(2dp), rand()', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('pi').exists()) + .addFields( + Expression.field('pi').trunc().as('pi_trunc'), + Expression.field('pi').trunc(Expression.constant(2)).as('pi_2'), + Expression.rand().as('rnd'), + ) + .limit(3) + .execute(), + ); Future _runPipeline55() => _runPipeline( - 'Pipeline 55: title Item G → arrayFirst, arrayLast(tags)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue('Item G')) - .addFields( - Expression.field('tags').arrayFirst().as('tag_first'), - Expression.field('tags').arrayLast().as('tag_last'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 55: title Item G → arrayFirst, arrayLast(tags)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue('Item G')) + .addFields( + Expression.field('tags').arrayFirst().as('tag_first'), + Expression.field('tags').arrayLast().as('tag_last'), + ) + .limit(3) + .execute(), + ); Future _runPipeline56() => _runPipeline( - 'Pipeline 56: Item G → arrayFirstN(2), arrayLastN(2)(tags)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue('Item G')) - .addFields( - Expression.field('tags').arrayFirstN(2).as('tags_head'), - Expression.field('tags').arrayLastN(2).as('tags_tail'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 56: Item G → arrayFirstN(2), arrayLastN(2)(tags)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue('Item G')) + .addFields( + Expression.field('tags').arrayFirstN(2).as('tags_head'), + Expression.field('tags').arrayLastN(2).as('tags_tail'), + ) + .limit(3) + .execute(), + ); Future _runPipeline57() => _runPipeline( - 'Pipeline 57: where(has scores) → arrayMaximum, arrayMinimum', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('scores').exists()) - .addFields( - Expression.field('scores').arrayMaximum().as('smax'), - Expression.field('scores').arrayMinimum().as('smin'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 57: where(has scores) → arrayMaximum, arrayMinimum', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('scores').exists()) + .addFields( + Expression.field('scores').arrayMaximum().as('smax'), + Expression.field('scores').arrayMinimum().as('smin'), + ) + .limit(3) + .execute(), + ); Future _runPipeline58() => _runPipeline( - 'Pipeline 58: where(has scores) → arrayMaximumN(2), arrayMinimumN(2)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('scores').exists()) - .addFields( - Expression.field('scores').arrayMaximumN(2).as('top2'), - Expression.field('scores').arrayMinimumN(2).as('bottom2'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 58: where(has scores) → arrayMaximumN(2), arrayMinimumN(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('scores').exists()) + .addFields( + Expression.field('scores').arrayMaximumN(2).as('top2'), + Expression.field('scores').arrayMinimumN(2).as('bottom2'), + ) + .limit(3) + .execute(), + ); Future _runPipeline59() => _runPipeline( - 'Pipeline 59: Dup Tags → arrayIndexOf(a), arrayLastIndexOf(a), arrayIndexOfAll(a)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('title').equalValue('Dup Tags')) - .addFields( - Expression.field('tags').arrayIndexOf('a').as('idx_first_a'), - Expression.field('tags').arrayLastIndexOf('a').as('idx_last_a'), - Expression.field('tags').arrayIndexOfAll('a').as('all_a'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 59: Dup Tags → arrayIndexOf(a), arrayLastIndexOf(a), arrayIndexOfAll(a)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').equalValue('Dup Tags')) + .addFields( + Expression.field('tags').arrayIndexOf('a').as('idx_first_a'), + Expression.field('tags').arrayLastIndexOf('a').as('idx_last_a'), + Expression.field('tags').arrayIndexOfAll('a').as('all_a'), + ) + .limit(3) + .execute(), + ); Future _runPipeline60() => _runPipeline( - 'Pipeline 60: aggregate first(score), last(score)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(50) - .aggregate( - Expression.field('score').first().as('first_score'), - Expression.field('score').last().as('last_score'), - ) - .execute(), - ); + 'Pipeline 60: aggregate first(score), last(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(50) + .aggregate( + Expression.field('score').first().as('first_score'), + Expression.field('score').last().as('last_score'), + ) + .execute(), + ); Future _runPipeline61() => _runPipeline( - 'Pipeline 61: limit 25 → aggregate array_agg(title)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(25) - .aggregate(Expression.field('title').arrayAgg().as('all_titles')) - .execute(), - ); + 'Pipeline 61: limit 25 → aggregate array_agg(title)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(25) + .aggregate(Expression.field('title').arrayAgg().as('all_titles')) + .execute(), + ); Future _runPipeline62() => _runPipeline( - 'Pipeline 62: limit 25 → aggregate array_agg_distinct(category)', - () => _firestore - .pipeline() - .collection(_collectionId) - .limit(25) - .aggregate( - Expression.field('category').arrayAggDistinct().as('cats')) - .execute(), - ); + 'Pipeline 62: limit 25 → aggregate array_agg_distinct(category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(25) + .aggregate(Expression.field('category').arrayAggDistinct().as('cats')) + .execute(), + ); /// map_keys, timestamp_diff, nor, switchOn, if_null, coalesce, parent — expressions row score 60. Future _runPipeline63() => _runPipeline( - 'Pipeline 63: test=expressions score=60 → maps, ts diff/extract, nor, switchOn, ifNull, coalesce, parent', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.field('m').mapKeys().as('m_keys'), - Expression.field('m').mapValues().as('m_vals'), - Expression.field('t_end') - .timestampDiff(Expression.field('t_start'), 'day') - .as('diff_days_field'), - Expression.timestampDiffStatic( - Expression.field('t_end'), - Expression.field('t_start'), - 'hour', - ).as('diff_hours_static'), - Expression.field('t_end') - .timestampExtract('month') - .as('end_month'), - Expression.currentTimestamp() - .timestampExtract('year') - .as('now_year'), - Expression.currentTimestamp() - .timestampExtract('hour', 'UTC') - .as('now_hour_utc'), - Expression.field('nope').ifNullValue(-1).as('if_null_n'), - Expression.coalesce( - Expression.field('title'), - Expression.field('missing'), - [Expression.constant('fb')], - ).as('coalesce_title'), - Expression.nor( - Expression.field('score').lessThanValue(0), - Expression.field('a').equalValue(9999), - ).as('nor_ok'), - Expression.switchOn([ - Expression.field('a').greaterThanValue(50), - Expression.constant('high'), - Expression.field('a').greaterThanValue(5), - Expression.constant('mid'), - Expression.constant('low'), - ]).as('bucket'), - Expression.parentFromRef( - _firestore.collection(_collectionId).doc('demo_parent'), - ).as('parent_from_ref'), - Constant( - _firestore.collection(_collectionId).doc('demo_parent'), - ).parent().as('parent_of_const_ref'), - ) - .limit(5) - .execute(), - ); + 'Pipeline 63: test=expressions score=60 → maps, ts diff/extract, nor, switchOn, ifNull, coalesce, parent', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('m').mapKeys().as('m_keys'), + Expression.field('m').mapValues().as('m_vals'), + Expression.field('t_end') + .timestampDiff(Expression.field('t_start'), 'day') + .as('diff_days_field'), + Expression.timestampDiffStatic( + Expression.field('t_end'), + Expression.field('t_start'), + 'hour', + ).as('diff_hours_static'), + Expression.field('t_end').timestampExtract('month').as('end_month'), + Expression.currentTimestamp().timestampExtract('year').as('now_year'), + Expression.currentTimestamp() + .timestampExtract('hour', 'UTC') + .as('now_hour_utc'), + Expression.field('nope').ifNullValue(-1).as('if_null_n'), + Expression.coalesce( + Expression.field('title'), + Expression.field('missing'), + [Expression.constant('fb')], + ).as('coalesce_title'), + Expression.nor( + Expression.field('score').lessThanValue(0), + Expression.field('a').equalValue(9999), + ).as('nor_ok'), + Expression.switchOn([ + Expression.field('a').greaterThanValue(50), + Expression.constant('high'), + Expression.field('a').greaterThanValue(5), + Expression.constant('mid'), + Expression.constant('low'), + ]).as('bucket'), + Expression.parentFromRef( + _firestore.collection(_collectionId).doc('demo_parent'), + ).as('parent_from_ref'), + Constant( + _firestore.collection(_collectionId).doc('demo_parent'), + ).parent().as('parent_of_const_ref'), + ) + .limit(5) + .execute(), + ); Future _runPipeline64() => _runPipeline( - 'Pipeline 64: test=expressions score=60 → mapKeys, mapValues only', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.field('m').mapKeys().as('m_keys'), - Expression.field('m').mapValues().as('m_vals'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 64: test=expressions score=60 → mapKeys, mapValues only', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('m').mapKeys().as('m_keys'), + Expression.field('m').mapValues().as('m_vals'), + ) + .limit(3) + .execute(), + ); Future _runPipeline65() => _runPipeline( - 'Pipeline 65: test=expressions score=60 → timestampDiff + timestampExtract', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.field('t_end') - .timestampDiff(Expression.field('t_start'), 'day') - .as('diff_days'), - Expression.timestampDiffStatic( - Expression.field('t_end'), - Expression.field('t_start'), - 'hour', - ).as('diff_hours'), - Expression.field('t_end').timestampExtract('day').as('end_day'), - Expression.currentTimestamp() - .timestampExtract('year') - .as('now_year'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 65: test=expressions score=60 → timestampDiff + timestampExtract', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field( + 't_end', + ).timestampDiff(Expression.field('t_start'), 'day').as('diff_days'), + Expression.timestampDiffStatic( + Expression.field('t_end'), + Expression.field('t_start'), + 'hour', + ).as('diff_hours'), + Expression.field('t_end').timestampExtract('day').as('end_day'), + Expression.currentTimestamp().timestampExtract('year').as('now_year'), + ) + .limit(3) + .execute(), + ); Future _runPipeline66() => _runPipeline( - 'Pipeline 66: test=expressions score=60 → Expression.nor only (two falsey arms → true)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.nor( - Expression.field('score').lessThanValue(0), - Expression.field('a').equalValue(9999), - ).as('nor_ok'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 66: test=expressions score=60 → Expression.nor only (two falsey arms → true)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.nor( + Expression.field('score').lessThanValue(0), + Expression.field('a').equalValue(9999), + ).as('nor_ok'), + ) + .limit(3) + .execute(), + ); Future _runPipeline67() => _runPipeline( - 'Pipeline 67: test=expressions score=60 → Expression.switchOn only (a=1 → low)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.switchOn([ - Expression.field('a').greaterThanValue(50), - Expression.constant('high'), - Expression.field('a').greaterThanValue(5), - Expression.constant('mid'), - Expression.constant('low'), - ]).as('bucket'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 67: test=expressions score=60 → Expression.switchOn only (a=1 → low)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.switchOn([ + Expression.field('a').greaterThanValue(50), + Expression.constant('high'), + Expression.field('a').greaterThanValue(5), + Expression.constant('mid'), + Expression.constant('low'), + ]).as('bucket'), + ) + .limit(3) + .execute(), + ); Future _runPipeline68() => _runPipeline( - 'Pipeline 68: test=expressions score=60 → ifNull, coalesce, parentFromRef, parent(expr)', - () => _firestore - .pipeline() - .collection(_collectionId) - .where(Expression.field('test').equalValue('expressions')) - .where(Expression.field('score').equalValue(60)) - .addFields( - Expression.field('nope').ifNullValue(-1).as('if_null_n'), - Expression.coalesce( - Expression.field('title'), - Expression.field('missing'), - [Expression.constant('fb')], - ).as('coalesce_title'), - Expression.parentFromRef( - _firestore.collection(_collectionId).doc('demo_parent'), - ).as('parent_from_ref'), - Constant( - _firestore.collection(_collectionId).doc('demo_parent'), - ).parent().as('parent_of_const_ref'), - ) - .limit(3) - .execute(), - ); + 'Pipeline 68: test=expressions score=60 → ifNull, coalesce, parentFromRef, parent(expr)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('nope').ifNullValue(-1).as('if_null_n'), + Expression.coalesce( + Expression.field('title'), + Expression.field('missing'), + [Expression.constant('fb')], + ).as('coalesce_title'), + Expression.parentFromRef( + _firestore.collection(_collectionId).doc('demo_parent'), + ).as('parent_from_ref'), + Constant( + _firestore.collection(_collectionId).doc('demo_parent'), + ).parent().as('parent_of_const_ref'), + ) + .limit(3) + .execute(), + ); @override Widget build(BuildContext context) { From 08230001230a3ea838465fd69bacc16ada77d750 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 20 Apr 2026 11:28:47 +0000 Subject: [PATCH 7/8] refactor: enhance coalesce and switchOn expressions to accept multiple arguments directly --- .../lib/src/pipeline_expression.dart | 132 ++++++++++++++++-- .../pipeline/pipeline_expressions_e2e.dart | 6 +- .../pipeline_example/lib/main.dart | 12 +- .../test/pipeline_expression_test.dart | 14 +- .../lib/src/interop/firestore_interop.dart | 4 +- .../src/pipeline_expression_parser_web.dart | 34 +++-- 6 files changed, 162 insertions(+), 40 deletions(-) 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 7bca91635cb0..b25c63caaa90 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart @@ -1072,22 +1072,136 @@ abstract class Expression implements PipelineSerializable { static Expression coalesce( Expression first, Object second, [ - List? more, + Object? expression3, + Object? expression4, + Object? expression5, + Object? expression6, + Object? expression7, + Object? expression8, + Object? expression9, + Object? expression10, + Object? expression11, + Object? expression12, + Object? expression13, + Object? expression14, + Object? expression15, + Object? expression16, + Object? expression17, + Object? expression18, + Object? expression19, + Object? expression20, + Object? expression21, + Object? expression22, + Object? expression23, + Object? expression24, + Object? expression25, + Object? expression26, + Object? expression27, + Object? expression28, + Object? expression29, + Object? expression30, ]) { final expressions = [first, _toExpression(second)]; - if (more != null) { - for (final o in more) { - expressions.add(_toExpression(o)); - } - } + if (expression3 != null) expressions.add(_toExpression(expression3)); + if (expression4 != null) expressions.add(_toExpression(expression4)); + if (expression5 != null) expressions.add(_toExpression(expression5)); + if (expression6 != null) expressions.add(_toExpression(expression6)); + if (expression7 != null) expressions.add(_toExpression(expression7)); + if (expression8 != null) expressions.add(_toExpression(expression8)); + if (expression9 != null) expressions.add(_toExpression(expression9)); + if (expression10 != null) expressions.add(_toExpression(expression10)); + if (expression11 != null) expressions.add(_toExpression(expression11)); + if (expression12 != null) expressions.add(_toExpression(expression12)); + if (expression13 != null) expressions.add(_toExpression(expression13)); + if (expression14 != null) expressions.add(_toExpression(expression14)); + if (expression15 != null) expressions.add(_toExpression(expression15)); + if (expression16 != null) expressions.add(_toExpression(expression16)); + if (expression17 != null) expressions.add(_toExpression(expression17)); + if (expression18 != null) expressions.add(_toExpression(expression18)); + if (expression19 != null) expressions.add(_toExpression(expression19)); + if (expression20 != null) expressions.add(_toExpression(expression20)); + if (expression21 != null) expressions.add(_toExpression(expression21)); + if (expression22 != null) expressions.add(_toExpression(expression22)); + if (expression23 != null) expressions.add(_toExpression(expression23)); + if (expression24 != null) expressions.add(_toExpression(expression24)); + if (expression25 != null) expressions.add(_toExpression(expression25)); + if (expression26 != null) expressions.add(_toExpression(expression26)); + if (expression27 != null) expressions.add(_toExpression(expression27)); + if (expression28 != null) expressions.add(_toExpression(expression28)); + if (expression29 != null) expressions.add(_toExpression(expression29)); + if (expression30 != null) expressions.add(_toExpression(expression30)); return _CoalesceExpression(expressions); } /// Switch: first matching [BooleanExpression] condition wins. /// - /// [parts] alternates condition, result, ... If [parts] has odd length, the last - /// value is a default [Expression] when no condition matches. - static Expression switchOn(List parts) { + /// After the first [condition] and [result], pass an alternating sequence of + /// additional conditions and results. If you pass an odd number of + /// additional arguments, the last one is a default [Expression] when no + /// condition matches. + static Expression switchOn( + BooleanExpression condition, + Expression result, [ + Object? arg3, + Object? arg4, + Object? arg5, + Object? arg6, + Object? arg7, + Object? arg8, + Object? arg9, + Object? arg10, + Object? arg11, + Object? arg12, + Object? arg13, + Object? arg14, + Object? arg15, + Object? arg16, + Object? arg17, + Object? arg18, + Object? arg19, + Object? arg20, + Object? arg21, + Object? arg22, + Object? arg23, + Object? arg24, + Object? arg25, + Object? arg26, + Object? arg27, + Object? arg28, + Object? arg29, + Object? arg30, + Object? arg31, + ]) { + final parts = [condition, result]; + if (arg3 != null) parts.add(arg3); + if (arg4 != null) parts.add(arg4); + if (arg5 != null) parts.add(arg5); + if (arg6 != null) parts.add(arg6); + if (arg7 != null) parts.add(arg7); + if (arg8 != null) parts.add(arg8); + if (arg9 != null) parts.add(arg9); + if (arg10 != null) parts.add(arg10); + if (arg11 != null) parts.add(arg11); + if (arg12 != null) parts.add(arg12); + if (arg13 != null) parts.add(arg13); + if (arg14 != null) parts.add(arg14); + if (arg15 != null) parts.add(arg15); + if (arg16 != null) parts.add(arg16); + if (arg17 != null) parts.add(arg17); + if (arg18 != null) parts.add(arg18); + if (arg19 != null) parts.add(arg19); + if (arg20 != null) parts.add(arg20); + if (arg21 != null) parts.add(arg21); + if (arg22 != null) parts.add(arg22); + if (arg23 != null) parts.add(arg23); + if (arg24 != null) parts.add(arg24); + if (arg25 != null) parts.add(arg25); + if (arg26 != null) parts.add(arg26); + if (arg27 != null) parts.add(arg27); + if (arg28 != null) parts.add(arg28); + if (arg29 != null) parts.add(arg29); + if (arg30 != null) parts.add(arg30); + if (arg31 != null) parts.add(arg31); return _SwitchOnExpression(_parseSwitchOnParts(parts)); } 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 a9e8c4f6b586..52948ff815f3 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 @@ -1190,13 +1190,13 @@ void runPipelineExpressionsTests() { .where(Expression.field('test').equalValue('expressions')) .where(Expression.field('score').equalValue(60)) .addFields( - Expression.switchOn([ + Expression.switchOn( Expression.field('a').greaterThanValue(50), Expression.constant('high'), Expression.field('a').greaterThanValue(5), Expression.constant('mid'), Expression.constant('low'), - ]).as('bucket'), + ).as('bucket'), ) .limit(1) .execute(); @@ -1216,7 +1216,7 @@ void runPipelineExpressionsTests() { Expression.coalesce( Expression.field('title'), Expression.field('missing'), - [Expression.constant('fb')], + Expression.constant('fb'), ).as('coalesce_title'), ) .limit(1) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart index b153cc335ddc..d830a7de2c1e 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart @@ -1249,19 +1249,19 @@ class _PipelineExamplePageState extends State { Expression.coalesce( Expression.field('title'), Expression.field('missing'), - [Expression.constant('fb')], + Expression.constant('fb'), ).as('coalesce_title'), Expression.nor( Expression.field('score').lessThanValue(0), Expression.field('a').equalValue(9999), ).as('nor_ok'), - Expression.switchOn([ + Expression.switchOn( Expression.field('a').greaterThanValue(50), Expression.constant('high'), Expression.field('a').greaterThanValue(5), Expression.constant('mid'), Expression.constant('low'), - ]).as('bucket'), + ).as('bucket'), Expression.parentFromRef( _firestore.collection(_collectionId).doc('demo_parent'), ).as('parent_from_ref'), @@ -1336,13 +1336,13 @@ class _PipelineExamplePageState extends State { .where(Expression.field('test').equalValue('expressions')) .where(Expression.field('score').equalValue(60)) .addFields( - Expression.switchOn([ + Expression.switchOn( Expression.field('a').greaterThanValue(50), Expression.constant('high'), Expression.field('a').greaterThanValue(5), Expression.constant('mid'), Expression.constant('low'), - ]).as('bucket'), + ).as('bucket'), ) .limit(3) .execute(), @@ -1360,7 +1360,7 @@ class _PipelineExamplePageState extends State { Expression.coalesce( Expression.field('title'), Expression.field('missing'), - [Expression.constant('fb')], + Expression.constant('fb'), ).as('coalesce_title'), Expression.parentFromRef( _firestore.collection(_collectionId).doc('demo_parent'), 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 bbe5fe486adb..7fb784e9a369 100644 --- a/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart @@ -1072,23 +1072,27 @@ void main() { }); test('coalesce serializes correctly', () { - final expr = Expression.coalesce(Field('a'), Field('b'), [Constant('c')]); + final expr = Expression.coalesce(Field('a'), Field('b'), Constant('c')); expect(expr.toMap()['name'], 'coalesce'); expect((expr.toMap()['args']['expressions'] as List).length, 3); }); test('switchOn serializes correctly', () { - final expr = Expression.switchOn([ + final expr = Expression.switchOn( Field('x').greaterThanValue(0), Constant('pos'), Constant('zero'), - ]); + ); expect(expr.toMap()['name'], 'switch_on'); }); - test('switchOn rejects invalid parts', () { + test('switchOn rejects invalid default', () { expect( - () => Expression.switchOn([Field('x')]), + () => Expression.switchOn( + Field('x').equalValue(0), + Constant('a'), + 42, + ), throwsA(isA()), ); }); 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 89e95292edb4..dbc6f2e93404 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 @@ -412,8 +412,8 @@ extension type PipelinesJsImpl._(JSObject _) implements JSObject { external ExpressionJsImpl ifNull(JSAny ifExpr, JSAny elseExpr); external ExpressionJsImpl coalesce( JSAny first, JSAny second, JSArray more); - external ExpressionJsImpl switchOn( - JSAny condition, JSAny result, JSArray others); + @JS('switchOn') + external JSFunction get switchOnJs; external ExpressionJsImpl abs(JSAny expr); external ExpressionJsImpl arrayLength(JSAny array); external ExpressionJsImpl arraySum(JSAny expression); 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 6ffdf37f2f3a..cbc426fea488 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 @@ -4,6 +4,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'dart:typed_data'; import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart' @@ -462,24 +463,27 @@ class PipelineExpressionParserWeb { throw UnsupportedError('switch_on requires a boolean condition'); } final second = toExpression(exprMaps[1] as Map); - if (n == 2) { - return _pipelines.switchOn(first, second, [].toJS); - } - final tail = []; - for (var i = 2; i < n; i++) { - if (n.isOdd && i == n - 1) { - tail.add(toExpression(exprMaps[i] as Map)); - } else if (i.isEven) { - final b = toBooleanExpression(exprMaps[i] as Map); - if (b == null) { - throw UnsupportedError('switch_on expected a boolean expression'); + final allArgs = [first, second]; + if (n > 2) { + for (var i = 2; i < n; i++) { + if (n.isOdd && i == n - 1) { + allArgs.add(toExpression(exprMaps[i] as Map)); + } else if (i.isEven) { + final b = toBooleanExpression(exprMaps[i] as Map); + if (b == null) { + throw UnsupportedError('switch_on expected a boolean expression'); + } + allArgs.add(b); + } else { + allArgs.add(toExpression(exprMaps[i] as Map)); } - tail.add(b); - } else { - tail.add(toExpression(exprMaps[i] as Map)); } } - return _pipelines.switchOn(first, second, tail.toJS); + return (_pipelines.switchOnJs as JSObject).callMethodVarArgs< + interop.ExpressionJsImpl>( + 'apply'.toJS, + [_pipelines, allArgs.toJS], + ); } // ── Boolean expressions ─────────────────────────────────────────────────── From bfd12dd07334cb3a4058929e6d82f0d82d2e804d Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 20 Apr 2026 11:37:18 +0000 Subject: [PATCH 8/8] fix formatting --- .../lib/src/pipeline_expression_parser_web.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 cbc426fea488..82fbf54f639d 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 @@ -479,11 +479,11 @@ class PipelineExpressionParserWeb { } } } - return (_pipelines.switchOnJs as JSObject).callMethodVarArgs< - interop.ExpressionJsImpl>( - 'apply'.toJS, - [_pipelines, allArgs.toJS], - ); + return (_pipelines.switchOnJs as JSObject) + .callMethodVarArgs( + 'apply'.toJS, + [_pipelines, allArgs.toJS], + ); } // ── Boolean expressions ───────────────────────────────────────────────────