|
| 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