lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

database.rs (31782B)


      1 //! Explicit GeoNames database lifecycle.
      2 
      3 use std::collections::BTreeSet;
      4 use std::fmt;
      5 use std::path::Path;
      6 use std::time::Duration;
      7 
      8 use futures::TryStreamExt;
      9 use sqlx::{
     10     ConnectOptions, Connection as _, Row, SqliteConnection,
     11     sqlite::{SqliteConnectOptions, SqliteRow},
     12 };
     13 use tokio::sync::Mutex;
     14 
     15 use crate::asset::verify_file;
     16 use crate::model::Country;
     17 use crate::query::{QueryKind, QueryResult};
     18 use crate::{AssetSpec, Candidate, Error, Point, Query};
     19 
     20 const REQUIRED_GEONAMES_COLUMNS: &[&str] = &[
     21     "id",
     22     "name",
     23     "admin1_id",
     24     "admin1_name",
     25     "country_id",
     26     "country_name",
     27     "latitude",
     28     "longitude",
     29 ];
     30 const REQUIRED_COORDINATE_COLUMNS: &[&str] = &["feature_id", "latitude", "longitude"];
     31 
     32 /// An opened, verified GeoNames database.
     33 ///
     34 /// The connection is read-only and serialized by this type. It owns no path
     35 /// policy, migration authority, runtime, download, or background task.
     36 pub struct Geocoder {
     37     connection: Mutex<SqliteConnection>,
     38 }
     39 
     40 impl fmt::Debug for Geocoder {
     41     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     42         formatter.debug_struct("Geocoder").finish_non_exhaustive()
     43     }
     44 }
     45 
     46 impl Geocoder {
     47     /// Opens an explicitly selected asset after complete identity and schema checks.
     48     pub async fn open(path: impl AsRef<Path>, spec: &AssetSpec) -> Result<Self, Error> {
     49         let path = path.as_ref();
     50         let metadata = path
     51             .symlink_metadata()
     52             .map_err(|error| crate::asset::io_error("inspect database asset", error))?;
     53         if metadata.file_type().is_symlink() || !metadata.is_file() {
     54             return Err(Error::UnsafeAssetDestination);
     55         }
     56         verify_file(path, spec)?;
     57 
     58         let options = SqliteConnectOptions::new()
     59             .filename(path)
     60             .read_only(true)
     61             .create_if_missing(false)
     62             .immutable(true)
     63             .busy_timeout(Duration::from_secs(5))
     64             .disable_statement_logging();
     65         let mut connection = SqliteConnection::connect_with(&options)
     66             .await
     67             .map_err(|_| Error::InvalidDatabase)?;
     68         let validation = async {
     69             configure_connection(&mut connection).await?;
     70             validate_integrity(&mut connection).await?;
     71             validate_schema(&mut connection).await
     72         }
     73         .await;
     74         if let Err(error) = validation {
     75             let _ = connection.close().await;
     76             return Err(error);
     77         }
     78         Ok(Self {
     79             connection: Mutex::new(connection),
     80         })
     81     }
     82 
     83     /// Closes the database and reports a terminal SQLite close failure.
     84     pub async fn close(self) -> Result<(), Error> {
     85         let connection = self.connection.into_inner();
     86         connection
     87             .close()
     88             .await
     89             .map_err(|_| Error::DatabaseOperationFailed { operation: "close" })
     90     }
     91 
     92     /// Executes one validated query with deterministic provider ordering.
     93     pub async fn query(&self, query: &Query) -> Result<QueryResult, Error> {
     94         let mut connection = self.connection.lock().await;
     95         match &query.kind {
     96             QueryKind::Locality {
     97                 locality,
     98                 region,
     99                 country,
    100             } => {
    101                 query_locality(
    102                     &mut connection,
    103                     locality,
    104                     region.as_deref(),
    105                     country.as_deref(),
    106                     query.limit(),
    107                 )
    108                 .await
    109             }
    110             QueryKind::Freeform(query_text) => {
    111                 let parsed = parse_freeform_query(query_text);
    112                 query_locality(
    113                     &mut connection,
    114                     &parsed.locality,
    115                     parsed.region.as_deref(),
    116                     parsed.country.as_deref(),
    117                     query.limit(),
    118                 )
    119                 .await
    120             }
    121             QueryKind::FeatureId(feature_id) => query_feature(&mut connection, *feature_id).await,
    122             QueryKind::Reverse {
    123                 point,
    124                 radius_degrees,
    125             } => query_reverse(&mut connection, *point, *radius_degrees, query.limit()).await,
    126             QueryKind::Countries => query_countries(&mut connection, query.limit()).await,
    127         }
    128     }
    129 }
    130 
    131 async fn query_locality(
    132     connection: &mut SqliteConnection,
    133     locality: &str,
    134     region: Option<&str>,
    135     country: Option<&str>,
    136     limit: usize,
    137 ) -> Result<QueryResult, Error> {
    138     let locality = normalize_name(locality);
    139     let country = country.map(normalize_name);
    140     let region = region.map(normalize_name);
    141     let mut rows = sqlx::query(
    142         "
    143         SELECT id, name, CAST(admin1_id AS TEXT), admin1_name,
    144                country_id, country_name, latitude, longitude
    145         FROM geonames
    146         WHERE lower(name) = ?1
    147         ORDER BY lower(name), lower(country_id),
    148                  lower(coalesce(country_name, '')),
    149                  lower(coalesce(admin1_name, '')),
    150                  CASE
    151                    WHEN admin1_id IS NULL THEN 0
    152                    WHEN typeof(admin1_id) IN ('integer', 'real') THEN 1
    153                    WHEN typeof(admin1_id) = 'text' THEN 2
    154                    ELSE 3
    155                  END,
    156                  CASE WHEN typeof(admin1_id) IN ('integer', 'real')
    157                       THEN admin1_id ELSE NULL END,
    158                  CASE WHEN typeof(admin1_id) = 'text'
    159                       THEN CAST(admin1_id AS TEXT) ELSE NULL END COLLATE BINARY,
    160                  id
    161         ",
    162     )
    163     .bind(locality)
    164     .fetch(&mut *connection);
    165     let mut candidates = Vec::with_capacity(limit);
    166     while let Some(row) = rows.try_next().await.map_err(query_failed)? {
    167         let candidate = map_candidate(&row)?;
    168         if country
    169             .as_deref()
    170             .is_none_or(|value| country_matches(&candidate, value))
    171             && region
    172                 .as_deref()
    173                 .is_none_or(|value| region_matches(&candidate, value))
    174         {
    175             candidates.push(candidate);
    176             if candidates.len() == limit {
    177                 break;
    178             }
    179         }
    180     }
    181     Ok(QueryResult::candidates(candidates))
    182 }
    183 
    184 async fn query_feature(
    185     connection: &mut SqliteConnection,
    186     feature_id: i64,
    187 ) -> Result<QueryResult, Error> {
    188     let row = sqlx::query(
    189         "
    190         SELECT id, name, CAST(admin1_id AS TEXT), admin1_name,
    191                country_id, country_name, latitude, longitude
    192         FROM geonames
    193         WHERE id = ?1
    194         LIMIT 1
    195         ",
    196     )
    197     .bind(feature_id)
    198     .fetch_optional(connection)
    199     .await
    200     .map_err(query_failed)?;
    201     let candidates = row
    202         .as_ref()
    203         .map(map_candidate)
    204         .transpose()?
    205         .into_iter()
    206         .collect();
    207     Ok(QueryResult::candidates(candidates))
    208 }
    209 
    210 async fn query_reverse(
    211     connection: &mut SqliteConnection,
    212     point: Point,
    213     radius_degrees: f64,
    214     limit: usize,
    215 ) -> Result<QueryResult, Error> {
    216     let latitude = point.latitude();
    217     let longitude = point.longitude();
    218     let longitude_weight = latitude.to_radians().cos().powi(2);
    219     let rows = sqlx::query(
    220         "
    221         SELECT g.id, g.name, CAST(g.admin1_id AS TEXT), g.admin1_name,
    222                g.country_id, g.country_name, g.latitude, g.longitude
    223         FROM geonames AS g
    224         JOIN coordinates AS c ON g.id = c.feature_id
    225         WHERE c.latitude BETWEEN ?1 - ?3 AND ?1 + ?3
    226           AND (
    227             abs(?1) + ?3 >= 90.0
    228             OR (
    229               ?2 - ?3 >= -180.0 AND ?2 + ?3 <= 180.0
    230               AND c.longitude BETWEEN ?2 - ?3 AND ?2 + ?3
    231             )
    232             OR (
    233               ?2 - ?3 < -180.0
    234               AND (c.longitude >= ?2 - ?3 + 360.0 OR c.longitude <= ?2 + ?3)
    235             )
    236             OR (
    237               ?2 + ?3 > 180.0
    238               AND (c.longitude >= ?2 - ?3 OR c.longitude <= ?2 + ?3 - 360.0)
    239             )
    240           )
    241         ORDER BY ((?1 - c.latitude) * (?1 - c.latitude))
    242                + (min(abs(?2 - c.longitude), 360.0 - abs(?2 - c.longitude))
    243                   * min(abs(?2 - c.longitude), 360.0 - abs(?2 - c.longitude))
    244                   * ?4),
    245                  g.id
    246         LIMIT ?5
    247         ",
    248     )
    249     .bind(latitude)
    250     .bind(longitude)
    251     .bind(radius_degrees)
    252     .bind(longitude_weight)
    253     .bind(i64::try_from(limit).unwrap_or(i64::MAX))
    254     .fetch_all(connection)
    255     .await
    256     .map_err(query_failed)?;
    257     let candidates = rows
    258         .iter()
    259         .map(map_candidate)
    260         .collect::<Result<Vec<_>, _>>()?;
    261     Ok(QueryResult::candidates(candidates))
    262 }
    263 
    264 async fn query_countries(
    265     connection: &mut SqliteConnection,
    266     limit: usize,
    267 ) -> Result<QueryResult, Error> {
    268     let rows = sqlx::query(
    269         "
    270         SELECT country_id, country_name, AVG(latitude), AVG(longitude)
    271         FROM geonames
    272         GROUP BY country_id, country_name
    273         ORDER BY lower(country_id), lower(coalesce(country_name, ''))
    274         LIMIT ?1
    275         ",
    276     )
    277     .bind(i64::try_from(limit).unwrap_or(i64::MAX))
    278     .fetch_all(connection)
    279     .await
    280     .map_err(query_failed)?;
    281     let countries = rows
    282         .iter()
    283         .map(map_country)
    284         .collect::<Result<Vec<_>, _>>()?;
    285     Ok(QueryResult::countries(countries))
    286 }
    287 
    288 fn map_candidate(row: &SqliteRow) -> Result<Candidate, Error> {
    289     let feature_id = row.try_get::<i64, _>(0).map_err(query_failed)?;
    290     let feature_id = u64::try_from(feature_id).map_err(|_| query_failed(()))?;
    291     let latitude = row.try_get::<f64, _>(6).map_err(query_failed)?;
    292     let longitude = row.try_get::<f64, _>(7).map_err(query_failed)?;
    293     let point = Point::new(latitude, longitude).map_err(|_| query_failed(()))?;
    294     Ok(Candidate::from_provider_row(
    295         feature_id,
    296         row.try_get(1).map_err(query_failed)?,
    297         row.try_get(2).map_err(query_failed)?,
    298         row.try_get(3).map_err(query_failed)?,
    299         row.try_get(4).map_err(query_failed)?,
    300         row.try_get(5).map_err(query_failed)?,
    301         point,
    302     ))
    303 }
    304 
    305 fn map_country(row: &SqliteRow) -> Result<Country, Error> {
    306     let latitude = row.try_get::<f64, _>(2).map_err(query_failed)?;
    307     let longitude = row.try_get::<f64, _>(3).map_err(query_failed)?;
    308     let point = Point::new(latitude, longitude).map_err(|_| query_failed(()))?;
    309     Ok(Country::from_provider_row(
    310         row.try_get(0).map_err(query_failed)?,
    311         row.try_get(1).map_err(query_failed)?,
    312         point,
    313     ))
    314 }
    315 
    316 fn query_failed<T>(_source: T) -> Error {
    317     Error::DatabaseOperationFailed { operation: "query" }
    318 }
    319 
    320 struct ParsedQuery {
    321     locality: String,
    322     region: Option<String>,
    323     country: Option<String>,
    324 }
    325 
    326 fn parse_freeform_query(query: &str) -> ParsedQuery {
    327     let parts = query
    328         .split(',')
    329         .map(str::trim)
    330         .filter(|part| !part.is_empty())
    331         .collect::<Vec<_>>();
    332     match parts.as_slice() {
    333         [] => ParsedQuery {
    334             locality: String::new(),
    335             region: None,
    336             country: None,
    337         },
    338         [locality] => ParsedQuery {
    339             locality: (*locality).to_owned(),
    340             region: None,
    341             country: None,
    342         },
    343         [locality, region] => ParsedQuery {
    344             locality: (*locality).to_owned(),
    345             region: Some((*region).to_owned()),
    346             country: None,
    347         },
    348         parts => ParsedQuery {
    349             locality: parts[..parts.len() - 2].join(", "),
    350             region: Some(parts[parts.len() - 2].to_owned()),
    351             country: Some(parts[parts.len() - 1].to_owned()),
    352         },
    353     }
    354 }
    355 
    356 fn normalize_name(value: &str) -> String {
    357     value
    358         .split_whitespace()
    359         .collect::<Vec<_>>()
    360         .join(" ")
    361         .to_ascii_lowercase()
    362 }
    363 
    364 fn normalize_region_code(value: &str) -> String {
    365     value
    366         .chars()
    367         .filter(char::is_ascii_alphanumeric)
    368         .map(|character| character.to_ascii_uppercase())
    369         .collect()
    370 }
    371 
    372 fn country_matches(candidate: &Candidate, expected: &str) -> bool {
    373     normalize_name(candidate.country_id()) == expected
    374         || candidate
    375             .country_name()
    376             .is_some_and(|name| normalize_name(name) == expected)
    377 }
    378 
    379 fn region_matches(candidate: &Candidate, expected: &str) -> bool {
    380     if candidate
    381         .admin1_id()
    382         .is_some_and(|id| normalize_name(id) == expected)
    383         || candidate
    384             .admin1_name()
    385             .is_some_and(|name| normalize_name(name) == expected)
    386     {
    387         return true;
    388     }
    389     let expected_code = normalize_region_code(expected);
    390     region_aliases(candidate.country_id())
    391         .iter()
    392         .any(|(code, name)| {
    393             normalize_region_code(code) == expected_code
    394                 && candidate
    395                     .admin1_name()
    396                     .is_some_and(|admin_name| normalize_name(admin_name) == normalize_name(name))
    397         })
    398 }
    399 
    400 fn region_aliases(country_id: &str) -> &'static [(&'static str, &'static str)] {
    401     match country_id.to_ascii_uppercase().as_str() {
    402         "CA" => &[
    403             ("AB", "Alberta"),
    404             ("BC", "British Columbia"),
    405             ("MB", "Manitoba"),
    406             ("NB", "New Brunswick"),
    407             ("NL", "Newfoundland and Labrador"),
    408             ("NS", "Nova Scotia"),
    409             ("NT", "Northwest Territories"),
    410             ("NU", "Nunavut"),
    411             ("ON", "Ontario"),
    412             ("PE", "Prince Edward Island"),
    413             ("QC", "Quebec"),
    414             ("SK", "Saskatchewan"),
    415             ("YT", "Yukon"),
    416         ],
    417         "US" => &[
    418             ("AL", "Alabama"),
    419             ("AK", "Alaska"),
    420             ("AZ", "Arizona"),
    421             ("AR", "Arkansas"),
    422             ("CA", "California"),
    423             ("CO", "Colorado"),
    424             ("CT", "Connecticut"),
    425             ("DC", "District of Columbia"),
    426             ("DE", "Delaware"),
    427             ("FL", "Florida"),
    428             ("GA", "Georgia"),
    429             ("HI", "Hawaii"),
    430             ("ID", "Idaho"),
    431             ("IL", "Illinois"),
    432             ("IN", "Indiana"),
    433             ("IA", "Iowa"),
    434             ("KS", "Kansas"),
    435             ("KY", "Kentucky"),
    436             ("LA", "Louisiana"),
    437             ("ME", "Maine"),
    438             ("MD", "Maryland"),
    439             ("MA", "Massachusetts"),
    440             ("MI", "Michigan"),
    441             ("MN", "Minnesota"),
    442             ("MS", "Mississippi"),
    443             ("MO", "Missouri"),
    444             ("MT", "Montana"),
    445             ("NE", "Nebraska"),
    446             ("NV", "Nevada"),
    447             ("NH", "New Hampshire"),
    448             ("NJ", "New Jersey"),
    449             ("NM", "New Mexico"),
    450             ("NY", "New York"),
    451             ("NC", "North Carolina"),
    452             ("ND", "North Dakota"),
    453             ("OH", "Ohio"),
    454             ("OK", "Oklahoma"),
    455             ("OR", "Oregon"),
    456             ("PA", "Pennsylvania"),
    457             ("RI", "Rhode Island"),
    458             ("SC", "South Carolina"),
    459             ("SD", "South Dakota"),
    460             ("TN", "Tennessee"),
    461             ("TX", "Texas"),
    462             ("UT", "Utah"),
    463             ("VT", "Vermont"),
    464             ("VA", "Virginia"),
    465             ("WA", "Washington"),
    466             ("WV", "West Virginia"),
    467             ("WI", "Wisconsin"),
    468             ("WY", "Wyoming"),
    469         ],
    470         _ => &[],
    471     }
    472 }
    473 
    474 async fn configure_connection(connection: &mut SqliteConnection) -> Result<(), Error> {
    475     sqlx::query("PRAGMA query_only = ON")
    476         .execute(&mut *connection)
    477         .await
    478         .map_err(|_| Error::InvalidDatabase)?;
    479     sqlx::query("PRAGMA trusted_schema = OFF")
    480         .execute(&mut *connection)
    481         .await
    482         .map_err(|_| Error::InvalidDatabase)?;
    483     let query_only = sqlx::query_scalar::<_, i64>("PRAGMA query_only")
    484         .fetch_one(&mut *connection)
    485         .await
    486         .map_err(|_| Error::InvalidDatabase)?;
    487     let trusted_schema = sqlx::query_scalar::<_, i64>("PRAGMA trusted_schema")
    488         .fetch_one(connection)
    489         .await
    490         .map_err(|_| Error::InvalidDatabase)?;
    491     if query_only != 1 || trusted_schema != 0 {
    492         return Err(Error::InvalidDatabase);
    493     }
    494     Ok(())
    495 }
    496 
    497 async fn validate_integrity(connection: &mut SqliteConnection) -> Result<(), Error> {
    498     let rows = sqlx::query_scalar::<_, String>("PRAGMA quick_check(1)")
    499         .fetch_all(connection)
    500         .await
    501         .map_err(|_| Error::InvalidDatabase)?;
    502     if rows.len() != 1 || rows[0] != "ok" {
    503         return Err(Error::InvalidDatabase);
    504     }
    505     Ok(())
    506 }
    507 
    508 async fn validate_schema(connection: &mut SqliteConnection) -> Result<(), Error> {
    509     validate_table(connection, "geonames", REQUIRED_GEONAMES_COLUMNS).await?;
    510     validate_table(connection, "coordinates", REQUIRED_COORDINATE_COLUMNS).await
    511 }
    512 
    513 async fn validate_table(
    514     connection: &mut SqliteConnection,
    515     table: &str,
    516     required_columns: &[&str],
    517 ) -> Result<(), Error> {
    518     let table_exists = sqlx::query_scalar::<_, i64>(
    519         "SELECT 1 FROM sqlite_schema WHERE name = ?1 AND type = 'table' LIMIT 1",
    520     )
    521     .bind(table)
    522     .fetch_optional(&mut *connection)
    523     .await
    524     .map_err(|_| Error::InvalidDatabaseSchema)?;
    525     if table_exists != Some(1) {
    526         return Err(Error::InvalidDatabaseSchema);
    527     }
    528 
    529     let columns = sqlx::query_scalar::<_, String>("SELECT name FROM pragma_table_info(?1)")
    530         .bind(table)
    531         .fetch_all(connection)
    532         .await
    533         .map_err(|_| Error::InvalidDatabaseSchema)?;
    534     let columns = columns.into_iter().collect::<BTreeSet<_>>();
    535     if required_columns
    536         .iter()
    537         .any(|column| !columns.contains(*column))
    538     {
    539         return Err(Error::InvalidDatabaseSchema);
    540     }
    541     Ok(())
    542 }
    543 
    544 #[cfg(test)]
    545 mod tests {
    546     use std::fs;
    547 
    548     use sha2::{Digest, Sha256};
    549     use sqlx::{ConnectOptions, Connection as _, SqliteConnection, sqlite::SqliteConnectOptions};
    550     use tempfile::{TempDir, tempdir};
    551 
    552     use super::{
    553         Geocoder, country_matches, normalize_name, normalize_region_code, parse_freeform_query,
    554         region_aliases, region_matches,
    555     };
    556     use crate::{AssetSpec, Candidate, Error, Point};
    557 
    558     async fn database_fixture(schema: &str) -> (TempDir, std::path::PathBuf, AssetSpec) {
    559         let directory = tempdir().expect("tempdir");
    560         let path = directory.path().join("geonames-test.db");
    561         let options = SqliteConnectOptions::new()
    562             .filename(&path)
    563             .create_if_missing(true)
    564             .disable_statement_logging();
    565         let mut connection = SqliteConnection::connect_with(&options)
    566             .await
    567             .expect("create fixture database");
    568         sqlx::raw_sql(sqlx::AssertSqlSafe(schema))
    569             .execute(&mut connection)
    570             .await
    571             .expect("install fixture schema");
    572         connection.close().await.expect("close fixture writer");
    573         let bytes = fs::read(&path).expect("read fixture");
    574         let spec = AssetSpec::new(
    575             "test-v1",
    576             "geonames-test.db",
    577             "https://assets.example/geonames-test.db",
    578             "assets.example",
    579             u64::try_from(bytes.len()).expect("fixture length"),
    580             Sha256::digest(&bytes).into(),
    581         )
    582         .expect("fixture spec");
    583         (directory, path, spec)
    584     }
    585 
    586     fn governed_schema() -> &'static str {
    587         "
    588         CREATE TABLE geonames (
    589             id INTEGER PRIMARY KEY,
    590             name TEXT NOT NULL,
    591             admin1_id,
    592             admin1_name TEXT,
    593             country_id TEXT NOT NULL,
    594             country_name TEXT,
    595             latitude REAL NOT NULL,
    596             longitude REAL NOT NULL
    597         );
    598         CREATE TABLE coordinates (
    599             feature_id INTEGER PRIMARY KEY,
    600             latitude REAL NOT NULL,
    601             longitude REAL NOT NULL
    602         );
    603         INSERT INTO geonames VALUES
    604             (6174041, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656),
    605             (5815135, 'Victoria', 'WA', 'Washington', 'US', 'United States', 48.1000, -122.8000),
    606             (10, 'Twin A', 'BC', 'British Columbia', 'CA', 'Canada', 49.0000, -124.0000),
    607             (11, 'Twin B', 'BC', 'British Columbia', 'CA', 'Canada', 49.0000, -124.0000),
    608             (20, 'Date East', NULL, NULL, 'FJ', 'Fiji', 0.0000, 179.9000),
    609             (21, 'Date West', NULL, NULL, 'FJ', 'Fiji', 0.0000, -179.9000),
    610             (30, 'Pole Prime', NULL, NULL, 'AQ', 'Antarctica', 89.9000, 0.0000),
    611             (31, 'Pole East', NULL, NULL, 'AQ', 'Antarctica', 89.9000, 120.0000);
    612         INSERT INTO coordinates VALUES
    613             (6174041, 48.4284, -123.3656),
    614             (5815135, 48.1000, -122.8000),
    615             (10, 49.0000, -124.0000),
    616             (11, 49.0000, -124.0000),
    617             (20, 0.0000, 179.9000),
    618             (21, 0.0000, -179.9000),
    619             (30, 89.9000, 0.0000),
    620             (31, 89.9000, 120.0000);
    621         "
    622     }
    623 
    624     #[tokio::test(flavor = "current_thread")]
    625     async fn verified_governed_database_opens_read_only_and_closes_explicitly() {
    626         let (_directory, path, spec) = database_fixture(governed_schema()).await;
    627         let geocoder = Geocoder::open(path.clone(), &spec)
    628             .await
    629             .expect("open verified database");
    630         let mut connection = geocoder.connection.lock().await;
    631         let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM geonames")
    632             .fetch_one(&mut *connection)
    633             .await
    634             .expect("query fixture");
    635         assert_eq!(count, 8);
    636         let query_only = sqlx::query_scalar::<_, i64>("PRAGMA query_only")
    637             .fetch_one(&mut *connection)
    638             .await
    639             .expect("read query-only policy");
    640         let trusted_schema = sqlx::query_scalar::<_, i64>("PRAGMA trusted_schema")
    641             .fetch_one(&mut *connection)
    642             .await
    643             .expect("read trusted-schema policy");
    644         assert_eq!((query_only, trusted_schema), (1, 0));
    645         assert!(
    646             sqlx::query("DELETE FROM geonames")
    647                 .execute(&mut *connection)
    648                 .await
    649                 .is_err()
    650         );
    651         drop(connection);
    652         geocoder.close().await.expect("explicit close");
    653     }
    654 
    655     #[tokio::test(flavor = "current_thread")]
    656     async fn forward_and_feature_queries_preserve_text_ids_and_stable_order() {
    657         let (_directory, path, spec) = database_fixture(governed_schema()).await;
    658         let geocoder = Geocoder::open(path, &spec).await.expect("geocoder");
    659 
    660         let structured = crate::Query::locality("Victoria")
    661             .expect("locality")
    662             .with_region("BC")
    663             .expect("region")
    664             .with_country("Canada")
    665             .expect("country");
    666         let result = geocoder.query(&structured).await.expect("structured query");
    667         let candidates = result.as_candidates().expect("candidate result");
    668         assert_eq!(candidates.len(), 1);
    669         assert_eq!(candidates[0].feature_id(), 6_174_041);
    670         assert_eq!(candidates[0].admin1_id(), Some("2"));
    671         assert_eq!(
    672             candidates[0].display_name(),
    673             "Victoria, British Columbia, Canada"
    674         );
    675 
    676         let freeform = crate::Query::freeform("Victoria, BC, CA").expect("freeform");
    677         assert_eq!(
    678             geocoder
    679                 .query(&freeform)
    680                 .await
    681                 .expect("freeform query")
    682                 .as_candidates()
    683                 .expect("candidates")[0]
    684                 .feature_id(),
    685             6_174_041
    686         );
    687 
    688         let ambiguous = crate::Query::locality("Victoria")
    689             .expect("locality")
    690             .with_limit(2)
    691             .expect("limit");
    692         let candidates = geocoder
    693             .query(&ambiguous)
    694             .await
    695             .expect("ambiguous query")
    696             .as_candidates()
    697             .expect("candidates")
    698             .to_vec();
    699         assert_eq!(
    700             candidates
    701                 .iter()
    702                 .map(|candidate| candidate.country_id())
    703                 .collect::<Vec<_>>(),
    704             vec!["CA", "US"]
    705         );
    706 
    707         let feature = crate::Query::feature_id(5_815_135).expect("feature query");
    708         assert_eq!(
    709             geocoder
    710                 .query(&feature)
    711                 .await
    712                 .expect("feature result")
    713                 .as_candidates()
    714                 .expect("candidates")[0]
    715                 .admin1_id(),
    716             Some("WA")
    717         );
    718     }
    719 
    720     #[tokio::test(flavor = "current_thread")]
    721     async fn reverse_and_country_queries_are_bounded_and_deterministic() {
    722         let (_directory, path, spec) = database_fixture(governed_schema()).await;
    723         let geocoder = Geocoder::open(path, &spec).await.expect("geocoder");
    724         let reverse = crate::Query::reverse(crate::Point::new(49.0, -124.0).expect("point"))
    725             .with_radius_degrees(0.1)
    726             .expect("radius")
    727             .with_limit(2)
    728             .expect("limit");
    729         let candidates = geocoder
    730             .query(&reverse)
    731             .await
    732             .expect("reverse result")
    733             .as_candidates()
    734             .expect("candidates")
    735             .to_vec();
    736         assert_eq!(
    737             candidates
    738                 .iter()
    739                 .map(|candidate| candidate.feature_id())
    740                 .collect::<Vec<_>>(),
    741             vec![10, 11]
    742         );
    743 
    744         let countries = geocoder
    745             .query(&crate::Query::countries())
    746             .await
    747             .expect("country result");
    748         let countries = countries.as_countries().expect("countries");
    749         assert_eq!(
    750             countries
    751                 .iter()
    752                 .map(|country| country.id())
    753                 .collect::<Vec<_>>(),
    754             vec!["AQ", "CA", "FJ", "US"]
    755         );
    756         assert_eq!(countries[1].name(), Some("Canada"));
    757         assert!(countries[0].center().latitude().is_finite());
    758 
    759         let dateline = crate::Query::reverse(crate::Point::new(0.0, 180.0).expect("point"))
    760             .with_radius_degrees(0.2)
    761             .expect("radius")
    762             .with_limit(2)
    763             .expect("limit");
    764         assert_eq!(
    765             geocoder
    766                 .query(&dateline)
    767                 .await
    768                 .expect("dateline result")
    769                 .as_candidates()
    770                 .expect("candidates")
    771                 .iter()
    772                 .map(|candidate| candidate.feature_id())
    773                 .collect::<Vec<_>>(),
    774             vec![20, 21]
    775         );
    776 
    777         let pole = crate::Query::reverse(crate::Point::new(90.0, 0.0).expect("point"))
    778             .with_radius_degrees(0.2)
    779             .expect("radius")
    780             .with_limit(2)
    781             .expect("limit");
    782         assert_eq!(
    783             geocoder
    784                 .query(&pole)
    785                 .await
    786                 .expect("pole result")
    787                 .as_candidates()
    788                 .expect("candidates")
    789                 .iter()
    790                 .map(|candidate| candidate.feature_id())
    791                 .collect::<Vec<_>>(),
    792             vec![30, 31]
    793         );
    794     }
    795 
    796     #[tokio::test(flavor = "current_thread")]
    797     async fn corrupt_bytes_and_incomplete_schema_fail_closed() {
    798         let directory = tempdir().expect("tempdir");
    799         let path = directory.path().join("geonames-test.db");
    800         fs::write(&path, b"not sqlite").expect("write corrupt fixture");
    801         let corrupt_spec = AssetSpec::new(
    802             "test-v1",
    803             "geonames-test.db",
    804             "https://assets.example/geonames-test.db",
    805             "assets.example",
    806             10,
    807             Sha256::digest(b"not sqlite").into(),
    808         )
    809         .expect("corrupt spec");
    810         assert!(matches!(
    811             Geocoder::open(&path, &corrupt_spec).await,
    812             Err(Error::InvalidDatabase)
    813         ));
    814 
    815         let (_directory, path, spec) =
    816             database_fixture("CREATE TABLE geonames (id INTEGER);").await;
    817         assert!(matches!(
    818             Geocoder::open(path, &spec).await,
    819             Err(Error::InvalidDatabaseSchema)
    820         ));
    821     }
    822 
    823     #[cfg(unix)]
    824     #[tokio::test(flavor = "current_thread")]
    825     async fn verified_database_open_rejects_symlink_assets() {
    826         use std::os::unix::fs::symlink;
    827 
    828         let (directory, path, spec) = database_fixture(governed_schema()).await;
    829         let link = directory.path().join("linked.db");
    830         symlink(path, &link).expect("asset symlink");
    831         assert!(matches!(
    832             Geocoder::open(link, &spec).await,
    833             Err(Error::UnsafeAssetDestination)
    834         ));
    835     }
    836 
    837     #[test]
    838     fn parsing_and_filter_helpers_cover_direct_alias_and_no_match_paths() {
    839         let point = Point::new(1.0, 2.0).expect("point");
    840         let washington = Candidate::from_provider_row(
    841             1,
    842             "Victoria".to_owned(),
    843             Some("WA".to_owned()),
    844             Some("Washington".to_owned()),
    845             "US".to_owned(),
    846             Some("United States".to_owned()),
    847             point,
    848         );
    849         assert!(country_matches(&washington, "us"));
    850         assert!(country_matches(&washington, "united states"));
    851         assert!(!country_matches(&washington, "canada"));
    852         assert!(region_matches(&washington, "wa"));
    853         assert!(region_matches(&washington, "washington"));
    854 
    855         let legacy_washington = Candidate::from_provider_row(
    856             2,
    857             "Legacy".to_owned(),
    858             Some("53".to_owned()),
    859             Some("Washington".to_owned()),
    860             "US".to_owned(),
    861             None,
    862             point,
    863         );
    864         assert!(region_matches(&legacy_washington, "wa"));
    865         assert!(!country_matches(&legacy_washington, "canada"));
    866 
    867         let unclassified = Candidate::from_provider_row(
    868             3,
    869             "Island".to_owned(),
    870             None,
    871             None,
    872             "FJ".to_owned(),
    873             None,
    874             point,
    875         );
    876         assert!(!region_matches(&unclassified, "unknown"));
    877         assert!(region_aliases("FJ").is_empty());
    878         assert!(!region_aliases("CA").is_empty());
    879         assert!(!region_aliases("us").is_empty());
    880         assert_eq!(normalize_name("  New   York  "), "new york");
    881         assert_eq!(normalize_region_code("b.c."), "BC");
    882 
    883         let empty = parse_freeform_query(", ,");
    884         assert!(empty.locality.is_empty());
    885         let one = parse_freeform_query("Victoria");
    886         assert_eq!(one.locality, "Victoria");
    887         let two = parse_freeform_query("Victoria, BC");
    888         assert_eq!(two.region.as_deref(), Some("BC"));
    889         assert_eq!(two.country, None);
    890         let many = parse_freeform_query("Greater, Victoria, BC, CA");
    891         assert_eq!(many.locality, "Greater, Victoria");
    892         assert_eq!(many.country.as_deref(), Some("CA"));
    893     }
    894 
    895     #[tokio::test(flavor = "current_thread")]
    896     async fn database_open_and_row_mapping_fail_closed_for_invalid_shapes() {
    897         let directory = tempdir().expect("tempdir");
    898         let placeholder = AssetSpec::new(
    899             "v1",
    900             "asset.db",
    901             "https://assets.example/a",
    902             "assets.example",
    903             1,
    904             [0; 32],
    905         )
    906         .expect("placeholder spec");
    907         assert!(matches!(
    908             Geocoder::open(directory.path(), &placeholder).await,
    909             Err(Error::UnsafeAssetDestination)
    910         ));
    911 
    912         let invalid_row_schema = governed_schema().replace(
    913             "(6174041, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656)",
    914             "(-1, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656)",
    915         );
    916         let (_directory, path, spec) = database_fixture(&invalid_row_schema).await;
    917         let geocoder = Geocoder::open(path, &spec)
    918             .await
    919             .expect("open negative-id fixture");
    920         let query = crate::Query::locality("Victoria").expect("query");
    921         assert!(matches!(
    922             geocoder.query(&query).await,
    923             Err(Error::DatabaseOperationFailed { operation: "query" })
    924         ));
    925     }
    926 }