diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 9beb9de42ae..5786f264770 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -523,6 +523,14 @@ public extension Expression { return FunctionExpression(functionName: "array_get", args: [self, offsetExpression]) } + func arrayMaximum() -> FunctionExpression { + return FunctionExpression(functionName: "maximum", args: [self]) + } + + func arrayMinimum() -> FunctionExpression { + return FunctionExpression(functionName: "minimum", args: [self]) + } + func greaterThan(_ other: Expression) -> BooleanExpression { return BooleanExpression(functionName: "greater_than", args: [self, other]) } @@ -622,6 +630,14 @@ public extension Expression { return FunctionExpression(functionName: "join", args: [self, Constant(delimiter)]) } + func split(delimiter: String) -> FunctionExpression { + return FunctionExpression(functionName: "split", args: [self, Constant(delimiter)]) + } + + func split(delimiter: Expression) -> FunctionExpression { + return FunctionExpression(functionName: "split", args: [self, delimiter]) + } + func length() -> FunctionExpression { return FunctionExpression(functionName: "length", args: [self]) } @@ -709,6 +725,10 @@ public extension Expression { return FunctionExpression(functionName: "trim", args: [self, value]) } + func trim() -> FunctionExpression { + return FunctionExpression(functionName: "trim", args: [self]) + } + func stringConcat(_ strings: [Expression]) -> FunctionExpression { return FunctionExpression(functionName: "string_concat", args: [self] + strings) } @@ -773,20 +793,6 @@ public extension Expression { return FunctionExpression(functionName: "map_merge", args: [self] + maps) } - func mapSet(key: Expression, value: Sendable) -> FunctionExpression { - return FunctionExpression( - functionName: "map_set", - args: [self, key, Helper.sendableToExpr(value)] - ) - } - - func mapSet(key: String, value: Sendable) -> FunctionExpression { - return FunctionExpression( - functionName: "map_set", - args: [self, Helper.sendableToExpr(key), Helper.sendableToExpr(value)] - ) - } - // --- Added Aggregate Operations (on Expr) --- func countDistinct() -> AggregateFunction { @@ -919,7 +925,7 @@ public extension Expression { return FunctionExpression(functionName: "timestamp_to_unix_seconds", args: [self]) } - func timestampTruncate(granularity: TimeUnit) -> FunctionExpression { + func timestampTruncate(granularity: TimeGranularity) -> FunctionExpression { return FunctionExpression( functionName: "timestamp_trunc", args: [self, Helper.sendableToExpr(granularity.rawValue)] @@ -1001,4 +1007,8 @@ public extension Expression { let exprs = [self] + values.map { Helper.sendableToExpr($0) } return FunctionExpression(functionName: "concat", args: exprs) } + + func type() -> FunctionExpression { + return FunctionExpression(functionName: "type", args: [self]) + } } diff --git a/Firestore/Swift/Source/Stages.swift b/Firestore/Swift/Source/Stages.swift index eab46bf60ff..42d01ef42bb 100644 --- a/Firestore/Swift/Source/Stages.swift +++ b/Firestore/Swift/Source/Stages.swift @@ -26,7 +26,8 @@ import Foundation protocol Stage { var name: String { get } var bridge: StageBridge { get } - /// The `errorMessage` defaults to `nil`. Errors during stage construction are captured and thrown later when `execute()` is called. + /// The `errorMessage` defaults to `nil`. Errors during stage construction are captured and thrown + /// later when `execute()` is called. var errorMessage: String? { get } } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index b02fcd23604..8b3367b299c 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -461,6 +461,30 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the "arrayGet" operation. func arrayGet(_ offsetExpression: Expression) -> FunctionExpression + /// Creates an expression that returns the maximum element of an array. + /// + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the maximum value in the "scores" array. + /// Field("scores").arrayMaximum() + /// ``` + /// + /// - Returns: A new `FunctionExpression` representing the maximum element of the array. + func arrayMaximum() -> FunctionExpression + + /// Creates an expression that returns the minimum element of an array. + /// + /// Assumes `self` evaluates to an array. + /// + /// ```swift + /// // Get the minimum value in the "scores" array. + /// Field("scores").arrayMinimum() + /// ``` + /// + /// - Returns: A new `FunctionExpression` representing the minimum element of the array. + func arrayMinimum() -> FunctionExpression + /// Creates a `BooleanExpression` that returns `true` if this expression is greater /// than the given expression. /// @@ -681,6 +705,18 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the joined string. func join(delimiter: String) -> FunctionExpression + /// Creates an expression that splits a string into an array of substrings based on a delimiter. + /// + /// - Parameter delimiter: The string to split on. + /// - Returns: A new `FunctionExpression` representing the array of substrings. + func split(delimiter: String) -> FunctionExpression + + /// Creates an expression that splits a string into an array of substrings based on a delimiter. + /// + /// - Parameter delimiter: An expression that evaluates to a string or bytes to split on. + /// - Returns: A new `FunctionExpression` representing the array of substrings. + func split(delimiter: Expression) -> FunctionExpression + /// Creates an expression that returns the length of a string. /// /// ```swift @@ -886,6 +922,18 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the uppercase string. func toUpper() -> FunctionExpression + /// Creates an expression that removes leading and trailing whitespace from a string. + /// + /// Assumes `self` evaluates to a string. + /// + /// ```swift + /// // Trim leading/trailing whitespace from the "comment" field. + /// Field("comment").trim() + /// ``` + /// + /// - Returns: A new `FunctionExpression` representing the trimmed string. + func trim() -> FunctionExpression + /// Creates an expression that removes leading and trailing occurrences of specified characters /// from a string (from `self`). /// Assumes `self` evaluates to a string, and `value` evaluates to a string. @@ -961,8 +1009,8 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the reversed string. func stringReverse() -> FunctionExpression - /// Creates an expression that calculates the length of this expression in bytes. - /// Assumes `self` evaluates to a string. + /// Creates an expression that calculates the length of this string or bytes expression in bytes. + /// Assumes `self` evaluates to a string or bytes. /// /// ```swift /// // Calculate the length of the "myString" field in bytes. @@ -975,9 +1023,9 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the length in bytes. func byteLength() -> FunctionExpression - /// Creates an expression that returns a substring of this expression using + /// Creates an expression that returns a substring of this expression (String or Bytes) using /// literal integers for position and optional length. - /// Indexing is 0-based. Assumes `self` evaluates to a string. + /// Indexing is 0-based. Assumes `self` evaluates to a string or bytes. /// /// ```swift /// // Get substring from index 5 with length 10 @@ -992,9 +1040,9 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the substring. func substring(position: Int, length: Int?) -> FunctionExpression - /// Creates an expression that returns a substring of this expression using + /// Creates an expression that returns a substring of this expression (String or Bytes) using /// expressions for position and optional length. - /// Indexing is 0-based. Assumes `self` evaluates to a string, and parameters evaluate to + /// Indexing is 0-based. Assumes `self` evaluates to a string or bytes, and parameters evaluate to /// integers. /// /// ```swift @@ -1080,34 +1128,6 @@ public protocol Expression: Sendable { /// - Returns: A new `FunctionExpression` representing the "map_merge" operation. func mapMerge(_ maps: [Expression]) -> FunctionExpression - /// Creates an expression that adds or updates a specified field in a map. - /// Assumes `self` evaluates to a Map, `key` evaluates to a string, and `value` can be - /// any type. - /// - /// ```swift - /// // Set a field using a key from another field - /// Field("config").mapSet(key: Field("keyName"), value: Field("keyValue")) - /// ``` - /// - /// - Parameter key: An `Expression` (evaluating to a string) representing the key of - /// the field to set or update. - /// - Parameter value: The `Expression` representing the value to set for the field. - /// - Returns: A new `FunctionExpression` representing the map with the updated field. - func mapSet(key: Expression, value: Sendable) -> FunctionExpression - - /// Creates an expression that adds or updates a specified field in a map. - /// Assumes `self` evaluates to a Map. - /// - /// ```swift - /// // Set the "status" field to "active" in the "order" map - /// Field("order").mapSet(key: "status", value: "active") - /// ``` - /// - /// - Parameter key: The literal string key of the field to set or update. - /// - Parameter value: The `Sendable` literal value to set for the field. - /// - Returns: A new `FunctionExpression` representing the map with the updated field. - func mapSet(key: String, value: Sendable) -> FunctionExpression - // MARK: Aggregations /// Creates an aggregation that counts the number of distinct values of this expression. @@ -1429,19 +1449,23 @@ public protocol Expression: Sendable { /// Field("timestamp").timestampTruncate(granularity: .day) /// ``` /// - /// - Parameter granularity: A `TimeUnit` enum representing the truncation unit. + /// - Parameter granularity: A `TimeGranularity` representing the truncation unit. /// - Returns: A new `FunctionExpression` representing the truncated timestamp. - func timestampTruncate(granularity: TimeUnit) -> FunctionExpression + func timestampTruncate(granularity: TimeGranularity) -> FunctionExpression /// Creates an expression that truncates a timestamp to a specified granularity. - /// Assumes `self` evaluates to a Timestamp, and `granularity` is a literal string. + /// Assumes `self` evaluates to a Timestamp. /// /// ```swift /// // Truncate "timestamp" field to the nearest day using a literal string. /// Field("timestamp").timestampTruncate(granularity: "day") + /// + /// // Truncate "timestamp" field to the nearest day using an expression. + /// Field("timestamp").timestampTruncate(granularity: Field("granularity_field")) /// ``` /// - /// - Parameter granularity: A `Sendable` literal string specifying the truncation unit. + /// - Parameter granularity: A `Sendable` literal string or an `Expression` that evaluates to a + /// string, specifying the truncation unit. /// - Returns: A new `FunctionExpression` representing the truncated timestamp. func timestampTruncate(granularity: Sendable) -> FunctionExpression @@ -1596,4 +1620,14 @@ public protocol Expression: Sendable { /// - Parameter values: The values to concatenate. /// - Returns: A new `FunctionExpression` representing the concatenated result. func concat(_ values: [Sendable]) -> FunctionExpression + + /// Creates an expression that returns the type of the expression. + /// + /// ```swift + /// // Get the type of the "rating" field. + /// Field("rating").type() + /// ``` + /// + /// - Returns: A new `FunctionExpression` representing the type of the expression as a string. + func type() -> FunctionExpression } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift index a54ee48813a..978316ca62b 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift @@ -103,6 +103,17 @@ public struct Pipeline: @unchecked Sendable { } } + /// Creates a new `Pipeline` instance in a faulted state. + /// + /// This function is used to propagate an error through the pipeline chain. When a stage + /// fails to initialize or if a preceding stage has already failed, this method is called + /// to create a new pipeline that holds the error message. The `stages` array is cleared, + /// and the `errorMessage` is set. + /// + /// The stored error is eventually thrown by the `execute()` method. + /// + /// - Parameter message: The error message to store in the pipeline. + /// - Returns: A new `Pipeline` instance with the specified error message. private func withError(_ message: String) -> Pipeline { return Pipeline(stages: [], db: db, errorMessage: message) } @@ -127,7 +138,7 @@ public struct Pipeline: @unchecked Sendable { /// - Throws: An error if the pipeline execution fails on the backend. /// - Returns: A `Pipeline.Snapshot` containing the result of the pipeline execution. public func execute() async throws -> Pipeline.Snapshot { - // Check if any Error exist during Stage contruction + // Check if any errors occurred during stage construction. if let errorMessage = errorMessage { throw NSError( domain: "com.google.firebase.firestore", diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/TimeGranularity.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/TimeGranularity.swift new file mode 100644 index 00000000000..ca8272e4db8 --- /dev/null +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/TimeGranularity.swift @@ -0,0 +1,82 @@ +// 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 struct TimeGranularity: Sendable, Equatable, Hashable { + enum Kind: String { + case microsecond + case millisecond + case second + case minute + case hour + case day + case week + case weekMonday = "week(monday)" + case weekTuesday = "week(tuesday)" + case weekWednesday = "week(wednesday)" + case weekThursday = "week(thursday)" + case weekFriday = "week(friday)" + case weekSaturday = "week(saturday)" + case weekSunday = "week(sunday)" + case isoweek + case month + case quarter + case year + case isoyear + } + + public static let microsecond = TimeGranularity(kind: .microsecond) + public static let millisecond = TimeGranularity(kind: .millisecond) + public static let second = TimeGranularity(kind: .second) + public static let minute = TimeGranularity(kind: .minute) + public static let hour = TimeGranularity(kind: .hour) + /// The day in the Gregorian calendar year that contains the value to truncate. + public static let day = TimeGranularity(kind: .day) + /// The first day in the week that contains the value to truncate. Weeks begin on Sundays. WEEK is + /// equivalent to WEEK(SUNDAY). + public static let week = TimeGranularity(kind: .week) + /// The first day in the week that contains the value to truncate. Weeks begin on Monday. + public static let weekMonday = TimeGranularity(kind: .weekMonday) + /// The first day in the week that contains the value to truncate. Weeks begin on Tuesday. + public static let weekTuesday = TimeGranularity(kind: .weekTuesday) + /// The first day in the week that contains the value to truncate. Weeks begin on Wednesday. + public static let weekWednesday = TimeGranularity(kind: .weekWednesday) + /// The first day in the week that contains the value to truncate. Weeks begin on Thursday. + public static let weekThursday = TimeGranularity(kind: .weekThursday) + /// The first day in the week that contains the value to truncate. Weeks begin on Friday. + public static let weekFriday = TimeGranularity(kind: .weekFriday) + /// The first day in the week that contains the value to truncate. Weeks begin on Saturday. + public static let weekSaturday = TimeGranularity(kind: .weekSaturday) + /// The first day in the week that contains the value to truncate. Weeks begin on Sunday. + public static let weekSunday = TimeGranularity(kind: .weekSunday) + /// The first day in the ISO 8601 week that contains the value to truncate. The ISO week begins on + /// Monday. The first ISO week of each ISO year contains the first Thursday of the corresponding + /// Gregorian calendar year. + public static let isoweek = TimeGranularity(kind: .isoweek) + /// The first day in the month that contains the value to truncate. + public static let month = TimeGranularity(kind: .month) + /// The first day in the quarter that contains the value to truncate. + public static let quarter = TimeGranularity(kind: .quarter) + /// The first day in the year that contains the value to truncate. + public static let year = TimeGranularity(kind: .year) + /// The first day in the ISO 8601 week-numbering year that contains the value to truncate. The ISO + /// year is the Monday of the first week where Thursday belongs to the corresponding Gregorian + /// calendar year. + public static let isoyear = TimeGranularity(kind: .isoyear) + + public let rawValue: String + + init(kind: Kind) { + rawValue = kind.rawValue + } +} diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 0971432ddbd..9eb545cb617 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3107,142 +3107,6 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { } } - func testMapSetAddsNewField() async throws { - let collRef = collectionRef(withDocuments: bookDocs) - let db = collRef.firestore - - let pipeline = db.pipeline() - .collection(collRef.path) - .where(Field("title").equal("The Hitchhiker's Guide to the Galaxy")) - .select([ - Field("awards").mapSet(key: "newAward", value: true).as("modifiedAwards"), - Field("title"), - ]) - - let snapshot = try await pipeline.execute() - - XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") - if let resultDoc = snapshot.results.first { - let expectedAwards: [String: Sendable?] = [ - "hugo": true, - "nebula": false, - "others": ["unknown": ["year": 1980]], - "newAward": true, - ] - let expectedResult: [String: Sendable?] = [ - "title": "The Hitchhiker's Guide to the Galaxy", - "modifiedAwards": expectedAwards, - ] - TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) - } else { - XCTFail("No document retrieved for testMapSetAddsNewField") - } - } - - func testMapSetUpdatesExistingField() async throws { - let collRef = collectionRef(withDocuments: bookDocs) - let db = collRef.firestore - - let pipeline = db.pipeline() - .collection(collRef.path) - .where(Field("title").equal("The Hitchhiker's Guide to the Galaxy")) - .select([ - Field("awards").mapSet(key: "hugo", value: false).as("modifiedAwards"), - Field("title"), - ]) - - let snapshot = try await pipeline.execute() - - XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") - if let resultDoc = snapshot.results.first { - let expectedAwards: [String: Sendable?] = [ - "hugo": false, - "nebula": false, - "others": ["unknown": ["year": 1980]], - ] - let expectedResult: [String: Sendable?] = [ - "title": "The Hitchhiker's Guide to the Galaxy", - "modifiedAwards": expectedAwards, - ] - TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) - } else { - XCTFail("No document retrieved for testMapSetUpdatesExistingField") - } - } - - func testMapSetWithExpressionValue() async throws { - let collRef = collectionRef(withDocuments: bookDocs) - let db = collRef.firestore - - let pipeline = db.pipeline() - .collection(collRef.path) - .where(Field("title").equal("The Hitchhiker's Guide to the Galaxy")) - .select( - [ - Field("awards") - .mapSet( - key: "ratingCategory", - value: Field("rating").greaterThan(4.0).then(Constant("high"), else: Constant("low")) - ) - .as("modifiedAwards"), - Field("title"), - ] - ) - - let snapshot = try await pipeline.execute() - - XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") - if let resultDoc = snapshot.results.first { - let expectedAwards: [String: Sendable?] = [ - "hugo": true, - "nebula": false, - "others": ["unknown": ["year": 1980]], - "ratingCategory": "high", - ] - let expectedResult: [String: Sendable?] = [ - "title": "The Hitchhiker's Guide to the Galaxy", - "modifiedAwards": expectedAwards, - ] - TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) - } else { - XCTFail("No document retrieved for testMapSetWithExpressionValue") - } - } - - func testMapSetWithExpressionKey() async throws { - let collRef = collectionRef(withDocuments: bookDocs) - let db = collRef.firestore - - let pipeline = db.pipeline() - .collection(collRef.path) - .where(Field("title").equal("The Hitchhiker's Guide to the Galaxy")) - .select([ - Field("awards") - .mapSet(key: Constant("dynamicKey"), value: "dynamicValue") - .as("modifiedAwards"), - Field("title"), - ]) - - let snapshot = try await pipeline.execute() - - XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") - if let resultDoc = snapshot.results.first { - let expectedAwards: [String: Sendable?] = [ - "hugo": true, - "nebula": false, - "others": ["unknown": ["year": 1980]], - "dynamicKey": "dynamicValue", - ] - let expectedResult: [String: Sendable?] = [ - "title": "The Hitchhiker's Guide to the Galaxy", - "modifiedAwards": expectedAwards, - ] - TestHelper.compare(pipelineResult: resultDoc, expected: expectedResult) - } else { - XCTFail("No document retrieved for testMapSetWithExpressionKey") - } - } - func testSupportsTimestampConversions() async throws { let db = firestore() let randomCol = collectionRef() // Unique collection for this test @@ -3365,15 +3229,22 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { .limit(1) .select( [ - Constant(baseTimestamp).timestampTruncate(granularity: "nanosecond").as("truncNano"), Constant(baseTimestamp).timestampTruncate(granularity: .microsecond).as("truncMicro"), Constant(baseTimestamp).timestampTruncate(granularity: .millisecond).as("truncMilli"), Constant(baseTimestamp).timestampTruncate(granularity: .second).as("truncSecond"), Constant(baseTimestamp).timestampTruncate(granularity: .minute).as("truncMinute"), Constant(baseTimestamp).timestampTruncate(granularity: .hour).as("truncHour"), Constant(baseTimestamp).timestampTruncate(granularity: .day).as("truncDay"), - Constant(baseTimestamp).timestampTruncate(granularity: "month").as("truncMonth"), - Constant(baseTimestamp).timestampTruncate(granularity: "year").as("truncYear"), + Constant(baseTimestamp).timestampTruncate(granularity: .week).as("truncWeek"), + Constant(baseTimestamp).timestampTruncate(granularity: .weekMonday).as("truncWeekMonday"), + Constant(baseTimestamp).timestampTruncate(granularity: .weekTuesday) + .as("truncWeekTuesday"), + Constant(baseTimestamp).timestampTruncate(granularity: .isoweek).as("truncIsoWeek"), + Constant(baseTimestamp).timestampTruncate(granularity: .month).as("truncMonth"), + Constant(baseTimestamp).timestampTruncate(granularity: .quarter).as("truncQuarter"), + Constant(baseTimestamp).timestampTruncate(granularity: .year).as("truncYear"), + Constant(baseTimestamp).timestampTruncate(granularity: .isoyear).as("truncIsoYear"), + Constant(baseTimestamp).timestampTruncate(granularity: "day").as("truncDayString"), Constant(baseTimestamp).timestampTruncate(granularity: Constant("day")) .as("truncDayExpr"), ] @@ -3384,16 +3255,22 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") let expectedResults: [String: Timestamp] = [ - "truncNano": Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_000), "truncMicro": Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_000), "truncMilli": Timestamp(seconds: 1_741_380_235, nanoseconds: 123_000_000), "truncSecond": Timestamp(seconds: 1_741_380_235, nanoseconds: 0), "truncMinute": Timestamp(seconds: 1_741_380_180, nanoseconds: 0), "truncHour": Timestamp(seconds: 1_741_377_600, nanoseconds: 0), - "truncDay": Timestamp(seconds: 1_741_305_600, nanoseconds: 0), // Assuming UTC day start - "truncMonth": Timestamp(seconds: 1_740_787_200, nanoseconds: 0), // Assuming UTC month start - "truncYear": Timestamp(seconds: 1_735_689_600, nanoseconds: 0), // Assuming UTC year start - "truncDayExpr": Timestamp(seconds: 1_741_305_600, nanoseconds: 0), // Assuming UTC day start + "truncDay": Timestamp(seconds: 1_741_305_600, nanoseconds: 0), + "truncWeek": Timestamp(seconds: 1_740_873_600, nanoseconds: 0), + "truncWeekMonday": Timestamp(seconds: 1_740_960_000, nanoseconds: 0), + "truncWeekTuesday": Timestamp(seconds: 1_741_046_400, nanoseconds: 0), + "truncIsoWeek": Timestamp(seconds: 1_740_960_000, nanoseconds: 0), + "truncMonth": Timestamp(seconds: 1_740_787_200, nanoseconds: 0), + "truncQuarter": Timestamp(seconds: 1_735_689_600, nanoseconds: 0), + "truncYear": Timestamp(seconds: 1_735_689_600, nanoseconds: 0), + "truncIsoYear": Timestamp(seconds: 1_735_516_800, nanoseconds: 0), + "truncDayString": Timestamp(seconds: 1_741_305_600, nanoseconds: 0), + "truncDayExpr": Timestamp(seconds: 1_741_305_600, nanoseconds: 0), ] if let resultDoc = snapshot.results.first { @@ -3506,7 +3383,6 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { } 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 @@ -3514,7 +3390,7 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { .collection(collRef.path) .sort([Field("rating").descending()]) .limit(1) - .select([Field("__path__").documentId().as("docId")]) + .select([Field(FieldPath.documentID()).documentId().as("docId")]) let snapshot = try await pipeline.execute() TestHelper.compare( snapshot: snapshot, @@ -3554,70 +3430,88 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { } 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 + let stringArrayDocs = [ + "doc1": ["tags": ["a", "b"], "more_tags": ["c", "d"]], + "doc2": ["tags": ["e", "f"], "more_tags": ["g", "h"]], + ] - 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 numberArrayDocs = [ + "doc1": ["tags": [1, 2], "more_tags": [3, 4]], + "doc2": ["tags": [5, 6], "more_tags": [7, 8]], + ] - let expectedTags: [Sendable?] = [ - "comedy", "space", "adventure", - "newTag1", "newTag2", - "comedy", "space", "adventure", - nil, + let stringCollRef = collectionRef(withDocuments: stringArrayDocs) + let numberCollRef = collectionRef(withDocuments: numberArrayDocs) + let db = stringCollRef.firestore + + // Test case 1: Concatenating string arrays. + let stringPipeline = db.pipeline() + .collection(stringCollRef.path) + .select([ + Field("tags").arrayConcat([Field("more_tags"), ArrayExpression(["i", "j"])]) + .as("concatenatedTags"), + ]) + + let stringSnapshot = try await stringPipeline.execute() + + let expectedStringResults: [[String: Sendable]] = [ + ["concatenatedTags": ["a", "b", "c", "d", "i", "j"]], + ["concatenatedTags": ["e", "f", "g", "h", "i", "j"]], ] TestHelper.compare( - snapshot: snapshot, - expected: [["modifiedTags": expectedTags]], + snapshot: stringSnapshot, + expected: expectedStringResults, 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() + // Test case 2: Concatenating number arrays. + let numberPipeline = db.pipeline() + .collection(numberCollRef.path) + .select([ + Field("tags").arrayConcat([Field("more_tags"), ArrayExpression([9, 10])]) + .as("concatenatedTags"), + ]) + + let numberSnapshot = try await numberPipeline.execute() + + let expectedNumberResults: [[String: Sendable]] = [ + ["concatenatedTags": [1, 2, 3, 4, 9, 10]], + ["concatenatedTags": [5, 6, 7, 8, 9, 10]], + ] TestHelper.compare( - snapshot: snapshot, - expected: [["modifiedTags": expectedTags]], + snapshot: numberSnapshot, + expected: expectedNumberResults, enforceOrder: false ) + + // Test case 3: Mix string and number arrays. + let mixPipeline = db.pipeline() + .collection(numberCollRef.path) + .select([ + Field("tags").arrayConcat([Field("more_tags"), ArrayExpression(["i", "j"])]) + .as("concatenatedTags"), + ]) + + let mixSnapshot = try await mixPipeline.execute() + + let expectedMixResults: [[String: Sendable]] = [ + ["concatenatedTags": [1, 2, 3, 4, "i", "j"]], + ["concatenatedTags": [5, 6, 7, 8, "i", "j"]], + ] + + TestHelper.compare(snapshot: mixSnapshot, expected: expectedMixResults, enforceOrder: false) } func testToLower() async throws { - try XCTSkipIf(true, "Skip this test since backend has not yet supported.") - let collRef = collectionRef(withDocuments: bookDocs) + let collRef = collectionRef(withDocuments: [ + "doc1": ["title": "The Hitchhiker's Guide to the Galaxy"], + ]) let db = collRef.firestore let pipeline = db.pipeline() .collection(collRef.path) - .limit(1) .select([Field("title").toLower().as("lowercaseTitle")]) let snapshot = try await pipeline.execute() TestHelper.compare( @@ -3628,13 +3522,13 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { } func testToUpper() async throws { - try XCTSkipIf(true, "Skip this test since backend has not yet supported.") - let collRef = collectionRef(withDocuments: bookDocs) + let collRef = collectionRef(withDocuments: [ + "doc1": ["author": "Douglas Adams"], + ]) let db = collRef.firestore let pipeline = db.pipeline() .collection(collRef.path) - .limit(1) .select([Field("author").toUpper().as("uppercaseAuthor")]) let snapshot = try await pipeline.execute() TestHelper.compare( @@ -3682,152 +3576,159 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { ) } - 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) + func testSplitWorks() async throws { + let collRef = collectionRef(withDocuments: [ + "doc1": ["text": "a-b-c"], + "doc2": ["text": "x,y,z", "delimiter": ","], + "doc3": ["text": Data([0x61, 0x00, 0x62, 0x00, 0x63]), "delimiter": Data([0x00])], + ]) let db = collRef.firestore - let pipeline = db.pipeline() - .collection(collRef.path) - .where(Field("title").equal("1984")) - .limit(1) - .select([Field("title").reverse().as("reverseTitle")]) - let snapshot = try await pipeline.execute() - TestHelper.compare( - snapshot: snapshot, - expected: [["reverseTitle": "4891"]], - enforceOrder: false - ) - } + // Test with string literal delimiter + var pipeline = db.pipeline() + .documents([collRef.document("doc1").path]) + .select([ + Field("text").split(delimiter: "-").as("split_text"), + ]) + var snapshot = try await pipeline.execute() - 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], - ]) - } + var expectedResults: [[String: Sendable]] = [ + ["split_text": ["a", "b", "c"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: false) - func testSupportsPaginationWithOffsetsUsingName() async throws { - try XCTSkipIf(true, "Skip this test since backend has not yet supported.") + // Test with expression delimiter (string) + pipeline = db.pipeline() + .documents([collRef.document("doc2").path]) + .select([ + Field("text").split(delimiter: Field("delimiter")).as("split_text"), + ]) + snapshot = try await pipeline.execute() - let collRef = collectionRef(withDocuments: bookDocs) - let db = collRef.firestore - try await addBooks(to: collRef) + expectedResults = [ + ["split_text": ["x", "y", "z"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: false) - let pageSize = 2 + // Test with expression delimiter (bytes) + pipeline = db.pipeline() + .documents([collRef.document("doc3").path]) + .select([ + Field("text").split(delimiter: Field("delimiter")).as("split_text"), + ]) + snapshot = try await pipeline.execute() - let pipeline = db.pipeline() - .collection(collRef.path) - .select(["title", "rating", "__name__"]) - .sort( - [ - Field("rating").descending(), - Field("__name__").ascending(), - ] - ) + let expectedByteResults: [[String: Sendable]] = [ + ["split_text": [Data([0x61]), Data([0x62]), Data([0x63])]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedByteResults, enforceOrder: false) + } - var snapshot = try await pipeline.limit(Int32(pageSize)).execute() + func testTrimWorksWithoutArguments() async throws { + let collRef = collectionRef(withDocuments: [ + "doc1": ["text": " hello world "], + "doc2": ["text": "\t\tFirebase\n\n"], + "doc3": ["text": "no_whitespace"], + ]) + let db = collRef.firestore - TestHelper.compare( - snapshot: snapshot, - expected: [ - ["title": "The Lord of the Rings", "rating": 4.7], - ["title": "Jonathan Strange & Mr Norrell", "rating": 4.6], - ], - enforceOrder: true - ) + let pipeline = db.pipeline() + .collection(collRef.path) + .select([ + Field("text").trim().as("trimmedText"), + ]) + .sort([Field("trimmedText").ascending()]) - let lastDoc = snapshot.results.last! + let snapshot = try await pipeline.execute() - snapshot = try await pipeline.where( - (Field("rating").equal(lastDoc.get("rating")!) - && Field("rating").lessThan(lastDoc.get("rating")!)) - || Field("rating").lessThan(lastDoc.get("rating")!) - ).limit(Int32(pageSize)).execute() + let expectedResults: [[String: Sendable]] = [ + ["trimmedText": "Firebase"], + ["trimmedText": "hello world"], + ["trimmedText": "no_whitespace"], + ] - TestHelper.compare( - snapshot: snapshot, - expected: [ - ["title": "Pride and Prejudice", "rating": 4.5], - ["title": "Crime and Punishment", "rating": 4.3], - ], - enforceOrder: false - ) + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) } - func testSupportsPaginationWithOffsetsUsingPath() async throws { - try XCTSkipIf(true, "Skip this test since backend has not yet supported.") - - let collRef = collectionRef(withDocuments: bookDocs) + 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 - 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(), - ] - ) + .sort([Field(FieldPath.documentID()).ascending()]) + .select([ + Field("scores").arrayMaximum().as("maxScore"), + Field("scores").arrayMinimum().as("minScore"), + ]) + + let snapshot = try await pipeline.execute() - var snapshot = try await pipeline.offset(Int32(currPage) * Int32(pageSize)).limit( - Int32(pageSize) - ).execute() + let expectedResults: [[String: Sendable?]] = [ + ["maxScore": 20, "minScore": 5], + ["maxScore": 0, "minScore": -5], + ["maxScore": 100.6, "minScore": 99.5], + ["maxScore": nil, "minScore": nil], + ] - currPage += 1 + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + } - TestHelper.compare( - snapshot: snapshot, - expected: [ - ["title": "The Lord of the Rings", "rating": 4.7], - ["title": "Dune", "rating": 4.6], + func testTypeWorks() async throws { + let collRef = collectionRef(withDocuments: [ + "doc1": [ + "a": 1, + "b": "hello", + "c": true, + "d": [1, 2], + "e": ["f": "g"], + "f": GeoPoint(latitude: 1, longitude: 2), + "g": Timestamp(date: Date()), + "h": Data([1, 2, 3]), + "i": NSNull(), + "j": Double.nan, ], - enforceOrder: true - ) + ]) + let db = collRef.firestore - snapshot = try await pipeline.offset(Int32(currPage) * Int32(pageSize)).limit( - Int32(pageSize) - ).execute() + let pipeline = db.pipeline() + .collection(collRef.path) + .select([ + Field("a").type().as("type_a"), + Field("b").type().as("type_b"), + Field("c").type().as("type_c"), + Field("d").type().as("type_d"), + Field("e").type().as("type_e"), + Field("f").type().as("type_f"), + Field("g").type().as("type_g"), + Field("h").type().as("type_h"), + Field("i").type().as("type_i"), + Field("j").type().as("type_j"), + ]) - currPage += 1 + let snapshot = try await pipeline.execute() - TestHelper.compare( - snapshot: snapshot, - expected: [ - ["title": "A Long Way to a Small, Angry Planet", "rating": 4.6], - ["title": "Pride and Prejudice", "rating": 4.5], + let expectedResults: [[String: Sendable]] = [ + [ + "type_a": "int64", + "type_b": "string", + "type_c": "boolean", + "type_d": "array", + "type_e": "map", + "type_f": "geo_point", + "type_g": "timestamp", + "type_h": "bytes", + "type_i": "null", + "type_j": "float64", ], - enforceOrder: true - ) + ] + + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: false) } func testAggregateThrowsOnDuplicateAliases() async throws { @@ -3864,8 +3765,10 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { } } - func testAddFieldsThrowsOnDuplicateAliases() async throws { - let collRef = collectionRef() + func testDuplicateAliasInAddFields() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + let pipeline = db.pipeline() .collection(collRef.path) .select(["title", "author"]) @@ -3882,4 +3785,128 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { XCTAssert(error.localizedDescription.contains("Duplicate alias 'foo'")) } } + + // MARK: - Pagination Tests + + private var addedDocs: [DocumentReference] = [] + + private func addBooks(to collectionReference: CollectionReference) async throws { + var newDocs: [DocumentReference] = [] + var docRef = collectionReference.document("book11") + newDocs.append(docRef) + try await docRef.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], + ]) + + docRef = collectionReference.document("book12") + newDocs.append(docRef) + try await docRef.setData([ + "title": "The Master and Margarita", + "author": "Mikhail Bulgakov", + "genre": "Satire", + "published": 1967, // Though written much earlier + "rating": 4.6, + "tags": ["russian literature", "supernatural", "philosophy", "dark comedy"], + "awards": [:], + ]) + + docRef = collectionReference.document("book13") + newDocs.append(docRef) + try await docRef.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], + ]) + addedDocs.append(contentsOf: newDocs) + } + + func testPaginationWithFilters() async throws { + let randomCol = collectionRef(withDocuments: bookDocs) + try await addBooks(to: randomCol) + + let pageSize = 2 + let pipeline = randomCol.firestore.pipeline() + .collection(randomCol.path) + .select(["title", "rating", "__name__"]) + .sort([Field("rating").descending(), Field("__name__").ascending()]) + + var snapshot = try await pipeline.limit(Int32(pageSize)).execute() + var expectedResults: [[String: Sendable]] = [ + ["title": "The Lord of the Rings", "rating": 4.7], + ["title": "Dune", "rating": 4.6], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + let lastDoc = snapshot.results.last! + let lastRating = lastDoc.get("rating")! + + snapshot = try await pipeline + .where( + (Field("rating").equal(lastRating) + && Field("__name__").greaterThan(lastDoc.ref!)) + || Field("rating").lessThan(lastRating) + ) + .limit(Int32(pageSize)) + .execute() + + expectedResults = [ + ["title": "Jonathan Strange & Mr Norrell", "rating": 4.6], + ["title": "The Master and Margarita", "rating": 4.6], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testPaginationWithOffsets() async throws { + let randomCol = collectionRef(withDocuments: bookDocs) + try await addBooks(to: randomCol) + + let secondFilterField = "__name__" + + let pipeline = randomCol.firestore.pipeline() + .collection(randomCol.path) + .select(["title", "rating", secondFilterField]) + .sort([ + Field("rating").descending(), + Field(secondFilterField).ascending(), + ]) + + let pageSize = 2 + var currPage = 0 + + var snapshot = try await pipeline.offset(Int32(currPage * pageSize)).limit(Int32(pageSize)) + .execute() + var expectedResults: [[String: Sendable]] = [ + ["title": "The Lord of the Rings", "rating": 4.7], + ["title": "Dune", "rating": 4.6], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + currPage += 1 + snapshot = try await pipeline.offset(Int32(currPage * pageSize)).limit(Int32(pageSize)) + .execute() + expectedResults = [ + ["title": "Jonathan Strange & Mr Norrell", "rating": 4.6], + ["title": "The Master and Margarita", "rating": 4.6], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + + currPage += 1 + snapshot = try await pipeline.offset(Int32(currPage * pageSize)).limit(Int32(pageSize)) + .execute() + expectedResults = [ + ["title": "A Long Way to a Small, Angry Planet", "rating": 4.6], + ["title": "Pride and Prejudice", "rating": 4.5], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + } }