@@ -4,6 +4,14 @@ import CoreModel
44import 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.
4880private 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