Skip to content

Commit 8f6d0fa

Browse files
committed
Add R*Tree + distance-function nearest-location example
Demonstrates combining an R*Tree virtual table for a fast bounding-box prefilter with a registered `distance` scalar function (haversine) to filter and sort sites by great-circle distance from a query point. Loads real coordinates from the bundled fixture; verified on macOS and Linux (R*Tree is enabled in swift-sqlcipher's default traits). Adds latitude/longitude to DataFile.Location so coordinates read through the existing API.
1 parent 8028491 commit 8f6d0fa

2 files changed

Lines changed: 123 additions & 0 deletions

File tree

‎Tests/SQLiteTests/DataFile/DataFile.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ public extension DataFile {
7474

7575
public var zipCode: String
7676

77+
public var latitude: Double
78+
79+
public var longitude: Double
80+
7781
public var directions: String?
7882
}
7983
}
@@ -87,6 +91,8 @@ internal extension DataFile.Location {
8791
self.state = row.string("state") ?? ""
8892
self.city = row.string("city") ?? ""
8993
self.zipCode = row.string("zipcode") ?? ""
94+
self.latitude = row.string("latitude").flatMap { Double($0) } ?? 0
95+
self.longitude = row.string("longitude").flatMap { Double($0) } ?? 0
9096
self.directions = row.string("directions")
9197
}
9298
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//
2+
// RTreeDistanceExample.swift
3+
// SQLite
4+
//
5+
// Created by Alsey Coleman Miller on 7/17/26.
6+
//
7+
8+
import Foundation
9+
import Testing
10+
@testable import SQLite
11+
12+
/// Example: nearest-location search combining an R*Tree spatial index with a custom
13+
/// SQL distance function.
14+
///
15+
/// The R*Tree virtual table gives a fast, index-backed bounding-box prefilter (cheap
16+
/// comparisons on coordinate ranges), and the registered `distance` function then
17+
/// computes the exact great-circle distance to reject bounding-box corners that fall
18+
/// outside the search radius and to sort the results.
19+
@Suite struct RTreeDistanceExample {
20+
21+
@Test func nearestLocations() throws {
22+
// Load the real site coordinates from the bundled database.
23+
guard let path = path(for: "data.sqlite") else {
24+
throw CocoaError(.fileNoSuchFile)
25+
}
26+
let locations = try DataFile(path: path).fetchLocations()
27+
28+
// Build an in-memory spatial database:
29+
// - `location` holds the site rows
30+
// - `location_index` is an R*Tree mapping each site to a (degenerate) bounding box
31+
let database = try Connection(path: ":memory:")
32+
try database.run("CREATE TABLE location (id INTEGER PRIMARY KEY, name TEXT, lat REAL, lon REAL)")
33+
try database.run("CREATE VIRTUAL TABLE location_index USING rtree(id, minLat, maxLat, minLon, maxLon)")
34+
35+
// Register a `distance(lat1, lon1, lat2, lon2)` function returning kilometres.
36+
try database.createFunction("distance", argumentCount: 4, deterministic: true) { arguments in
37+
guard let lat1 = arguments[0].double,
38+
let lon1 = arguments[1].double,
39+
let lat2 = arguments[2].double,
40+
let lon2 = arguments[3].double else {
41+
return .null
42+
}
43+
return .double(haversineDistance(lat1: lat1, lon1: lon1, lat2: lat2, lon2: lon2))
44+
}
45+
46+
// Load the sites. Each point is stored as a zero-area rectangle (min == max).
47+
try database.transaction {
48+
for (index, location) in locations.enumerated() {
49+
let id = Int64(index + 1)
50+
try database.run(
51+
"INSERT INTO location (id, name, lat, lon) VALUES (?, ?, ?, ?)",
52+
[id.binding, location.name.binding, location.latitude.binding, location.longitude.binding]
53+
)
54+
try database.run(
55+
"INSERT INTO location_index (id, minLat, maxLat, minLon, maxLon) VALUES (?, ?, ?, ?, ?)",
56+
[id.binding, location.latitude.binding, location.latitude.binding, location.longitude.binding, location.longitude.binding]
57+
)
58+
}
59+
}
60+
61+
// Search: all sites within 100 km of Richmond, VA, nearest first.
62+
let queryLatitude = 37.5407
63+
let queryLongitude = -77.4360
64+
let radius = 100.0
65+
66+
// Convert the radius into a latitude/longitude bounding box for the R*Tree prefilter.
67+
let latitudeDelta = radius / 111.0
68+
let longitudeDelta = radius / (111.0 * cos(queryLatitude * .pi / 180))
69+
70+
let sql = """
71+
SELECT location.name, distance(?, ?, location.lat, location.lon) AS km
72+
FROM location_index
73+
JOIN location ON location.id = location_index.id
74+
WHERE location_index.minLat >= ? AND location_index.maxLat <= ?
75+
AND location_index.minLon >= ? AND location_index.maxLon <= ?
76+
AND km <= ?
77+
ORDER BY km ASC
78+
"""
79+
let bindings: [Binding?] = [
80+
queryLatitude.binding, queryLongitude.binding,
81+
(queryLatitude - latitudeDelta).binding, (queryLatitude + latitudeDelta).binding,
82+
(queryLongitude - longitudeDelta).binding, (queryLongitude + longitudeDelta).binding,
83+
radius.binding
84+
]
85+
86+
let statement = try database.prepare(sql, bindings)
87+
var results = [(name: String, km: Double)]()
88+
while let row = try statement.failableNext() {
89+
guard let name = row[0]?.string, let km = row[1]?.double else { continue }
90+
results.append((name, km))
91+
}
92+
93+
// Four sites lie within 100 km, returned nearest-first.
94+
#expect(results.map(\.name) == [
95+
"TA Richmond",
96+
"TA Ashland",
97+
"TA Express Stony Creek",
98+
"TA Express Warfield"
99+
])
100+
#expect(results.first?.km ?? 0 < 25)
101+
// returned nearest-first (distances ascending)
102+
let distances = results.map(\.km)
103+
#expect(distances == distances.sorted())
104+
}
105+
}
106+
107+
/// Great-circle distance between two coordinates, in kilometres.
108+
private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
109+
let earthRadius = 6371.0 // km
110+
let phi1 = lat1 * .pi / 180
111+
let phi2 = lat2 * .pi / 180
112+
let deltaPhi = (lat2 - lat1) * .pi / 180
113+
let deltaLambda = (lon2 - lon1) * .pi / 180
114+
let a = sin(deltaPhi / 2) * sin(deltaPhi / 2)
115+
+ cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2)
116+
return 2 * earthRadius * asin(min(1, sqrt(a)))
117+
}

0 commit comments

Comments
 (0)