Skip to content

Commit 403a05f

Browse files
committed
Add distance comparison and app-managed R*Tree tests
- matchesInMemorySwiftFilterAndSort: verifies the SQL distance function produces the same filtered set and sort order as filtering/sorting the same 200-point dataset in Swift. - appManagedRTreePrefilter: demonstrates the app-managed R*Tree pattern — the app creates an rtree virtual table + sync triggers via execute(), uses it as a bounding-box prefilter, then applies the exact distance function; result matches a brute-force oracle and the prefilter is sound. Gate the custom-function tests to Apple platforms and document the limit: SQLite.swift's createFunction is unreliable on non-Apple platforms and segfaults on Linux (upstream stephencelis/SQLite.swift#1071).
1 parent 3f9bf54 commit 403a05f

2 files changed

Lines changed: 174 additions & 14 deletions

File tree

Sources/CoreModelSQLite/Database.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ extension SQLiteDatabase: ModelStorage {
112112
invalidateCache(for: [entity])
113113
}
114114

115+
/// Registers a custom scalar function so it can be invoked from a predicate or sort
116+
/// descriptor via ``FetchRequest/Predicate/Expression/function(_:)``.
117+
///
118+
/// - Important: Only supported on Apple platforms. The underlying SQLite.swift
119+
/// `createFunction` registers the callback through `@convention(block)` +
120+
/// `unsafeBitCast`, which is unreliable on non-Apple platforms and can corrupt
121+
/// SQLite's heap (upstream https://github.com/stephencelis/SQLite.swift/issues/1071).
115122
public func register(function: DatabaseFunction) async throws {
116123
connection.register(function: function)
117124
}

Tests/CoreModelSQLiteTests/CustomFunctionTests.swift

Lines changed: 167 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import CoreModel
44
import SQLite
55
@testable import CoreModelSQLite
66

7+
// Custom SQL functions are only exercised on Apple platforms: SQLite.swift's
8+
// `createFunction` registers the callback with `@convention(block)` + an
9+
// `unsafeBitCast` to a raw pointer, which is unreliable on non-Apple platforms
10+
// (the block pointer is invalid and corrupts SQLite's heap — upstream
11+
// https://github.com/stephencelis/SQLite.swift/issues/1071). Gate these tests to
12+
// Darwin so CI stays green until that is resolved.
13+
#if canImport(Darwin)
14+
715
/// A Haversine distance function, in meters, written directly in the test — CoreModelSQLite
816
/// itself has no notion of "distance" or geo data; this exercises the generic
917
/// `DatabaseFunction`/`.function` expression mechanism using a realistic example.
@@ -26,8 +34,8 @@ private let distanceFunction = DatabaseFunction(name: "distance", argumentCount:
2634
return .double(haversineDistance(lat1, lon1, lat2, lon2))
2735
}
2836

