From 72555c7d5f0f2a4de5d6dd36f0758472df88362c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 16:38:43 -0400 Subject: [PATCH 1/2] Translate arithmetic expressions to SQL --- Sources/CoreModelSQLite/Predicate.swift | 106 +++++++++++++++++++++++- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/Sources/CoreModelSQLite/Predicate.swift b/Sources/CoreModelSQLite/Predicate.swift index 842fe04..5383020 100644 --- a/Sources/CoreModelSQLite/Predicate.swift +++ b/Sources/CoreModelSQLite/Predicate.swift @@ -107,6 +107,35 @@ internal extension FetchRequest.Predicate.Comparison { } } + // `(a b) constant` comparisons compile to a SQL expression. + if case let .arithmetic(arithmetic) = left { + guard modifier == nil else { + throw SQLiteDatabaseError.invalidPredicate(predicate) + } + let arithmeticFragment = try arithmetic.sqlFragment(for: entity, predicate: predicate) + switch type { + case .lessThan, .lessThanOrEqualTo, .greaterThan, .greaterThanOrEqualTo: + let value = try right.constantBinding(predicate: predicate) + return SQLFragment( + sql: "\(arithmeticFragment.sql) \(type.rawValue) ?", + bindings: arithmeticFragment.bindings + [value] + ) + case .equalTo, .notEqualTo: + let value = try right.constantBinding(predicate: predicate) + let sqlOperator = (type == .equalTo) ? "=" : "<>" + guard let value else { + let nullOperator = (type == .equalTo) ? "IS NULL" : "IS NOT NULL" + return SQLFragment(sql: "\(arithmeticFragment.sql) \(nullOperator)", bindings: arithmeticFragment.bindings) + } + return SQLFragment( + sql: "\(arithmeticFragment.sql) \(sqlOperator) ?", + bindings: arithmeticFragment.bindings + [value] + ) + default: + throw SQLiteDatabaseError.invalidPredicate(predicate) + } + } + // Only `keyPath constant` comparisons map directly to columns. guard case let .keyPath(keyPath) = left else { throw SQLiteDatabaseError.invalidPredicate(predicate) @@ -258,6 +287,77 @@ internal extension FetchRequest.Predicate.FunctionExpression { } } +private extension FetchRequest.Predicate.ArithmeticExpression { + + /// The arithmetic expression as a parenthesized SQL expression. + /// + /// SQLite's native operators match CoreModel's in-memory semantics for the cases + /// that matter: `/` truncates when both operands are `INTEGER` (`7 / 2` is `3`), + /// promotes to `REAL` when either operand is, and division or remainder by zero + /// yields SQL `NULL` — which fails every comparison, exactly as the in-memory + /// engine's `nil` does. + /// + /// `.modulus` is rejected when a statically-known operand is non-integer: + /// SQLite's `%` casts its operands to `INTEGER`, but the in-memory engine + /// defines remainder for integers only, and a silently cast result would + /// diverge between backends. + /// + /// - Note: The only knowing divergence is at the edges of `Int64`: the in-memory + /// engine wraps (`&+`), while SQLite promotes an overflowing `INTEGER` result + /// to a `REAL` approximation. + func sqlFragment( + for entity: EntityDescription, + predicate: FetchRequest.Predicate + ) throws -> SQLFragment { + if function == .modulus { + for operand in [left, right] where operand.isKnownNonInteger(for: entity) { + throw SQLiteDatabaseError.invalidPredicate(predicate) + } + } + let leftFragment = try left.argumentSQLFragment(for: entity, predicate: predicate) + let rightFragment = try right.argumentSQLFragment(for: entity, predicate: predicate) + return SQLFragment( + sql: "(" + leftFragment.sql + " " + function.symbol + " " + rightFragment.sql + ")", + bindings: leftFragment.bindings + rightFragment.bindings + ) + } +} + +private extension FetchRequest.Predicate.Expression { + + /// Whether this operand is statically known to be non-integer. + /// + /// Used to reject `.modulus` on floating-point operands. `false` means "integer + /// or unknown" — a custom function's result type can't be known here, and SQL + /// `NULL` propagation makes a wrong guess harmless for every case but `%`. + func isKnownNonInteger(for entity: EntityDescription) -> Bool { + switch self { + case let .attribute(value): + switch value { + case .int16, .int32, .int64, .bool: + return false + case .null, .string, .uuid, .url, .data, .date, .float, .double, .decimal, .composite: + return true + } + case let .keyPath(keyPath): + guard let column = entity.attributeColumns.first(where: { $0.name == keyPath.rawValue }) else { + return false + } + switch column.type { + case .int16, .int32, .int64, .bool: + return false + case .string, .uuid, .url, .data, .date, .float, .double, .decimal, .composite: + return true + } + case let .arithmetic(nested): + // integer only if both operands are; division stays integer for integers + return nested.left.isKnownNonInteger(for: entity) || nested.right.isKnownNonInteger(for: entity) + case .function, .relationship: + return false + } + } +} + private extension FetchRequest.Predicate.Expression { /// The expression as a SQL fragment suitable for use as a function argument @@ -274,10 +374,8 @@ private extension FetchRequest.Predicate.Expression { return try function.sqlFragment(for: entity, predicate: predicate) case .attribute, .relationship: return SQLFragment(sql: "?", bindings: [try constantBinding(predicate: predicate)]) - case .arithmetic: - // - TODO: Translate arithmetic expressions to SQL. Until then they are - // rejected so the caller can fall back to in-memory evaluation. - throw SQLiteDatabaseError.invalidPredicate(predicate) + case let .arithmetic(arithmetic): + return try arithmetic.sqlFragment(for: entity, predicate: predicate) } } From 26e78bedfd6e92805843aa2d87e84c7367f6f2ce Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 16:38:44 -0400 Subject: [PATCH 2/2] Add arithmetic expression tests --- .../ArithmeticExpressionTests.swift | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 Tests/CoreModelSQLiteTests/ArithmeticExpressionTests.swift diff --git a/Tests/CoreModelSQLiteTests/ArithmeticExpressionTests.swift b/Tests/CoreModelSQLiteTests/ArithmeticExpressionTests.swift new file mode 100644 index 0000000..03d1bfb --- /dev/null +++ b/Tests/CoreModelSQLiteTests/ArithmeticExpressionTests.swift @@ -0,0 +1,201 @@ +// +// ArithmeticExpressionTests.swift +// CoreModel-SQLite +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import Testing +import CoreModel +import SQLite +@testable import CoreModelSQLite + +/// Arithmetic expressions, which compile to native SQL operators. +@Suite struct ArithmeticExpressionTests { + + static func makeDatabase() throws -> SQLiteDatabase { + try SQLiteDatabase(path: temporaryDatabasePath(named: "Arithmetic"), model: testModel) + } + + static func person(_ id: ObjectID, name: String, age: Int32, weight: Double) -> ModelData { + ModelData( + entity: "Person", + id: id, + attributes: [ + "name": .string(name), + "age": .int32(age), + "weight": .double(weight) + ] + ) + } + + static func insertPeople(_ database: SQLiteDatabase) async throws { + try await database.insert(person("alice", name: "Alice", age: 30, weight: 60.5)) + try await database.insert(person("bob", name: "Bob", age: 41, weight: 80.0)) + } + + private static 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: - Integer arithmetic + + @Test func integerAddition() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + // age + 10 > 45 — matches Bob (51), not Alice (40) + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.add, .keyPath("age"), .attribute(.int32(10))) + .compare(.greaterThan, .attribute(.int64(45))) + ) + let results = try await database.fetch(request) + #expect(results.map(\.id) == ["bob"]) + } + + /// Integer division truncates, matching the in-memory engine (`7 / 2` is `3`). + @Test func integerDivisionTruncates() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + // age / 7 == 4 — Alice: 30/7 = 4 (truncated). Bob: 41/7 = 5. + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.divide, .keyPath("age"), .attribute(.int64(7))) + .compare(.equalTo, .attribute(.int64(4))) + ) + let results = try await database.fetch(request) + #expect(results.map(\.id) == ["alice"]) + } + + @Test func integerModulus() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + // age % 2 == 0 — Alice (30), not Bob (41) + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.modulus, .keyPath("age"), .attribute(.int64(2))) + .compare(.equalTo, .attribute(.int64(0))) + ) + let results = try await database.fetch(request) + #expect(results.map(\.id) == ["alice"]) + } + + /// Division by zero yields SQL `NULL`, which fails every comparison — the same + /// outcome as the in-memory engine's `nil`. + @Test func divisionByZeroMatchesNothing() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + for op: FetchRequest.Predicate.Comparison.Operator in [.equalTo, .greaterThan, .lessThan] { + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.divide, .keyPath("age"), .attribute(.int64(0))) + .compare(op, .attribute(.int64(0))) + ) + let results = try await database.fetch(request) + #expect(results.isEmpty, "\(op) against a division by zero should match nothing") + } + } + + // MARK: - Floating point + + @Test func floatingPointArithmetic() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + // weight * 2 > 130 — Bob (160), not Alice (121) + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.multiply, .keyPath("weight"), .attribute(.int64(2))) + .compare(.greaterThan, .attribute(.double(130))) + ) + let results = try await database.fetch(request) + #expect(results.map(\.id) == ["bob"]) + } + + /// Mixed integer/floating-point operands compute in floating point, so integer + /// division does not truncate when either side is a float — as in memory. + @Test func mixedOperandsPromote() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + // weight / 2 == 30.25 — Alice (60.5 / 2), floating point division + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.divide, .keyPath("weight"), .attribute(.int64(2))) + .compare(.equalTo, .attribute(.double(30.25))) + ) + let results = try await database.fetch(request) + #expect(results.map(\.id) == ["alice"]) + } + + /// The in-memory engine defines remainder for integers only; SQLite's `%` would + /// cast a float to integer and silently diverge, so it is rejected instead. + @Test func floatModulusRejected() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.modulus, .keyPath("weight"), .attribute(.int64(2))) + .compare(.equalTo, .attribute(.int64(0))) + ) + await #expect(throws: (any Error).self) { + try await database.fetch(request) + } + } + + // MARK: - Composition + + @Test func nestedArithmetic() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + // (age + 10) * 2 == 80 — Alice ((30+10)*2), not Bob ((41+10)*2 = 102) + let inner = Self.arithmetic(.add, .keyPath("age"), .attribute(.int64(10))) + let request = FetchRequest( + entity: "Person", + predicate: Self.arithmetic(.multiply, inner, .attribute(.int64(2))) + .compare(.equalTo, .attribute(.int64(80))) + ) + let results = try await database.fetch(request) + #expect(results.map(\.id) == ["alice"]) + } + + @Test func compoundPredicateWithArithmetic() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + // (age + 1 > 31) AND (name == "Bob") + let request = FetchRequest( + entity: "Person", + predicate: .compound(.and([ + Self.arithmetic(.add, .keyPath("age"), .attribute(.int64(1))) + .compare(.greaterThan, .attribute(.int64(31))), + "name".compare(.equalTo, .attribute(.string("Bob"))) + ])) + ) + let results = try await database.fetch(request) + #expect(results.map(\.id) == ["bob"]) + } + + /// SQL and the in-memory engine agree on the same inputs. + @Test func agreesWithInMemoryEvaluation() async throws { + let database = try Self.makeDatabase() + try await Self.insertPeople(database) + let rows = try await database.fetch(FetchRequest(entity: "Person")) + let cases: [(FetchRequest.Predicate.ArithmeticExpression.Function, Int64, FetchRequest.Predicate.Comparison.Operator, Int64)] = [ + (.add, 10, .greaterThan, 45), + (.subtract, 5, .lessThanOrEqualTo, 25), + (.multiply, 3, .equalTo, 90), + (.divide, 7, .equalTo, 4), + (.modulus, 2, .equalTo, 0) + ] + for (function, operand, op, constant) in cases { + let predicate = Self.arithmetic(.init(rawValue: function.rawValue)!, .keyPath("age"), .attribute(.int64(operand))) + .compare(op, .attribute(.int64(constant))) + let sql = try await database.fetch(FetchRequest(entity: "Person", predicate: predicate)).map(\.id) + let memory = rows.filter { predicate.evaluate(with: $0) }.map(\.id) + #expect(Set(sql) == Set(memory), "\(function) diverged: sql=\(sql) memory=\(memory)") + } + } +}