lib

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

geocoder.rs (15365B)


      1 use radroots_geocoder::{
      2     Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderPoint, GeocoderReverseOptions,
      3 };
      4 use rusqlite::Connection;
      5 use std::fs;
      6 use std::path::Path;
      7 use tempfile::NamedTempFile;
      8 
      9 #[test]
     10 fn reverse_returns_nearest_match_by_default() {
     11     let geocoder = open_fixture_geocoder();
     12 
     13     let results = geocoder
     14         .reverse(
     15             GeocoderPoint {
     16                 lat: 37.7749,
     17                 lng: -122.4194,
     18             },
     19             None,
     20         )
     21         .expect("reverse query");
     22 
     23     assert_eq!(results.len(), 1);
     24     assert_eq!(results[0].id, 1);
     25     assert_eq!(results[0].name, "San Francisco");
     26     assert_eq!(results[0].country_id, "US");
     27     assert_eq!(results[0].admin1_id, Some(6));
     28     assert_eq!(results[0].admin1_name.as_deref(), Some("California"));
     29 }
     30 
     31 #[test]
     32 fn reverse_respects_limit_and_returns_sorted_matches() {
     33     let geocoder = open_fixture_geocoder();
     34 
     35     let results = geocoder
     36         .reverse(
     37             GeocoderPoint {
     38                 lat: 37.7749,
     39                 lng: -122.4194,
     40             },
     41             Some(GeocoderReverseOptions {
     42                 limit: 2,
     43                 degree_offset: 10.0,
     44             }),
     45         )
     46         .expect("reverse query");
     47 
     48     assert_eq!(results.len(), 2);
     49     assert_eq!(results[0].id, 1);
     50     assert_eq!(results[1].id, 2);
     51 }
     52 
     53 #[test]
     54 fn reverse_orders_high_latitude_results_by_scaled_longitude_distance() {
     55     let geocoder = open_high_latitude_geocoder();
     56 
     57     let results = geocoder
     58         .reverse(
     59             GeocoderPoint {
     60                 lat: 75.0,
     61                 lng: 0.0,
     62             },
     63             Some(GeocoderReverseOptions {
     64                 limit: 2,
     65                 degree_offset: 1.0,
     66             }),
     67         )
     68         .expect("reverse query");
     69 
     70     assert_eq!(results.len(), 2);
     71     assert_eq!(results[0].id, 1);
     72     assert_eq!(results[0].name, "Polar East");
     73     assert_eq!(results[1].id, 2);
     74     assert_eq!(results[1].name, "Polar North");
     75 }
     76 
     77 #[test]
     78 fn open_bytes_supports_reverse_queries() {
     79     let path = build_fixture_database();
     80     let bytes = fs::read(&path).expect("read fixture database bytes");
     81     let geocoder = Geocoder::open_bytes(&bytes).expect("open byte-backed geocoder");
     82 
     83     let results = geocoder
     84         .reverse(
     85             GeocoderPoint {
     86                 lat: 34.0522,
     87                 lng: -118.2437,
     88             },
     89             None,
     90         )
     91         .expect("reverse query");
     92 
     93     assert_eq!(results.len(), 1);
     94     assert_eq!(results[0].id, 2);
     95     assert_eq!(results[0].name, "Los Angeles");
     96 }
     97 
     98 #[test]
     99 fn open_path_supports_string_and_path_ref_inputs() {
    100     let path = build_fixture_database();
    101     let path_str = path.to_str().expect("utf-8 fixture path");
    102 
    103     let geocoder_from_str = Geocoder::open_path(path_str).expect("open geocoder from string path");
    104     let string_results = geocoder_from_str
    105         .country("US")
    106         .expect("country query from string-path geocoder");
    107     assert_eq!(string_results.len(), 3);
    108 
    109     let geocoder_from_path =
    110         Geocoder::open_path(Path::new(path_str)).expect("open geocoder from path ref");
    111     let path_results = geocoder_from_path
    112         .country("US")
    113         .expect("country query from path-ref geocoder");
    114     assert_eq!(path_results.len(), 3);
    115 }
    116 
    117 #[test]
    118 fn open_path_supports_pathbuf_inputs() {
    119     let temp_path = build_fixture_database();
    120     let path = temp_path.to_path_buf();
    121 
    122     let geocoder_from_pathbuf =
    123         Geocoder::open_path(path.clone()).expect("open geocoder from pathbuf");
    124     let pathbuf_results = geocoder_from_pathbuf
    125         .country("US")
    126         .expect("country query from pathbuf geocoder");
    127     assert_eq!(pathbuf_results.len(), 3);
    128 
    129     let geocoder_from_pathbuf_ref =
    130         Geocoder::open_path(&path).expect("open geocoder from pathbuf ref");
    131     let pathbuf_ref_results = geocoder_from_pathbuf_ref
    132         .country("US")
    133         .expect("country query from pathbuf-ref geocoder");
    134     assert_eq!(pathbuf_ref_results.len(), 3);
    135 }
    136 
    137 #[test]
    138 fn country_returns_all_rows_for_requested_country() {
    139     let geocoder = open_fixture_geocoder();
    140 
    141     let results = geocoder.country("US").expect("country query");
    142 
    143     assert_eq!(results.len(), 3);
    144     assert!(results.iter().all(|result| result.country_id == "US"));
    145 }
    146 
    147 #[test]
    148 fn country_list_returns_average_centers() {
    149     let geocoder = open_fixture_geocoder();
    150 
    151     let results = geocoder.country_list().expect("country list query");
    152 
    153     assert_eq!(results.len(), 2);
    154     assert_eq!(
    155         results[0],
    156         GeocoderCountryListResult {
    157             country_id: "BR".to_owned(),
    158             country: Some("Brazil".to_owned()),
    159             lat: -23.5505,
    160             lng: -46.6333,
    161         }
    162     );
    163     assert_eq!(results[1].country_id, "US");
    164     assert_eq!(results[1].country.as_deref(), Some("United States"));
    165     assert!(approx_eq(
    166         results[1].lat,
    167         (37.7749 + 34.0522 + 40.7128) / 3.0
    168     ));
    169     assert!(approx_eq(
    170         results[1].lng,
    171         (-122.4194 + -118.2437 + -74.0060) / 3.0
    172     ));
    173 }
    174 
    175 #[test]
    176 fn country_center_returns_average_for_country() {
    177     let geocoder = open_fixture_geocoder();
    178 
    179     let result = geocoder.country_center("US").expect("country center query");
    180 
    181     assert!(approx_eq(result.lat, (37.7749 + 34.0522 + 40.7128) / 3.0));
    182     assert!(approx_eq(
    183         result.lng,
    184         (-122.4194 + -118.2437 + -74.0060) / 3.0
    185     ));
    186 }
    187 
    188 #[test]
    189 fn country_center_returns_not_found_for_missing_country() {
    190     let geocoder = open_fixture_geocoder();
    191 
    192     let err = geocoder
    193         .country_center("ZZ")
    194         .expect_err("missing country should return not found");
    195     assert_country_center_not_found(err, "ZZ");
    196 }
    197 
    198 #[test]
    199 fn reverse_country_and_country_list_report_missing_schema_errors() {
    200     let geocoder = open_empty_geocoder();
    201 
    202     let reverse_err = geocoder
    203         .reverse(
    204             GeocoderPoint {
    205                 lat: 37.7749,
    206                 lng: -122.4194,
    207             },
    208             None,
    209         )
    210         .expect_err("reverse should fail without schema");
    211     assert_sqlite_error_contains(reverse_err, "no such");
    212 
    213     let country_err = geocoder
    214         .country("US")
    215         .expect_err("country should fail without schema");
    216     assert_sqlite_error_contains(country_err, "no such");
    217 
    218     let country_list_err = geocoder
    219         .country_list()
    220         .expect_err("country_list should fail without schema");
    221     assert_sqlite_error_contains(country_list_err, "no such");
    222 }
    223 
    224 #[test]
    225 fn country_center_reports_missing_schema_errors() {
    226     let geocoder = open_empty_geocoder();
    227 
    228     let err = geocoder
    229         .country_center("US")
    230         .expect_err("country_center should fail without schema");
    231     assert_sqlite_error_contains(err, "no such");
    232 }
    233 
    234 #[test]
    235 fn reverse_and_country_propagate_row_mapping_errors() {
    236     let geocoder = open_reverse_country_row_error_geocoder();
    237 
    238     let reverse_err = geocoder
    239         .reverse(
    240             GeocoderPoint {
    241                 lat: 37.7749,
    242                 lng: -122.4194,
    243             },
    244             Some(GeocoderReverseOptions {
    245                 limit: 1,
    246                 degree_offset: 10.0,
    247             }),
    248         )
    249         .expect_err("reverse should fail on invalid row mapping");
    250     assert_sqlite_error_contains(reverse_err, "Invalid column type");
    251 
    252     let country_err = geocoder
    253         .country("US")
    254         .expect_err("country should fail on invalid row mapping");
    255     assert_sqlite_error_contains(country_err, "Invalid column type");
    256 }
    257 
    258 #[test]
    259 fn country_list_propagates_aggregate_row_mapping_errors() {
    260     let geocoder = open_country_list_row_error_geocoder();
    261 
    262     let err = geocoder
    263         .country_list()
    264         .expect_err("country_list should fail on null aggregate row");
    265     assert_sqlite_error_contains(err, "Invalid column type");
    266 }
    267 
    268 fn open_fixture_geocoder() -> Geocoder {
    269     let path = build_fixture_database();
    270     Geocoder::open_path(&path).expect("open geocoder")
    271 }
    272 
    273 fn open_high_latitude_geocoder() -> Geocoder {
    274     let path = build_high_latitude_database();
    275     Geocoder::open_path(&path).expect("open geocoder")
    276 }
    277 
    278 fn open_empty_geocoder() -> Geocoder {
    279     let temp = NamedTempFile::new().expect("temp db");
    280     let path = temp.into_temp_path();
    281     Geocoder::open_path(&path).expect("open empty geocoder")
    282 }
    283 
    284 fn open_reverse_country_row_error_geocoder() -> Geocoder {
    285     let temp = NamedTempFile::new().expect("temp db");
    286     let path = temp.into_temp_path();
    287     seed_reverse_country_row_error_database(path.to_str().expect("utf-8 temp path"));
    288     Geocoder::open_path(&path).expect("open invalid row geocoder")
    289 }
    290 
    291 fn open_country_list_row_error_geocoder() -> Geocoder {
    292     let temp = NamedTempFile::new().expect("temp db");
    293     let path = temp.into_temp_path();
    294     seed_country_list_row_error_database(path.to_str().expect("utf-8 temp path"));
    295     Geocoder::open_path(&path).expect("open aggregate error geocoder")
    296 }
    297 
    298 fn build_fixture_database() -> tempfile::TempPath {
    299     let temp = NamedTempFile::new().expect("temp db");
    300     let path = temp.into_temp_path();
    301     seed_fixture_database(path.to_str().expect("utf-8 temp path"));
    302     path
    303 }
    304 
    305 fn build_high_latitude_database() -> tempfile::TempPath {
    306     let temp = NamedTempFile::new().expect("temp db");
    307     let path = temp.into_temp_path();
    308     seed_high_latitude_database(path.to_str().expect("utf-8 temp path"));
    309     path
    310 }
    311 
    312 fn seed_fixture_database(path: &str) {
    313     let conn = Connection::open(path).expect("open fixture database");
    314     seed_schema(&conn);
    315 
    316     insert_country(&conn, "US", "United States");
    317     insert_country(&conn, "BR", "Brazil");
    318 
    319     insert_admin1(&conn, "US", 6, "California");
    320     insert_admin1(&conn, "US", 36, "New York");
    321     insert_admin1(&conn, "BR", 27, "Sao Paulo");
    322 
    323     insert_feature(&conn, 1, "San Francisco", "US", 6, 37.7749, -122.4194);
    324     insert_feature(&conn, 2, "Los Angeles", "US", 6, 34.0522, -118.2437);
    325     insert_feature(&conn, 3, "New York City", "US", 36, 40.7128, -74.0060);
    326     insert_feature(&conn, 4, "Sao Paulo", "BR", 27, -23.5505, -46.6333);
    327 }
    328 
    329 fn seed_high_latitude_database(path: &str) {
    330     let conn = Connection::open(path).expect("open fixture database");
    331     seed_schema(&conn);
    332 
    333     insert_country(&conn, "NO", "Norway");
    334     insert_admin1(&conn, "NO", 1, "Nord");
    335 
    336     insert_feature(&conn, 1, "Polar East", "NO", 1, 75.02, 0.10);
    337     insert_feature(&conn, 2, "Polar North", "NO", 1, 75.05, 0.05);
    338 }
    339 
    340 fn seed_reverse_country_row_error_database(path: &str) {
    341     let conn = Connection::open(path).expect("open invalid row fixture database");
    342     conn.execute_batch(
    343         r#"
    344         CREATE TABLE geonames(
    345           id INTEGER,
    346           name TEXT,
    347           admin1_id INTEGER,
    348           admin1_name TEXT,
    349           country_id TEXT,
    350           country_name TEXT,
    351           latitude REAL,
    352           longitude REAL
    353         );
    354         CREATE TABLE coordinates(
    355           feature_id INTEGER,
    356           latitude REAL,
    357           longitude REAL
    358         );
    359         "#,
    360     )
    361     .expect("create invalid row schema");
    362     conn.execute(
    363         "INSERT INTO geonames (id, name, admin1_id, admin1_name, country_id, country_name, latitude, longitude) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
    364         rusqlite::params![1_i64, Option::<String>::None, Option::<i64>::None, Option::<String>::None, "US", "United States", 37.7749_f64, -122.4194_f64],
    365     )
    366     .expect("insert invalid reverse/country row");
    367     conn.execute(
    368         "INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (?1, ?2, ?3)",
    369         (1_i64, 37.7749_f64, -122.4194_f64),
    370     )
    371     .expect("insert invalid reverse/country coordinate");
    372 }
    373 
    374 fn seed_country_list_row_error_database(path: &str) {
    375     let conn = Connection::open(path).expect("open aggregate error fixture database");
    376     conn.execute_batch(
    377         r#"
    378         CREATE TABLE geonames(
    379           country_id TEXT,
    380           country_name TEXT,
    381           latitude REAL,
    382           longitude REAL
    383         );
    384         "#,
    385     )
    386     .expect("create aggregate error schema");
    387     conn.execute(
    388         "INSERT INTO geonames (country_id, country_name, latitude, longitude) VALUES (?1, ?2, ?3, ?4)",
    389         rusqlite::params!["US", "United States", Option::<f64>::None, Option::<f64>::None],
    390     )
    391     .expect("insert aggregate error row");
    392 }
    393 
    394 fn seed_schema(conn: &Connection) {
    395     conn.execute_batch(
    396         r#"
    397         CREATE TABLE countries(
    398           id TEXT,
    399           name TEXT,
    400           PRIMARY KEY (id)
    401         );
    402         CREATE TABLE admin1(
    403           country_id TEXT,
    404           id INTEGER,
    405           name TEXT,
    406           PRIMARY KEY (country_id, id)
    407         );
    408         CREATE TABLE features(
    409           id INTEGER,
    410           name TEXT,
    411           country_id TEXT,
    412           admin1_id INTEGER,
    413           PRIMARY KEY (id)
    414         );
    415         CREATE TABLE coordinates(
    416           feature_id INTEGER,
    417           latitude REAL,
    418           longitude REAL,
    419           PRIMARY KEY (feature_id)
    420         );
    421         CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude);
    422         CREATE VIEW geonames AS
    423           SELECT
    424             features.id,
    425             features.name,
    426             admin1.id AS admin1_id,
    427             admin1.name AS admin1_name,
    428             countries.id AS country_id,
    429             countries.name AS country_name,
    430             coordinates.latitude AS latitude,
    431             coordinates.longitude AS longitude
    432           FROM features
    433             LEFT JOIN countries ON features.country_id = countries.id
    434             LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id
    435             JOIN coordinates ON features.id = coordinates.feature_id;
    436         "#,
    437     )
    438     .expect("create fixture schema");
    439 }
    440 
    441 fn insert_country(conn: &Connection, id: &str, name: &str) {
    442     conn.execute(
    443         "INSERT INTO countries (id, name) VALUES (?1, ?2)",
    444         (id, name),
    445     )
    446     .expect("insert country");
    447 }
    448 
    449 fn insert_admin1(conn: &Connection, country_id: &str, id: i64, name: &str) {
    450     conn.execute(
    451         "INSERT INTO admin1 (country_id, id, name) VALUES (?1, ?2, ?3)",
    452         (country_id, id, name),
    453     )
    454     .expect("insert admin1");
    455 }
    456 
    457 fn insert_feature(
    458     conn: &Connection,
    459     id: i64,
    460     name: &str,
    461     country_id: &str,
    462     admin1_id: i64,
    463     latitude: f64,
    464     longitude: f64,
    465 ) {
    466     conn.execute(
    467         "INSERT INTO features (id, name, country_id, admin1_id) VALUES (?1, ?2, ?3, ?4)",
    468         (id, name, country_id, admin1_id),
    469     )
    470     .expect("insert feature");
    471     conn.execute(
    472         "INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (?1, ?2, ?3)",
    473         (id, latitude, longitude),
    474     )
    475     .expect("insert coordinate");
    476 }
    477 
    478 fn approx_eq(left: f64, right: f64) -> bool {
    479     (left - right).abs() < 0.000_001
    480 }
    481 
    482 fn assert_sqlite_error_contains(err: GeocoderError, needle: &str) {
    483     match err {
    484         GeocoderError::Sqlite(inner) => assert!(
    485             inner.to_string().contains(needle),
    486             "expected sqlite error containing {needle:?}, got {inner}"
    487         ),
    488         other => panic!("expected sqlite error, got {other}"),
    489     }
    490 }
    491 
    492 fn assert_country_center_not_found(err: GeocoderError, country_id: &str) {
    493     match err {
    494         GeocoderError::CountryCenterNotFound { country_id: actual } => {
    495             assert_eq!(actual, country_id);
    496         }
    497         other => panic!("expected CountryCenterNotFound, got {other}"),
    498     }
    499 }