|
| 1 | +// |
| 2 | +// ArithmeticExpressionTests.swift |
| 3 | +// CoreModel-SQLite |
| 4 | +// |
| 5 | +// Created by Alsey Coleman Miller on 8/16/26. |
| 6 | +// |
| 7 | + |
| 8 | +import Foundation |
| 9 | +import Testing |
| 10 | +import CoreModel |
| 11 | +import SQLite |
| 12 | +@testable import CoreModelSQLite |
| 13 | + |
| 14 | +/// Arithmetic expressions, which compile to native SQL operators. |
| 15 | +@Suite struct ArithmeticExpressionTests { |
| 16 | + |
| 17 | + static func makeDatabase() throws -> SQLiteDatabase { |
| 18 | + try SQLiteDatabase(path: temporaryDatabasePath(named: "Arithmetic"), model: testModel) |
| 19 | + } |
| 20 | + |
| 21 | + static func person(_ id: ObjectID, name: String, age: Int32, weight: Double) -> ModelData { |
| 22 | + ModelData( |
| 23 | + entity: "Person", |
| 24 | + id: id, |
| 25 | + attributes: [ |
| 26 | + "name": .string(name), |
| 27 | + "age": .int32(age), |
| 28 | + "weight": .double(weight) |
| 29 | + ] |
| 30 | + ) |
| 31 | + } |
| 32 | + |
| 33 | + static func insertPeople(_ database: SQLiteDatabase) async throws { |
| 34 | + try await database.insert(person("alice", name: "Alice", age: 30, weight: 60.5)) |
| 35 | + try await database.insert(person("bob", name: "Bob", age: 41, weight: 80.0)) |
| 36 | + } |
| 37 | + |
| 38 | + private static func arithmetic( |
| 39 | + _ function: FetchRequest.Predicate.ArithmeticExpression.Function, |
| 40 | + _ left: FetchRequest.Predicate.Expression, |
| 41 | + _ right: FetchRequest.Predicate.Expression |
| 42 | + ) -> FetchRequest.Predicate.Expression { |
| 43 | + .arithmetic(.init(function: function, left: left, right: right)) |
| 44 | + } |
| 45 | + |
| 46 | + // MARK: - Integer arithmetic |
| 47 | + |
| 48 | + @Test func integerAddition() async throws { |
| 49 | + let database = try Self.makeDatabase() |
| 50 | + try await Self.insertPeople(database) |
| 51 | + // age + 10 > 45 — matches Bob (51), not Alice (40) |
| 52 | + let request = FetchRequest( |
| 53 | + entity: "Person", |
| 54 | + predicate: Self.arithmetic(.add, .keyPath("age"), .attribute(.int32(10))) |
| 55 | + .compare(.greaterThan, .attribute(.int64(45))) |
| 56 | + ) |
| 57 | + let results = try await database.fetch(request) |
| 58 | + #expect(results.map(\.id) == ["bob"]) |
| 59 | + } |
| 60 | + |
| 61 | + /// Integer division truncates, matching the in-memory engine (`7 / 2` is `3`). |
| 62 | + @Test func integerDivisionTruncates() async throws { |
| 63 | + let database = try Self.makeDatabase() |
| 64 | + try await Self.insertPeople(database) |
| 65 | + // age / 7 == 4 — Alice: 30/7 = 4 (truncated). Bob: 41/7 = 5. |
| 66 | + let request = FetchRequest( |
| 67 | + entity: "Person", |
| 68 | + predicate: Self.arithmetic(.divide, .keyPath("age"), .attribute(.int64(7))) |
| 69 | + .compare(.equalTo, .attribute(.int64(4))) |
| 70 | + ) |
| 71 | + let results = try await database.fetch(request) |
| 72 | + #expect(results.map(\.id) == ["alice"]) |
| 73 | + } |
| 74 | + |
| 75 | + @Test func integerModulus() async throws { |
| 76 | + let database = try Self.makeDatabase() |
| 77 | + try await Self.insertPeople(database) |
| 78 | + // age % 2 == 0 — Alice (30), not Bob (41) |
| 79 | + let request = FetchRequest( |
| 80 | + entity: "Person", |
| 81 | + predicate: Self.arithmetic(.modulus, .keyPath("age"), .attribute(.int64(2))) |
| 82 | + .compare(.equalTo, .attribute(.int64(0))) |
| 83 | + ) |
| 84 | + let results = try await database.fetch(request) |
| 85 | + #expect(results.map(\.id) == ["alice"]) |
| 86 | + } |
| 87 | + |
| 88 | + /// Division by zero yields SQL `NULL`, which fails every comparison — the same |
| 89 | + /// outcome as the in-memory engine's `nil`. |
| 90 | + @Test func divisionByZeroMatchesNothing() async throws { |
| 91 | + let database = try Self.makeDatabase() |
| 92 | + try await Self.insertPeople(database) |
| 93 | + for op: FetchRequest.Predicate.Comparison.Operator in [.equalTo, .greaterThan, .lessThan] { |
| 94 | + let request = FetchRequest( |
| 95 | + entity: "Person", |
| 96 | + predicate: Self.arithmetic(.divide, .keyPath("age"), .attribute(.int64(0))) |
| 97 | + .compare(op, .attribute(.int64(0))) |
| 98 | + ) |
| 99 | + let results = try await database.fetch(request) |
| 100 | + #expect(results.isEmpty, "\(op) against a division by zero should match nothing") |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + // MARK: - Floating point |
| 105 | + |
| 106 | + @Test func floatingPointArithmetic() async throws { |
| 107 | + let database = try Self.makeDatabase() |
| 108 | + try await Self.insertPeople(database) |
| 109 | + // weight * 2 > 130 — Bob (160), not Alice (121) |
| 110 | + let request = FetchRequest( |
| 111 | + entity: "Person", |
| 112 | + predicate: Self.arithmetic(.multiply, .keyPath("weight"), .attribute(.int64(2))) |
| 113 | + .compare(.greaterThan, .attribute(.double(130))) |
| 114 | + ) |
| 115 | + let results = try await database.fetch(request) |
| 116 | + #expect(results.map(\.id) == ["bob"]) |
| 117 | + } |
| 118 | + |
| 119 | + /// Mixed integer/floating-point operands compute in floating point, so integer |
| 120 | + /// division does not truncate when either side is a float — as in memory. |
| 121 | + @Test func mixedOperandsPromote() async throws { |
| 122 | + let database = try Self.makeDatabase() |
| 123 | + try await Self.insertPeople(database) |
| 124 | + // weight / 2 == 30.25 — Alice (60.5 / 2), floating point division |
| 125 | + let request = FetchRequest( |
| 126 | + entity: "Person", |
| 127 | + predicate: Self.arithmetic(.divide, .keyPath("weight"), .attribute(.int64(2))) |
| 128 | + .compare(.equalTo, .attribute(.double(30.25))) |
| 129 | + ) |
| 130 | + let results = try await database.fetch(request) |
| 131 | + #expect(results.map(\.id) == ["alice"]) |
| 132 | + } |
| 133 | + |
| 134 | + /// The in-memory engine defines remainder for integers only; SQLite's `%` would |
| 135 | + /// cast a float to integer and silently diverge, so it is rejected instead. |
| 136 | + @Test func floatModulusRejected() async throws { |
| 137 | + let database = try Self.makeDatabase() |
| 138 | + try await Self.insertPeople(database) |
| 139 | + let request = FetchRequest( |
| 140 | + entity: "Person", |
| 141 | + predicate: Self.arithmetic(.modulus, .keyPath("weight"), .attribute(.int64(2))) |
| 142 | + .compare(.equalTo, .attribute(.int64(0))) |
| 143 | + ) |
| 144 | + await #expect(throws: (any Error).self) { |
| 145 | + try await database.fetch(request) |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + // MARK: - Composition |
| 150 | + |
| 151 | + @Test func nestedArithmetic() async throws { |
| 152 | + let database = try Self.makeDatabase() |
| 153 | + try await Self.insertPeople(database) |
| 154 | + // (age + 10) * 2 == 80 — Alice ((30+10)*2), not Bob ((41+10)*2 = 102) |
| 155 | + let inner = Self.arithmetic(.add, .keyPath("age"), .attribute(.int64(10))) |
| 156 | + let request = FetchRequest( |
| 157 | + entity: "Person", |
| 158 | + predicate: Self.arithmetic(.multiply, inner, .attribute(.int64(2))) |
| 159 | + .compare(.equalTo, .attribute(.int64(80))) |
| 160 | + ) |
| 161 | + let results = try await database.fetch(request) |
| 162 | + #expect(results.map(\.id) == ["alice"]) |
| 163 | + } |
| 164 | + |
| 165 | + @Test func compoundPredicateWithArithmetic() async throws { |
| 166 | + let database = try Self.makeDatabase() |
| 167 | + try await Self.insertPeople(database) |
| 168 | + // (age + 1 > 31) AND (name == "Bob") |
| 169 | + let request = FetchRequest( |
| 170 | + entity: "Person", |
| 171 | + predicate: .compound(.and([ |
| 172 | + Self.arithmetic(.add, .keyPath("age"), .attribute(.int64(1))) |
| 173 | + .compare(.greaterThan, .attribute(.int64(31))), |
| 174 | + "name".compare(.equalTo, .attribute(.string("Bob"))) |
| 175 | + ])) |
| 176 | + ) |
| 177 | + let results = try await database.fetch(request) |
| 178 | + #expect(results.map(\.id) == ["bob"]) |
| 179 | + } |
| 180 | + |
| 181 | + /// SQL and the in-memory engine agree on the same inputs. |
| 182 | + @Test func agreesWithInMemoryEvaluation() async throws { |
| 183 | + let database = try Self.makeDatabase() |
| 184 | + try await Self.insertPeople(database) |
| 185 | + let rows = try await database.fetch(FetchRequest(entity: "Person")) |
| 186 | + let cases: [(FetchRequest.Predicate.ArithmeticExpression.Function, Int64, FetchRequest.Predicate.Comparison.Operator, Int64)] = [ |
| 187 | + (.add, 10, .greaterThan, 45), |
| 188 | + (.subtract, 5, .lessThanOrEqualTo, 25), |
| 189 | + (.multiply, 3, .equalTo, 90), |
| 190 | + (.divide, 7, .equalTo, 4), |
| 191 | + (.modulus, 2, .equalTo, 0) |
| 192 | + ] |
| 193 | + for (function, operand, op, constant) in cases { |
| 194 | + let predicate = Self.arithmetic(.init(rawValue: function.rawValue)!, .keyPath("age"), .attribute(.int64(operand))) |
| 195 | + .compare(op, .attribute(.int64(constant))) |
| 196 | + let sql = try await database.fetch(FetchRequest(entity: "Person", predicate: predicate)).map(\.id) |
| 197 | + let memory = rows.filter { predicate.evaluate(with: $0) }.map(\.id) |
| 198 | + #expect(Set(sql) == Set(memory), "\(function) diverged: sql=\(sql) memory=\(memory)") |
| 199 | + } |
| 200 | + } |
| 201 | +} |
0 commit comments