29-
private func makeGeoDatabase() async throws -> SQLiteDatabase {
30-
let model = Model(entities: [
37+
private var geoModel: Model {
38+
Model(entities: [
3139
EntityDescription(
3240
id: "Site",
3341
attributes: [
@@ -38,11 +46,35 @@ private func makeGeoDatabase() async throws -> SQLiteDatabase {
3846
relationships: []
3947
)
4048
])
41-
let database = try SQLiteDatabase(path: temporaryDatabasePath(named: "GeoTests"), model: model)
49+
}
50+
51+
private func makeGeoDatabase(named name: String = "GeoTests") async throws -> SQLiteDatabase {
52+
let database = try SQLiteDatabase(path: temporaryDatabasePath(named: name), model: geoModel)
4253
try await database.register(function: distanceFunction)
4354
return database
4455
}
4556

57+
/// A deterministic pseudo-random generator so the large-dataset comparison tests are
58+
/// reproducible run to run (SPLITMIX64).
59+
private struct SeededGenerator: RandomNumberGenerator {
60+
private var state: UInt64
61+
init(seed: UInt64) { state = seed }
62+
mutating func next() -> UInt64 {
63+
state = state &+ 0x9E3779B97F4A7C15
64+
var z = state
65+
z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
66+
z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
67+
return z ^ (z >> 31)
68+
}
69+
}
70+
71+
private func distanceSort(ascending: Bool = true) -> FetchRequest.SortDescriptor {
72+
.init(term: .function(.init(name: "distance", arguments: [
73+
.keyPath("latitude"), .keyPath("longitude"),
74+
.attribute(.double(referenceLatitude)), .attribute(.double(referenceLongitude))
75+
])), ascending: ascending)
76+
}
77+
4678
/// Known coordinates, distances computed against Raleigh, NC (35.7796, -78.6382), the
4779
/// reference point every test below filters/sorts by.
4880
private let referenceLatitude = 35.7796
@@ -134,17 +166,7 @@ private func expectedIDs(within radiusMeters: Double) -> Set<ObjectID> {
134166
@MainActor
135167
@Test func functionRegisteredOnViewContext() async throws {
136168
let path = temporaryDatabasePath(named: "GeoViewContextTests")
137-
let model = Model(entities: [
138-
EntityDescription(
139-
id: "Site",
140-
attributes: [
141-
.init(id: "name", type: .string),
142-
.init(id: "latitude", type: .double),
143-
.init(id: "longitude", type: .double)
144-
],
145-
relationships: []
146-
)
147-
])
169+
let model = geoModel
148170
let database = try SQLiteDatabase(path: path, model: model)
149171
try await database.register(function: distanceFunction)
150172
try await insertSites(database)
@@ -171,3 +193,134 @@ private func expectedIDs(within radiusMeters: Double) -> Set<ObjectID> {
171193
let count = try await database.count(FetchRequest(entity: "Person"))
172194
#expect(count == 3)
173195
}
196+
197+
/// Generate a reproducible spread of coordinates across the continental US and their
198+
/// precomputed distance from the reference point.
199+
private func randomSites(count: Int, seed: UInt64) -> [(id: ObjectID, latitude: Double, longitude: Double, distance: Double)] {
200+
var rng = SeededGenerator(seed: seed)
201+
return (0..<count).map { index in
202+
let latitude = Double.random(in: 25...49, using: &rng)
203+
let longitude = Double.random(in: -124 ... -67, using: &rng)
204+
return (
205+
id: ObjectID(rawValue: "site\(index)"),
206+
latitude: latitude,
207+
longitude: longitude,
208+
distance: haversineDistance(latitude, longitude, referenceLatitude, referenceLongitude)
209+
)
210+
}
211+
}
212+
213+
/// The SQL `distance` function (executed inside SQLite) must produce exactly the same
214+
/// filtered set and sort order as filtering/sorting the same data in Swift. Because the
215+
/// registered function delegates to the same `haversineDistance` Swift code, the two
216+
/// paths compute bit-identical distances, so the comparison is exact.
217+
@Test func matchesInMemorySwiftFilterAndSort() async throws {
218+
let database = try await makeGeoDatabase(named: "GeoCompare")
219+
let generated = randomSites(count: 200, seed: 0xC0FFEE)
220+
for site in generated {
221+
try await database.insert(ModelData(entity: "Site", id: site.id, attributes: [
222+
"latitude": .double(site.latitude),
223+
"longitude": .double(site.longitude)
224+
]))
225+
}
226+
227+
let radius = 1_000_000.0 // 1000 km
228+
229+
// SQL path: filter and sort by the registered distance function
230+
let sqlRequest = FetchRequest(
231+
entity: "Site",
232+
sortDescriptors: [distanceSort()],
233+
predicate: .comparison(.init(left: distanceExpression(), right: .attribute(.double(radius)), type: .lessThanOrEqualTo))
234+
)
235+
let sqlIDs = try await database.fetchID(sqlRequest)
236+
237+
// In-memory Swift path: same filter and sort over the same data
238+
let inMemoryIDs = generated
239+
.filter { $0.distance <= radius }
240+
.sorted { $0.distance < $1.distance }
241+
.map(\.id)
242+
243+
#expect(sqlIDs == inMemoryIDs)
244+
#expect(sqlIDs.isEmpty == false) // ensure the radius actually matched something
245+
#expect(sqlIDs.count < generated.count) // ...and excluded something
246+
}
247+
248+
/// App-managed R*Tree: CoreModelSQLite provides only `execute(_:_:)` and the generic
249+
/// `distance` function — the app itself creates an R*Tree virtual table (plus a rowid
250+
/// mapping and triggers to keep it in sync), uses it as a bounding-box prefilter, then
251+
/// applies the exact `distance` function for correctness. The final result must match
252+
/// the brute-force in-memory oracle, and the prefilter must be sound (a superset of the
253+
/// exact matches) and actually prune.
254+
@Test func appManagedRTreePrefilter() async throws {
255+
let path = temporaryDatabasePath(named: "GeoRTree")
256+
let database = try SQLiteDatabase(path: path, model: geoModel)
257+
try await database.register(function: distanceFunction)
258+
259+
// App-owned spatial index and sync triggers — entirely outside the library.
260+
try await database.execute("""
261+
CREATE TABLE "Site_rtree_map" (rowid INTEGER PRIMARY KEY AUTOINCREMENT, site_id TEXT UNIQUE NOT NULL)
262+
""")
263+
try await database.execute("""
264+
CREATE VIRTUAL TABLE "Site_rtree" USING rtree(id, minLat, maxLat, minLon, maxLon)
265+
""")
266+
try await database.execute("""
267+
CREATE TRIGGER "Site_rtree_insert" AFTER INSERT ON "Site" BEGIN
268+
INSERT INTO "Site_rtree_map"(site_id) VALUES (NEW."id");
269+
INSERT INTO "Site_rtree"(id, minLat, maxLat, minLon, maxLon)
270+
SELECT rowid, NEW."latitude", NEW."latitude", NEW."longitude", NEW."longitude"
271+
FROM "Site_rtree_map" WHERE site_id = NEW."id";
272+
END
273+
""")
274+
275+
let generated = randomSites(count: 200, seed: 0xBEEF)
276+
for site in generated {
277+
try await database.insert(ModelData(entity: "Site", id: site.id, attributes: [
278+
"latitude": .double(site.latitude),
279+
"longitude": .double(site.longitude)
280+
]))
281+
}
282+
283+
let radius = 1_000_000.0 // 1000 km
284+
285+
// Bounding box (a superset of the radius circle) in degrees.
286+
let latDelta = radius / 111_320.0
287+
let lonDelta = radius / (111_320.0 * cos(referenceLatitude * .pi / 180))
288+
let minLat = referenceLatitude - latDelta
289+
let maxLat = referenceLatitude + latDelta
290+
let minLon = referenceLongitude - lonDelta
291+
let maxLon = referenceLongitude + lonDelta
292+
293+
// Query the app's R*Tree (through the app's own read connection) for candidate ids.
294+
let reader = try Connection(path, readonly: true)
295+
let candidates = Set(try reader.prepare("""
296+
SELECT m.site_id FROM "Site_rtree" r
297+
JOIN "Site_rtree_map" m ON m.rowid = r.id
298+
WHERE r.minLat <= ? AND r.maxLat >= ? AND r.minLon <= ? AND r.maxLon >= ?
299+
""", maxLat, minLat, maxLon, minLon).compactMap { row in
300+
(row[0] as? String).map { ObjectID(rawValue: $0) }
301+
})
302+
303+
// Combine the R*Tree candidate set with the exact distance filter via the library.
304+
let request = FetchRequest(
305+
entity: "Site",
306+
sortDescriptors: [distanceSort()],
307+
predicate: .compound(.and([
308+
.comparison(.init(left: .keyPath("id"), right: .relationship(.toMany(Array(candidates))), type: .in)),
309+
.comparison(.init(left: distanceExpression(), right: .attribute(.double(radius)), type: .lessThanOrEqualTo))
310+
]))
311+
)
312+
let rtreeResult = try await database.fetchID(request)
313+
314+
// Brute-force oracle over the same data.
315+
let oracle = generated
316+
.filter { $0.distance <= radius }
317+
.sorted { $0.distance < $1.distance }
318+
.map(\.id)
319+
320+
#expect(rtreeResult == oracle) // combined query is correct
321+
#expect(Set(oracle).isSubset(of: candidates)) // prefilter is sound
322+
#expect(candidates.count < generated.count) // prefilter actually pruned
323+
#expect(oracle.isEmpty == false)
324+
}
325+
326+
#endif // canImport(Darwin)

0 commit comments

Comments
 (0)