diff --git a/Sources/MongoDBModel/FunctionEvaluation.swift b/Sources/MongoDBModel/FunctionEvaluation.swift index 2845fce..f638c12 100644 --- a/Sources/MongoDBModel/FunctionEvaluation.swift +++ b/Sources/MongoDBModel/FunctionEvaluation.swift @@ -19,6 +19,13 @@ internal extension FetchRequest { if predicate?.containsFunction == true { return true } + // `$divide` aborts the whole query on division by zero and always produces + // floating point (no truncating integer division), and `$mod` diverges on + // floats — so division and remainder run in memory, where the semantics + // exactly match CoreModel's evaluation engine. + if predicate?.containsMemoryOnlyArithmetic == true { + return true + } return sortDescriptors.contains { descriptor in if case .function = descriptor.term { return true } else { return false } } @@ -47,8 +54,9 @@ internal extension FetchRequest.Predicate { case .value: return self case let .comparison(comparison): - let usesFunction = comparison.left.containsFunction || comparison.right.containsFunction - return usesFunction ? .value(true) : self + let requiresMemory = comparison.left.containsFunction || comparison.right.containsFunction + || comparison.left.containsMemoryOnlyArithmetic || comparison.right.containsMemoryOnlyArithmetic + return requiresMemory ? .value(true) : self case let .compound(compound): switch compound { case let .and(subpredicates): @@ -62,8 +70,39 @@ internal extension FetchRequest.Predicate { } } +internal extension FetchRequest.Predicate { + + /// Whether this predicate contains an arithmetic operation that must run in memory. + var containsMemoryOnlyArithmetic: Bool { + switch self { + case .value: + return false + case let .comparison(comparison): + return comparison.left.containsMemoryOnlyArithmetic || comparison.right.containsMemoryOnlyArithmetic + case let .compound(compound): + return compound.subpredicates.contains { $0.containsMemoryOnlyArithmetic } + } + } +} + internal extension FetchRequest.Predicate.Expression { + /// Whether this expression contains a division or remainder, which the server + /// cannot evaluate with CoreModel's semantics — see `requiresInMemoryEvaluation`. + var containsMemoryOnlyArithmetic: Bool { + switch self { + case let .arithmetic(expression): + if expression.function == .divide || expression.function == .modulus { + return true + } + return expression.left.containsMemoryOnlyArithmetic || expression.right.containsMemoryOnlyArithmetic + case let .function(function): + return function.arguments.contains { $0.containsMemoryOnlyArithmetic } + case .attribute, .relationship, .keyPath: + return false + } + } + var containsFunction: Bool { switch self { case .function: @@ -139,9 +178,10 @@ internal extension FetchRequest.Predicate.Expression { case .relationship: // relationships aren't compared by the in-memory function path return nil - case .arithmetic: - // - TODO: Evaluate arithmetic expressions in the in-memory fallback. - return nil + case let .arithmetic(arithmetic): + let lhs = arithmetic.left.evaluate(with: data, functions: functions) + let rhs = arithmetic.right.evaluate(with: data, functions: functions) + return AttributeValue.arithmetic(arithmetic.function, lhs, rhs) } } } @@ -220,6 +260,56 @@ private extension AttributeValue { return nil } + /// An integer representation for integer value types, for integer arithmetic. + var integerValue: Int64? { + switch self { + case let .int16(value): return Int64(value) + case let .int32(value): return Int64(value) + case let .int64(value): return value + default: return nil + } + } + + /// Apply an arithmetic function to two values. + /// + /// Mirrors CoreModel's in-memory engine (which is internal to that module): + /// integer operands stay in integer arithmetic, so division truncates + /// (`7 / 2` is `3`); mixed or floating-point operands compute as `Double`. + /// Returns `nil` for non-numeric operands, division or remainder by zero, + /// an overflowing division, or a floating-point remainder. + static func arithmetic( + _ function: FetchRequest.Predicate.ArithmeticExpression.Function, + _ lhs: AttributeValue?, + _ rhs: AttributeValue? + ) -> AttributeValue? { + guard let lhs, let rhs else { return nil } + if let leftInteger = lhs.integerValue, let rightInteger = rhs.integerValue { + switch function { + case .add: return .int64(leftInteger &+ rightInteger) + case .subtract: return .int64(leftInteger &- rightInteger) + case .multiply: return .int64(leftInteger &* rightInteger) + case .divide: + guard rightInteger != 0 else { return nil } + let (quotient, overflow) = leftInteger.dividedReportingOverflow(by: rightInteger) + return overflow ? nil : .int64(quotient) + case .modulus: + guard rightInteger != 0 else { return nil } + let (remainder, overflow) = leftInteger.remainderReportingOverflow(dividingBy: rightInteger) + return overflow ? nil : .int64(remainder) + } + } + guard let leftNumber = lhs.comparableDouble, let rightNumber = rhs.comparableDouble else { + return nil + } + switch function { + case .add: return .double(leftNumber + rightNumber) + case .subtract: return .double(leftNumber - rightNumber) + case .multiply: return .double(leftNumber * rightNumber) + case .divide: return rightNumber == 0 ? nil : .double(leftNumber / rightNumber) + case .modulus: return nil // integers only + } + } + static func areEqual(_ lhs: AttributeValue?, _ rhs: AttributeValue?, caseInsensitive: Bool) -> Bool { switch (lhs, rhs) { case (.none, .none), (.some(.null), .none), (.none, .some(.null)), (.some(.null), .some(.null)): diff --git a/Sources/MongoDBModel/Predicate.swift b/Sources/MongoDBModel/Predicate.swift index e7ab51c..51e44a3 100644 --- a/Sources/MongoDBModel/Predicate.swift +++ b/Sources/MongoDBModel/Predicate.swift @@ -47,6 +47,14 @@ public extension BSONDocument { public extension BSONDocument { init?(predicate: FetchRequest.Predicate.Comparison) { + // `(a b) constant` compiles to an aggregation `$expr` filter. + if case let .arithmetic(arithmetic) = predicate.left { + guard let document = BSONDocument(arithmetic: arithmetic, comparison: predicate) else { + return nil + } + self = document + return + } // { : { $eq: } } guard case let .keyPath(keyPath) = predicate.left, let comparisonOperator = ComparisonQueryOperator(predicate: predicate.type), @@ -71,3 +79,85 @@ public extension BSONDocument { keyPath.rawValue: .document([comparisonOperator.rawValue: valueBSON])] } } + +// MARK: - Arithmetic + +internal extension BSONDocument { + + /// The `$expr` filter for an `arithmetic constant` comparison. + /// + /// ``` + /// { $expr: { $and: [ { $isNumber: A }, { $gt: [ A, constant ] } ] } } + /// ``` + /// + /// The `$isNumber` guard makes a missing or null operand fail the comparison — + /// `$add` of a missing field yields `null`, and without the guard aggregation + /// comparisons would rank `null` *below* every number, so `arithmetic < constant` + /// would spuriously match. CoreModel's engine yields `nil` for such rows, which + /// matches no comparison. + /// + /// Only `.add`, `.subtract` and `.multiply` are translated; `.divide` and + /// `.modulus` are routed to in-memory evaluation before this is reached — see + /// `FetchRequest.requiresInMemoryEvaluation`. + init?( + arithmetic: FetchRequest.Predicate.ArithmeticExpression, + comparison: FetchRequest.Predicate.Comparison + ) { + guard comparison.options.isEmpty, + comparison.modifier == nil, + let comparisonOperator = ComparisonQueryOperator(predicate: comparison.type), + // only aggregation comparison operators are valid inside `$expr` + [.equalTo, .notEqualTo, .greaterThan, .greaterThanOrEqualTo, .lessThan, .lessThanOrEqualTo].contains(comparisonOperator), + case let .attribute(value) = comparison.right, + let constant = try? BSON(attributeValue: value), + let expression = BSON(aggregation: .arithmetic(arithmetic)) else { + return nil + } + self = [ + "$expr": .document([ + "$and": .array([ + .document(["$isNumber": expression]), + .document([comparisonOperator.rawValue: .array([expression, constant])]) + ]) + ]) + ] + } +} + +internal extension BSON { + + /// The aggregation expression for a predicate expression, for use inside `$expr`. + /// + /// A key path becomes a `$`-prefixed field path — dotted paths address composite + /// attribute elements natively. Returns `nil` for expressions the server cannot + /// evaluate (custom functions, relationships, division and remainder). + init?(aggregation expression: FetchRequest.Predicate.Expression) { + switch expression { + case let .attribute(value): + guard let bson = try? BSON(attributeValue: value) else { + return nil + } + self = bson + case let .keyPath(keyPath): + self = .string("$" + keyPath.rawValue) + case let .arithmetic(arithmetic): + let aggregationOperator: String + switch arithmetic.function { + case .add: aggregationOperator = "$add" + case .subtract: aggregationOperator = "$subtract" + case .multiply: aggregationOperator = "$multiply" + case .divide, .modulus: + // `$divide` aborts the query on division by zero and has no truncating + // integer form; `$mod` diverges on floats. Evaluated in memory instead. + return nil + } + guard let left = BSON(aggregation: arithmetic.left), + let right = BSON(aggregation: arithmetic.right) else { + return nil + } + self = .document([aggregationOperator: .array([left, right])]) + case .function, .relationship: + return nil + } + } +} diff --git a/Tests/CoreModelMongoDBTests/ArithmeticExpressionTests.swift b/Tests/CoreModelMongoDBTests/ArithmeticExpressionTests.swift new file mode 100644 index 0000000..550400d --- /dev/null +++ b/Tests/CoreModelMongoDBTests/ArithmeticExpressionTests.swift @@ -0,0 +1,192 @@ +// +// ArithmeticExpressionTests.swift +// CoreModel-MongoDB +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import XCTest +@testable import CoreModel +@testable import MongoDBModel +import MongoSwift + +/// Arithmetic expressions: `$expr` translation and the in-memory fallback. +final class ArithmeticExpressionTests: XCTestCase { + + private func arithmetic( + _ function: FetchRequest.Predicate.ArithmeticExpression.Function, + _ left: FetchRequest.Predicate.Expression, + _ right: FetchRequest.Predicate.Expression + ) -> FetchRequest.Predicate.Expression { + .arithmetic(.init(function: function, left: left, right: right)) + } + + // MARK: - Server-side translation + + /// `age + 10 > 45` compiles to a guarded aggregation `$expr`. + func testExprTranslation() throws { + let comparison = FetchRequest.Predicate.Comparison( + left: arithmetic(.add, .keyPath("age"), .attribute(.int64(10))), + right: .attribute(.int64(45)), + type: .greaterThan + ) + let document = try XCTUnwrap(BSONDocument(predicate: comparison)) + let expr = try XCTUnwrap(document["$expr"]?.documentValue) + let and = try XCTUnwrap(expr["$and"]?.arrayValue) + XCTAssertEqual(and.count, 2) + // guard: { $isNumber: { $add: ["$age", 10] } } + let guardDoc = try XCTUnwrap(and[0].documentValue) + let added = try XCTUnwrap(guardDoc["$isNumber"]?.documentValue) + XCTAssertEqual(added["$add"], .array([.string("$age"), .int64(10)])) + // comparison: { $gt: [ { $add: [...] }, 45 ] } + let compare = try XCTUnwrap(and[1].documentValue) + let operands = try XCTUnwrap(compare["$gt"]?.arrayValue) + XCTAssertEqual(operands.count, 2) + XCTAssertEqual(operands[1], .int64(45)) + } + + /// Nested add/multiply translates recursively. + func testNestedExprTranslation() throws { + let inner = arithmetic(.add, .keyPath("age"), .attribute(.int64(10))) + let comparison = FetchRequest.Predicate.Comparison( + left: arithmetic(.multiply, inner, .attribute(.int64(2))), + right: .attribute(.int64(80)), + type: .equalTo + ) + let document = try XCTUnwrap(BSONDocument(predicate: comparison)) + let expr = try XCTUnwrap(document["$expr"]?.documentValue) + let and = try XCTUnwrap(expr["$and"]?.arrayValue) + let compare = try XCTUnwrap(and[1].documentValue) + let operands = try XCTUnwrap(compare["$eq"]?.arrayValue) + let multiply = try XCTUnwrap(operands[0].documentValue) + let factors = try XCTUnwrap(multiply["$multiply"]?.arrayValue) + XCTAssertEqual(factors[0].documentValue?["$add"], .array([.string("$age"), .int64(10)])) + XCTAssertEqual(factors[1], .int64(2)) + } + + /// A dotted key path addresses a composite element inside `$expr`. + func testCompositeElementOperand() throws { + let comparison = FetchRequest.Predicate.Comparison( + left: arithmetic(.multiply, .keyPath("location.latitude"), .attribute(.int64(2))), + right: .attribute(.double(80)), + type: .greaterThan + ) + let document = try XCTUnwrap(BSONDocument(predicate: comparison)) + let expr = try XCTUnwrap(document["$expr"]?.documentValue) + let and = try XCTUnwrap(expr["$and"]?.arrayValue) + let guardDoc = try XCTUnwrap(and[0].documentValue) + let multiply = try XCTUnwrap(guardDoc["$isNumber"]?.documentValue) + XCTAssertEqual(multiply["$multiply"], .array([.string("$location.latitude"), .int64(2)])) + } + + /// Division and remainder are not server-translatable; the whole request routes + /// through in-memory evaluation instead. + func testDivisionRoutesToMemory() { + for function: FetchRequest.Predicate.ArithmeticExpression.Function in [.divide, .modulus] { + let predicate = arithmetic(function, .keyPath("age"), .attribute(.int64(2))) + .compare(.equalTo, .attribute(.int64(0))) + let request = FetchRequest(entity: "Person", predicate: predicate) + XCTAssertTrue(request.requiresInMemoryEvaluation, "\(function) should evaluate in memory") + // and the stripped superset filter drops the comparison + XCTAssertEqual(predicate.strippingFunctionComparisons(), .value(true)) + // the expression translator refuses it too + XCTAssertNil(BSON(aggregation: arithmetic(function, .keyPath("age"), .attribute(.int64(2))))) + } + } + + /// A nested division anywhere in the tree routes the request to memory. + func testNestedDivisionRoutesToMemory() { + let inner = arithmetic(.divide, .keyPath("age"), .attribute(.int64(7))) + let predicate = arithmetic(.add, inner, .attribute(.int64(1))) + .compare(.equalTo, .attribute(.int64(5))) + XCTAssertTrue(FetchRequest(entity: "Person", predicate: predicate).requiresInMemoryEvaluation) + } + + /// Add/subtract/multiply do not require in-memory evaluation. + func testServerSideFunctionsStayNative() { + for function: FetchRequest.Predicate.ArithmeticExpression.Function in [.add, .subtract, .multiply] { + let predicate = arithmetic(function, .keyPath("age"), .attribute(.int64(2))) + .compare(.greaterThan, .attribute(.int64(0))) + XCTAssertFalse(FetchRequest(entity: "Person", predicate: predicate).requiresInMemoryEvaluation) + } + } + + // MARK: - In-memory evaluation + + private static let person = ModelData( + entity: "Person", + id: "alice", + attributes: ["age": .int32(30), "weight": .double(60.5), "name": .string("Alice")] + ) + + func testIntegerArithmetic() { + // 30 + 10 > 45 is false; 30 + 20 > 45 is true + XCTAssertFalse( + arithmetic(.add, .keyPath("age"), .attribute(.int64(10))) + .compare(.greaterThan, .attribute(.int64(45))) + .evaluate(with: Self.person, functions: [:]) + ) + XCTAssertTrue( + arithmetic(.add, .keyPath("age"), .attribute(.int64(20))) + .compare(.greaterThan, .attribute(.int64(45))) + .evaluate(with: Self.person, functions: [:]) + ) + } + + /// Integer division truncates (`30 / 7` is `4`), matching CoreModel's engine. + func testIntegerDivisionTruncates() { + XCTAssertTrue( + arithmetic(.divide, .keyPath("age"), .attribute(.int64(7))) + .compare(.equalTo, .attribute(.int64(4))) + .evaluate(with: Self.person, functions: [:]) + ) + } + + func testModulus() { + XCTAssertTrue( + arithmetic(.modulus, .keyPath("age"), .attribute(.int64(2))) + .compare(.equalTo, .attribute(.int64(0))) + .evaluate(with: Self.person, functions: [:]) + ) + } + + /// Division by zero yields nil, which fails every comparison. + func testDivisionByZero() { + for op: FetchRequest.Predicate.Comparison.Operator in [.equalTo, .greaterThan, .lessThan] { + XCTAssertFalse( + arithmetic(.divide, .keyPath("age"), .attribute(.int64(0))) + .compare(op, .attribute(.int64(0))) + .evaluate(with: Self.person, functions: [:]), + "\(op) against division by zero should be false" + ) + } + } + + /// Mixed operands compute in floating point. + func testMixedOperandsPromote() { + XCTAssertTrue( + arithmetic(.divide, .keyPath("weight"), .attribute(.int64(2))) + .compare(.equalTo, .attribute(.double(30.25))) + .evaluate(with: Self.person, functions: [:]) + ) + } + + /// Remainder is integers-only; a float operand yields nil. + func testFloatModulusIsNil() { + XCTAssertFalse( + arithmetic(.modulus, .keyPath("weight"), .attribute(.int64(2))) + .compare(.equalTo, .attribute(.int64(0))) + .evaluate(with: Self.person, functions: [:]) + ) + } + + /// Non-numeric operands yield nil. + func testNonNumericOperands() { + XCTAssertFalse( + arithmetic(.add, .keyPath("name"), .attribute(.int64(1))) + .compare(.equalTo, .attribute(.int64(1))) + .evaluate(with: Self.person, functions: [:]) + ) + } +} diff --git a/Tests/CoreModelMongoDBTests/MongoDBModelTests.swift b/Tests/CoreModelMongoDBTests/MongoDBModelTests.swift index 3c261f4..59c2433 100644 --- a/Tests/CoreModelMongoDBTests/MongoDBModelTests.swift +++ b/Tests/CoreModelMongoDBTests/MongoDBModelTests.swift @@ -11,8 +11,10 @@ final class MongoDBModelTests: XCTestCase { let elg = MultiThreadedEventLoopGroup(numberOfThreads: 4) let client = try MongoClient("mongodb://localhost:27017", using: elg) let database = client.db("test") - try await database.drop() + // - Note: The defer must be registered before the first `await` that can + // throw (e.g. no server listening), or the client deinitializes unclosed + // and MongoSwift asserts, crashing the test process with SIGTRAP. defer { // clean up driver resources try? client.syncClose() @@ -24,6 +26,8 @@ final class MongoDBModelTests: XCTestCase { } } + try await database.drop() + let model = Model(entities: Person.self, Event.self, Campground.self, Campground.RentalUnit.self) let store = MongoModelStorage( database: database,