diff --git a/Firestore/CHANGELOG.md b/Firestore/CHANGELOG.md index 5977a12bab6..a2d9e311e50 100644 --- a/Firestore/CHANGELOG.md +++ b/Firestore/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased - [changed] Minor refactor to avoid using an absl internal function. (#15889) +- [feature] Added support for Pipeline expressions `arrayFirst`, `arrayFirstN`, `arrayLast`, + `arrayLastN`, `arrayMinimumN`, `arrayMaximumN`, `arrayIndexOf`, `arrayLastIndexOf` and `arrayIndexOfAll`. (#15900) # 12.10.0 - [feature] Added support for `regexFind` and `regexFindAll` Pipeline expressions. diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 919f5282e37..6fd2770f358 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -548,6 +548,36 @@ public extension Expression { return FunctionExpression(functionName: "array_length", args: [self]) } + func arrayFirst() -> FunctionExpression { + return FunctionExpression(functionName: "array_first", args: [self]) + } + + func arrayFirstN(_ n: Int) -> FunctionExpression { + return FunctionExpression( + functionName: "array_first_n", + args: [self, Helper.sendableToExpr(n)] + ) + } + + func arrayFirstN(_ n: Expression) -> FunctionExpression { + return FunctionExpression(functionName: "array_first_n", args: [self, n]) + } + + func arrayLast() -> FunctionExpression { + return FunctionExpression(functionName: "array_last", args: [self]) + } + + func arrayLastN(_ n: Int) -> FunctionExpression { + return FunctionExpression( + functionName: "array_last_n", + args: [self, Helper.sendableToExpr(n)] + ) + } + + func arrayLastN(_ n: Expression) -> FunctionExpression { + return FunctionExpression(functionName: "array_last_n", args: [self, n]) + } + func arrayGet(_ offset: Int) -> FunctionExpression { return FunctionExpression( functionName: "array_get", @@ -559,6 +589,42 @@ public extension Expression { return FunctionExpression(functionName: "array_get", args: [self, offsetExpression]) } + func arrayIndexOf(_ value: Sendable) -> FunctionExpression { + return FunctionExpression( + functionName: "array_index_of", + args: [self, Helper.sendableToExpr(value), Constant("first")] + ) + } + + func arrayIndexOf(_ value: Expression) -> FunctionExpression { + return FunctionExpression( + functionName: "array_index_of", + args: [self, value, Constant("first")] + ) + } + + func arrayLastIndexOf(_ value: Sendable) -> FunctionExpression { + return FunctionExpression( + functionName: "array_index_of", + args: [self, Helper.sendableToExpr(value), Constant("last")] + ) + } + + func arrayLastIndexOf(_ value: Expression) -> FunctionExpression { + return FunctionExpression(functionName: "array_index_of", args: [self, value, Constant("last")]) + } + + func arrayIndexOfAll(_ value: Sendable) -> FunctionExpression { + return FunctionExpression( + functionName: "array_index_of_all", + args: [self, Helper.sendableToExpr(value)] + ) + } + + func arrayIndexOfAll(_ value: Expression) -> FunctionExpression { + return FunctionExpression(functionName: "array_index_of_all", args: [self, value]) + } + func arrayMaximum() -> FunctionExpression { return FunctionExpression(functionName: "maximum", args: [self]) } @@ -567,6 +633,28 @@ public extension Expression { return FunctionExpression(functionName: "minimum", args: [self]) } + func arrayMaximumN(_ n: Int) -> FunctionExpression { + return FunctionExpression( + functionName: "maximum_n", + args: [self, Helper.sendableToExpr(n)] + ) + } + + func arrayMaximumN(_ n: Expression) -> FunctionExpression { + return FunctionExpression(functionName: "maximum_n", args: [self, n]) + } + + func arrayMinimumN(_ n: Int) -> FunctionExpression { + return FunctionExpression( + functionName: "minimum_n", + args: [self, Helper.sendableToExpr(n)] + ) + } + + func arrayMinimumN(_ n: Expression) -> FunctionExpression { + return FunctionExpression(functionName: "minimum_n", args: [self, n]) + } + func greaterThan(_ other: Expression) -> BooleanExpression { return BooleanFunctionExpression(functionName: "greater_than", args: [self, other]) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index 88f55430c00..94551830b88 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -432,6 +432,78 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the length of the array. func arrayLength() -> FunctionExpression + /// Creates an expression that returns the first element of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the first item in the "tags" array. + /// Field("tags").arrayFirst() + /// ``` + /// + /// - Returns: A new `FunctionExpression` representing the first element of the array. + func arrayFirst() -> FunctionExpression + + /// Creates an expression that returns the first `n` elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the first 3 items in the "tags" array. + /// Field("tags").arrayFirstN(3) + /// ``` + /// + /// - Parameter n: The number of elements to return. + /// - Returns: A new `FunctionExpression` representing the first `n` elements of the array. + func arrayFirstN(_ n: Int) -> FunctionExpression + + /// Creates an expression that returns the first `n` elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the first n items in the "tags" array where n is specified by field "count". + /// Field("tags").arrayFirstN(Field("count")) + /// ``` + /// + /// - Parameter n: An `Expression` (evaluating to an Int) representing the number of elements to + /// return. + /// - Returns: A new `FunctionExpression` representing the first `n` elements of the array. + func arrayFirstN(_ n: Expression) -> FunctionExpression + + /// Creates an expression that returns the last element of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the last item in the "tags" array. + /// Field("tags").arrayLast() + /// ``` + /// + /// - Returns: A new `FunctionExpression` representing the last element of the array. + func arrayLast() -> FunctionExpression + + /// Creates an expression that returns the last `n` elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the last 3 items in the "tags" array. + /// Field("tags").arrayLastN(3) + /// ``` + /// + /// - Parameter n: The number of elements to return. + /// - Returns: A new `FunctionExpression` representing the last `n` elements of the array. + func arrayLastN(_ n: Int) -> FunctionExpression + + /// Creates an expression that returns the last `n` elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the last n items in the "tags" array where n is specified by field "count". + /// Field("tags").arrayLastN(Field("count")) + /// ``` + /// + /// - Parameter n: An `Expression` (evaluating to an Int) representing the number of elements to + /// return. + /// - Returns: A new `FunctionExpression` representing the last `n` elements of the array. + func arrayLastN(_ n: Expression) -> FunctionExpression + /// Creates an expression that accesses an element in an array (from `self`) at the specified /// integer offset. /// A negative offset starts from the end. If the offset is out of bounds, an error may be @@ -466,6 +538,84 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the "arrayGet" operation. func arrayGet(_ offsetExpression: Expression) -> FunctionExpression + /// Creates an expression that returns the index of the first occurrence of a value in an array. + /// Returns nil if the value is not found. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the index of "urgent" in the "tags" array. + /// Field("tags").arrayIndexOf("urgent") + /// ``` + /// + /// - Parameter value: The literal `Sendable` value to search for. + /// - Returns: A new `FunctionExpression` representing the index of the value. + func arrayIndexOf(_ value: Sendable) -> FunctionExpression + + /// Creates an expression that returns the index of the first occurrence of a value in an array. + /// Returns nil if the value is not found. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the index of the value of field "searchTag" in the "tags" array. + /// Field("tags").arrayIndexOf(Field("searchTag")) + /// ``` + /// + /// - Parameter value: An `Expression` representing the value to search for. + /// - Returns: A new `FunctionExpression` representing the index of the value. + func arrayIndexOf(_ value: Expression) -> FunctionExpression + + /// Creates an expression that returns the index of the last occurrence of a value in an array. + /// Returns nil if the value is not found. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the last index of "urgent" in the "tags" array. + /// Field("tags").arrayLastIndexOf("urgent") + /// ``` + /// + /// - Parameter value: The literal `Sendable` value to search for. + /// - Returns: A new `FunctionExpression` representing the last index of the value. + func arrayLastIndexOf(_ value: Sendable) -> FunctionExpression + + /// Creates an expression that returns the index of the last occurrence of a value in an array. + /// Returns nil if the value is not found. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the last index of the value of field "searchTag" in the "tags" array. + /// Field("tags").arrayLastIndexOf(Field("searchTag")) + /// ``` + /// + /// - Parameter value: An `Expression` representing the value to search for. + /// - Returns: A new `FunctionExpression` representing the last index of the value. + func arrayLastIndexOf(_ value: Expression) -> FunctionExpression + + /// Creates an expression that returns all indices of a value in an array. + /// Returns an empty array if the value is not found. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get all indices of "urgent" in the "tags" array. + /// Field("tags").arrayIndexOfAll("urgent") + /// ``` + /// + /// - Parameter value: The literal `Sendable` value to search for. + /// - Returns: A new `FunctionExpression` representing the indices of the value. + func arrayIndexOfAll(_ value: Sendable) -> FunctionExpression + + /// Creates an expression that returns all indices of a value in an array. + /// Returns an empty array if the value is not found. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get all indices of the value of field "searchTag" in the "tags" array. + /// Field("tags").arrayIndexOfAll(Field("searchTag")) + /// ``` + /// + /// - Parameter value: An `Expression` representing the value to search for. + /// - Returns: A new `FunctionExpression` representing the indices of the value. + func arrayIndexOfAll(_ value: Expression) -> FunctionExpression + /// Creates an expression that returns the maximum element of an array. /// /// Assumes `self` evaluates to an array. @@ -490,6 +640,56 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the minimum element of the array. func arrayMinimum() -> FunctionExpression + /// Creates an expression that returns the `n` smallest elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the 3 lowest scores in the "scores" array. + /// Field("scores").arrayMinimumN(3) + /// ``` + /// + /// - Parameter n: The number of elements to return. + /// - Returns: A new `FunctionExpression` representing the `n` smallest elements of the array. + func arrayMinimumN(_ n: Int) -> FunctionExpression + + /// Creates an expression that returns the `n` smallest elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the n lowest scores in the "scores" array where n is specified by field "count". + /// Field("scores").arrayMinimumN(Field("count")) + /// ``` + /// + /// - Parameter n: An `Expression` (evaluating to an Int) representing the number of elements to + /// return. + /// - Returns: A new `FunctionExpression` representing the `n` smallest elements of the array. + func arrayMinimumN(_ n: Expression) -> FunctionExpression + + /// Creates an expression that returns the `n` largest elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the 3 highest scores in the "scores" array. + /// Field("scores").arrayMaximumN(3) + /// ``` + /// + /// - Parameter n: The number of elements to return. + /// - Returns: A new `FunctionExpression` representing the `n` largest elements of the array. + func arrayMaximumN(_ n: Int) -> FunctionExpression + + /// Creates an expression that returns the `n` largest elements of an array. + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the n highest scores in the "scores" array where n is specified by field "count". + /// Field("scores").arrayMaximumN(Field("count")) + /// ``` + /// + /// - Parameter n: An `Expression` (evaluating to an Int) representing the number of elements to + /// return. + /// - Returns: A new `FunctionExpression` representing the `n` largest elements of the array. + func arrayMaximumN(_ n: Expression) -> FunctionExpression + /// Creates a `BooleanExpression` that returns `true` if this expression is greater /// than the given expression. /// diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 886c4a949ad..6dcc9ae091f 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3702,35 +3702,6 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) } - func testArrayMaxMinWorks() async throws { - let collRef = collectionRef(withDocuments: [ - "doc1": ["scores": [10, 20, 5]], - "doc2": ["scores": [-1, -5, 0]], - "doc3": ["scores": [100.5, 99.5, 100.6]], - "doc4": ["scores": []], - ]) - let db = collRef.firestore - - let pipeline = db.pipeline() - .collection(collRef.path) - .sort([Field(FieldPath.documentID()).ascending()]) - .select([ - Field("scores").arrayMaximum().as("maxScore"), - Field("scores").arrayMinimum().as("minScore"), - ]) - - let snapshot = try await pipeline.execute() - - let expectedResults: [[String: Sendable?]] = [ - ["maxScore": 20, "minScore": 5], - ["maxScore": 0, "minScore": -5], - ["maxScore": 100.6, "minScore": 99.5], - ["maxScore": nil, "minScore": nil], - ] - - TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) - } - func testTypeWorks() async throws { let collRef = collectionRef(withDocuments: [ "doc1": [ @@ -3992,4 +3963,437 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { snapshot = try await pipeline.execute() TestHelper.compare(snapshot: snapshot, expectedCount: 0) } + + func testArrayFirst() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(3) + .select([Field("tags").arrayFirst().as("firstTag")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["firstTag": "adventure"], + ["firstTag": "politics"], + ["firstTag": "classic"], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with empty/null/non-existent + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "empty": [], + "nullVal": Constant.nil, + ])) + .select([ + Field("empty").arrayFirst().as("emptyResult"), + Field("nullVal").arrayFirst().as("nullResult"), + Field("nonExistent").arrayFirst().as("absentResult"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "nullResult": nil, + "absentResult": nil, + // emptyResult is missing because UNSET + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayFirstN() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(3) + .select([Field("tags").arrayFirstN(2).as("firstTwoTags")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["firstTwoTags": ["adventure", "magic"]], + ["firstTwoTags": ["politics", "desert"]], + ["firstTwoTags": ["classic", "social commentary"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with empty/null/non-existent + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "empty": [], + "nullVal": Constant.nil, + ])) + .select([ + Field("empty").arrayFirstN(1).as("emptyResult"), + Field("nullVal").arrayFirstN(1).as("nullResult"), + Field("nonExistent").arrayFirstN(1).as("absentResult"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "emptyResult": [], + "nullResult": nil, + "absentResult": nil, + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayLast() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(3) + .select([Field("tags").arrayLast().as("lastTag")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["lastTag": "epic"], + ["lastTag": "ecology"], + ["lastTag": "love"], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with empty/null/non-existent + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "empty": [], + "nullVal": Constant.nil, + ])) + .select([ + Field("empty").arrayLast().as("emptyResult"), + Field("nullVal").arrayLast().as("nullResult"), + Field("nonExistent").arrayLast().as("absentResult"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "nullResult": nil, + "absentResult": nil, + // emptyResult is missing because UNSET + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayLastN() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(3) + .select([Field("tags").arrayLastN(2).as("lastTwoTags")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["lastTwoTags": ["magic", "epic"]], + ["lastTwoTags": ["desert", "ecology"]], + ["lastTwoTags": ["social commentary", "love"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with empty/null/non-existent + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "empty": [], + "nullVal": Constant.nil, + ])) + .select([ + Field("empty").arrayLastN(1).as("emptyResult"), + Field("nullVal").arrayLastN(1).as("nullResult"), + Field("nonExistent").arrayLastN(1).as("absentResult"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "emptyResult": [], + "nullResult": nil, + "absentResult": nil, + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayMinimum() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(1) + .select([Field("tags").arrayMinimum().as("minTag")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["minTag": "adventure"], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with empty/null/non-existent/mixed + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "empty": [], + "nullVal": Constant.nil, + "mixed": [1, "2", 3, "10"], // Strings > Numbers in Firestore + ])) + .select([ + Field("empty").arrayMinimum().as("emptyResult"), + Field("nullVal").arrayMinimum().as("nullResult"), + Field("nonExistent").arrayMinimum().as("absentResult"), + Field("mixed").arrayMinimum().as("mixedResult"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "emptyResult": nil, + "nullResult": nil, + "absentResult": nil, + "mixedResult": 1, // 1 < 3 < "10" < "2" + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayMinimumN() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(1) + .select([Field("tags").arrayMinimumN(2).as("minTwoTags")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["minTwoTags": ["adventure", "epic"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testArrayMaximum() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(1) + .select([Field("tags").arrayMaximum().as("maxTag")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["maxTag": "magic"], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with empty/null/non-existent/mixed + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "empty": [], + "nullVal": Constant.nil, + "mixed": [1, "2", 3, "10"], // Strings > Numbers in Firestore + ])) + .select([ + Field("empty").arrayMaximum().as("emptyResult"), + Field("nullVal").arrayMaximum().as("nullResult"), + Field("nonExistent").arrayMaximum().as("absentResult"), + Field("mixed").arrayMaximum().as("mixedResult"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "emptyResult": nil, + "nullResult": nil, + "absentResult": nil, + "mixedResult": "2", // "2" > "10" > 3 > 1 + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayMaximumN() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(1) + .select([Field("tags").arrayMaximumN(2).as("maxTwoTags")]) + .execute() + + let expectedResults: [[String: Sendable]] = [ + ["maxTwoTags": ["magic", "epic"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testArrayIndexOf() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(1) + .select([ + Field("tags").arrayIndexOf("adventure").as("indexFirst"), + Field("tags").arrayIndexOf(Constant("adventure")).as("indexFirst2"), + Field("tags").arrayIndexOf(Constant("nonexistent")).as("indexNone"), + Field("empty").arrayIndexOf(Constant("anything")).as("indexEmpty"), + ]) + .execute() + + let expectedResults: [[String: Sendable?]] = [ + ["indexFirst": 0, "indexFirst2": 0, "indexNone": -1, "indexEmpty": nil], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with duplicates/null/non-existent + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "arr": [1, 2, 3, 2, 1], + "nullArr": Constant.nil, + "empty": [], + "arrWithNull": [1, Constant.nil, 3], + ])) + .select([ + Field("arr").arrayIndexOf(2).as("firstIndex"), + Field("arr").arrayIndexOf(99).as("notFoundIndex"), + Field("nullArr").arrayIndexOf(1).as("nullResult"), + Field("empty").arrayIndexOf(1).as("emptyResult"), + Field("arrWithNull").arrayIndexOf(Constant.nil).as("indexOfNull"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "firstIndex": 1, + "notFoundIndex": -1, // Not found returns -1 + "nullResult": nil, // Input is null + "indexOfNull": 1, + "emptyResult": -1, + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayLastIndexOf() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(1) + .select([ + Field("tags").arrayLastIndexOf("adventure").as("lastIndexFirst"), + Field("tags").arrayLastIndexOf(Constant("adventure")).as("lastIndexFirst2"), + Field("tags").arrayLastIndexOf(Constant("nonexistent")).as("lastIndexNone"), + Field("empty").arrayLastIndexOf(Constant("anything")).as("lastIndexEmpty"), + ]) + .execute() + + let expectedResults: [[String: Sendable?]] = [ + ["lastIndexFirst": 0, "lastIndexFirst2": 0, "lastIndexNone": -1, "lastIndexEmpty": nil], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with duplicates/null/non-existent + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "arr": [1, 2, 3, 2, 1], + "nullArr": Constant.nil, + "empty": [], + "arrWithNull": [1, Constant.nil, 3], + ])) + .select([ + Field("arr").arrayLastIndexOf(2).as("lastIndex"), + Field("arr").arrayLastIndexOf(99).as("notFoundIndex"), + Field("nullArr").arrayLastIndexOf(1).as("nullResult"), + Field("empty").arrayLastIndexOf(1).as("emptyResult"), + Field("arrWithNull").arrayLastIndexOf(Constant.nil).as("lastIndexOfNull"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "lastIndex": 3, + "notFoundIndex": -1, + "nullResult": nil, + "emptyResult": -1, + "lastIndexOfNull": 1, + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } + + func testArrayIndexOfAll() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var snapshot = try await db.pipeline() + .collection(collRef.path) + .sort([Field("rating").descending()]) + .limit(1) + .select([ + Field("tags").arrayIndexOfAll("adventure").as("indicesFirst"), + Field("tags").arrayIndexOfAll(Constant("adventure")).as("indicesFirst2"), + Field("tags").arrayIndexOfAll(Constant("nonexistent")).as("indicesNone"), + Field("empty").arrayIndexOfAll(Constant("anything")).as("indicesEmpty"), + ]) + .execute() + + let expectedResults: [[String: Sendable?]] = [ + ["indicesFirst": [0], "indicesFirst2": [0], "indicesNone": [], "indicesEmpty": nil], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test with duplicates/null/non-existent + snapshot = try await db.pipeline() + .collection(collRef.path) + .limit(1) + .replace(with: MapExpression([ + "arr": [1, 2, 3, 2, 1], + "nullArr": Constant.nil, + "empty": [], + "arrWithNull": [1, Constant.nil, 3, Constant.nil], + ])) + .select([ + Field("arr").arrayIndexOfAll(1).as("indices1"), + Field("arr").arrayIndexOfAll(99).as("indicesNone"), + Field("nullArr").arrayIndexOfAll(1).as("indicesNull"), + Field("empty").arrayIndexOfAll(1).as("indicesEmpty"), + Field("arrWithNull").arrayIndexOfAll(Constant.nil).as("indicesOfNull"), + ]) + .execute() + + let expectedEdgeCases: [String: Sendable?] = [ + "indices1": [0, 4], + "indicesNone": [], // Not found returns empty array + "indicesNull": nil, // Input is null + "indicesEmpty": [], // Input is empty array + "indicesOfNull": [1, 3], + ] + TestHelper.compare(snapshot: snapshot, expected: [expectedEdgeCases], enforceOrder: true) + } }