Skip to content

Commit 26e78be

Browse files
committed
Add arithmetic expression tests
1 parent 72555c7 commit 26e78be

1 file changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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

Comments
 (0)