diff --git a/Firestore/Source/API/FIRPipelineBridge.mm b/Firestore/Source/API/FIRPipelineBridge.mm index 11f3f4c56d5..4a87351c4c5 100644 --- a/Firestore/Source/API/FIRPipelineBridge.mm +++ b/Firestore/Source/API/FIRPipelineBridge.mm @@ -73,7 +73,9 @@ using firebase::firestore::api::Union; using firebase::firestore::api::Unnest; using firebase::firestore::api::Where; +using firebase::firestore::model::DeepClone; using firebase::firestore::model::FieldPath; +using firebase::firestore::nanopb::MakeSharedMessage; using firebase::firestore::nanopb::SharedMessage; using firebase::firestore::util::ComparisonResult; using firebase::firestore::util::MakeCallback; @@ -94,13 +96,24 @@ @implementation FIRExprBridge @end @implementation FIRFieldBridge { + FIRFieldPath *field_path; std::shared_ptr field; } -- (id)init:(NSString *)name { +- (id)initWithName:(NSString *)name { self = [super init]; if (self) { - field = std::make_shared(MakeString(name)); + field_path = [FIRFieldPath pathWithDotSeparatedString:name]; + field = std::make_shared([field_path internalValue].CanonicalString()); + } + return self; +} + +- (id)initWithPath:(FIRFieldPath *)path { + self = [super init]; + if (self) { + field_path = path; + field = std::make_shared([field_path internalValue].CanonicalString()); } return self; } @@ -109,6 +122,10 @@ - (id)init:(NSString *)name { return field; } +- (NSString *)field_name { + return MakeNSString([field_path internalValue].CanonicalString()); +} + @end @implementation FIRConstantBridge { @@ -560,7 +577,7 @@ @implementation FIRFindNearestStageBridge { FIRVectorValue *_vectorValue; NSString *_distanceMeasure; NSNumber *_limit; - NSString *_Nullable _distanceField; + FIRExprBridge *_Nullable _distanceField; Boolean isUserDataRead; std::shared_ptr cpp_find_nearest; } @@ -569,7 +586,7 @@ - (id)initWithField:(FIRFieldBridge *)field vectorValue:(FIRVectorValue *)vectorValue distanceMeasure:(NSString *)distanceMeasure limit:(NSNumber *_Nullable)limit - distanceField:(NSString *_Nullable)distanceField { + distanceField:(FIRExprBridge *_Nullable)distanceField { self = [super init]; if (self) { _field = field; @@ -584,21 +601,16 @@ - (id)initWithField:(FIRFieldBridge *)field - (std::shared_ptr)cppStageWithReader:(FSTUserDataReader *)reader { if (!isUserDataRead) { - std::unordered_map> - optional_value; + std::unordered_map optional_value; if (_limit) { - optional_value.emplace( - std::make_pair(std::string("limit"), - nanopb::SharedMessage( - [reader parsedQueryValue:_limit]))); + optional_value.emplace(std::make_pair( + std::string("limit"), *DeepClone(*[reader parsedQueryValue:_limit]).release())); } if (_distanceField) { + std::shared_ptr cpp_distance_field = [_distanceField cppExprWithReader:reader]; optional_value.emplace( - std::make_pair(std::string("distance_field"), - nanopb::SharedMessage( - [reader parsedQueryValue:_distanceField]))); + std::make_pair(std::string("distance_field"), cpp_distance_field->to_proto())); } FindNearestStage::DistanceMeasure::Measure measure_enum; diff --git a/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h b/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h index cf72c897f3b..e148637d48a 100644 --- a/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h +++ b/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h @@ -23,6 +23,7 @@ @class FIRTimestamp; @class FIRVectorValue; @class FIRPipelineBridge; +@class FIRFieldPath; NS_ASSUME_NONNULL_BEGIN @@ -34,7 +35,9 @@ NS_SWIFT_NAME(ExprBridge) NS_SWIFT_SENDABLE NS_SWIFT_NAME(FieldBridge) @interface FIRFieldBridge : FIRExprBridge -- (id)init:(NSString *)name; +- (id)initWithName:(NSString *)name; +- (id)initWithPath:(FIRFieldPath *)path; +- (NSString *)field_name; @end NS_SWIFT_SENDABLE @@ -160,7 +163,7 @@ NS_SWIFT_NAME(FindNearestStageBridge) vectorValue:(FIRVectorValue *)vectorValue distanceMeasure:(NSString *)distanceMeasure limit:(NSNumber *_Nullable)limit - distanceField:(NSString *_Nullable)distanceField; + distanceField:(FIRExprBridge *_Nullable)distanceField; @end NS_SWIFT_SENDABLE diff --git a/Firestore/Swift/Source/ExprImpl.swift b/Firestore/Swift/Source/ExprImpl.swift index 6d55a7b479b..51a82966b86 100644 --- a/Firestore/Swift/Source/ExprImpl.swift +++ b/Firestore/Swift/Source/ExprImpl.swift @@ -25,14 +25,12 @@ public extension Expr { // MARK: Arithmetic Operators - func add(_ second: Expr, _ others: Expr...) -> FunctionExpr { - return FunctionExpr("add", [self, second] + others) + func add(_ value: Expr) -> FunctionExpr { + return FunctionExpr("add", [self, value]) } - func add(_ second: Sendable, _ others: Sendable...) -> FunctionExpr { - let exprs = [self] + [Helper.sendableToExpr(second)] + others - .map { Helper.sendableToExpr($0) } - return FunctionExpr("add", exprs) + func add(_ value: Sendable) -> FunctionExpr { + return FunctionExpr("add", [self, Helper.sendableToExpr(value)]) } func subtract(_ other: Expr) -> FunctionExpr { @@ -43,14 +41,12 @@ public extension Expr { return FunctionExpr("subtract", [self, Helper.sendableToExpr(other)]) } - func multiply(_ second: Expr, _ others: Expr...) -> FunctionExpr { - return FunctionExpr("multiply", [self, second] + others) + func multiply(_ value: Expr) -> FunctionExpr { + return FunctionExpr("multiply", [self, value]) } - func multiply(_ second: Sendable, _ others: Sendable...) -> FunctionExpr { - let exprs = [self] + [Helper.sendableToExpr(second)] + others - .map { Helper.sendableToExpr($0) } - return FunctionExpr("multiply", exprs) + func multiply(_ value: Sendable) -> FunctionExpr { + return FunctionExpr("multiply", [self, Helper.sendableToExpr(value)]) } func divide(_ other: Expr) -> FunctionExpr { @@ -89,34 +85,32 @@ public extension Expr { return BooleanExpr("array_contains", [self, Helper.sendableToExpr(element)]) } - func arrayContainsAll(_ values: Expr...) -> BooleanExpr { - return BooleanExpr("array_contains_all", [self] + values) + func arrayContainsAll(_ values: [Expr]) -> BooleanExpr { + return BooleanExpr("array_contains_all", [self, Helper.array(values)]) } - func arrayContainsAll(_ values: Sendable...) -> BooleanExpr { - let exprValues = values.map { Helper.sendableToExpr($0) } - return BooleanExpr("array_contains_all", [self] + exprValues) + func arrayContainsAll(_ values: [Sendable]) -> BooleanExpr { + return BooleanExpr("array_contains_all", [self, Helper.array(values)]) } - func arrayContainsAny(_ values: Expr...) -> BooleanExpr { - return BooleanExpr("array_contains_any", [self] + values) + func arrayContainsAny(_ values: [Expr]) -> BooleanExpr { + return BooleanExpr("array_contains_any", [self, Helper.array(values)]) } - func arrayContainsAny(_ values: Sendable...) -> BooleanExpr { - let exprValues = values.map { Helper.sendableToExpr($0) } - return BooleanExpr("array_contains_any", [self] + exprValues) + func arrayContainsAny(_ values: [Sendable]) -> BooleanExpr { + return BooleanExpr("array_contains_any", [self, Helper.array(values)]) } func arrayLength() -> FunctionExpr { return FunctionExpr("array_length", [self]) } - func arrayOffset(_ offset: Int) -> FunctionExpr { - return FunctionExpr("array_offset", [self, Helper.sendableToExpr(offset)]) + func arrayGet(_ offset: Int) -> FunctionExpr { + return FunctionExpr("array_get", [self, Helper.sendableToExpr(offset)]) } - func arrayOffset(_ offsetExpr: Expr) -> FunctionExpr { - return FunctionExpr("array_offset", [self, offsetExpr]) + func arrayGet(_ offsetExpr: Expr) -> FunctionExpr { + return FunctionExpr("array_get", [self, offsetExpr]) } func gt(_ other: Expr) -> BooleanExpr { @@ -172,31 +166,28 @@ public extension Expr { return BooleanExpr("eq", [self, exprOther]) } - func neq(_ others: Expr...) -> BooleanExpr { - return BooleanExpr("neq", [self] + others) + func neq(_ other: Expr) -> BooleanExpr { + return BooleanExpr("neq", [self, other]) } - func neq(_ others: Sendable...) -> BooleanExpr { - let exprOthers = others.map { Helper.sendableToExpr($0) } - return BooleanExpr("neq", [self] + exprOthers) + func neq(_ other: Sendable) -> BooleanExpr { + return BooleanExpr("neq", [self, Helper.sendableToExpr(other)]) } - func eqAny(_ others: Expr...) -> BooleanExpr { - return BooleanExpr("eq_any", [self] + others) + func eqAny(_ others: [Expr]) -> BooleanExpr { + return BooleanExpr("eq_any", [self, Helper.array(others)]) } - func eqAny(_ others: Sendable...) -> BooleanExpr { - let exprOthers = others.map { Helper.sendableToExpr($0) } - return BooleanExpr("eq_any", [self] + exprOthers) + func eqAny(_ others: [Sendable]) -> BooleanExpr { + return BooleanExpr("eq_any", [self, Helper.array(others)]) } - func notEqAny(_ others: Expr...) -> BooleanExpr { - return BooleanExpr("not_eq_any", [self] + others) + func notEqAny(_ others: [Expr]) -> BooleanExpr { + return BooleanExpr("not_eq_any", [self, Helper.array(others)]) } - func notEqAny(_ others: Sendable...) -> BooleanExpr { - let exprOthers = others.map { Helper.sendableToExpr($0) } - return BooleanExpr("not_eq_any", [self] + exprOthers) + func notEqAny(_ others: [Sendable]) -> BooleanExpr { + return BooleanExpr("not_eq_any", [self, Helper.array(others)]) } // MARK: Checks @@ -237,12 +228,12 @@ public extension Expr { return FunctionExpr("char_length", [self]) } - func like(_ pattern: String) -> FunctionExpr { - return FunctionExpr("like", [self, Helper.sendableToExpr(pattern)]) + func like(_ pattern: String) -> BooleanExpr { + return BooleanExpr("like", [self, Helper.sendableToExpr(pattern)]) } - func like(_ pattern: Expr) -> FunctionExpr { - return FunctionExpr("like", [self, pattern]) + func like(_ pattern: Expr) -> BooleanExpr { + return BooleanExpr("like", [self, pattern]) } func regexContains(_ pattern: String) -> BooleanExpr { @@ -414,13 +405,13 @@ public extension Expr { } func logicalMinimum(_ second: Expr, _ others: Expr...) -> FunctionExpr { - return FunctionExpr("logical_min", [self, second] + others) + return FunctionExpr("logical_minimum", [self, second] + others) } func logicalMinimum(_ second: Sendable, _ others: Sendable...) -> FunctionExpr { let exprs = [self] + [Helper.sendableToExpr(second)] + others .map { Helper.sendableToExpr($0) } - return FunctionExpr("logical_min", exprs) + return FunctionExpr("logical_minimum", exprs) } // MARK: Vector Operations diff --git a/Firestore/Swift/Source/Helper/PipelineHelper.swift b/Firestore/Swift/Source/Helper/PipelineHelper.swift index cde334b7ae8..0d0e6b55d59 100644 --- a/Firestore/Swift/Source/Helper/PipelineHelper.swift +++ b/Firestore/Swift/Source/Helper/PipelineHelper.swift @@ -18,12 +18,14 @@ enum Helper { return Constant.nil } - if value is Expr { - return value as! Expr - } else if value is [String: Sendable?] { - return map(value as! [String: Sendable?]) - } else if value is [Sendable?] { - return array(value as! [Sendable?]) + if let exprValue = value as? Expr { + return exprValue + } else if let dictionaryValue = value as? [String: Sendable?] { + return map(dictionaryValue) + } else if let arrayValue = value as? [Sendable?] { + return array(arrayValue) + } else if let timeUnitValue = value as? TimeUnit { + return Constant(timeUnitValue.rawValue) } else { return Constant(value) } @@ -31,7 +33,9 @@ enum Helper { static func selectablesToMap(selectables: [Selectable]) -> [String: Expr] { let exprMap = selectables.reduce(into: [String: Expr]()) { result, selectable in - let value = selectable as! SelectableWrapper + guard let value = selectable as? SelectableWrapper else { + fatalError("Selectable class must conform to SelectableWrapper.") + } result[value.alias] = value.expr } return exprMap @@ -55,22 +59,18 @@ enum Helper { // This function is used to convert Swift type into Objective-C type. static func sendableToAnyObjectForRawStage(_ value: Sendable?) -> AnyObject { - guard let value = value else { - return Constant.nil.bridge - } - - guard !(value is NSNull) else { + guard let value = value, !(value is NSNull) else { return Constant.nil.bridge } - if value is Expr { - return (value as! Expr).toBridge() - } else if value is AggregateFunction { - return (value as! AggregateFunction).toBridge() - } else if value is [String: Sendable?] { - let mappedValue: [String: Sendable?] = (value as! [String: Sendable?]).mapValues { - if $0 is AggregateFunction { - return ($0 as! AggregateFunction).toBridge() + if let exprValue = value as? Expr { + return exprValue.toBridge() + } else if let aggregateFunctionValue = value as? AggregateFunction { + return aggregateFunctionValue.toBridge() + } else if let dictionaryValue = value as? [String: Sendable?] { + let mappedValue: [String: Sendable] = dictionaryValue.mapValues { + if let aggFunc = $0 as? AggregateFunction { + return aggFunc.toBridge() } return sendableToExpr($0).toBridge() } @@ -79,4 +79,14 @@ enum Helper { return Constant(value).bridge } } + + static func convertObjCToSwift(_ objValue: Sendable) -> Sendable? { + switch objValue { + case is NSNull: + return nil + + default: + return objValue + } + } } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr.swift index 7cd9b0d5adf..d05c6a4c251 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr.swift @@ -49,10 +49,9 @@ public protocol Expr: Sendable { /// Field("subtotal").add(Field("tax"), Field("shipping")) /// ``` /// - /// - Parameter second: An `Expr` to add to this expression. - /// - Parameter others: Optional additional `Expr` values to add. + /// - Parameter value: Expr` values to add. /// - Returns: A new `FunctionExpr` representing the addition operation. - func add(_ second: Expr, _ others: Expr...) -> FunctionExpr + func add(_ value: Expr) -> FunctionExpr /// Creates an expression that adds this expression to one or more literal values. /// Assumes `self` and all parameters evaluate to compatible types for addition. @@ -65,10 +64,9 @@ public protocol Expr: Sendable { /// Field("score").add(10, 20, -5) /// ``` /// - /// - Parameter second: A `Sendable` literal value to add to this expression. - /// - Parameter others: Optional additional `Sendable` literal values to add. + /// - Parameter value: Expr` value to add. /// - Returns: A new `FunctionExpr` representing the addition operation. - func add(_ second: Sendable, _ others: Sendable...) -> FunctionExpr + func add(_ value: Sendable) -> FunctionExpr /// Creates an expression that subtracts another expression from this expression. /// Assumes `self` and `other` evaluate to numeric types. @@ -105,10 +103,9 @@ public protocol Expr: Sendable { /// Field("rate").multiply(Field("time"), Field("conversionFactor")) /// ``` /// - /// - Parameter second: An `Expr` to multiply by. - /// - Parameter others: Optional additional `Expr` values to multiply by. + /// - Parameter value: `Expr` value to multiply by. /// - Returns: A new `FunctionExpr` representing the multiplication operation. - func multiply(_ second: Expr, _ others: Expr...) -> FunctionExpr + func multiply(_ value: Expr) -> FunctionExpr /// Creates an expression that multiplies this expression by one or more literal values. /// Assumes `self` evaluates to a numeric type. @@ -121,10 +118,9 @@ public protocol Expr: Sendable { /// Field("base").multiply(2, 3.0) /// ``` /// - /// - Parameter second: A `Sendable` literal value to multiply by. - /// - Parameter others: Optional additional `Sendable` literal values to multiply by. + /// - Parameter value: `Sendable` literal value to multiply by. /// - Returns: A new `FunctionExpr` representing the multiplication operation. - func multiply(_ second: Sendable, _ others: Sendable...) -> FunctionExpr + func multiply(_ value: Sendable) -> FunctionExpr /// Creates an expression that divides this expression by another expression. /// Assumes `self` and `other` evaluate to numeric types. @@ -239,13 +235,13 @@ public protocol Expr: Sendable { /// ```swift /// // Check if 'candidateSkills' contains all skills from 'requiredSkill1' and 'requiredSkill2' /// fields - /// Field("candidateSkills").arrayContainsAll(Field("requiredSkill1"), Field("requiredSkill2")) + /// Field("candidateSkills").arrayContainsAll([Field("requiredSkill1"), Field("requiredSkill2")]) /// ``` /// - /// - Parameter values: A variadic list of `Expr` elements to check for in the array represented + /// - Parameter values: A list of `Expr` elements to check for in the array represented /// by `self`. /// - Returns: A new `BooleanExpr` representing the 'array_contains_all' comparison. - func arrayContainsAll(_ values: Expr...) -> BooleanExpr + func arrayContainsAll(_ values: [Expr]) -> BooleanExpr /// Creates an expression that checks if an array (from `self`) contains all the specified literal /// elements. @@ -253,13 +249,13 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if 'tags' contains both "urgent" and "review" - /// Field("tags").arrayContainsAll("urgent", "review") + /// Field("tags").arrayContainsAll(["urgent", "review"]) /// ``` /// - /// - Parameter values: A variadic list of `Sendable` literal elements to check for in the array + /// - Parameter values: A list of `Sendable` literal elements to check for in the array /// represented by `self`. /// - Returns: A new `BooleanExpr` representing the 'array_contains_all' comparison. - func arrayContainsAll(_ values: Sendable...) -> BooleanExpr + func arrayContainsAll(_ values: [Sendable]) -> BooleanExpr /// Creates an expression that checks if an array (from `self`) contains any of the specified /// element expressions. @@ -267,13 +263,13 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if 'userGroups' contains any group from 'allowedGroup1' or 'allowedGroup2' fields - /// Field("userGroups").arrayContainsAny(Field("allowedGroup1"), Field("allowedGroup2")) + /// Field("userGroups").arrayContainsAny([Field("allowedGroup1"), Field("allowedGroup2")]) /// ``` /// - /// - Parameter values: A variadic list of `Expr` elements to check for in the array represented + /// - Parameter values: A list of `Expr` elements to check for in the array represented /// by `self`. /// - Returns: A new `BooleanExpr` representing the 'array_contains_any' comparison. - func arrayContainsAny(_ values: Expr...) -> BooleanExpr + func arrayContainsAny(_ values: [Expr]) -> BooleanExpr /// Creates an expression that checks if an array (from `self`) contains any of the specified /// literal elements. @@ -281,13 +277,13 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if 'categories' contains either "electronics" or "books" - /// Field("categories").arrayContainsAny("electronics", "books") + /// Field("categories").arrayContainsAny(["electronics", "books"]) /// ``` /// - /// - Parameter values: A variadic list of `Sendable` literal elements to check for in the array + /// - Parameter values: A list of `Sendable` literal elements to check for in the array /// represented by `self`. /// - Returns: A new `BooleanExpr` representing the 'array_contains_any' comparison. - func arrayContainsAny(_ values: Sendable...) -> BooleanExpr + func arrayContainsAny(_ values: [Sendable]) -> BooleanExpr /// Creates an expression that calculates the length of an array. /// Assumes `self` evaluates to an array. @@ -308,14 +304,14 @@ public protocol Expr: Sendable { /// /// ```swift /// // Return the value in the 'tags' field array at index 1. - /// Field("tags").arrayOffset(1) + /// Field("tags").arrayGet(1) /// // Return the last element in the 'tags' field array. - /// Field("tags").arrayOffset(-1) + /// Field("tags").arrayGet(-1) /// ``` /// /// - Parameter offset: The literal `Int` offset of the element to return. - /// - Returns: A new `FunctionExpr` representing the 'arrayOffset' operation. - func arrayOffset(_ offset: Int) -> FunctionExpr + /// - Returns: A new `FunctionExpr` representing the 'arrayGet' operation. + func arrayGet(_ offset: Int) -> FunctionExpr /// Creates an expression that accesses an element in an array (from `self`) at the offset /// specified by an expression. @@ -325,13 +321,13 @@ public protocol Expr: Sendable { /// /// ```swift /// // Return the value in the tags field array at index specified by field 'favoriteTagIndex'. - /// Field("tags").arrayOffset(Field("favoriteTagIndex")) + /// Field("tags").arrayGet(Field("favoriteTagIndex")) /// ``` /// /// - Parameter offsetExpr: An `Expr` (evaluating to an Int) representing the offset of the /// element to return. - /// - Returns: A new `FunctionExpr` representing the 'arrayOffset' operation. - func arrayOffset(_ offsetExpr: Expr) -> FunctionExpr + /// - Returns: A new `FunctionExpr` representing the 'arrayGet' operation. + func arrayGet(_ offsetExpr: Expr) -> FunctionExpr // MARK: Equality with Sendable @@ -341,12 +337,12 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if 'categoryID' field is equal to 'featuredCategory' or 'popularCategory' fields - /// Field("categoryID").eqAny(Field("featuredCategory"), Field("popularCategory")) + /// Field("categoryID").eqAny([Field("featuredCategory"), Field("popularCategory")]) /// ``` /// - /// - Parameter others: A variadic list of `Expr` values to check against. + /// - Parameter others: A list of `Expr` values to check against. /// - Returns: A new `BooleanExpr` representing the 'IN' comparison (eq_any). - func eqAny(_ others: Expr...) -> BooleanExpr + func eqAny(_ others: [Expr]) -> BooleanExpr /// Creates an expression that checks if this expression is equal to any of the provided literal /// values. @@ -354,12 +350,12 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if 'category' is "Electronics", "Books", or "Home Goods" - /// Field("category").eqAny("Electronics", "Books", "Home Goods") + /// Field("category").eqAny(["Electronics", "Books", "Home Goods"]) /// ``` /// - /// - Parameter others: A variadic list of `Sendable` literal values to check against. + /// - Parameter others: A list of `Sendable` literal values to check against. /// - Returns: A new `BooleanExpr` representing the 'IN' comparison (eq_any). - func eqAny(_ others: Sendable...) -> BooleanExpr + func eqAny(_ others: [Sendable]) -> BooleanExpr /// Creates an expression that checks if this expression is not equal to any of the provided /// expression values. @@ -367,12 +363,12 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if 'statusValue' is not equal to 'archivedStatus' or 'deletedStatus' fields - /// Field("statusValue").notEqAny(Field("archivedStatus"), Field("deletedStatus")) + /// Field("statusValue").notEqAny([Field("archivedStatus"), Field("deletedStatus")]) /// ``` /// - /// - Parameter others: A variadic list of `Expr` values to check against. + /// - Parameter others: A list of `Expr` values to check against. /// - Returns: A new `BooleanExpr` representing the 'NOT IN' comparison (not_eq_any). - func notEqAny(_ others: Expr...) -> BooleanExpr + func notEqAny(_ others: [Expr]) -> BooleanExpr /// Creates an expression that checks if this expression is not equal to any of the provided /// literal values. @@ -380,12 +376,12 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if 'status' is neither "pending" nor "archived" - /// Field("status").notEqAny("pending", "archived") + /// Field("status").notEqAny(["pending", "archived"]) /// ``` /// - /// - Parameter others: A variadic list of `Sendable` literal values to check against. + /// - Parameter others: A list of `Sendable` literal values to check against. /// - Returns: A new `BooleanExpr` representing the 'NOT IN' comparison (not_eq_any). - func notEqAny(_ others: Sendable...) -> BooleanExpr + func notEqAny(_ others: [Sendable]) -> BooleanExpr // MARK: Checks @@ -428,7 +424,7 @@ public protocol Expr: Sendable { /// /// ```swift /// // Check if accessing a non-existent array index causes an error - /// Field("myArray").arrayOffset(100).isError() + /// Field("myArray").arrayGet(100).isError() /// ``` /// /// - Returns: A new `BooleanExpr` representing the 'isError' check. @@ -495,7 +491,7 @@ public protocol Expr: Sendable { /// /// - Parameter pattern: The literal string pattern to search for. Use "%" as a wildcard. /// - Returns: A new `FunctionExpr` representing the 'like' comparison. - func like(_ pattern: String) -> FunctionExpr + func like(_ pattern: String) -> BooleanExpr /// Creates an expression that performs a case-sensitive string comparison using wildcards against /// an expression pattern. @@ -509,7 +505,7 @@ public protocol Expr: Sendable { /// - Parameter pattern: An `Expr` (evaluating to a string) representing the pattern to search /// for. /// - Returns: A new `FunctionExpr` representing the 'like' comparison. - func like(_ pattern: Expr) -> FunctionExpr + func like(_ pattern: Expr) -> BooleanExpr /// Creates an expression that checks if a string (from `self`) contains a specified regular /// expression literal as a substring. @@ -1524,7 +1520,7 @@ public protocol Expr: Sendable { /// /// ```swift /// // Get first item in 'title' array, or return "Default Title" if error (e.g., empty array) - /// Field("title").arrayOffset(0).ifError("Default Title") + /// Field("title").arrayGet(0).ifError("Default Title") /// ``` /// /// - Parameter catchValue: The literal `Sendable` value to return if this expression errors. diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift index bfb958b468c..8f6b3709892 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift @@ -33,7 +33,12 @@ public struct Constant: Expr, BridgeWrapper, @unchecked Sendable { } } - // Initializer for numbers + // Initializer for integer + public init(_ value: Int) { + self.init(value as Any) + } + + // Initializer for double public init(_ value: Double) { self.init(value as Any) } @@ -49,7 +54,7 @@ public struct Constant: Expr, BridgeWrapper, @unchecked Sendable { } // Initializer for Bytes - public init(_ value: [UInt8]) { + public init(_ value: Data) { self.init(value as Any) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Field.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Field.swift index fa1dc7d7510..99dc7e1b21d 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Field.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Field.swift @@ -24,9 +24,17 @@ public class Field: ExprBridge, Expr, Selectable, BridgeWrapper, SelectableWrapp public let fieldName: String - public init(_ fieldName: String) { - self.fieldName = fieldName + public init(_ name: String) { + let fieldBridge = FieldBridge(name: name) + bridge = fieldBridge + fieldName = fieldBridge.field_name() + alias = fieldName + } + + public init(_ path: FieldPath) { + let fieldBridge = FieldBridge(path: path) + bridge = fieldBridge + fieldName = fieldBridge.field_name() alias = fieldName - bridge = FieldBridge(alias) } } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift index 8b4bfe23b80..701276d51f7 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift @@ -21,6 +21,10 @@ public class BooleanExpr: FunctionExpr, @unchecked Sendable { return AggregateFunction("count_if", [self]) } + public func then(_ thenExpr: Expr, else elseExpr: Expr) -> FunctionExpr { + return FunctionExpr("cond", [self, thenExpr, elseExpr]) + } + public static func && (lhs: BooleanExpr, rhs: @autoclosure () throws -> BooleanExpr) rethrows -> BooleanExpr { try BooleanExpr("and", [lhs, rhs()]) diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/RandomExpr.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/RandomExpr.swift new file mode 100644 index 00000000000..5ea39db81fc --- /dev/null +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/RandomExpr.swift @@ -0,0 +1,19 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +public class RandomExpr: FunctionExpr, @unchecked Sendable { + public init() { + super.init("rand", []) + } +} diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift index e5728d44409..67e55663268 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift @@ -27,7 +27,7 @@ public struct PipelineResult: @unchecked Sendable { self.bridge = bridge ref = self.bridge.reference id = self.bridge.documentID - data = self.bridge.data() + data = self.bridge.data().mapValues { Helper.convertObjCToSwift($0) } createTime = self.bridge.create_time updateTime = self.bridge.update_time } @@ -51,20 +51,20 @@ public struct PipelineResult: @unchecked Sendable { /// - Parameter fieldPath: The field path (e.g., "foo" or "foo.bar"). /// - Returns: The data at the specified field location or `nil` if no such field exists. public func get(_ fieldName: String) -> Sendable? { - return bridge.get(fieldName) + return Helper.convertObjCToSwift(bridge.get(fieldName)) } /// Retrieves the field specified by `fieldPath`. /// - Parameter fieldPath: The field path (e.g., "foo" or "foo.bar"). /// - Returns: The data at the specified field location or `nil` if no such field exists. public func get(_ fieldPath: FieldPath) -> Sendable? { - return bridge.get(fieldPath) + return Helper.convertObjCToSwift(bridge.get(fieldPath)) } /// Retrieves the field specified by `fieldPath`. /// - Parameter fieldPath: The field path (e.g., "foo" or "foo.bar"). /// - Returns: The data at the specified field location or `nil` if no such field exists. public func get(_ field: Field) -> Sendable? { - return bridge.get(field.fieldName) + return Helper.convertObjCToSwift(bridge.get(field.fieldName)) } } diff --git a/Firestore/Swift/Source/SwiftAPI/Stages.swift b/Firestore/Swift/Source/SwiftAPI/Stages.swift index 9ecab6945f4..9f6d071d9ff 100644 --- a/Firestore/Swift/Source/SwiftAPI/Stages.swift +++ b/Firestore/Swift/Source/SwiftAPI/Stages.swift @@ -262,7 +262,7 @@ class FindNearest: Stage { vectorValue: VectorValue(vectorValue), distanceMeasure: distanceMeasure.kind.rawValue, limit: limit as NSNumber?, - distanceField: distanceField + distanceField: distanceField.map { Field($0).toBridge() } ?? nil ) } } diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index cf522b9e1f1..f05a7dcc9eb 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -485,10 +485,9 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { Constant(GeoPoint(latitude: 0.1, longitude: 0.2)).as("geoPoint"), Constant(refTimestamp).as("timestamp"), Constant(refDate).as("date"), // Firestore will convert this to a Timestamp - Constant([1, 2, 3, 4, 5, 6, 7, 0] as [UInt8]).as("bytes"), + Constant(Data([1, 2, 3, 4, 5, 6, 7, 0])).as("bytes"), Constant(db.document("foo/bar")).as("documentReference"), Constant(VectorValue([1, 2, 3])).as("vectorValue"), - Constant([1, 2, 3]).as("arrayValue"), // Treated as an array of numbers ] let constantsSecond: [Selectable] = [ @@ -500,7 +499,7 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { "geoPoint": GeoPoint(latitude: 0.1, longitude: 0.2), "timestamp": refTimestamp, "date": refDate, - "uint8Array": Data([1, 2, 3, 4, 5, 6, 7, 0]), + "bytesArray": Data([1, 2, 3, 4, 5, 6, 7, 0]), "documentReference": Constant(db.document("foo/bar")), "vectorValue": VectorValue([1, 2, 3]), "map": [ @@ -517,7 +516,7 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { GeoPoint(latitude: 10.1, longitude: 20.2), Timestamp(date: Date(timeIntervalSince1970: 1_700_000_000)), // Different timestamp Date(timeIntervalSince1970: 1_700_000_000), // Different date - [11, 22, 33] as [UInt8], + Data([11, 22, 33]), db.document("another/doc"), VectorValue([7, 8, 9]), [ @@ -536,10 +535,9 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { "geoPoint": GeoPoint(latitude: 0.1, longitude: 0.2), "timestamp": refTimestamp, "date": refTimestamp, // Dates are converted to Timestamps - "bytes": [1, 2, 3, 4, 5, 6, 7, 0] as [UInt8], + "bytes": Data([1, 2, 3, 4, 5, 6, 7, 0]), "documentReference": db.document("foo/bar"), "vectorValue": VectorValue([1, 2, 3]), - "arrayValue": [1, 2, 3], "map": [ "number": 1, "string": "a string", @@ -548,7 +546,7 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { "geoPoint": GeoPoint(latitude: 0.1, longitude: 0.2), "timestamp": refTimestamp, "date": refTimestamp, - "uint8Array": Data([1, 2, 3, 4, 5, 6, 7, 0]), + "bytesArray": Data([1, 2, 3, 4, 5, 6, 7, 0]), "documentReference": db.document("foo/bar"), "vectorValue": VectorValue([1, 2, 3]), "map": [ @@ -565,7 +563,7 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { GeoPoint(latitude: 10.1, longitude: 20.2), Timestamp(date: Date(timeIntervalSince1970: 1_700_000_000)), Timestamp(date: Date(timeIntervalSince1970: 1_700_000_000)), // Dates are converted - [11, 22, 33] as [UInt8], + Data([11, 22, 33]), db.document("another/doc"), VectorValue([7, 8, 9]), [ @@ -595,9 +593,6 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { // A pipeline query with .select against an empty collection might not behave as expected. try await randomCol.document("dummyDoc").setData(["field": "value"]) - let refDate = Date(timeIntervalSince1970: 1_678_886_400) - let refTimestamp = Timestamp(date: refDate) - let constantsFirst: [Selectable] = [ Constant.nil.as("nil"), ] @@ -1379,12 +1374,33 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { .collection(collRef.path) .union(db.pipeline() .collection(collRef.path)) + .sort(Field(FieldPath.documentID()).ascending()) let snapshot = try await pipeline.execute() - let bookSequence = (1 ... 10).map { "book\($0)" } - let repeatedIDs = bookSequence + bookSequence - TestHelper.compare(pipelineSnapshot: snapshot, expectedIDs: repeatedIDs, enforceOrder: false) + let books = [ + "book1", + "book1", + "book10", + "book10", + "book2", + "book2", + "book3", + "book3", + "book4", + "book4", + "book5", + "book5", + "book6", + "book6", + "book7", + "book7", + "book8", + "book8", + "book9", + "book9", + ] + TestHelper.compare(pipelineSnapshot: snapshot, expectedIDs: books, enforceOrder: false) } func testUnnestStage() async throws { @@ -1508,4 +1524,1642 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) } + + func testFindNearest() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let measures: [DistanceMeasure] = [.euclidean, .dotProduct, .cosine] + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy"], + ["title": "One Hundred Years of Solitude"], + ["title": "The Handmaid's Tale"], + ] + + for measure in measures { + let pipeline = db.pipeline() + .collection(collRef.path) + .findNearest( + field: Field("embedding"), + vectorValue: [10, 1, 3, 1, 2, 1, 1, 1, 1, 1], + distanceMeasure: measure, limit: 3 + ) + .select("title") + let snapshot = try await pipeline.execute() + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + } + + func testFindNearestWithDistance() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let expectedResults: [[String: Sendable]] = [ + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "computedDistance": 1.0, + ], + [ + "title": "One Hundred Years of Solitude", + "computedDistance": 12.041594578792296, + ], + ] + + let pipeline = db.pipeline() + .collection(collRef.path) + .findNearest( + field: Field("embedding"), + vectorValue: [10, 1, 2, 1, 1, 1, 1, 1, 1, 1], + distanceMeasure: .euclidean, limit: 2, + distanceField: "computedDistance" + ) + .select("title", "computedDistance") + let snapshot = try await pipeline.execute() + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) + } + + func testLogicalMaxWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select( + Field("title"), + Field("published").logicalMaximum(Constant(1960), 1961).as("published-safe") + ) + .sort(Field("title").ascending()) + .limit(3) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "1984", "published-safe": 1961], + ["title": "Crime and Punishment", "published-safe": 1961], + ["title": "Dune", "published-safe": 1965], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testLogicalMinWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select( + Field("title"), + Field("published").logicalMinimum(Constant(1960), 1961).as("published-safe") + ) + .sort(Field("title").ascending()) + .limit(3) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "1984", "published-safe": 1949], + ["title": "Crime and Punishment", "published-safe": 1866], + ["title": "Dune", "published-safe": 1960], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testCondWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select( + Field("title"), + Field("published").lt(1960).then(Constant(1960), else: Field("published")) + .as("published-safe") + ) + .sort(Field("title").ascending()) + .limit(3) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "1984", "published-safe": 1960], + ["title": "Crime and Punishment", "published-safe": 1960], + ["title": "Dune", "published-safe": 1965], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testEqAnyWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("published").eqAny([1979, 1999, 1967])) + .sort(Field("title").descending()) + .select("title") + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy"], + ["title": "One Hundred Years of Solitude"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testNotEqAnyWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("published").notEqAny([1965, 1925, 1949, 1960, 1866, 1985, 1954, 1967, 1979])) + .select("title") + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "Pride and Prejudice"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) + } + + func testArrayContainsWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("tags").arrayContains("comedy")) + .select("title") + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) + } + + func testArrayContainsAnyWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("tags").arrayContainsAny(["comedy", "classic"])) + .sort(Field("title").descending()) + .select("title") + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy"], + ["title": "Pride and Prejudice"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testArrayContainsAllWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("tags").arrayContainsAll(["adventure", "magic"])) + .select("title") + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Lord of the Rings"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) + } + + func testArrayLengthWorks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select(Field("tags").arrayLength().as("tagsCount")) + .where(Field("tagsCount").eq(3)) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 10) + } + + func testStrConcat() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("author").ascending()) + .select(Field("author").strConcat(Constant(" - "), Field("title")).as("bookInfo")) + .limit(1) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["bookInfo": "Douglas Adams - The Hitchhiker's Guide to the Galaxy"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testStartsWith() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").startsWith("The")) + .select("title") + .sort(Field("title").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Great Gatsby"], + ["title": "The Handmaid's Tale"], + ["title": "The Hitchhiker's Guide to the Galaxy"], + ["title": "The Lord of the Rings"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testEndsWith() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").endsWith("y")) + .select("title") + .sort(Field("title").descending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy"], + ["title": "The Great Gatsby"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testStrContains() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").strContains("'s")) + .select("title") + .sort(Field("title").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Handmaid's Tale"], + ["title": "The Hitchhiker's Guide to the Galaxy"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testCharLength() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select( + Field("title").charLength().as("titleLength"), + Field("title") + ) + .where(Field("titleLength").gt(20)) + .sort(Field("title").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["titleLength": 29, "title": "One Hundred Years of Solitude"], + ["titleLength": 36, "title": "The Hitchhiker's Guide to the Galaxy"], + ["titleLength": 21, "title": "The Lord of the Rings"], + ["titleLength": 21, "title": "To Kill a Mockingbird"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testLike() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").like("%Guide%")) + .select("title") + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) + } + + func testRegexContains() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").regexContains("(?i)(the|of)")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 5) + } + + func testRegexMatches() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").regexMatch(".*(?i)(the|of).*")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 5) + } + + func testArithmeticOperations() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("To Kill a Mockingbird")) + .select( + Field("rating").add(1).as("ratingPlusOne"), + Field("published").subtract(1900).as("yearsSince1900"), + Field("rating").multiply(10).as("ratingTimesTen"), + Field("rating").divide(2).as("ratingDividedByTwo"), + Field("rating").multiply(20).as("ratingTimes20"), + Field("rating").add(3).as("ratingPlus3"), + Field("rating").mod(2).as("ratingMod2") + ) + .limit(1) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + if let resultDoc = snapshot.results.first { + let expectedResults: [String: Sendable?] = [ + "ratingPlusOne": 5.2, + "yearsSince1900": 60, + "ratingTimesTen": 42.0, + "ratingDividedByTwo": 2.1, + "ratingTimes20": 84.0, + "ratingPlus3": 7.2, + "ratingMod2": 0.20000000000000018, + ] + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for arithmetic operations test") + } + } + + func testComparisonOperators() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where( + Field("rating").gt(4.2) && + Field("rating").lte(4.5) && + Field("genre").neq("Science Fiction") + ) + .select("rating", "title") + .sort(Field("title").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["rating": 4.3, "title": "Crime and Punishment"], + ["rating": 4.3, "title": "One Hundred Years of Solitude"], + ["rating": 4.5, "title": "Pride and Prejudice"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testLogicalOperators() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where( + (Field("rating").gt(4.5) && Field("genre").eq("Science Fiction")) || + Field("published").lt(1900) + ) + .select("title") + .sort(Field("title").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "Crime and Punishment"], + ["title": "Dune"], + ["title": "Pride and Prejudice"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testChecks() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + // Part 1 + var pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select( + Field("rating").isNull().as("ratingIsNull"), + Field("rating").isNan().as("ratingIsNaN"), + Field("title").arrayGet(0).isError().as("isError"), + Field("title").arrayGet(0).ifError(Constant("was error")).as("ifError"), + Field("foo").isAbsent().as("isAbsent"), + Field("title").isNotNull().as("titleIsNotNull"), + Field("cost").isNotNan().as("costIsNotNan"), + Field("fooBarBaz").exists().as("fooBarBazExists"), + Field("title").exists().as("titleExists") + ) + + var snapshot = try await pipeline.execute() + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document for checks part 1") + + if let resultDoc = snapshot.results.first { + let expectedResults: [String: Sendable?] = [ + "ratingIsNull": false, + "ratingIsNaN": false, + "isError": true, + "ifError": "was error", + "isAbsent": true, + "titleIsNotNull": true, + "costIsNotNan": false, + "fooBarBazExists": false, + "titleExists": true, + ] + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for checks part 1") + } + + // Part 2 + pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select( + Field("rating").isNull().as("ratingIsNull"), + Field("rating").isNan().as("ratingIsNaN"), + Field("title").arrayGet(0).isError().as("isError"), + Field("title").arrayGet(0).ifError(Constant("was error")).as("ifError"), + Field("foo").isAbsent().as("isAbsent"), + Field("title").isNotNull().as("titleIsNotNull"), + Field("cost").isNotNan().as("costIsNotNan") + ) + + snapshot = try await pipeline.execute() + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document for checks part 2") + + if let resultDoc = snapshot.results.first { + let expectedResults: [String: Sendable?] = [ + "ratingIsNull": false, + "ratingIsNaN": false, + "isError": true, + "ifError": "was error", + "isAbsent": true, + "titleIsNotNull": true, + "costIsNotNan": false, + ] + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for checks part 2") + } + } + + func testMapGet() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("published").descending()) + .select( + Field("awards").mapGet("hugo").as("hugoAward"), + Field("awards").mapGet("others").as("others"), + Field("title") + ) + .where(Field("hugoAward").eq(true)) + + let snapshot = try await pipeline.execute() + + // Expected results are ordered by "published" descending for those with hugoAward == true + // 1. The Hitchhiker's Guide to the Galaxy (1979) + // 2. Dune (1965) + let expectedResults: [[String: Sendable?]] = [ + [ + "hugoAward": true, + "title": "The Hitchhiker's Guide to the Galaxy", + "others": ["unknown": ["year": 1980]], + ], + [ + "hugoAward": true, + "title": "Dune", + "others": nil, + ], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testDistanceFunctions() async throws { + let db = firestore() + let randomCol = collectionRef() // Ensure a unique collection for the test + // Add a dummy document to the collection for the select stage to operate on. + try await randomCol.document("dummyDocForDistanceTest").setData(["field": "value"]) + + let sourceVector: [Double] = [0.1, 0.1] + let targetVector: [Double] = [0.5, 0.8] + let targetVectorValue = VectorValue(targetVector) + + let expectedCosineDistance = 0.02560880430538015 + let expectedDotProductDistance = 0.13 + let expectedEuclideanDistance = 0.806225774829855 + let accuracy = 0.000000000000001 // Define a suitable accuracy for floating-point comparisons + + let pipeline = db.pipeline() + .collection(randomCol.path) + .select( + Constant(VectorValue(sourceVector)).cosineDistance(targetVectorValue).as("cosineDistance"), + Constant(VectorValue(sourceVector)).dotProduct(targetVectorValue).as("dotProductDistance"), + Constant(VectorValue(sourceVector)).euclideanDistance(targetVectorValue) + .as("euclideanDistance") + ) + .limit(1) + + let snapshot = try await pipeline.execute() + XCTAssertEqual( + snapshot.results.count, + 1, + "Should retrieve one document for distance functions part 1" + ) + + if let resultDoc = snapshot.results.first { + XCTAssertEqual( + resultDoc.get("cosineDistance")! as! Double, + expectedCosineDistance, + accuracy: accuracy + ) + XCTAssertEqual( + resultDoc.get("dotProductDistance")! as! Double, + expectedDotProductDistance, + accuracy: accuracy + ) + XCTAssertEqual( + resultDoc.get("euclideanDistance")! as! Double, + expectedEuclideanDistance, + accuracy: accuracy + ) + } else { + XCTFail("No document retrieved for distance functions part 1") + } + } + + func testVectorLength() async throws { + let collRef = collectionRef() // Using a new collection for this test + let db = collRef.firestore + let docRef = collRef.document("vectorDocForLengthTestFinal") + + // Add a document with a known vector field + try await docRef.setData(["embedding": VectorValue([1.0, 2.0, 3.0])]) + + // Construct a pipeline query + let pipeline = db.pipeline() + .collection(collRef.path) + .limit(1) // Limit to the document we just added + .select(Field("embedding").vectorLength().as("vectorLength")) + + // Execute the pipeline + let snapshot = try await pipeline.execute() + + // Assert that the vectorLength in the result is 3 + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + if let resultDoc = snapshot.results.first { + let expectedResult: [String: Sendable?] = ["vectorLength": 3] + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) + } else { + XCTFail("No document retrieved for vectorLength test") + } + } + + func testNestedFields() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("awards.hugo").eq(true)) + .sort(Field("title").descending()) + .select(Field("title"), Field("awards.hugo")) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable?]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy", "awards.hugo": true], + ["title": "Dune", "awards.hugo": true], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testMapGetWithFieldNameIncludingDotNotation() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("awards.hugo").eq(true)) // Filters to book1 and book10 + .select( + Field("title"), + Field("nestedField.level.1"), + Field("nestedField").mapGet("level.1").mapGet("level.2").as("nested") + ) + .sort(Field("title").descending()) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 2, "Should retrieve two documents") + + let expectedResultsArray: [[String: Sendable?]] = [ + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "nestedField.level.`1`": nil, + "nested": true, + ], + [ + "title": "Dune", + "nestedField.level.`1`": nil, + "nested": nil, + ], + ] + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: expectedResultsArray, + enforceOrder: true + ) + } + + func testGenericFunctionAddSelectable() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select( + FunctionExpr("add", [Field("rating"), Constant(1)]).as( + "rating" + ) + ) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + let expectedResult: [String: Sendable?] = [ + "rating": 5.7, + ] + + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) + } else { + XCTFail("No document retrieved for testGenericFunctionAddSelectable") + } + } + + func testGenericFunctionAndVariadicSelectable() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where( + BooleanExpr("and", [Field("rating").gt(0), + Field("title").charLength().lt(5), + Field("tags").arrayContains("propaganda")]) + ) + .select("title") + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + let expectedResult: [[String: Sendable?]] = [ + ["title": "1984"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResult, enforceOrder: false) + } + + func testGenericFunctionArrayContainsAny() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(BooleanExpr("array_contains_any", [Field("tags"), ArrayExpression(["politics"])])) + .select(Field("title")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + let expectedResult: [[String: Sendable?]] = [ + ["title": "Dune"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResult, enforceOrder: false) + } + + func testGenericFunctionCountIfAggregate() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .aggregate(AggregateFunction("count_if", [Field("rating").gte(4.5)]).as("countOfBest")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Aggregate should return a single document") + + let expectedResult: [String: Sendable?] = [ + "countOfBest": 3, + ] + + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) + } else { + XCTFail("No document retrieved for testGenericFunctionCountIfAggregate") + } + } + + func testGenericFunctionSortByCharLen() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort( + FunctionExpr("char_length", [Field("title")]).ascending(), + Field("__name__").descending() + ) + .limit(3) + .select(Field("title")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 3, "Should retrieve three documents") + + let expectedResults: [[String: Sendable?]] = [ + ["title": "1984"], + ["title": "Dune"], + ["title": "The Great Gatsby"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testSupportsRand() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .limit(10) + .select(RandomExpr().as("result")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 10, "Should fetch 10 documents") + + for doc in snapshot.results { + guard let resultValue = doc.get("result") else { + XCTFail("Document \(doc.id ?? "unknown") should have a 'result' field") + continue + } + guard let doubleValue = resultValue as? Double else { + XCTFail("Result value for document \(doc.id ?? "unknown") is not a Double: \(resultValue)") + continue + } + XCTAssertGreaterThanOrEqual( + doubleValue, + 0.0, + "Result for \(doc.id ?? "unknown") should be >= 0.0" + ) + XCTAssertLessThan(doubleValue, 1.0, "Result for \(doc.id ?? "unknown") should be < 1.0") + } + } + + func testSupportsArray() async throws { + let db = firestore() + let collRef = collectionRef(withDocuments: bookDocs) + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(ArrayExpression([1, 2, 3, 4]).as("metadata")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + let expectedResults: [String: Sendable?] = ["metadata": [1, 2, 3, 4]] + + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for testSupportsArray") + } + } + + func testEvaluatesExpressionInArray() async throws { + let db = firestore() + let collRef = collectionRef(withDocuments: bookDocs) + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(ArrayExpression([ + 1, + 2, + Field("genre"), + Field("rating").multiply(10), + ]).as("metadata")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + let expectedResults: [String: Sendable?] = ["metadata": [1, 2, "Fantasy", 47.0]] + + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for testEvaluatesExpressionInArray") + } + } + + func testSupportsArrayOffset() async throws { + let db = firestore() + let collRef = collectionRef(withDocuments: bookDocs) + + let expectedResultsPart1: [[String: Sendable?]] = [ + ["firstTag": "adventure"], + ["firstTag": "politics"], + ["firstTag": "classic"], + ] + + let pipeline1 = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(3) + .select(Field("tags").arrayGet(0).as("firstTag")) + + let snapshot1 = try await pipeline1.execute() + XCTAssertEqual(snapshot1.results.count, 3, "Part 1: Should retrieve three documents") + TestHelper.compare( + pipelineSnapshot: snapshot1, + expected: expectedResultsPart1, + enforceOrder: true + ) + } + + func testSupportsMap() async throws { + let db = firestore() + let collRef = collectionRef(withDocuments: bookDocs) + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(MapExpression(["foo": "bar"]).as("metadata")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + let expectedResult: [String: Sendable?] = ["metadata": ["foo": "bar"]] + + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) + } else { + XCTFail("No document retrieved for testSupportsMap") + } + } + + func testEvaluatesExpressionInMap() async throws { + let db = firestore() + let collRef = collectionRef(withDocuments: bookDocs) + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(MapExpression([ + "genre": Field("genre"), // "Fantasy" + "rating": Field("rating").multiply(10), // 4.7 * 10 = 47.0 + ]).as("metadata")) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + // Expected: genre is "Fantasy", rating is 4.7 for book4 + let expectedResult: [String: Sendable?] = ["metadata": ["genre": "Fantasy", "rating": 47.0]] + + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) + } else { + XCTFail("No document retrieved for testEvaluatesExpressionInMap") + } + } + + func testSupportsMapRemove() async throws { + let db = firestore() + let collRef = collectionRef(withDocuments: bookDocs) + + let expectedResult: [String: Sendable?] = ["awards": ["nebula": false]] + + let pipeline2 = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(Field("awards").mapRemove("hugo").as("awards")) + + let snapshot2 = try await pipeline2.execute() + XCTAssertEqual(snapshot2.results.count, 1, "Should retrieve one document") + if let resultDoc2 = snapshot2.results.first { + TestHelper.compare(pipelineResult: resultDoc2, expected: expectedResult) + } else { + XCTFail("No document retrieved for testSupportsMapRemove") + } + } + + func testSupportsMapMerge() async throws { + let db = firestore() + let collRef = collectionRef(withDocuments: bookDocs) + + let expectedResult: [String: Sendable?] = + ["awards": ["hugo": false, "nebula": false, "fakeAward": true]] + let mergeMap: [String: Sendable] = ["fakeAward": true] + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(Field("awards").mapMerge(mergeMap).as("awards")) + + let snapshot = try await pipeline.execute() + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) + } else { + XCTFail("No document retrieved for testSupportsMapMerge") + } + } + + func testSupportsTimestampConversions() async throws { + let db = firestore() + let randomCol = collectionRef() // Unique collection for this test + + // Add a dummy document to ensure the select stage has an input + try await randomCol.document("dummyTimeDoc").setData(["field": "value"]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select( + Constant(1_741_380_235).unixSecondsToTimestamp().as("unixSecondsToTimestamp"), + Constant(1_741_380_235_123).unixMillisToTimestamp().as("unixMillisToTimestamp"), + Constant(1_741_380_235_123_456).unixMicrosToTimestamp().as("unixMicrosToTimestamp"), + Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) + .timestampToUnixSeconds().as("timestampToUnixSeconds"), + Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) + .timestampToUnixMillis().as("timestampToUnixMillis"), + Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) + .timestampToUnixMicros().as("timestampToUnixMicros") + ) + + let snapshot = try await pipeline.execute() + XCTAssertEqual( + snapshot.results.count, + 1, + "Should retrieve one document for timestamp conversions" + ) + + let expectedResults: [String: Sendable?] = [ + "unixSecondsToTimestamp": Timestamp(seconds: 1_741_380_235, nanoseconds: 0), + "unixMillisToTimestamp": Timestamp(seconds: 1_741_380_235, nanoseconds: 123_000_000), + "unixMicrosToTimestamp": Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_000), + "timestampToUnixSeconds": 1_741_380_235, + "timestampToUnixMillis": 1_741_380_235_123, + "timestampToUnixMicros": 1_741_380_235_123_456, + ] + + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for testSupportsTimestampConversions") + } + } + + func testSupportsTimestampMath() async throws { + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let initialTimestamp = Timestamp(seconds: 1_741_380_235, nanoseconds: 0) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select( + Constant(initialTimestamp).as("timestamp") + ) + .select( + Field("timestamp").timestampAdd(.day, 10).as("plus10days"), + Field("timestamp").timestampAdd(.hour, 10).as("plus10hours"), + Field("timestamp").timestampAdd(.minute, 10).as("plus10minutes"), + Field("timestamp").timestampAdd(.second, 10).as("plus10seconds"), + Field("timestamp").timestampAdd(.microsecond, 10).as("plus10micros"), + Field("timestamp").timestampAdd(.millisecond, 10).as("plus10millis"), + Field("timestamp").timestampSub(.day, 10).as("minus10days"), + Field("timestamp").timestampSub(.hour, 10).as("minus10hours"), + Field("timestamp").timestampSub(.minute, 10).as("minus10minutes"), + Field("timestamp").timestampSub(.second, 10).as("minus10seconds"), + Field("timestamp").timestampSub(.microsecond, 10).as("minus10micros"), + Field("timestamp").timestampSub(.millisecond, 10).as("minus10millis") + ) + + let snapshot = try await pipeline.execute() + + let expectedResults: [String: Timestamp] = [ + "plus10days": Timestamp(seconds: 1_742_244_235, nanoseconds: 0), + "plus10hours": Timestamp(seconds: 1_741_416_235, nanoseconds: 0), + "plus10minutes": Timestamp(seconds: 1_741_380_835, nanoseconds: 0), + "plus10seconds": Timestamp(seconds: 1_741_380_245, nanoseconds: 0), + "plus10micros": Timestamp(seconds: 1_741_380_235, nanoseconds: 10000), + "plus10millis": Timestamp(seconds: 1_741_380_235, nanoseconds: 10_000_000), + "minus10days": Timestamp(seconds: 1_740_516_235, nanoseconds: 0), + "minus10hours": Timestamp(seconds: 1_741_344_235, nanoseconds: 0), + "minus10minutes": Timestamp(seconds: 1_741_379_635, nanoseconds: 0), + "minus10seconds": Timestamp(seconds: 1_741_380_225, nanoseconds: 0), + "minus10micros": Timestamp(seconds: 1_741_380_234, nanoseconds: 999_990_000), + "minus10millis": Timestamp(seconds: 1_741_380_234, nanoseconds: 990_000_000), + ] + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for timestamp math test") + } + } + + func testSupportsByteLength() async throws { + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let bytes = Data([1, 2, 3, 4, 5, 6, 7, 0]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select( + Constant(bytes).as("bytes") + ) + .select( + Field("bytes").byteLength().as("byteLength") + ) + + let snapshot = try await pipeline.execute() + + let expectedResults: [String: Sendable] = [ + "byteLength": 8, + ] + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + if let resultDoc = snapshot.results.first { + TestHelper.compare( + pipelineResult: resultDoc, + expected: expectedResults.mapValues { $0 as Sendable } + ) + } else { + XCTFail("No document retrieved for byte length test") + } + } + + func testSupportsNot() async throws { + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select(Constant(true).as("trueField")) + .select( + Field("trueField"), + (!(Field("trueField").eq(true))).as("falseField") + ) + + let snapshot = try await pipeline.execute() + + let expectedResults: [String: Bool] = [ + "trueField": true, + "falseField": false, + ] + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + if let resultDoc = snapshot.results.first { + TestHelper.compare(pipelineResult: resultDoc, expected: expectedResults) + } else { + XCTFail("No document retrieved for not operator test") + } + } + + func testReplaceFirst() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("The Lord of the Rings")) + .limit(1) + .select(Field("title").replaceFirst("o", "0").as("newName")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["newName": "The L0rd of the Rings"]], + enforceOrder: false + ) + } + + func testReplaceAll() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("The Lord of the Rings")) + .limit(1) + .select(Field("title").replaceAll("o", "0").as("newName")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["newName": "The L0rd 0f the Rings"]], + enforceOrder: false + ) + } + + func testBitAnd() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select(Constant(5).bitAnd(12).as("result")) + let snapshot = try await pipeline.execute() + TestHelper.compare(pipelineSnapshot: snapshot, expected: [["result": 4]], enforceOrder: false) + } + + func testBitOr() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select(Constant(5).bitOr(12).as("result")) + let snapshot = try await pipeline.execute() + TestHelper.compare(pipelineSnapshot: snapshot, expected: [["result": 13]], enforceOrder: false) + } + + func testBitXor() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select(Constant(5).bitXor(12).as("result")) + let snapshot = try await pipeline.execute() + TestHelper.compare(pipelineSnapshot: snapshot, expected: [["result": 9]], enforceOrder: false) + } + + func testBitNot() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + let bytesInput = Data([0xFD]) + let expectedOutput = Data([0x02]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select(Constant(bytesInput).bitNot().as("result")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["result": expectedOutput]], + enforceOrder: false + ) + } + + func testBitLeftShift() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + let bytesInput = Data([0x02]) + let expectedOutput = Data([0x08]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select(Constant(bytesInput).bitLeftShift(2).as("result")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["result": expectedOutput]], + enforceOrder: false + ) + } + + func testBitRightShift() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let db = firestore() + let randomCol = collectionRef() + try await randomCol.document("dummyDoc").setData(["field": "value"]) + let bytesInput = Data([0x02]) + let expectedOutput = Data([0x00]) + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select(Constant(bytesInput).bitRightShift(2).as("result")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["result": expectedOutput]], + enforceOrder: false + ) + } + + func testDocumentId() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(Field("__path__").documentId().as("docId")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["docId": "book4"]], + enforceOrder: false + ) + } + + func testSubstr() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(Field("title").substr(9, 2).as("of")) + let snapshot = try await pipeline.execute() + TestHelper.compare(pipelineSnapshot: snapshot, expected: [["of": "of"]], enforceOrder: false) + } + + func testSubstrWithoutLength() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) + .select(Field("title").substr(9).as("of")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["of": "of the Rings"]], + enforceOrder: false + ) + } + + func testArrayConcat() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var pipeline = db.pipeline() + .collection(collRef.path) + .limit(1) // Assuming we operate on the first book (book1) + .select( + Field("tags").arrayConcat( + ["newTag1", "newTag2"], + [Field("tags")], + [Constant.nil] + ).as("modifiedTags") + ) + var snapshot = try await pipeline.execute() + + let expectedTags: [Sendable?] = [ + "comedy", "space", "adventure", + "newTag1", "newTag2", + "comedy", "space", "adventure", + nil, + ] + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["modifiedTags": expectedTags]], + enforceOrder: false + ) + + pipeline = db.pipeline() + .collection(collRef.path) + .limit(1) // Assuming we operate on the first book (book1) + .select( + Field("tags").arrayConcat( + Field("newTag1"), Field("newTag2"), + Field("tags"), + Constant.nil + ).as("modifiedTags") + ) + snapshot = try await pipeline.execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["modifiedTags": expectedTags]], + enforceOrder: false + ) + } + + func testToLowercase() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .limit(1) + .select(Field("title").lowercased().as("lowercaseTitle")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["lowercaseTitle": "the hitchhiker's guide to the galaxy"]], + enforceOrder: false + ) + } + + func testToUppercase() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .limit(1) + .select(Field("author").uppercased().as("uppercaseAuthor")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["uppercaseAuthor": "DOUGLAS ADAMS"]], + enforceOrder: false + ) + } + + func testTrim() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .addFields(Constant(" The Hitchhiker's Guide to the Galaxy ").as("spacedTitle")) + .select(Field("spacedTitle").trim().as("trimmedTitle"), Field("spacedTitle")) + .limit(1) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [[ + "spacedTitle": " The Hitchhiker's Guide to the Galaxy ", + "trimmedTitle": "The Hitchhiker's Guide to the Galaxy", + ]], + enforceOrder: false + ) + } + + func testReverseString() async throws { + // Renamed from testReverse to avoid conflict if a generic reverse exists elsewhere + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("1984")) + .limit(1) + .select(Field("title").reverse().as("reverseTitle")) + let snapshot = try await pipeline.execute() + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [["reverseTitle": "4891"]], + enforceOrder: false + ) + } + + private func addBooks(to collectionReference: CollectionReference) async throws { + try await collectionReference.document("book11").setData([ + "title": "Jonathan Strange & Mr Norrell", + "author": "Susanna Clarke", + "genre": "Fantasy", + "published": 2004, + "rating": 4.6, + "tags": ["historical fantasy", "magic", "alternate history", "england"], + "awards": ["hugo": false, "nebula": false], + ]) + try await collectionReference.document("book12").setData([ + "title": "The Master and Margarita", + "author": "Mikhail Bulgakov", + "genre": "Satire", + "published": 1967, + "rating": 4.6, + "tags": ["russian literature", "supernatural", "philosophy", "dark comedy"], + "awards": [:], + ]) + try await collectionReference.document("book13").setData([ + "title": "A Long Way to a Small, Angry Planet", + "author": "Becky Chambers", + "genre": "Science Fiction", + "published": 2014, + "rating": 4.6, + "tags": ["space opera", "found family", "character-driven", "optimistic"], + "awards": ["hugo": false, "nebula": false, "kitschies": true], + ]) + } + + func testSupportsPaginationWithOffsetsUsingName() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + try await addBooks(to: collRef) + + let pageSize = 2 + + let pipeline = db.pipeline() + .collection(collRef.path) + .select("title", "rating", "__name__") + .sort( + Field("rating").descending(), + Field("__name__").ascending() + ) + + var snapshot = try await pipeline.limit(Int32(pageSize)).execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + ["title": "The Lord of the Rings", "rating": 4.7], + ["title": "Jonathan Strange & Mr Norrell", "rating": 4.6], + ], + enforceOrder: true + ) + + let lastDoc = snapshot.results.last! + + snapshot = try await pipeline.where( + (Field("rating").eq(lastDoc.get("rating")!) + && Field("rating").lt(lastDoc.get("rating")!)) + || Field("rating").lt(lastDoc.get("rating")!) + ).limit(Int32(pageSize)).execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + ["title": "Pride and Prejudice", "rating": 4.5], + ["title": "Crime and Punishment", "rating": 4.3], + ], + enforceOrder: false + ) + } + + func testSupportsPaginationWithOffsetsUsingPath() async throws { + try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + try await addBooks(to: collRef) + + let pageSize = 2 + var currPage = 0 + + let pipeline = db.pipeline() + .collection(collRef.path) + .select("title", "rating", "__path__") + .sort( + Field("rating").descending(), + Field("__path__").ascending() + ) + + var snapshot = try await pipeline.offset(Int32(currPage) * Int32(pageSize)).limit( + Int32(pageSize) + ).execute() + + currPage += 1 + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + ["title": "The Lord of the Rings", "rating": 4.7], + ["title": "Dune", "rating": 4.6], + ], + enforceOrder: true + ) + + snapshot = try await pipeline.offset(Int32(currPage) * Int32(pageSize)).limit( + Int32(pageSize) + ).execute() + + currPage += 1 + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + ["title": "A Long Way to a Small, Angry Planet", "rating": 4.6], + ["title": "Pride and Prejudice", "rating": 4.5], + ], + enforceOrder: true + ) + } } diff --git a/Firestore/Swift/Tests/TestHelper/TestHelper.swift b/Firestore/Swift/Tests/TestHelper/TestHelper.swift index e65d3960176..a98e1bd4fa2 100644 --- a/Firestore/Swift/Tests/TestHelper/TestHelper.swift +++ b/Firestore/Swift/Tests/TestHelper/TestHelper.swift @@ -163,10 +163,10 @@ public enum TestHelper { guard let value2 = dict2[key], areEqual(value1, value2) else { XCTFail(""" Dictionary value mismatch for key: '\(key)' - Expected value: '\(String(describing: value1))' (from dict1) - Actual value: '\(String(describing: dict2[key]))' (from dict2) - Full dict1: \(String(describing: dict1)) - Full dict2: \(String(describing: dict2)) + Actual value: '\(String(describing: value1))' (from dict1) + Expected value: '\(String(describing: dict2[key]))' (from dict2) + Full actual value: \(String(describing: dict1)) + Full expected value: \(String(describing: dict2)) """) return false } @@ -182,8 +182,8 @@ public enum TestHelper { if !areEqual(value1, value2) { XCTFail(""" Array value mismatch. - Expected array value: '\(String(describing: value1))' - Actual array value: '\(String(describing: value2))' + Actual array value: '\(String(describing: value1))' + Expected array value: '\(String(describing: value2))' """) return false } diff --git a/Firestore/core/src/api/stages.cc b/Firestore/core/src/api/stages.cc index 0c514fe0ee6..a32dfe6f40e 100644 --- a/Firestore/core/src/api/stages.cc +++ b/Firestore/core/src/api/stages.cc @@ -28,6 +28,8 @@ namespace firebase { namespace firestore { namespace api { +using model::DeepClone; + google_firestore_v1_Pipeline_Stage CollectionSource::to_proto() const { google_firestore_v1_Pipeline_Stage result; @@ -191,16 +193,14 @@ google_firestore_v1_Pipeline_Stage FindNearestStage::to_proto() const { result.args_count = 3; result.args = nanopb::MakeArray(3); result.args[0] = property_->to_proto(); - result.args[1] = *vector_; + result.args[1] = *DeepClone(*vector_).release(); result.args[2] = distance_measure_.proto(); nanopb::SetRepeatedField( &result.options, &result.options_count, options_, - [](const std::pair>& - entry) { + [](const std::pair& entry) { return _google_firestore_v1_Pipeline_Stage_OptionsEntry{ - nanopb::MakeBytesArray(entry.first), *entry.second}; + nanopb::MakeBytesArray(entry.first), entry.second}; }); return result; diff --git a/Firestore/core/src/api/stages.h b/Firestore/core/src/api/stages.h index e8bf34ac70a..be0cbf3e68b 100644 --- a/Firestore/core/src/api/stages.h +++ b/Firestore/core/src/api/stages.h @@ -150,13 +150,11 @@ class FindNearestStage : public Stage { std::shared_ptr property, nanopb::SharedMessage vector, DistanceMeasure distance_measure, - std::unordered_map> - options) + std::unordered_map options) : property_(std::move(property)), vector_(std::move(vector)), distance_measure_(distance_measure), - options_(options) { + options_(std::move(options)) { } ~FindNearestStage() override = default; @@ -167,9 +165,7 @@ class FindNearestStage : public Stage { std::shared_ptr property_; nanopb::SharedMessage vector_; DistanceMeasure distance_measure_; - std::unordered_map> - options_; + std::unordered_map options_; }; class LimitStage : public Stage {