diff --git a/Firestore/Swift/Source/ExpressionImplementation.swift b/Firestore/Swift/Source/ExpressionImplementation.swift index 5786f264770..aecfb3c75b1 100644 --- a/Firestore/Swift/Source/ExpressionImplementation.swift +++ b/Firestore/Swift/Source/ExpressionImplementation.swift @@ -376,6 +376,24 @@ extension Expression { } public extension Expression { + func asBoolean() -> BooleanExpression { + switch self { + case let boolExpr as BooleanExpression: + return boolExpr + case let constant as Constant: + return BooleanConstant(constant) + case let field as Field: + return BooleanField(field) + case let funcExpr as FunctionExpression: + return BooleanFunctionExpression(funcExpr) + default: + // This should be unreachable if all expression types are handled. + fatalError( + "Unknown expression type \(Swift.type(of: self)) cannot be converted to BooleanExpression" + ) + } + } + func `as`(_ name: String) -> AliasedExpression { return AliasedExpression(self, name) } @@ -474,38 +492,56 @@ public extension Expression { } func arrayContains(_ element: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "array_contains", args: [self, element]) + return BooleanFunctionExpression(functionName: "array_contains", args: [self, element]) } func arrayContains(_ element: Sendable) -> BooleanExpression { - return BooleanExpression( + return BooleanFunctionExpression( functionName: "array_contains", args: [self, Helper.sendableToExpr(element)] ) } func arrayContainsAll(_ values: [Expression]) -> BooleanExpression { - return BooleanExpression(functionName: "array_contains_all", args: [self, Helper.array(values)]) + return BooleanFunctionExpression( + functionName: "array_contains_all", + args: [self, Helper.array(values)] + ) } func arrayContainsAll(_ values: [Sendable]) -> BooleanExpression { - return BooleanExpression(functionName: "array_contains_all", args: [self, Helper.array(values)]) + return BooleanFunctionExpression( + functionName: "array_contains_all", + args: [self, Helper.array(values)] + ) } func arrayContainsAll(_ arrayExpression: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "array_contains_all", args: [self, arrayExpression]) + return BooleanFunctionExpression( + functionName: "array_contains_all", + args: [self, arrayExpression] + ) } func arrayContainsAny(_ values: [Expression]) -> BooleanExpression { - return BooleanExpression(functionName: "array_contains_any", args: [self, Helper.array(values)]) + return BooleanFunctionExpression( + functionName: "array_contains_any", + args: [self, Helper.array(values)] + ) } func arrayContainsAny(_ values: [Sendable]) -> BooleanExpression { - return BooleanExpression(functionName: "array_contains_any", args: [self, Helper.array(values)]) + return BooleanFunctionExpression( + functionName: "array_contains_any", + args: [self, Helper.array(values)] + ) } func arrayContainsAny(_ arrayExpression: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "array_contains_any", args: [self, arrayExpression]) + return BooleanFunctionExpression( + functionName: "array_contains_any", + args: [self, arrayExpression] + ) } func arrayLength() -> FunctionExpression { @@ -532,80 +568,89 @@ public extension Expression { } func greaterThan(_ other: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "greater_than", args: [self, other]) + return BooleanFunctionExpression(functionName: "greater_than", args: [self, other]) } func greaterThan(_ other: Sendable) -> BooleanExpression { let exprOther = Helper.sendableToExpr(other) - return BooleanExpression(functionName: "greater_than", args: [self, exprOther]) + return BooleanFunctionExpression(functionName: "greater_than", args: [self, exprOther]) } func greaterThanOrEqual(_ other: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "greater_than_or_equal", args: [self, other]) + return BooleanFunctionExpression(functionName: "greater_than_or_equal", args: [self, other]) } func greaterThanOrEqual(_ other: Sendable) -> BooleanExpression { let exprOther = Helper.sendableToExpr(other) - return BooleanExpression(functionName: "greater_than_or_equal", args: [self, exprOther]) + return BooleanFunctionExpression(functionName: "greater_than_or_equal", args: [self, exprOther]) } func lessThan(_ other: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "less_than", args: [self, other]) + return BooleanFunctionExpression(functionName: "less_than", args: [self, other]) } func lessThan(_ other: Sendable) -> BooleanExpression { let exprOther = Helper.sendableToExpr(other) - return BooleanExpression(functionName: "less_than", args: [self, exprOther]) + return BooleanFunctionExpression(functionName: "less_than", args: [self, exprOther]) } func lessThanOrEqual(_ other: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "less_than_or_equal", args: [self, other]) + return BooleanFunctionExpression(functionName: "less_than_or_equal", args: [self, other]) } func lessThanOrEqual(_ other: Sendable) -> BooleanExpression { let exprOther = Helper.sendableToExpr(other) - return BooleanExpression(functionName: "less_than_or_equal", args: [self, exprOther]) + return BooleanFunctionExpression(functionName: "less_than_or_equal", args: [self, exprOther]) } func equal(_ other: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "equal", args: [self, other]) + return BooleanFunctionExpression(functionName: "equal", args: [self, other]) } func equal(_ other: Sendable) -> BooleanExpression { let exprOther = Helper.sendableToExpr(other) - return BooleanExpression(functionName: "equal", args: [self, exprOther]) + return BooleanFunctionExpression(functionName: "equal", args: [self, exprOther]) } func notEqual(_ other: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "not_equal", args: [self, other]) + return BooleanFunctionExpression(functionName: "not_equal", args: [self, other]) } func notEqual(_ other: Sendable) -> BooleanExpression { - return BooleanExpression(functionName: "not_equal", args: [self, Helper.sendableToExpr(other)]) + return BooleanFunctionExpression( + functionName: "not_equal", + args: [self, Helper.sendableToExpr(other)] + ) } func equalAny(_ others: [Expression]) -> BooleanExpression { - return BooleanExpression(functionName: "equal_any", args: [self, Helper.array(others)]) + return BooleanFunctionExpression(functionName: "equal_any", args: [self, Helper.array(others)]) } func equalAny(_ others: [Sendable]) -> BooleanExpression { - return BooleanExpression(functionName: "equal_any", args: [self, Helper.array(others)]) + return BooleanFunctionExpression(functionName: "equal_any", args: [self, Helper.array(others)]) } func equalAny(_ arrayExpression: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "equal_any", args: [self, arrayExpression]) + return BooleanFunctionExpression(functionName: "equal_any", args: [self, arrayExpression]) } func notEqualAny(_ others: [Expression]) -> BooleanExpression { - return BooleanExpression(functionName: "not_equal_any", args: [self, Helper.array(others)]) + return BooleanFunctionExpression( + functionName: "not_equal_any", + args: [self, Helper.array(others)] + ) } func notEqualAny(_ others: [Sendable]) -> BooleanExpression { - return BooleanExpression(functionName: "not_equal_any", args: [self, Helper.array(others)]) + return BooleanFunctionExpression( + functionName: "not_equal_any", + args: [self, Helper.array(others)] + ) } func notEqualAny(_ arrayExpression: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "not_equal_any", args: [self, arrayExpression]) + return BooleanFunctionExpression(functionName: "not_equal_any", args: [self, arrayExpression]) } // MARK: Checks @@ -613,15 +658,15 @@ public extension Expression { // --- Added Type Check Operations --- func exists() -> BooleanExpression { - return BooleanExpression(functionName: "exists", args: [self]) + return BooleanFunctionExpression(functionName: "exists", args: [self]) } func isError() -> BooleanExpression { - return BooleanExpression(functionName: "is_error", args: [self]) + return BooleanFunctionExpression(functionName: "is_error", args: [self]) } func isAbsent() -> BooleanExpression { - return BooleanExpression(functionName: "is_absent", args: [self]) + return BooleanFunctionExpression(functionName: "is_absent", args: [self]) } // --- Added String Operations --- @@ -647,63 +692,69 @@ public extension Expression { } func like(_ pattern: String) -> BooleanExpression { - return BooleanExpression(functionName: "like", args: [self, Helper.sendableToExpr(pattern)]) + return BooleanFunctionExpression( + functionName: "like", + args: [self, Helper.sendableToExpr(pattern)] + ) } func like(_ pattern: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "like", args: [self, pattern]) + return BooleanFunctionExpression(functionName: "like", args: [self, pattern]) } func regexContains(_ pattern: String) -> BooleanExpression { - return BooleanExpression( + return BooleanFunctionExpression( functionName: "regex_contains", args: [self, Helper.sendableToExpr(pattern)] ) } func regexContains(_ pattern: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "regex_contains", args: [self, pattern]) + return BooleanFunctionExpression(functionName: "regex_contains", args: [self, pattern]) } func regexMatch(_ pattern: String) -> BooleanExpression { - return BooleanExpression( + return BooleanFunctionExpression( functionName: "regex_match", args: [self, Helper.sendableToExpr(pattern)] ) } func regexMatch(_ pattern: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "regex_match", args: [self, pattern]) + return BooleanFunctionExpression(functionName: "regex_match", args: [self, pattern]) } func stringContains(_ substring: String) -> BooleanExpression { - return BooleanExpression( + return BooleanFunctionExpression( functionName: "string_contains", args: [self, Helper.sendableToExpr(substring)] ) } func stringContains(_ expression: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "string_contains", args: [self, expression]) + return BooleanFunctionExpression(functionName: "string_contains", args: [self, expression]) } func startsWith(_ prefix: String) -> BooleanExpression { - return BooleanExpression( + return BooleanFunctionExpression( functionName: "starts_with", args: [self, Helper.sendableToExpr(prefix)] ) } func startsWith(_ prefix: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "starts_with", args: [self, prefix]) + return BooleanFunctionExpression(functionName: "starts_with", args: [self, prefix]) } func endsWith(_ suffix: String) -> BooleanExpression { - return BooleanExpression(functionName: "ends_with", args: [self, Helper.sendableToExpr(suffix)]) + return BooleanFunctionExpression( + functionName: "ends_with", + args: [self, Helper.sendableToExpr(suffix)] + ) } func endsWith(_ suffix: Expression) -> BooleanExpression { - return BooleanExpression(functionName: "ends_with", args: [self, suffix]) + return BooleanFunctionExpression(functionName: "ends_with", args: [self, suffix]) } func toLower() -> FunctionExpression { diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift index 8b3367b299c..fb2475f3140 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/Expression.swift @@ -20,6 +20,11 @@ import Foundation public protocol Expression: Sendable { + /// Casts the expression to a `BooleanExpression`. + /// + /// - Returns: A `BooleanExpression` representing the same expression. + func asBoolean() -> BooleanExpression + /// Assigns an alias to this expression. /// /// Aliases are useful for renaming fields in the output of a stage or for giving meaningful diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/FunctionExpressions/BooleanExpression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/FunctionExpressions/BooleanExpression.swift index 700d4aa0476..85d436d0e91 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/FunctionExpressions/BooleanExpression.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expressions/FunctionExpressions/BooleanExpression.swift @@ -15,7 +15,7 @@ import Foundation /// -/// A `BooleanExpression` is a specialized `FunctionExpression` that evaluates to a boolean value. +/// A `BooleanExpression` is an `Expression` that evaluates to a boolean value. /// /// It is used to construct conditional logic within Firestore pipelines, such as in `where` /// clauses or `ConditionalExpression`. `BooleanExpression` instances can be combined using standard @@ -30,11 +30,126 @@ import Foundation /// (Field("category").equal("electronics") || Field("on_sale").equal(true)) /// ) /// ``` -public class BooleanExpression: FunctionExpression, @unchecked Sendable { - override public init(functionName: String, args: [Expression]) { - super.init(functionName: functionName, args: args) +public protocol BooleanExpression: Expression {} + +struct BooleanFunctionExpression: BooleanExpression, BridgeWrapper { + let expr: FunctionExpression + public var bridge: ExprBridge { return expr.bridge } + + init(_ expr: FunctionExpression) { + self.expr = expr + } + + init(functionName: String, args: [Expression]) { + expr = FunctionExpression(functionName: functionName, args: args) + } +} + +struct BooleanConstant: BooleanExpression, BridgeWrapper { + private let constant: Constant + public var bridge: ExprBridge { return constant.bridge } + + init(_ constant: Constant) { + self.constant = constant + } +} + +struct BooleanField: BooleanExpression, BridgeWrapper { + private let field: Field + public var bridge: ExprBridge { return field.bridge } + + init(_ field: Field) { + self.field = field } +} + +/// Combines two boolean expressions with a logical AND (`&&`). +/// +/// The resulting expression is `true` only if both the left-hand side (`lhs`) and the right-hand +/// side (`rhs`) are `true`. +/// +/// ```swift +/// // Find books in the "Fantasy" genre with a rating greater than 4.5 +/// firestore.pipeline() +/// .collection("books") +/// .where( +/// Field("genre").equal("Fantasy") && Field("rating").greaterThan(4.5) +/// ) +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand boolean expression. +/// - rhs: The right-hand boolean expression. +/// - Returns: A new `BooleanExpression` representing the logical AND. +public func && (lhs: BooleanExpression, + rhs: @autoclosure () throws -> BooleanExpression) rethrows -> BooleanExpression { + return try BooleanFunctionExpression(functionName: "and", args: [lhs, rhs()]) +} + +/// Combines two boolean expressions with a logical OR (`||`). +/// +/// The resulting expression is `true` if either the left-hand side (`lhs`) or the right-hand +/// side (`rhs`) is `true`. +/// +/// ```swift +/// // Find books that are either in the "Romance" genre or were published before 1900 +/// firestore.pipeline() +/// .collection("books") +/// .where( +/// Field("genre").equal("Romance") || Field("published").lessThan(1900) +/// ) +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand boolean expression. +/// - rhs: The right-hand boolean expression. +/// - Returns: A new `BooleanExpression` representing the logical OR. +public func || (lhs: BooleanExpression, + rhs: @autoclosure () throws -> BooleanExpression) rethrows -> BooleanExpression { + return try BooleanFunctionExpression(functionName: "or", args: [lhs, rhs()]) +} + +/// Combines two boolean expressions with a logical XOR (`^`). +/// +/// The resulting expression is `true` if the left-hand side (`lhs`) and the right-hand side +/// (`rhs`) have different boolean values. +/// +/// ```swift +/// // Find books that are in the "Dystopian" genre OR have a rating of 5.0, but not both. +/// firestore.pipeline() +/// .collection("books") +/// .where( +/// Field("genre").equal("Dystopian") ^ Field("rating").equal(5.0) +/// ) +/// ``` +/// +/// - Parameters: +/// - lhs: The left-hand boolean expression. +/// - rhs: The right-hand boolean expression. +/// - Returns: A new `BooleanExpression` representing the logical XOR. +public func ^ (lhs: BooleanExpression, + rhs: @autoclosure () throws -> BooleanExpression) rethrows -> BooleanExpression { + return try BooleanFunctionExpression(functionName: "xor", args: [lhs, rhs()]) +} + +/// Negates a boolean expression with a logical NOT (`!`). +/// +/// The resulting expression is `true` if the original expression is `false`, and vice versa. +/// +/// ```swift +/// // Find books that are NOT in the "Science Fiction" genre +/// firestore.pipeline() +/// .collection("books") +/// .where(!Field("genre").equal("Science Fiction")) +/// ``` +/// +/// - Parameter lhs: The boolean expression to negate. +/// - Returns: A new `BooleanExpression` representing the logical NOT. +public prefix func ! (lhs: BooleanExpression) -> BooleanExpression { + return BooleanFunctionExpression(functionName: "not", args: [lhs]) +} +public extension BooleanExpression { /// Creates an aggregation that counts the number of documents for which this boolean expression /// evaluates to `true`. /// @@ -52,7 +167,7 @@ public class BooleanExpression: FunctionExpression, @unchecked Sendable { /// ``` /// /// - Returns: An `AggregateFunction` that performs the conditional count. - public func countIf() -> AggregateFunction { + func countIf() -> AggregateFunction { return AggregateFunction(functionName: "count_if", args: [self]) } @@ -77,100 +192,11 @@ public class BooleanExpression: FunctionExpression, @unchecked Sendable { /// - thenExpression: The `Expression` to evaluate if this boolean expression is `true`. /// - elseExpression: The `Expression` to evaluate if this boolean expression is `false`. /// - Returns: A new `FunctionExpression` representing the conditional logic. - public func then(_ thenExpression: Expression, - else elseExpression: Expression) -> FunctionExpression { + func then(_ thenExpression: Expression, + else elseExpression: Expression) -> FunctionExpression { return FunctionExpression( functionName: "conditional", args: [self, thenExpression, elseExpression] ) } - - /// Combines two boolean expressions with a logical AND (`&&`). - /// - /// The resulting expression is `true` only if both the left-hand side (`lhs`) and the right-hand - /// side (`rhs`) are `true`. - /// - /// ```swift - /// // Find books in the "Fantasy" genre with a rating greater than 4.5 - /// firestore.pipeline() - /// .collection("books") - /// .where( - /// Field("genre").equal("Fantasy") && Field("rating").greaterThan(4.5) - /// ) - /// ``` - /// - /// - Parameters: - /// - lhs: The left-hand boolean expression. - /// - rhs: The right-hand boolean expression. - /// - Returns: A new `BooleanExpression` representing the logical AND. - public static func && (lhs: BooleanExpression, - rhs: @autoclosure () throws -> BooleanExpression) rethrows - -> BooleanExpression { - try BooleanExpression(functionName: "and", args: [lhs, rhs()]) - } - - /// Combines two boolean expressions with a logical OR (`||`). - /// - /// The resulting expression is `true` if either the left-hand side (`lhs`) or the right-hand - /// side (`rhs`) is `true`. - /// - /// ```swift - /// // Find books that are either in the "Romance" genre or were published before 1900 - /// firestore.pipeline() - /// .collection("books") - /// .where( - /// Field("genre").equal("Romance") || Field("published").lessThan(1900) - /// ) - /// ``` - /// - /// - Parameters: - /// - lhs: The left-hand boolean expression. - /// - rhs: The right-hand boolean expression. - /// - Returns: A new `BooleanExpression` representing the logical OR. - public static func || (lhs: BooleanExpression, - rhs: @autoclosure () throws -> BooleanExpression) rethrows - -> BooleanExpression { - try BooleanExpression(functionName: "or", args: [lhs, rhs()]) - } - - /// Combines two boolean expressions with a logical XOR (`^`). - /// - /// The resulting expression is `true` if the left-hand side (`lhs`) and the right-hand side - /// (`rhs`) have different boolean values. - /// - /// ```swift - /// // Find books that are in the "Dystopian" genre OR have a rating of 5.0, but not both. - /// firestore.pipeline() - /// .collection("books") - /// .where( - /// Field("genre").equal("Dystopian") ^ Field("rating").equal(5.0) - /// ) - /// ``` - /// - /// - Parameters: - /// - lhs: The left-hand boolean expression. - /// - rhs: The right-hand boolean expression. - /// - Returns: A new `BooleanExpression` representing the logical XOR. - public static func ^ (lhs: BooleanExpression, - rhs: @autoclosure () throws -> BooleanExpression) rethrows - -> BooleanExpression { - try BooleanExpression(functionName: "xor", args: [lhs, rhs()]) - } - - /// Negates a boolean expression with a logical NOT (`!`). - /// - /// The resulting expression is `true` if the original expression is `false`, and vice versa. - /// - /// ```swift - /// // Find books that are NOT in the "Science Fiction" genre - /// firestore.pipeline() - /// .collection("books") - /// .where(!Field("genre").equal("Science Fiction")) - /// ``` - /// - /// - Parameter lhs: The boolean expression to negate. - /// - Returns: A new `BooleanExpression` representing the logical NOT. - public static prefix func ! (lhs: BooleanExpression) -> BooleanExpression { - return BooleanExpression(functionName: "not", args: [lhs]) - } } diff --git a/Firestore/Swift/Tests/Integration/PipelineApiTests.swift b/Firestore/Swift/Tests/Integration/PipelineApiTests.swift index 20096529f97..e4434b97830 100644 --- a/Firestore/Swift/Tests/Integration/PipelineApiTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineApiTests.swift @@ -405,9 +405,6 @@ final class PipelineApiTests: FSTIntegrationTestCase { // This is the same of the logicalMin('price', 0)', if it did not exist _ = FunctionExpression(functionName: "logicalMin", args: [Field("price"), Constant(0)]) - // Create a generic BooleanExpr for use where BooleanExpr is required - _ = BooleanExpression(functionName: "eq", args: [Field("price"), Constant(10)]) - // Create a generic AggregateFunction for use where AggregateFunction is required _ = AggregateFunction(functionName: "sum", args: [Field("price")]) } diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 9eb545cb617..050fd173068 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -2783,9 +2783,10 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { let pipeline = db.pipeline() .collection(collRef.path) .where( - BooleanExpression(functionName: "and", args: [Field("rating").greaterThan(0), - Field("title").charLength().lessThan(5), - Field("tags").arrayContains("propaganda")]) + FunctionExpression(functionName: "and", args: [Field("rating").greaterThan(0), + Field("title").charLength().lessThan(5), + Field("tags") + .arrayContains("propaganda")]).asBoolean() ) .select(["title"]) @@ -2806,10 +2807,10 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { let pipeline = db.pipeline() .collection(collRef.path) - .where(BooleanExpression( + .where(FunctionExpression( functionName: "array_contains_any", args: [Field("tags"), ArrayExpression(["politics"])] - )) + ).asBoolean()) .select([Field("title")]) let snapshot = try await pipeline.execute() @@ -3909,4 +3910,35 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { ] TestHelper.compare(snapshot: snapshot, expected: expectedResults, enforceOrder: true) } + + func testFieldAndConstantAsBooleanExpression() async throws { + let collRef = collectionRef(withDocuments: [ + "doc1": ["a": true], + "doc2": ["a": false], + "doc3": ["b": true], + ]) + let db = collRef.firestore + + var pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("a").asBoolean()) + var snapshot = try await pipeline.execute() + TestHelper.compare(snapshot: snapshot, expectedIDs: ["doc1"], enforceOrder: false) + + pipeline = db.pipeline() + .collection(collRef.path) + .where(Constant(true).asBoolean()) + snapshot = try await pipeline.execute() + TestHelper.compare( + snapshot: snapshot, + expectedIDs: ["doc1", "doc2", "doc3"], + enforceOrder: false + ) + + pipeline = db.pipeline() + .collection(collRef.path) + .where(Constant(false).asBoolean()) + snapshot = try await pipeline.execute() + TestHelper.compare(snapshot: snapshot, expectedCount: 0) + } }