From 4eb402ef3f90531a2ee048f069fb0f9767e7f35d Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 29 Oct 2025 15:58:52 -0400 Subject: [PATCH 1/6] change API of timestampTruncate --- .../Source/ExpressionImplementation.swift | 2 +- .../Pipeline/Expressions/Expression.swift | 12 ++- .../SwiftAPI/Pipeline/TimeGranularity.swift | 82 +++++++++++++++++++ .../Tests/Integration/PipelineTests.swift | 29 +++++-- 4 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 Firestore/Swift/Source/SwiftAPI/Pipeline/TimeGranularity.swift diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 836a571f07c..8dde7693c4a 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -935,7 +935,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)] diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index 1faca18ad0f..c0b47859ebd 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -1452,19 +1452,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 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 339478b24d3..824ae071c3a 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3365,15 +3365,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 +3391,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 { From 857e71c7239127f0d4d1af78344b35730235790b Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 29 Oct 2025 16:14:23 -0400 Subject: [PATCH 2/6] remove map_set --- .../Source/ExpressionImplementation.swift | 14 -- .../Pipeline/Expressions/Expression.swift | 28 ---- .../Tests/Integration/PipelineTests.swift | 136 ------------------ 3 files changed, 178 deletions(-) diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 8dde7693c4a..14536722ca8 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -789,20 +789,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 { diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index c0b47859ebd..41ba8bae2b0 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -1103,34 +1103,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. diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 824ae071c3a..f6dae513118 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 From e19e9fd428887bfa1fac8f24edd67d1ade7a7799 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 29 Oct 2025 21:11:45 -0400 Subject: [PATCH 3/6] add Split --- .../Source/ExpressionImplementation.swift | 8 +++ .../Pipeline/Expressions/Expression.swift | 12 +++++ .../Tests/Integration/PipelineTests.swift | 51 ++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 14536722ca8..09056e2ba61 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -638,6 +638,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]) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index 41ba8bae2b0..69f355eb4ee 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -704,6 +704,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 diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index f6dae513118..6a9278bf2ee 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3521,7 +3521,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 @@ -3529,7 +3528,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, @@ -3844,4 +3843,52 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { enforceOrder: true ) } + + 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 + + // 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() + + var expectedResults: [[String: Sendable]] = [ + ["split_text": ["a", "b", "c"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: false) + + // 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() + + expectedResults = [ + ["split_text": ["x", "y", "z"]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: false) + + // 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 expectedByteResults: [[String: Sendable]] = [ + ["split_text": [Data([0x61]), Data([0x62]), Data([0x63])]], + ] + TestHelper.compare(snapshot: snapshot, expected: expectedByteResults, enforceOrder: false) + } } From d0f60c45a8ced3880e379553d3ac601fb82f03ca Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 31 Oct 2025 12:32:55 -0400 Subject: [PATCH 4/6] add trim() --- .../Source/ExpressionImplementation.swift | 4 +++ .../Pipeline/Expressions/Expression.swift | 12 +++++++++ .../Tests/Integration/PipelineTests.swift | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 09056e2ba61..95de46d0206 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -733,6 +733,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) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index 69f355eb4ee..b00baa849dc 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -921,6 +921,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. diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 6a9278bf2ee..b36c4b40667 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3891,4 +3891,30 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { ] TestHelper.compare(snapshot: snapshot, expected: expectedByteResults, enforceOrder: false) } + + 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 + + let pipeline = db.pipeline() + .collection(collRef.path) + .select([ + Field("text").trim().as("trimmedText"), + ]) + .sort([Field("trimmedText").ascending()]) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["trimmedText": "Firebase"], + ["trimmedText": "hello world"], + ["trimmedText": "no_whitespace"], + ] + + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) + } } From 80a79aea1082d9514ab3ed62705b0dbc7d8fcbdb Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 31 Oct 2025 14:38:25 -0400 Subject: [PATCH 5/6] add array maximun and minimum --- .../Source/ExpressionImplementation.swift | 8 +++++ .../Pipeline/Expressions/Expression.swift | 24 +++++++++++++++ .../Tests/Integration/PipelineTests.swift | 29 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 95de46d0206..e186137bd54 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]) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index b00baa849dc..97a5af4628e 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. /// diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index b36c4b40667..86d23c99be7 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3917,4 +3917,33 @@ 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) + } } From e086ebd07b05982e1a61de88020dfaa82f79aead Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 31 Oct 2025 14:55:49 -0400 Subject: [PATCH 6/6] add type --- .../Source/ExpressionImplementation.swift | 4 ++ .../Pipeline/Expressions/Expression.swift | 10 ++++ .../Tests/Integration/PipelineTests.swift | 52 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index e186137bd54..ce982c1e8b1 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -1023,4 +1023,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/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index 97a5af4628e..8126db620b7 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -1643,4 +1643,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/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 86d23c99be7..61f2f6e2e92 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3946,4 +3946,56 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) } + + 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, + ], + ]) + let db = collRef.firestore + + 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"), + ]) + + let snapshot = try await pipeline.execute() + + 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", + ], + ] + + TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: false) + } }