Skip to content

Commit 3d6261c

Browse files
committed
Add predicate translation tests
Cover the predicate-to-SQL WHERE clause translation paths: constant and compound predicates, NULL handling, string operators, IN/BETWEEN, to-many relationship membership subqueries, function-call comparisons, and error paths. Raises library line coverage to 94.75%.
1 parent 3822366 commit 3d6261c

1 file changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
//
2+
// PredicateTests.swift
3+
// CoreModel-SQLite
4+
//
5+
// Exercises the predicate → SQL `WHERE` clause translation in Predicate.swift
6+
// through the public fetch API, covering the operators, modifiers, and error
7+
// paths not touched by the higher-level integration tests.
8+
//
9+
10+
import Foundation
11+
import Testing
12+
import CoreModel
13+
import SQLite
14+
@testable import CoreModelSQLite
15+
16+
/// A small fixture of people with predictable, easy-to-filter attributes.
17+
private func makePeopleDatabase() async throws -> SQLiteDatabase {
18+
let database = try makeDatabase()
19+
let people = [
20+
ModelData(entity: "Person", id: "alice", attributes: [
21+
"name": .string("Alice"), "age": .int32(30), "weight": .double(60)
22+
]),
23+
ModelData(entity: "Person", id: "bob", attributes: [
24+
"name": .string("Bob"), "age": .int32(40), "weight": .double(80)
25+
]),
26+
ModelData(entity: "Person", id: "carol", attributes: [
27+
"name": .string("Carol"), "age": .int32(50) // weight omitted -> NULL
28+
])
29+
]
30+
try await database.insert(people)
31+
return database
32+
}
33+
34+
// MARK: - Constant & compound predicates
35+
36+
@Test func constantPredicateTrueAndFalse() async throws {
37+
let database = try await makePeopleDatabase()
38+
let all = try await database.count(FetchRequest(entity: "Person", predicate: .value(true)))
39+
#expect(all == 3)
40+
let none = try await database.count(FetchRequest(entity: "Person", predicate: .value(false)))
41+
#expect(none == 0)
42+
}
43+
44+
@Test func notCompound() async throws {
45+
let database = try await makePeopleDatabase()
46+
let request = FetchRequest(
47+
entity: "Person",
48+
predicate: .compound(.not(
49+
.comparison(.init(left: .keyPath("name"), right: .attribute(.string("Alice")), type: .equalTo))
50+
))
51+
)
52+
let ids = try await database.fetchID(request).sorted { $0.rawValue < $1.rawValue }
53+
#expect(ids == ["bob", "carol"])
54+
}
55+
56+
@Test func emptyCompoundMatchesEverything() async throws {
57+
let database = try await makePeopleDatabase()
58+
let and = try await database.count(FetchRequest(entity: "Person", predicate: .compound(.and([]))))
59+
#expect(and == 3)
60+
let or = try await database.count(FetchRequest(entity: "Person", predicate: .compound(.or([]))))
61+
#expect(or == 3)
62+
}
63+
64+
// MARK: - NULL handling
65+
66+
@Test func nullEquality() async throws {
67+
let database = try await makePeopleDatabase()
68+
// weight IS NULL
69+
let isNull = FetchRequest(
70+
entity: "Person",
71+
predicate: .comparison(.init(left: .keyPath("weight"), right: .attribute(.null), type: .equalTo))
72+
)
73+
#expect(try await database.fetchID(isNull) == ["carol"])
74+
// weight IS NOT NULL
75+
let notNull = FetchRequest(
76+
entity: "Person",
77+
predicate: .comparison(.init(left: .keyPath("weight"), right: .attribute(.null), type: .notEqualTo))
78+
)
79+
let ids = try await database.fetchID(notNull).sorted { $0.rawValue < $1.rawValue }
80+
#expect(ids == ["alice", "bob"])
81+
}
82+
83+
@Test func toOneRelationshipNullComparison() async throws {
84+
let database = try await makePeopleDatabase()
85+
// team IS NULL for everyone (no team assigned)
86+
let request = FetchRequest(
87+
entity: "Person",
88+
predicate: .comparison(.init(left: .keyPath("team"), right: .relationship(.null), type: .equalTo))
89+
)
90+
#expect(try await database.count(request) == 3)
91+
}
92+
93+
// MARK: - String operators
94+
95+
@Test func caseInsensitiveEquality() async throws {
96+
let database = try await makePeopleDatabase()
97+
let request = FetchRequest(
98+
entity: "Person",
99+
predicate: .comparison(.init(
100+
left: .keyPath("name"),
101+
right: .attribute(.string("alice")),
102+
type: .equalTo,
103+
options: [.caseInsensitive]
104+
))
105+
)
106+
#expect(try await database.fetchID(request) == ["alice"])
107+
}
108+
109+
@Test func endsWithAndContains() async throws {
110+
let database = try await makePeopleDatabase()
111+
let endsWith = FetchRequest(
112+
entity: "Person",
113+
predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("ob")), type: .endsWith))
114+
)
115+
#expect(try await database.fetchID(endsWith) == ["bob"])
116+
117+
let contains = FetchRequest(
118+
entity: "Person",
119+
predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("aro")), type: .contains))
120+
)
121+
#expect(try await database.fetchID(contains) == ["carol"])
122+
}
123+
124+
@Test func likeWithWildcards() async throws {
125+
let database = try await makePeopleDatabase()
126+
// Cocoa-style wildcards: `*` -> any run, `?` -> single char
127+
let star = FetchRequest(
128+
entity: "Person",
129+
predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("A*")), type: .like))
130+
)
131+
#expect(try await database.fetchID(star) == ["alice"])
132+
133+
let question = FetchRequest(
134+
entity: "Person",
135+
predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("Bo?")), type: .like))
136+
)
137+
#expect(try await database.fetchID(question) == ["bob"])
138+
}
139+
140+
// MARK: - IN / BETWEEN
141+
142+
@Test func inWithEmptySetMatchesNothing() async throws {
143+
let database = try await makePeopleDatabase()
144+
let request = FetchRequest(
145+
entity: "Person",
146+
predicate: .comparison(.init(left: .keyPath("id"), right: .relationship(.toMany([])), type: .in))
147+
)
148+
#expect(try await database.count(request) == 0)
149+
}
150+
151+
@Test func betweenBounds() async throws {
152+
let database = try await makePeopleDatabase()
153+
// id BETWEEN "alice" AND "bob" (lexical) -> alice, bob
154+
let request = FetchRequest(
155+
entity: "Person",
156+
predicate: .comparison(.init(
157+
left: .keyPath("id"),
158+
right: .relationship(.toMany(["alice", "bob"])),
159+
type: .between
160+
))
161+
)
162+
let ids = try await database.fetchID(request).sorted { $0.rawValue < $1.rawValue }
163+
#expect(ids == ["alice", "bob"])
164+
}
165+
166+
@Test func betweenWithWrongCountThrows() async throws {
167+
let database = try await makePeopleDatabase()
168+
let request = FetchRequest(
169+
entity: "Person",
170+
predicate: .comparison(.init(
171+
left: .keyPath("id"),
172+
right: .relationship(.toMany(["alice"])), // needs exactly two
173+
type: .between
174+
))
175+
)
176+
await #expect(throws: SQLiteDatabaseError.self) {
177+
try await database.count(request)
178+
}
179+
}
180+
181+
// MARK: - To-many relationship membership
182+
183+
@Test func toManyMembershipViaJoinTable() async throws {
184+
// Person <-toMany-> Event (many-to-many, join table)
185+
let database = try makeDatabase()
186+
let people = ["p1", "p2", "p3"].map {
187+
ModelData(entity: "Person", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)])
188+
}
189+
try await database.insert(people)
190+
let event = ModelData(
191+
entity: "Event",
192+
id: "event1",
193+
attributes: ["name": .string("WWDC")],
194+
relationships: ["people": .toMany(["p1", "p2"])]
195+
)
196+
try await database.insert(event)
197+
198+
// ANY events == event1 (contains / equalTo on a to-many relationship)
199+
let contains = FetchRequest(
200+
entity: "Person",
201+
predicate: .comparison(.init(left: .keyPath("events"), right: .relationship(.toOne("event1")), type: .contains))
202+
)
203+
let ids = try await database.fetchID(contains).sorted { $0.rawValue < $1.rawValue }
204+
#expect(ids == ["p1", "p2"])
205+
206+
// events IN {event1}
207+
let inSet = FetchRequest(
208+
entity: "Person",
209+
predicate: .comparison(.init(left: .keyPath("events"), right: .relationship(.toMany(["event1"])), type: .in))
210+
)
211+
#expect(try await database.count(inSet) == 2)
212+
213+
// empty membership set matches nothing
214+
let empty = FetchRequest(
215+
entity: "Person",
216+
predicate: .comparison(.init(left: .keyPath("events"), right: .relationship(.toMany([])), type: .in))
217+
)
218+
#expect(try await database.count(empty) == 0)
219+
}
220+
221+
@Test func toManyMembershipViaForeignKey() async throws {
222+
// Team <-toMany- members, inverse Person.team is toOne (one-to-many branch)
223+
let database = try makeDatabase()
224+
let people = ["p1", "p2"].map {
225+
ModelData(entity: "Person", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)])
226+
}
227+
try await database.insert(people)
228+
let team = ModelData(
229+
entity: "Team",
230+
id: "team1",
231+
attributes: ["name": .string("Red")],
232+
relationships: ["members": .toMany(["p1"])]
233+
)
234+
try await database.insert(team)
235+
236+
// Teams whose members contain p1
237+
let request = FetchRequest(
238+
entity: "Team",
239+
predicate: .comparison(.init(left: .keyPath("members"), right: .relationship(.toOne("p1")), type: .contains))
240+
)
241+
#expect(try await database.fetchID(request) == ["team1"])
242+
}
243+
244+
@Test func toManyMembershipInvalidModifierThrows() async throws {
245+
let database = try makeDatabase()
246+
// `.all` has no direct SQL translation for a to-many relationship
247+
let request = FetchRequest(
248+
entity: "Person",
249+
predicate: .comparison(.init(
250+
left: .keyPath("events"),
251+
right: .relationship(.toOne("event1")),
252+
type: .contains,
253+
modifier: .all
254+
))
255+
)
256+
await #expect(throws: SQLiteDatabaseError.self) {
257+
try await database.count(request)
258+
}
259+
}
260+
261+
// MARK: - Function-call comparisons
262+
263+
private let magnitudeFunction = DatabaseFunction(name: "magnitude", argumentCount: 1) { arguments in
264+
guard case let .int64(value) = arguments[0] else { return nil }
265+
return .int64(abs(value))
266+
}
267+
268+
@Test func functionComparisonEqualTo() async throws {
269+
let database = try makeDatabase()
270+
try await database.register(function: magnitudeFunction)
271+
let people = [
272+
ModelData(entity: "Person", id: "a", attributes: ["name": .string("A"), "age": .int32(30)]),
273+
ModelData(entity: "Person", id: "b", attributes: ["name": .string("B"), "age": .int32(40)])
274+
]
275+
try await database.insert(people)
276+
277+
let magnitudeOfAge = FetchRequest.Predicate.Expression.function(
278+
.init(name: "magnitude", arguments: [.keyPath("age")])
279+
)
280+
// magnitude(age) == 30
281+
let equalTo = FetchRequest(
282+
entity: "Person",
283+
predicate: .comparison(.init(left: magnitudeOfAge, right: .attribute(.int32(30)), type: .equalTo))
284+
)
285+
#expect(try await database.fetchID(equalTo) == ["a"])
286+
287+
// magnitude(age) != 30
288+
let notEqualTo = FetchRequest(
289+
entity: "Person",
290+
predicate: .comparison(.init(left: magnitudeOfAge, right: .attribute(.int32(30)), type: .notEqualTo))
291+
)
292+
#expect(try await database.fetchID(notEqualTo) == ["b"])
293+
}
294+
295+
// MARK: - Error paths
296+
297+
@Test func modifierOnColumnComparisonThrows() async throws {
298+
let database = try await makePeopleDatabase()
299+
// A modifier is only valid on a to-many relationship, not a scalar column.
300+
let request = FetchRequest(
301+
entity: "Person",
302+
predicate: .comparison(.init(
303+
left: .keyPath("age"),
304+
right: .attribute(.int32(30)),
305+
type: .equalTo,
306+
modifier: .any
307+
))
308+
)
309+
await #expect(throws: SQLiteDatabaseError.self) {
310+
try await database.count(request)
311+
}
312+
}
313+
314+
@Test func matchesOperatorThrows() async throws {
315+
let database = try await makePeopleDatabase()
316+
// Regular-expression matching has no plain-SQL translation.
317+
let request = FetchRequest(
318+
entity: "Person",
319+
predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.string("A.*")), type: .matches))
320+
)
321+
await #expect(throws: SQLiteDatabaseError.self) {
322+
try await database.count(request)
323+
}
324+
}
325+
326+
@Test func likePatternRequiresStringThrows() async throws {
327+
let database = try await makePeopleDatabase()
328+
// beginsWith needs a string operand; an int cannot form a LIKE pattern.
329+
let request = FetchRequest(
330+
entity: "Person",
331+
predicate: .comparison(.init(left: .keyPath("name"), right: .attribute(.int32(5)), type: .beginsWith))
332+
)
333+
await #expect(throws: SQLiteDatabaseError.self) {
334+
try await database.count(request)
335+
}
336+
}
337+
338+
@Test func unknownColumnThrows() async throws {
339+
let database = try await makePeopleDatabase()
340+
let request = FetchRequest(
341+
entity: "Person",
342+
predicate: .comparison(.init(left: .keyPath("doesNotExist"), right: .attribute(.int32(1)), type: .equalTo))
343+
)
344+
await #expect(throws: SQLiteDatabaseError.self) {
345+
try await database.count(request)
346+
}
347+
}
348+
349+
@Test func toManyValueInScalarComparisonThrows() async throws {
350+
let database = try await makePeopleDatabase()
351+
// A to-many relationship value cannot be a single scalar binding.
352+
let request = FetchRequest(
353+
entity: "Person",
354+
predicate: .comparison(.init(
355+
left: .keyPath("team"),
356+
right: .relationship(.toMany(["x", "y"])),
357+
type: .equalTo
358+
))
359+
)
360+
await #expect(throws: SQLiteDatabaseError.self) {
361+
try await database.count(request)
362+
}
363+
}

0 commit comments

Comments
 (0)