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 }