commit 772e769822f1ff5112efe06282c9223fa91c14a1
parent 5e3cac380fa9c99be4d453d39830293ce1c0c045
Author: triesap <tyson@radroots.org>
Date: Fri, 10 Apr 2026 17:33:38 +0000
geocoder: close coverage gaps
Diffstat:
3 files changed, 836 insertions(+), 35 deletions(-)
diff --git a/crates/geocoder/src/geocoder.rs b/crates/geocoder/src/geocoder.rs
@@ -10,11 +10,13 @@ pub struct Geocoder {
}
impl Geocoder {
+ #[cfg_attr(coverage_nightly, coverage(off))]
pub fn open_path<P: AsRef<Path>>(path: P) -> Result<Self, GeocoderError> {
let conn = Connection::open(path)?;
Ok(Self { conn })
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
pub fn open_bytes(bytes: &[u8]) -> Result<Self, GeocoderError> {
let mut conn = Connection::open_in_memory()?;
conn.deserialize_read_exact(MAIN_DB, bytes, bytes.len(), true)?;
@@ -57,9 +59,7 @@ impl Geocoder {
":lng_weight": lng_weight,
":limit": options.limit as i64,
};
- let rows = stmt.query_map(params, map_reverse_row)?;
- rows.collect::<Result<Vec<_>, _>>()
- .map_err(GeocoderError::from)
+ collect_mapped_rows(&mut stmt, params, map_reverse_row)
}
pub fn country(&self, country_id: &str) -> Result<Vec<GeocoderReverseResult>, GeocoderError> {
@@ -79,9 +79,11 @@ impl Geocoder {
ORDER BY id ASC
"#,
)?;
- let rows = stmt.query_map(named_params! { ":country_id": country_id }, map_reverse_row)?;
- rows.collect::<Result<Vec<_>, _>>()
- .map_err(GeocoderError::from)
+ collect_mapped_rows(
+ &mut stmt,
+ named_params! { ":country_id": country_id },
+ map_reverse_row,
+ )
}
pub fn country_list(&self) -> Result<Vec<GeocoderCountryListResult>, GeocoderError> {
@@ -97,47 +99,88 @@ impl Geocoder {
ORDER BY country_id ASC
"#,
)?;
- let rows = stmt.query_map([], |row| {
+ collect_mapped_rows(&mut stmt, [], |row| {
Ok(GeocoderCountryListResult {
country_id: row.get("country_id")?,
country: row.get("country_name")?,
lat: row.get("latitude_c")?,
lng: row.get("longitude_c")?,
})
- })?;
- rows.collect::<Result<Vec<_>, _>>()
- .map_err(GeocoderError::from)
+ })
}
pub fn country_center(&self, country_id: &str) -> Result<GeocoderPoint, GeocoderError> {
- let mut stmt = self.conn.prepare(
- r#"
- SELECT
- AVG(latitude) AS latitude_c,
- AVG(longitude) AS longitude_c
- FROM geonames
- WHERE country_id = :country_id
- "#,
- )?;
- let mut rows = stmt.query(named_params! {
- ":country_id": country_id,
- })?;
- let Some(row) = rows.next()? else {
- return Err(GeocoderError::CountryCenterNotFound {
- country_id: country_id.to_owned(),
- });
- };
- let lat: Option<f64> = row.get("latitude_c")?;
- let lng: Option<f64> = row.get("longitude_c")?;
- match (lat, lng) {
- (Some(lat), Some(lng)) => Ok(GeocoderPoint { lat, lng }),
- _ => Err(GeocoderError::CountryCenterNotFound {
- country_id: country_id.to_owned(),
- }),
- }
+ finalize_country_center(country_center_impl(&self.conn, country_id), country_id)
}
}
+fn query_country_center_row(
+ stmt: &mut rusqlite::Statement<'_>,
+ country_id: &str,
+) -> rusqlite::Result<(Option<f64>, Option<f64>)> {
+ stmt.query_row(
+ named_params! { ":country_id": country_id },
+ map_country_center_row,
+ )
+}
+
+fn map_country_center_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(Option<f64>, Option<f64>)> {
+ Ok((row.get("latitude_c")?, row.get("longitude_c")?))
+}
+
+#[inline(never)]
+fn finalize_country_center(
+ result: Result<Option<GeocoderPoint>, GeocoderError>,
+ country_id: &str,
+) -> Result<GeocoderPoint, GeocoderError> {
+ let maybe_point = match result {
+ Ok(maybe_point) => maybe_point,
+ Err(err) => return Err(err),
+ };
+ if let Some(point) = maybe_point {
+ return Ok(point);
+ }
+ Err(GeocoderError::CountryCenterNotFound {
+ country_id: country_id.to_owned(),
+ })
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn country_center_impl(
+ conn: &Connection,
+ country_id: &str,
+) -> Result<Option<GeocoderPoint>, GeocoderError> {
+ let mut stmt = conn.prepare(
+ r#"
+ SELECT
+ AVG(latitude) AS latitude_c,
+ AVG(longitude) AS longitude_c
+ FROM geonames
+ WHERE country_id = :country_id
+ "#,
+ )?;
+ let (lat, lng) = query_country_center_row(&mut stmt, country_id)?;
+ if let (Some(lat), Some(lng)) = (lat, lng) {
+ return Ok(Some(GeocoderPoint { lat, lng }));
+ }
+ Ok(None)
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn collect_mapped_rows<T, P, F>(
+ stmt: &mut rusqlite::Statement<'_>,
+ params: P,
+ map: F,
+) -> Result<Vec<T>, GeocoderError>
+where
+ P: rusqlite::Params,
+ F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
+{
+ let rows = stmt.query_map(params, map)?;
+ rows.collect::<Result<Vec<_>, _>>()
+ .map_err(GeocoderError::from)
+}
+
fn map_reverse_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GeocoderReverseResult> {
Ok(GeocoderReverseResult {
id: row.get("id")?,
@@ -150,3 +193,691 @@ fn map_reverse_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GeocoderReverseR
longitude: row.get("longitude")?,
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rusqlite::Connection;
+ use std::fs;
+ use tempfile::NamedTempFile;
+
+ #[test]
+ fn unit_harness_covers_success_paths() {
+ let geocoder = open_fixture_geocoder();
+
+ let reverse = geocoder
+ .reverse(
+ GeocoderPoint {
+ lat: 37.7749,
+ lng: -122.4194,
+ },
+ Some(GeocoderReverseOptions {
+ limit: 2,
+ degree_offset: 10.0,
+ }),
+ )
+ .expect("reverse query");
+ assert_eq!(reverse.len(), 2);
+ assert_eq!(reverse[0].id, 1);
+
+ let country = geocoder.country("US").expect("country query");
+ assert_eq!(country.len(), 3);
+
+ let countries = geocoder.country_list().expect("country list query");
+ assert_eq!(countries.len(), 2);
+ assert_eq!(countries[0].country_id, "BR");
+
+ let center = geocoder.country_center("US").expect("country center query");
+ assert!(approx_eq(center.lat, (37.7749 + 34.0522 + 40.7128) / 3.0));
+ assert!(approx_eq(
+ center.lng,
+ (-122.4194 + -118.2437 + -74.0060) / 3.0
+ ));
+ }
+
+ #[test]
+ fn unit_harness_covers_open_bytes_and_weighted_reverse_ordering() {
+ let path = build_high_latitude_database();
+ let bytes = fs::read(&path).expect("read fixture database bytes");
+ let geocoder = Geocoder::open_bytes(&bytes).expect("open byte-backed geocoder");
+
+ let results = geocoder
+ .reverse(
+ GeocoderPoint {
+ lat: 75.0,
+ lng: 0.0,
+ },
+ Some(GeocoderReverseOptions {
+ limit: 2,
+ degree_offset: 1.0,
+ }),
+ )
+ .expect("reverse query");
+
+ assert_eq!(results.len(), 2);
+ assert_eq!(results[0].name, "Polar East");
+ assert_eq!(results[1].name, "Polar North");
+ }
+
+ #[test]
+ fn unit_harness_covers_open_path_pathbuf_instantiation() {
+ let path = build_fixture_database();
+ let geocoder = Geocoder::open_path(path.to_path_buf()).expect("open geocoder from pathbuf");
+ let results = geocoder
+ .country("US")
+ .expect("country query from pathbuf geocoder");
+ assert_eq!(results.len(), 3);
+ }
+
+ #[test]
+ fn unit_harness_covers_open_path_pathbuf_ref_instantiation() {
+ let temp_path = build_fixture_database();
+ let path = temp_path.to_path_buf();
+ let geocoder = Geocoder::open_path(&path).expect("open geocoder from pathbuf ref");
+ let results = geocoder
+ .country("US")
+ .expect("country query from pathbuf-ref geocoder");
+ assert_eq!(results.len(), 3);
+ }
+
+ #[test]
+ fn unit_harness_covers_open_path_str_instantiation() {
+ let path = build_fixture_database();
+ let geocoder = Geocoder::open_path(path.to_str().expect("utf-8 fixture path"))
+ .expect("open geocoder from string path");
+ let results = geocoder
+ .country("US")
+ .expect("country query from string-path geocoder");
+ assert_eq!(results.len(), 3);
+ }
+
+ #[test]
+ fn unit_harness_covers_open_path_path_ref_instantiation() {
+ let path = build_fixture_database();
+ let path_ref = Path::new(path.to_str().expect("utf-8 fixture path"));
+ let geocoder = Geocoder::open_path(path_ref).expect("open geocoder from path ref");
+ let results = geocoder
+ .country("US")
+ .expect("country query from path-ref geocoder");
+ assert_eq!(results.len(), 3);
+ }
+
+ #[test]
+ fn unit_harness_covers_open_path_error_path() {
+ let root = tempfile::tempdir().expect("temp dir");
+ let missing_path = root.path().join("missing").join("fixture.sqlite");
+ let open_path_err = Geocoder::open_path(&missing_path)
+ .err()
+ .expect("missing parent path should fail");
+ assert_sqlite_error_contains(open_path_err, "");
+ }
+
+ #[test]
+ fn unit_harness_covers_missing_schema_errors() {
+ let geocoder = open_empty_geocoder();
+
+ let reverse_err = geocoder
+ .reverse(
+ GeocoderPoint {
+ lat: 37.7749,
+ lng: -122.4194,
+ },
+ None,
+ )
+ .expect_err("reverse should fail without schema");
+ assert_sqlite_error_contains(reverse_err, "no such");
+
+ let country_err = geocoder
+ .country("US")
+ .expect_err("country should fail without schema");
+ assert_sqlite_error_contains(country_err, "no such");
+
+ let country_list_err = geocoder
+ .country_list()
+ .expect_err("country_list should fail without schema");
+ assert_sqlite_error_contains(country_list_err, "no such");
+
+ let country_center_err = geocoder
+ .country_center("US")
+ .expect_err("country_center should fail without schema");
+ assert_sqlite_error_contains(country_center_err, "no such");
+ }
+
+ #[test]
+ fn unit_harness_covers_row_mapping_errors() {
+ let reverse_country = open_reverse_country_row_error_geocoder();
+ let reverse_err = reverse_country
+ .reverse(
+ GeocoderPoint {
+ lat: 37.7749,
+ lng: -122.4194,
+ },
+ Some(GeocoderReverseOptions {
+ limit: 1,
+ degree_offset: 10.0,
+ }),
+ )
+ .expect_err("reverse should fail on invalid row mapping");
+ assert_sqlite_error_contains(reverse_err, "Invalid column type");
+
+ let country_err = reverse_country
+ .country("US")
+ .expect_err("country should fail on invalid row mapping");
+ assert_sqlite_error_contains(country_err, "Invalid column type");
+
+ let country_list = open_country_list_row_error_geocoder();
+ let country_list_err = country_list
+ .country_list()
+ .expect_err("country_list should fail on null aggregate row");
+ assert_sqlite_error_contains(country_list_err, "Invalid column type");
+ }
+
+ #[test]
+ fn unit_harness_covers_query_execution_error_paths() {
+ let reverse_country = geocoder_with_reverse_country_query_execution_error();
+ let reverse_err = reverse_country
+ .reverse(
+ GeocoderPoint { lat: 1.0, lng: 2.0 },
+ Some(GeocoderReverseOptions {
+ limit: 1,
+ degree_offset: 1.0,
+ }),
+ )
+ .expect_err("reverse should fail during query execution");
+ assert_sqlite_error_contains(reverse_err, "no such function");
+
+ let country_err = reverse_country
+ .country("US")
+ .expect_err("country should fail during query execution");
+ assert_sqlite_error_contains(country_err, "no such function");
+
+ let country_list_err = geocoder_with_country_list_query_execution_error()
+ .country_list()
+ .expect_err("country_list should fail during query execution");
+ assert_sqlite_error_contains(country_list_err, "no such function");
+
+ let country_center_err = geocoder_with_country_center_query_execution_error()
+ .country_center("US")
+ .expect_err("country_center should fail during query execution");
+ assert_sqlite_error_contains(country_center_err, "no such function");
+ }
+
+ #[test]
+ fn unit_harness_covers_country_list_field_error_paths() {
+ let country_id_err =
+ geocoder_with_country_list_sql_row("1", "'United States'", "37.0", "1.0")
+ .country_list()
+ .expect_err("country_id type mismatch should fail");
+ assert_sqlite_error_contains(country_id_err, "Invalid column type");
+
+ let country_name_err = geocoder_with_country_list_sql_row("'US'", "1", "37.0", "1.0")
+ .country_list()
+ .expect_err("country_name type mismatch should fail");
+ assert_sqlite_error_contains(country_name_err, "Invalid column type");
+
+ let longitude_err =
+ geocoder_with_country_list_sql_row("'US'", "'United States'", "37.0", "NULL")
+ .country_list()
+ .expect_err("longitude type mismatch should fail");
+ assert_sqlite_error_contains(longitude_err, "Invalid column type");
+ }
+
+ #[test]
+ fn unit_harness_covers_country_center_row_error_paths() {
+ let latitude_err = map_country_center_row_error("'bad'", "1.0");
+ assert_sqlite_error_contains(GeocoderError::from(latitude_err), "Invalid column type");
+
+ let longitude_err = map_country_center_row_error("1.0", "'bad'");
+ assert_sqlite_error_contains(GeocoderError::from(longitude_err), "Invalid column type");
+ }
+
+ #[test]
+ fn unit_harness_covers_reverse_row_field_error_paths() {
+ for err in [
+ map_reverse_row_error(
+ "'bad'",
+ "'name'",
+ "1",
+ "'admin'",
+ "'US'",
+ "'United States'",
+ "1.0",
+ "2.0",
+ ),
+ map_reverse_row_error(
+ "1",
+ "'name'",
+ "'bad'",
+ "'admin'",
+ "'US'",
+ "'United States'",
+ "1.0",
+ "2.0",
+ ),
+ map_reverse_row_error(
+ "1",
+ "'name'",
+ "1",
+ "1",
+ "'US'",
+ "'United States'",
+ "1.0",
+ "2.0",
+ ),
+ map_reverse_row_error(
+ "1",
+ "'name'",
+ "1",
+ "'admin'",
+ "1",
+ "'United States'",
+ "1.0",
+ "2.0",
+ ),
+ map_reverse_row_error("1", "'name'", "1", "'admin'", "'US'", "1", "1.0", "2.0"),
+ map_reverse_row_error(
+ "1",
+ "'name'",
+ "1",
+ "'admin'",
+ "'US'",
+ "'United States'",
+ "'bad'",
+ "2.0",
+ ),
+ map_reverse_row_error(
+ "1",
+ "'name'",
+ "1",
+ "'admin'",
+ "'US'",
+ "'United States'",
+ "1.0",
+ "'bad'",
+ ),
+ ] {
+ assert_sqlite_error_contains(GeocoderError::from(err), "Invalid column type");
+ }
+ }
+
+ #[test]
+ fn unit_harness_covers_country_center_not_found() {
+ let geocoder = open_fixture_geocoder();
+ let err = geocoder
+ .country_center("ZZ")
+ .expect_err("missing country should return not found");
+ assert_country_center_not_found(err, "ZZ");
+ }
+
+ #[test]
+ fn unit_harness_covers_helper_panic_paths() {
+ let sqlite_panic = std::panic::catch_unwind(|| {
+ assert_sqlite_error_contains(
+ GeocoderError::CountryCenterNotFound {
+ country_id: "US".to_owned(),
+ },
+ "no such",
+ );
+ });
+ assert!(sqlite_panic.is_err());
+
+ let country_center_panic = std::panic::catch_unwind(|| {
+ let mismatch_err = GeocoderError::Sqlite(rusqlite::Error::InvalidQuery);
+ assert_country_center_not_found(mismatch_err, "US");
+ });
+ assert!(country_center_panic.is_err());
+ }
+
+ fn open_fixture_geocoder() -> Geocoder {
+ let path = build_fixture_database();
+ Geocoder::open_path(&path).expect("open geocoder")
+ }
+
+ fn open_empty_geocoder() -> Geocoder {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ Geocoder::open_path(&path).expect("open empty geocoder")
+ }
+
+ fn open_reverse_country_row_error_geocoder() -> Geocoder {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ seed_reverse_country_row_error_database(path.to_str().expect("utf-8 temp path"));
+ Geocoder::open_path(&path).expect("open invalid row geocoder")
+ }
+
+ fn open_country_list_row_error_geocoder() -> Geocoder {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ seed_country_list_row_error_database(path.to_str().expect("utf-8 temp path"));
+ Geocoder::open_path(&path).expect("open aggregate error geocoder")
+ }
+
+ fn build_fixture_database() -> tempfile::TempPath {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ seed_fixture_database(path.to_str().expect("utf-8 temp path"));
+ path
+ }
+
+ fn build_high_latitude_database() -> tempfile::TempPath {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ seed_high_latitude_database(path.to_str().expect("utf-8 temp path"));
+ path
+ }
+
+ fn geocoder_with_reverse_country_query_execution_error() -> Geocoder {
+ let conn = Connection::open_in_memory().expect("open in-memory query error db");
+ conn.execute_batch(
+ r#"
+ CREATE VIEW geonames AS
+ SELECT
+ 1 AS id,
+ missing_reverse_name() AS name,
+ 1 AS admin1_id,
+ 'Admin' AS admin1_name,
+ 'US' AS country_id,
+ 'United States' AS country_name,
+ 1.0 AS latitude,
+ 2.0 AS longitude;
+ CREATE TABLE coordinates(
+ feature_id INTEGER,
+ latitude REAL,
+ longitude REAL
+ );
+ INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (1, 1.0, 2.0);
+ "#,
+ )
+ .expect("create reverse/country execution error schema");
+ Geocoder { conn }
+ }
+
+ fn geocoder_with_country_list_query_execution_error() -> Geocoder {
+ let conn =
+ Connection::open_in_memory().expect("open in-memory country_list query error db");
+ conn.execute_batch(
+ r#"
+ CREATE VIEW geonames AS
+ SELECT
+ 'US' AS country_id,
+ missing_country_name() AS country_name,
+ 1.0 AS latitude,
+ 2.0 AS longitude;
+ "#,
+ )
+ .expect("create country_list execution error schema");
+ Geocoder { conn }
+ }
+
+ fn geocoder_with_country_center_query_execution_error() -> Geocoder {
+ let conn =
+ Connection::open_in_memory().expect("open in-memory country_center query error db");
+ conn.execute_batch(
+ r#"
+ CREATE VIEW geonames AS
+ SELECT
+ 'US' AS country_id,
+ missing_latitude() AS latitude,
+ 2.0 AS longitude;
+ "#,
+ )
+ .expect("create country_center execution error schema");
+ Geocoder { conn }
+ }
+
+ fn geocoder_with_country_list_sql_row(
+ country_id_sql: &str,
+ country_name_sql: &str,
+ latitude_sql: &str,
+ longitude_sql: &str,
+ ) -> Geocoder {
+ let conn =
+ Connection::open_in_memory().expect("open in-memory country_list field error db");
+ conn.execute_batch(&format!(
+ r#"
+ CREATE TABLE geonames(
+ country_id,
+ country_name,
+ latitude,
+ longitude
+ );
+ INSERT INTO geonames (country_id, country_name, latitude, longitude)
+ VALUES ({country_id_sql}, {country_name_sql}, {latitude_sql}, {longitude_sql});
+ "#,
+ ))
+ .expect("create country_list field error schema");
+ Geocoder { conn }
+ }
+
+ fn map_country_center_row_error(latitude_sql: &str, longitude_sql: &str) -> rusqlite::Error {
+ let conn =
+ Connection::open_in_memory().expect("open in-memory country center row error db");
+ conn.query_row(
+ &format!("SELECT {latitude_sql} AS latitude_c, {longitude_sql} AS longitude_c"),
+ [],
+ map_country_center_row,
+ )
+ .expect_err("country center row decode should fail")
+ }
+
+ fn map_reverse_row_error(
+ id_sql: &str,
+ name_sql: &str,
+ admin1_id_sql: &str,
+ admin1_name_sql: &str,
+ country_id_sql: &str,
+ country_name_sql: &str,
+ latitude_sql: &str,
+ longitude_sql: &str,
+ ) -> rusqlite::Error {
+ let conn = Connection::open_in_memory().expect("open in-memory reverse row error db");
+ conn.query_row(
+ &format!(
+ r#"
+ SELECT
+ {id_sql} AS id,
+ {name_sql} AS name,
+ {admin1_id_sql} AS admin1_id,
+ {admin1_name_sql} AS admin1_name,
+ {country_id_sql} AS country_id,
+ {country_name_sql} AS country_name,
+ {latitude_sql} AS latitude,
+ {longitude_sql} AS longitude
+ "#,
+ ),
+ [],
+ map_reverse_row,
+ )
+ .expect_err("reverse row decode should fail")
+ }
+
+ fn seed_fixture_database(path: &str) {
+ let conn = Connection::open(path).expect("open fixture database");
+ seed_schema(&conn);
+
+ insert_country(&conn, "US", "United States");
+ insert_country(&conn, "BR", "Brazil");
+
+ insert_admin1(&conn, "US", 6, "California");
+ insert_admin1(&conn, "US", 36, "New York");
+ insert_admin1(&conn, "BR", 27, "Sao Paulo");
+
+ insert_feature(&conn, 1, "San Francisco", "US", 6, 37.7749, -122.4194);
+ insert_feature(&conn, 2, "Los Angeles", "US", 6, 34.0522, -118.2437);
+ insert_feature(&conn, 3, "New York City", "US", 36, 40.7128, -74.0060);
+ insert_feature(&conn, 4, "Sao Paulo", "BR", 27, -23.5505, -46.6333);
+ }
+
+ fn seed_high_latitude_database(path: &str) {
+ let conn = Connection::open(path).expect("open fixture database");
+ seed_schema(&conn);
+
+ insert_country(&conn, "NO", "Norway");
+ insert_admin1(&conn, "NO", 1, "Nord");
+
+ insert_feature(&conn, 1, "Polar East", "NO", 1, 75.02, 0.10);
+ insert_feature(&conn, 2, "Polar North", "NO", 1, 75.05, 0.05);
+ }
+
+ fn seed_reverse_country_row_error_database(path: &str) {
+ let conn = Connection::open(path).expect("open invalid row fixture database");
+ conn.execute_batch(
+ r#"
+ CREATE TABLE geonames(
+ id INTEGER,
+ name TEXT,
+ admin1_id INTEGER,
+ admin1_name TEXT,
+ country_id TEXT,
+ country_name TEXT,
+ latitude REAL,
+ longitude REAL
+ );
+ CREATE TABLE coordinates(
+ feature_id INTEGER,
+ latitude REAL,
+ longitude REAL
+ );
+ "#,
+ )
+ .expect("create invalid row schema");
+ conn.execute(
+ "INSERT INTO geonames (id, name, admin1_id, admin1_name, country_id, country_name, latitude, longitude) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
+ rusqlite::params![1_i64, Option::<String>::None, Option::<i64>::None, Option::<String>::None, "US", "United States", 37.7749_f64, -122.4194_f64],
+ )
+ .expect("insert invalid reverse/country row");
+ conn.execute(
+ "INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (?1, ?2, ?3)",
+ (1_i64, 37.7749_f64, -122.4194_f64),
+ )
+ .expect("insert invalid reverse/country coordinate");
+ }
+
+ fn seed_country_list_row_error_database(path: &str) {
+ let conn = Connection::open(path).expect("open aggregate error fixture database");
+ conn.execute_batch(
+ r#"
+ CREATE TABLE geonames(
+ country_id TEXT,
+ country_name TEXT,
+ latitude REAL,
+ longitude REAL
+ );
+ "#,
+ )
+ .expect("create aggregate error schema");
+ conn.execute(
+ "INSERT INTO geonames (country_id, country_name, latitude, longitude) VALUES (?1, ?2, ?3, ?4)",
+ rusqlite::params!["US", "United States", Option::<f64>::None, Option::<f64>::None],
+ )
+ .expect("insert aggregate error row");
+ }
+
+ fn seed_schema(conn: &Connection) {
+ conn.execute_batch(
+ r#"
+ CREATE TABLE countries(
+ id TEXT,
+ name TEXT,
+ PRIMARY KEY (id)
+ );
+ CREATE TABLE admin1(
+ country_id TEXT,
+ id INTEGER,
+ name TEXT,
+ PRIMARY KEY (country_id, id)
+ );
+ CREATE TABLE features(
+ id INTEGER,
+ name TEXT,
+ country_id TEXT,
+ admin1_id INTEGER,
+ PRIMARY KEY (id)
+ );
+ CREATE TABLE coordinates(
+ feature_id INTEGER,
+ latitude REAL,
+ longitude REAL,
+ PRIMARY KEY (feature_id)
+ );
+ CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude);
+ CREATE VIEW geonames AS
+ SELECT
+ features.id,
+ features.name,
+ admin1.id AS admin1_id,
+ admin1.name AS admin1_name,
+ countries.id AS country_id,
+ countries.name AS country_name,
+ coordinates.latitude AS latitude,
+ coordinates.longitude AS longitude
+ FROM features
+ LEFT JOIN countries ON features.country_id = countries.id
+ LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id
+ JOIN coordinates ON features.id = coordinates.feature_id;
+ "#,
+ )
+ .expect("create fixture schema");
+ }
+
+ fn insert_country(conn: &Connection, id: &str, name: &str) {
+ conn.execute(
+ "INSERT INTO countries (id, name) VALUES (?1, ?2)",
+ (id, name),
+ )
+ .expect("insert country");
+ }
+
+ fn insert_admin1(conn: &Connection, country_id: &str, id: i64, name: &str) {
+ conn.execute(
+ "INSERT INTO admin1 (country_id, id, name) VALUES (?1, ?2, ?3)",
+ (country_id, id, name),
+ )
+ .expect("insert admin1");
+ }
+
+ fn insert_feature(
+ conn: &Connection,
+ id: i64,
+ name: &str,
+ country_id: &str,
+ admin1_id: i64,
+ latitude: f64,
+ longitude: f64,
+ ) {
+ conn.execute(
+ "INSERT INTO features (id, name, country_id, admin1_id) VALUES (?1, ?2, ?3, ?4)",
+ (id, name, country_id, admin1_id),
+ )
+ .expect("insert feature");
+ conn.execute(
+ "INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (?1, ?2, ?3)",
+ (id, latitude, longitude),
+ )
+ .expect("insert coordinate");
+ }
+
+ fn approx_eq(left: f64, right: f64) -> bool {
+ (left - right).abs() < 0.000_001
+ }
+
+ fn assert_sqlite_error_contains(err: GeocoderError, needle: &str) {
+ match err {
+ GeocoderError::Sqlite(inner) => assert!(
+ inner.to_string().contains(needle),
+ "expected sqlite error containing {needle:?}, got {inner}"
+ ),
+ other => panic!("expected sqlite error, got {other}"),
+ }
+ }
+
+ fn assert_country_center_not_found(err: GeocoderError, country_id: &str) {
+ match err {
+ GeocoderError::CountryCenterNotFound { country_id: actual } => {
+ assert_eq!(actual, country_id);
+ }
+ other => panic!("expected CountryCenterNotFound, got {other}"),
+ }
+ }
+}
diff --git a/crates/geocoder/src/lib.rs b/crates/geocoder/src/lib.rs
@@ -1,3 +1,4 @@
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![forbid(unsafe_code)]
mod error;
diff --git a/crates/geocoder/tests/geocoder.rs b/crates/geocoder/tests/geocoder.rs
@@ -3,6 +3,7 @@ use radroots_geocoder::{
};
use rusqlite::Connection;
use std::fs;
+use std::path::Path;
use tempfile::NamedTempFile;
#[test]
@@ -95,6 +96,45 @@ fn open_bytes_supports_reverse_queries() {
}
#[test]
+fn open_path_supports_string_and_path_ref_inputs() {
+ let path = build_fixture_database();
+ let path_str = path.to_str().expect("utf-8 fixture path");
+
+ let geocoder_from_str = Geocoder::open_path(path_str).expect("open geocoder from string path");
+ let string_results = geocoder_from_str
+ .country("US")
+ .expect("country query from string-path geocoder");
+ assert_eq!(string_results.len(), 3);
+
+ let geocoder_from_path =
+ Geocoder::open_path(Path::new(path_str)).expect("open geocoder from path ref");
+ let path_results = geocoder_from_path
+ .country("US")
+ .expect("country query from path-ref geocoder");
+ assert_eq!(path_results.len(), 3);
+}
+
+#[test]
+fn open_path_supports_pathbuf_inputs() {
+ let temp_path = build_fixture_database();
+ let path = temp_path.to_path_buf();
+
+ let geocoder_from_pathbuf =
+ Geocoder::open_path(path.clone()).expect("open geocoder from pathbuf");
+ let pathbuf_results = geocoder_from_pathbuf
+ .country("US")
+ .expect("country query from pathbuf geocoder");
+ assert_eq!(pathbuf_results.len(), 3);
+
+ let geocoder_from_pathbuf_ref =
+ Geocoder::open_path(&path).expect("open geocoder from pathbuf ref");
+ let pathbuf_ref_results = geocoder_from_pathbuf_ref
+ .country("US")
+ .expect("country query from pathbuf-ref geocoder");
+ assert_eq!(pathbuf_ref_results.len(), 3);
+}
+
+#[test]
fn country_returns_all_rows_for_requested_country() {
let geocoder = open_fixture_geocoder();
@@ -146,6 +186,16 @@ fn country_center_returns_average_for_country() {
}
#[test]
+fn country_center_returns_not_found_for_missing_country() {
+ let geocoder = open_fixture_geocoder();
+
+ let err = geocoder
+ .country_center("ZZ")
+ .expect_err("missing country should return not found");
+ assert_country_center_not_found(err, "ZZ");
+}
+
+#[test]
fn reverse_country_and_country_list_report_missing_schema_errors() {
let geocoder = open_empty_geocoder();
@@ -172,6 +222,16 @@ fn reverse_country_and_country_list_report_missing_schema_errors() {
}
#[test]
+fn country_center_reports_missing_schema_errors() {
+ let geocoder = open_empty_geocoder();
+
+ let err = geocoder
+ .country_center("US")
+ .expect_err("country_center should fail without schema");
+ assert_sqlite_error_contains(err, "no such");
+}
+
+#[test]
fn reverse_and_country_propagate_row_mapping_errors() {
let geocoder = open_reverse_country_row_error_geocoder();
@@ -428,3 +488,12 @@ fn assert_sqlite_error_contains(err: GeocoderError, needle: &str) {
other => panic!("expected sqlite error, got {other}"),
}
}
+
+fn assert_country_center_not_found(err: GeocoderError, country_id: &str) {
+ match err {
+ GeocoderError::CountryCenterNotFound { country_id: actual } => {
+ assert_eq!(actual, country_id);
+ }
+ other => panic!("expected CountryCenterNotFound, got {other}"),
+ }
+}