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..b25c63caaa90 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,162 @@ 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, [ + 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 (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. + /// + /// 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)); + } + /// Checks if a value is in a list (IN operator) static BooleanExpression equalAny( Expression value, @@ -1217,6 +1468,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 +3423,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..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 @@ -1084,5 +1084,172 @@ 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_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/lib/main.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart index 1875e5da6ece..d830a7de2c1e 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', @@ -823,12 +829,16 @@ class _PipelineExamplePageState extends State { // 34: map_get, map_keys, map_values Future _runPipeline34() => _runPipeline( - 'Pipeline 34: where(has items) → addFields mapGet, mapKeys, mapValues', + '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')) + .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(), ); @@ -1211,6 +1221,158 @@ class _PipelineExamplePageState extends State { .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) { return Scaffold( @@ -1325,6 +1487,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..7fb784e9a369 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,100 @@ 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 default', () { + expect( + () => Expression.switchOn( + Field('x').equalValue(0), + Constant('a'), + 42, + ), + 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..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 @@ -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 --- @@ -404,6 +405,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); + @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 722c28095d02..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 @@ -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' @@ -360,6 +361,68 @@ 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); + // 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', @@ -371,6 +434,58 @@ 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) { + 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); + 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)); + } + } + } + return (_pipelines.switchOnJs as JSObject) + .callMethodVarArgs( + 'apply'.toJS, + [_pipelines, allArgs.toJS], + ); + } + // ── Boolean expressions ─────────────────────────────────────────────────── /// Converts a serialized boolean expression to a JS BooleanExpression. @@ -401,6 +516,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 +530,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;