diff --git a/Firestore/Swift/Source/Helper/PipelineHelper.swift b/Firestore/Swift/Source/Helper/PipelineHelper.swift index 197a5c530cb..1760c3c16f9 100644 --- a/Firestore/Swift/Source/Helper/PipelineHelper.swift +++ b/Firestore/Swift/Source/Helper/PipelineHelper.swift @@ -13,6 +13,17 @@ // limitations under the License. enum Helper { + enum HelperError: Error, LocalizedError { + case duplicateAlias(String) + + public var errorDescription: String? { + switch self { + case let .duplicateAlias(message): + return message + } + } + } + static func sendableToExpr(_ value: Sendable?) -> Expression { guard let value = value else { return Constant.nil @@ -31,14 +42,35 @@ enum Helper { } } - static func selectablesToMap(selectables: [Selectable]) -> [String: Expression] { - let exprMap = selectables.reduce(into: [String: Expression]()) { result, selectable in + static func selectablesToMap(selectables: [Selectable]) -> ([String: Expression], Error?) { + var exprMap = [String: Expression]() + for selectable in selectables { guard let value = selectable as? SelectableWrapper else { fatalError("Selectable class must conform to SelectableWrapper.") } - result[value.alias] = value.expr + let alias = value.alias + if exprMap.keys.contains(alias) { + return ([:], HelperError.duplicateAlias("Duplicate alias '\(alias)' found in selectables.")) + } + exprMap[alias] = value.expr + } + return (exprMap, nil) + } + + static func aliasedAggregatesToMap(accumulators: [AliasedAggregate]) + -> ([String: AggregateFunction], Error?) { + var accumulatorMap = [String: AggregateFunction]() + for aliasedAggregate in accumulators { + let alias = aliasedAggregate.alias + if accumulatorMap.keys.contains(alias) { + return ( + [:], + HelperError.duplicateAlias("Duplicate alias '\(alias)' found in accumulators.") + ) + } + accumulatorMap[alias] = aliasedAggregate.aggregate } - return exprMap + return (accumulatorMap, nil) } static func map(_ elements: [String: Sendable?]) -> FunctionExpression { @@ -66,11 +98,11 @@ enum Helper { if let exprValue = value as? Expression { return exprValue.toBridge() } else if let aggregateFunctionValue = value as? AggregateFunction { - return aggregateFunctionValue.toBridge() + return aggregateFunctionValue.bridge } else if let dictionaryValue = value as? [String: Sendable?] { let mappedValue: [String: Sendable] = dictionaryValue.mapValues { if let aggFunc = $0 as? AggregateFunction { - return aggFunc.toBridge() + return aggFunc.bridge } return sendableToExpr($0).toBridge() } diff --git a/Firestore/Swift/Source/Stages.swift b/Firestore/Swift/Source/Stages.swift index 24ed77e5d53..eab46bf60ff 100644 --- a/Firestore/Swift/Source/Stages.swift +++ b/Firestore/Swift/Source/Stages.swift @@ -26,6 +26,14 @@ 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. + var errorMessage: String? { get } +} + +extension Stage { + var errorMessage: String? { + return nil + } } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @@ -147,17 +155,19 @@ class AddFields: Stage { let name: String = "add_fields" let bridge: StageBridge private var selectables: [Selectable] + let errorMessage: String? init(selectables: [Selectable]) { self.selectables = selectables - let objc_accumulators = selectables.reduce(into: [String: ExprBridge]()) { - result, - selectable - in - let selectableWrapper = selectable as! SelectableWrapper - result[selectableWrapper.alias] = selectableWrapper.expr.toBridge() + let (map, error) = Helper.selectablesToMap(selectables: selectables) + if let error = error { + errorMessage = error.localizedDescription + bridge = AddFieldsStageBridge(fields: [:]) + } else { + errorMessage = nil + let objcAccumulators = map.mapValues { $0.toBridge() } + bridge = AddFieldsStageBridge(fields: objcAccumulators) } - bridge = AddFieldsStageBridge(fields: objc_accumulators) } } @@ -182,11 +192,18 @@ class RemoveFieldsStage: Stage { class Select: Stage { let name: String = "select" let bridge: StageBridge + let errorMessage: String? init(selections: [Selectable]) { - let map = Helper.selectablesToMap(selectables: selections) - bridge = SelectStageBridge(selections: map - .mapValues { Helper.sendableToExpr($0).toBridge() }) + let (map, error) = Helper.selectablesToMap(selectables: selections) + if let error = error { + errorMessage = error.localizedDescription + bridge = SelectStageBridge(selections: [:]) + } else { + errorMessage = nil + let objcSelections = map.mapValues { Helper.sendableToExpr($0).toBridge() } + bridge = SelectStageBridge(selections: objcSelections) + } } } @@ -194,11 +211,18 @@ class Select: Stage { class Distinct: Stage { let name: String = "distinct" let bridge: StageBridge + let errorMessage: String? init(groups: [Selectable]) { - let map = Helper.selectablesToMap(selectables: groups) - bridge = DistinctStageBridge(groups: map - .mapValues { Helper.sendableToExpr($0).toBridge() }) + let (map, error) = Helper.selectablesToMap(selectables: groups) + if let error = error { + errorMessage = error.localizedDescription + bridge = DistinctStageBridge(groups: [:]) + } else { + errorMessage = nil + let objcGroups = map.mapValues { Helper.sendableToExpr($0).toBridge() } + bridge = DistinctStageBridge(groups: objcGroups) + } } } @@ -208,18 +232,32 @@ class Aggregate: Stage { let bridge: StageBridge private var accumulators: [AliasedAggregate] private var groups: [String: Expression] = [:] + let errorMessage: String? init(accumulators: [AliasedAggregate], groups: [Selectable]?) { self.accumulators = accumulators - if groups != nil { - self.groups = Helper.selectablesToMap(selectables: groups!) - } - let accumulatorsMap = accumulators - .reduce(into: [String: AggregateFunctionBridge]()) { result, accumulator in - result[accumulator.alias] = accumulator.aggregate.bridge + + if let groups = groups { + let (map, error) = Helper.selectablesToMap(selectables: groups) + if let error = error { + errorMessage = error.localizedDescription + bridge = AggregateStageBridge(accumulators: [:], groups: [:]) + return } + self.groups = map + } + + let (accumulatorsMap, error) = Helper.aliasedAggregatesToMap(accumulators: accumulators) + if let error = error { + errorMessage = error.localizedDescription + bridge = AggregateStageBridge(accumulators: [:], groups: [:]) + return + } + + errorMessage = nil + let accumulatorBridgesMap = accumulatorsMap.mapValues { $0.bridge } bridge = AggregateStageBridge( - accumulators: accumulatorsMap, + accumulators: accumulatorBridgesMap, groups: self.groups.mapValues { Helper.sendableToExpr($0).toBridge() } ) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregates/AggregateFunction.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregates/AggregateFunction.swift index d4e224b7028..c6f080ab847 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregates/AggregateFunction.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregates/AggregateFunction.swift @@ -12,12 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -extension AggregateFunction { - func toBridge() -> AggregateFunctionBridge { - return (self as AggregateBridgeWrapper).bridge - } -} - /// Represents an aggregate function in a pipeline. /// /// An `AggregateFunction` is a function that computes a single value from a set of input values. diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift index 32fcb1ec64a..a54ee48813a 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift @@ -77,9 +77,12 @@ public struct Pipeline: @unchecked Sendable { let bridge: PipelineBridge let db: Firestore - init(stages: [Stage], db: Firestore) { + let errorMessage: String? + + init(stages: [Stage], db: Firestore, errorMessage: String? = nil) { self.stages = stages self.db = db + self.errorMessage = errorMessage bridge = PipelineBridge(stages: stages.map { $0.bridge }, db: db) } @@ -100,6 +103,10 @@ public struct Pipeline: @unchecked Sendable { } } + private func withError(_ message: String) -> Pipeline { + return Pipeline(stages: [], db: db, errorMessage: message) + } + /// Executes the defined pipeline and returns a `Pipeline.Snapshot` containing the results. /// /// This method asynchronously sends the pipeline definition to Firestore for execution. @@ -120,6 +127,15 @@ 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 + if let errorMessage = errorMessage { + throw NSError( + domain: "com.google.firebase.firestore", + code: 3 /* kErrorInvalidArgument */, + userInfo: [NSLocalizedDescriptionKey: errorMessage] + ) + } + return try await withCheckedThrowingContinuation { continuation in self.bridge.execute { result, error in if let error { @@ -150,7 +166,14 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter selectables: An array of at least one `Selectable` to add to the documents. /// - Returns: A new `Pipeline` object with this stage appended. public func addFields(_ selectables: [Selectable]) -> Pipeline { - return Pipeline(stages: stages + [AddFields(selectables: selectables)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let addFieldsStage = AddFields(selectables: selectables) + if let errorMessage = addFieldsStage.errorMessage { + return withError(errorMessage) + } + return Pipeline(stages: stages + [addFieldsStage], db: db) } /// Removes fields from outputs of previous stages. @@ -165,10 +188,18 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter fields: An array of at least one `Field` instance to remove. /// - Returns: A new `Pipeline` object with this stage appended. public func removeFields(_ fields: [Field]) -> Pipeline { - return Pipeline( - stages: stages + [RemoveFieldsStage(fields: fields)], - db: db - ) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = RemoveFieldsStage(fields: fields) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline( + stages: stages + [stage], + db: db + ) + } } /// Removes fields from outputs of previous stages using field names. @@ -183,10 +214,18 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter fields: An array of at least one field name to remove. /// - Returns: A new `Pipeline` object with this stage appended. public func removeFields(_ fields: [String]) -> Pipeline { - return Pipeline( - stages: stages + [RemoveFieldsStage(fields: fields)], - db: db - ) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = RemoveFieldsStage(fields: fields) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline( + stages: stages + [stage], + db: db + ) + } } /// Selects or creates a set of fields from the outputs of previous stages. @@ -215,10 +254,14 @@ public struct Pipeline: @unchecked Sendable { /// output documents. /// - Returns: A new `Pipeline` object with this stage appended. public func select(_ selections: [Selectable]) -> Pipeline { - return Pipeline( - stages: stages + [Select(selections: selections)], - db: db - ) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let selectStage = Select(selections: selections) + if let errorMessage = selectStage.errorMessage { + return withError(errorMessage) + } + return Pipeline(stages: stages + [selectStage], db: db) } /// Selects a set of fields from the outputs of previous stages using field names. @@ -236,11 +279,19 @@ public struct Pipeline: @unchecked Sendable { /// documents. /// - Returns: A new `Pipeline` object with this stage appended. public func select(_ selections: [String]) -> Pipeline { + if let errorMessage = errorMessage { + return withError(errorMessage) + } let selections = selections.map { Field($0) } - return Pipeline( - stages: stages + [Select(selections: selections)], - db: db - ) + let stage = Select(selections: selections) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline( + stages: stages + [stage], + db: db + ) + } } /// Filters documents from previous stages, including only those matching the specified @@ -264,7 +315,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter condition: The `BooleanExpression` to apply. /// - Returns: A new `Pipeline` object with this stage appended. public func `where`(_ condition: BooleanExpression) -> Pipeline { - return Pipeline(stages: stages + [Where(condition: condition)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Where(condition: condition) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Skips the first `offset` number of documents from the results of previous stages. @@ -286,7 +345,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter offset: The number of documents to skip (a `Int32` value). /// - Returns: A new `Pipeline` object with this stage appended. public func offset(_ offset: Int32) -> Pipeline { - return Pipeline(stages: stages + [Offset(offset)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Offset(offset) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Limits the maximum number of documents returned by previous stages to `limit`. @@ -309,7 +376,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter limit: The maximum number of documents to return (a `Int32` value). /// - Returns: A new `Pipeline` object with this stage appended. public func limit(_ limit: Int32) -> Pipeline { - return Pipeline(stages: stages + [Limit(limit)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Limit(limit) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Returns a set of distinct documents based on specified grouping field names. @@ -329,8 +404,16 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter groups: An array of at least one field name for distinct value combinations. /// - Returns: A new `Pipeline` object with this stage appended. public func distinct(_ groups: [String]) -> Pipeline { + if let errorMessage = errorMessage { + return withError(errorMessage) + } let selections = groups.map { Field($0) } - return Pipeline(stages: stages + [Distinct(groups: selections)], db: db) + let stage = Distinct(groups: selections) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Returns a set of distinct documents based on specified `Selectable` expressions. @@ -358,7 +441,14 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter groups: An array of at least one `Selectable` expression to consider. /// - Returns: A new `Pipeline` object with this stage appended. public func distinct(_ groups: [Selectable]) -> Pipeline { - return Pipeline(stages: stages + [Distinct(groups: groups)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let distinctStage = Distinct(groups: groups) + if let errorMessage = distinctStage.errorMessage { + return withError(errorMessage) + } + return Pipeline(stages: stages + [distinctStage], db: db) } /// Performs optionally grouped aggregation operations on documents from previous stages. @@ -393,7 +483,14 @@ public struct Pipeline: @unchecked Sendable { /// - Returns: A new `Pipeline` object with this stage appended. public func aggregate(_ aggregates: [AliasedAggregate], groups: [Selectable]? = nil) -> Pipeline { - return Pipeline(stages: stages + [Aggregate(accumulators: aggregates, groups: groups)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let aggregateStage = Aggregate(accumulators: aggregates, groups: groups) + if let errorMessage = aggregateStage.errorMessage { + return withError(errorMessage) + } + return Pipeline(stages: stages + [aggregateStage], db: db) } /// Performs a vector similarity search, ordering results by similarity. @@ -426,18 +523,21 @@ public struct Pipeline: @unchecked Sendable { distanceMeasure: DistanceMeasure, limit: Int? = nil, distanceField: String? = nil) -> Pipeline { - return Pipeline( - stages: stages + [ - FindNearest( - field: field, - vectorValue: vectorValue, - distanceMeasure: distanceMeasure, - limit: limit, - distanceField: distanceField - ), - ], - db: db + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = FindNearest( + field: field, + vectorValue: vectorValue, + distanceMeasure: distanceMeasure, + limit: limit, + distanceField: distanceField ) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Sorts documents from previous stages based on one or more `Ordering` criteria. @@ -459,7 +559,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter orderings: An array of at least one `Ordering` criterion. /// - Returns: A new `Pipeline` object with this stage appended. public func sort(_ orderings: [Ordering]) -> Pipeline { - return Pipeline(stages: stages + [Sort(orderings: orderings)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Sort(orderings: orderings) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Fully overwrites document fields with those from a nested map identified by an `Expr`. @@ -483,7 +591,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter expression: The `Expr` (typically a `Field`) that resolves to the nested map. /// - Returns: A new `Pipeline` object with this stage appended. public func replace(with expression: Expression) -> Pipeline { - return Pipeline(stages: stages + [ReplaceWith(expr: expression)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = ReplaceWith(expr: expression) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Fully overwrites document fields with those from a nested map identified by a field name. @@ -508,7 +624,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter fieldName: The name of the field containing the nested map. /// - Returns: A new `Pipeline` object with this stage appended. public func replace(with fieldName: String) -> Pipeline { - return Pipeline(stages: stages + [ReplaceWith(expr: Field(fieldName))], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = ReplaceWith(expr: Field(fieldName)) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Performs pseudo-random sampling of input documents, returning a specific count. @@ -527,7 +651,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter count: The target number of documents to sample (a `Int64` value). /// - Returns: A new `Pipeline` object with this stage appended. public func sample(count: Int64) -> Pipeline { - return Pipeline(stages: stages + [Sample(count: count)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Sample(count: count) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Performs pseudo-random sampling of input documents, returning a percentage. @@ -546,7 +678,15 @@ public struct Pipeline: @unchecked Sendable { /// value). /// - Returns: A new `Pipeline` object with this stage appended. public func sample(percentage: Double) -> Pipeline { - return Pipeline(stages: stages + [Sample(percentage: percentage)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Sample(percentage: percentage) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Performs a union of all documents from this pipeline and another, including duplicates. @@ -569,7 +709,15 @@ public struct Pipeline: @unchecked Sendable { /// - Parameter other: Another `Pipeline` whose documents will be unioned. /// - Returns: A new `Pipeline` object with this stage appended. public func union(with other: Pipeline) -> Pipeline { - return Pipeline(stages: stages + [Union(other: other)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Union(other: other) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Takes an array field from input documents and outputs a new document for each element. @@ -611,7 +759,15 @@ public struct Pipeline: @unchecked Sendable { /// zero-based index from the original array. /// - Returns: A new `Pipeline` object with this stage appended. public func unnest(_ field: Selectable, indexField: String? = nil) -> Pipeline { - return Pipeline(stages: stages + [Unnest(field: field, indexField: indexField)], db: db) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = Unnest(field: field, indexField: indexField) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } /// Adds a generic stage to the pipeline by specifying its name and parameters. @@ -641,9 +797,14 @@ public struct Pipeline: @unchecked Sendable { /// - Returns: A new `Pipeline` object with this stage appended. public func rawStage(name: String, params: [Sendable], options: [String: Sendable]? = nil) -> Pipeline { - return Pipeline( - stages: stages + [RawStage(name: name, params: params, options: options)], - db: db - ) + if let errorMessage = errorMessage { + return withError(errorMessage) + } + let stage = RawStage(name: name, params: params, options: options) + if let errorMessage = stage.errorMessage { + return withError(errorMessage) + } else { + return Pipeline(stages: stages + [stage], db: db) + } } } diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 7daae8f6938..0971432ddbd 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -3253,17 +3253,19 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { let pipeline = db.pipeline() .collection(randomCol.path) .limit(1) - .select([ - Constant(1_741_380_235).unixSecondsToTimestamp().as("unixSecondsToTimestamp"), - Constant(1_741_380_235_123).unixMillisToTimestamp().as("unixMillisToTimestamp"), - Constant(1_741_380_235_123_456).unixMicrosToTimestamp().as("unixMicrosToTimestamp"), - Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) - .timestampToUnixSeconds().as("timestampToUnixSeconds"), - Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) - .timestampToUnixMillis().as("timestampToUnixMillis"), - Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) - .timestampToUnixMicros().as("timestampToUnixMicros"), - ]) + .select( + [ + Constant(1_741_380_235).unixSecondsToTimestamp().as("unixSecondsToTimestamp"), + Constant(1_741_380_235_123).unixMillisToTimestamp().as("unixMillisToTimestamp"), + Constant(1_741_380_235_123_456).unixMicrosToTimestamp().as("unixMicrosToTimestamp"), + Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) + .timestampToUnixSeconds().as("timestampToUnixSeconds"), + Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) + .timestampToUnixMillis().as("timestampToUnixMillis"), + Constant(Timestamp(seconds: 1_741_380_235, nanoseconds: 123_456_789)) + .timestampToUnixMicros().as("timestampToUnixMicros"), + ] + ) let snapshot = try await pipeline.execute() XCTAssertEqual( @@ -3827,4 +3829,57 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { enforceOrder: true ) } + + func testAggregateThrowsOnDuplicateAliases() async throws { + let collRef = collectionRef() + let pipeline = db.pipeline() + .collection(collRef.path) + .aggregate([ + CountAll().as("count"), + Field("foo").count().as("count"), + ]) + + do { + _ = try await pipeline.execute() + XCTFail("Should have thrown an error") + } catch { + XCTAssert(error.localizedDescription.contains("Duplicate alias 'count'")) + } + } + + func testAggregateThrowsOnDuplicateGroupAliases() async throws { + let collRef = collectionRef() + let pipeline = db.pipeline() + .collection(collRef.path) + .aggregate( + [CountAll().as("count")], + groups: [Field("bax"), Field("bar").as("bax")] + ) + + do { + _ = try await pipeline.execute() + XCTFail("Should have thrown an error") + } catch { + XCTAssert(error.localizedDescription.contains("Duplicate alias 'bax'")) + } + } + + func testAddFieldsThrowsOnDuplicateAliases() async throws { + let collRef = collectionRef() + let pipeline = db.pipeline() + .collection(collRef.path) + .select(["title", "author"]) + .addFields([ + Constant("bar").as("foo"), + Constant("baz").as("foo"), + ]) + .sort([Field("author").ascending()]) + + do { + _ = try await pipeline.execute() + XCTFail("Should have thrown an error") + } catch { + XCTAssert(error.localizedDescription.contains("Duplicate alias 'foo'")) + } + } }