lib

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

commit 67d1b91a86d027465a37b4f462253cc1e03ed951
parent 2c6f1d7c1c97b6ce977afc7703fb6773aa75c07a
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 09:45:53 +0000

geonames: implement deterministic locality queries

- execute bounded forward feature and country lookups
- preserve textual administrative identifiers at row boundaries
- order every candidate and country result deterministically
- handle reverse queries across antimeridians and poles

Diffstat:
Mcrates/geonames/src/database.rs | 520++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/geonames/src/error.rs | 14+++++++++++++-
Mcrates/geonames/src/model.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/geonames/src/query.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
4 files changed, 696 insertions(+), 32 deletions(-)

diff --git a/crates/geonames/src/database.rs b/crates/geonames/src/database.rs @@ -5,10 +5,12 @@ use std::path::Path; use std::sync::Mutex; use std::time::Duration; -use rusqlite::{Connection, OpenFlags}; +use rusqlite::{Connection, OpenFlags, Row, params}; use crate::asset::verify_file; -use crate::{AssetSpec, Error}; +use crate::model::Country; +use crate::query::{QueryKind, QueryResult}; +use crate::{AssetSpec, Candidate, Error, Point, Query}; const REQUIRED_GEONAMES_COLUMNS: &[&str] = &[ "id", @@ -66,6 +68,365 @@ impl Geocoder { .close() .map_err(|_| Error::DatabaseOperationFailed { operation: "close" }) } + + /// Executes one validated query with deterministic provider ordering. + pub fn query(&self, query: &Query) -> Result<QueryResult, Error> { + self.with_connection("query", |connection| match &query.kind { + QueryKind::Locality { + locality, + region, + country, + } => query_locality( + connection, + locality, + region.as_deref(), + country.as_deref(), + query.limit(), + ), + QueryKind::Freeform(query_text) => { + let parsed = parse_freeform_query(query_text); + query_locality( + connection, + &parsed.locality, + parsed.region.as_deref(), + parsed.country.as_deref(), + query.limit(), + ) + } + QueryKind::FeatureId(feature_id) => query_feature(connection, *feature_id), + QueryKind::Reverse { + point, + radius_degrees, + } => query_reverse(connection, *point, *radius_degrees, query.limit()), + QueryKind::Countries => query_countries(connection, query.limit()), + }) + } + + fn with_connection<T>( + &self, + operation: &'static str, + use_connection: impl FnOnce(&Connection) -> rusqlite::Result<T>, + ) -> Result<T, Error> { + let connection = self + .connection + .lock() + .map_err(|_| Error::DatabaseConnectionUnavailable)?; + use_connection(&connection).map_err(|_| Error::DatabaseOperationFailed { operation }) + } +} + +fn query_locality( + connection: &Connection, + locality: &str, + region: Option<&str>, + country: Option<&str>, + limit: usize, +) -> rusqlite::Result<QueryResult> { + let locality = normalize_name(locality); + let country = country.map(normalize_name); + let region = region.map(normalize_name); + let mut statement = connection.prepare( + " + SELECT id, name, CAST(admin1_id AS TEXT), admin1_name, + country_id, country_name, latitude, longitude + FROM geonames + WHERE lower(name) = ?1 + ORDER BY lower(name), lower(country_id), + lower(coalesce(country_name, '')), + lower(coalesce(admin1_name, '')), + CASE + WHEN admin1_id IS NULL THEN 0 + WHEN typeof(admin1_id) IN ('integer', 'real') THEN 1 + WHEN typeof(admin1_id) = 'text' THEN 2 + ELSE 3 + END, + CASE WHEN typeof(admin1_id) IN ('integer', 'real') + THEN admin1_id ELSE NULL END, + CASE WHEN typeof(admin1_id) = 'text' + THEN CAST(admin1_id AS TEXT) ELSE NULL END COLLATE BINARY, + id + ", + )?; + let candidates = statement + .query_map([locality], map_candidate)? + .collect::<Result<Vec<_>, _>>()? + .into_iter() + .filter(|candidate| { + country + .as_deref() + .is_none_or(|value| country_matches(candidate, value)) + && region + .as_deref() + .is_none_or(|value| region_matches(candidate, value)) + }) + .take(limit) + .collect(); + Ok(QueryResult::candidates(candidates)) +} + +fn query_feature(connection: &Connection, feature_id: i64) -> rusqlite::Result<QueryResult> { + let mut statement = connection.prepare( + " + SELECT id, name, CAST(admin1_id AS TEXT), admin1_name, + country_id, country_name, latitude, longitude + FROM geonames + WHERE id = ?1 + LIMIT 1 + ", + )?; + let candidates = statement + .query_map([feature_id], map_candidate)? + .collect::<Result<Vec<_>, _>>()?; + Ok(QueryResult::candidates(candidates)) +} + +fn query_reverse( + connection: &Connection, + point: Point, + radius_degrees: f64, + limit: usize, +) -> rusqlite::Result<QueryResult> { + let latitude = point.latitude(); + let longitude = point.longitude(); + let longitude_weight = latitude.to_radians().cos().powi(2); + let mut statement = connection.prepare( + " + SELECT g.id, g.name, CAST(g.admin1_id AS TEXT), g.admin1_name, + g.country_id, g.country_name, g.latitude, g.longitude + FROM geonames AS g + JOIN coordinates AS c ON g.id = c.feature_id + WHERE c.latitude BETWEEN ?1 - ?3 AND ?1 + ?3 + AND ( + abs(?1) + ?3 >= 90.0 + OR ( + ?2 - ?3 >= -180.0 AND ?2 + ?3 <= 180.0 + AND c.longitude BETWEEN ?2 - ?3 AND ?2 + ?3 + ) + OR ( + ?2 - ?3 < -180.0 + AND (c.longitude >= ?2 - ?3 + 360.0 OR c.longitude <= ?2 + ?3) + ) + OR ( + ?2 + ?3 > 180.0 + AND (c.longitude >= ?2 - ?3 OR c.longitude <= ?2 + ?3 - 360.0) + ) + ) + ORDER BY ((?1 - c.latitude) * (?1 - c.latitude)) + + (min(abs(?2 - c.longitude), 360.0 - abs(?2 - c.longitude)) + * min(abs(?2 - c.longitude), 360.0 - abs(?2 - c.longitude)) + * ?4), + g.id + LIMIT ?5 + ", + )?; + let limit = i64::try_from(limit).unwrap_or(i64::MAX); + let candidates = statement + .query_map( + params![latitude, longitude, radius_degrees, longitude_weight, limit], + map_candidate, + )? + .collect::<Result<Vec<_>, _>>()?; + Ok(QueryResult::candidates(candidates)) +} + +fn query_countries(connection: &Connection, limit: usize) -> rusqlite::Result<QueryResult> { + let mut statement = connection.prepare( + " + SELECT country_id, country_name, AVG(latitude), AVG(longitude) + FROM geonames + GROUP BY country_id, country_name + ORDER BY lower(country_id), lower(coalesce(country_name, '')) + LIMIT ?1 + ", + )?; + let limit = i64::try_from(limit).unwrap_or(i64::MAX); + let countries = statement + .query_map([limit], map_country)? + .collect::<Result<Vec<_>, _>>()?; + Ok(QueryResult::countries(countries)) +} + +fn map_candidate(row: &Row<'_>) -> rusqlite::Result<Candidate> { + let feature_id = row.get::<_, i64>(0)?; + let feature_id = u64::try_from(feature_id) + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, feature_id))?; + let latitude = row.get::<_, f64>(6)?; + let longitude = row.get::<_, f64>(7)?; + let point = Point::new(latitude, longitude).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Real, Box::new(error)) + })?; + Ok(Candidate::from_provider_row( + feature_id, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + point, + )) +} + +fn map_country(row: &Row<'_>) -> rusqlite::Result<Country> { + let latitude = row.get::<_, f64>(2)?; + let longitude = row.get::<_, f64>(3)?; + let point = Point::new(latitude, longitude).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Real, Box::new(error)) + })?; + Ok(Country::from_provider_row(row.get(0)?, row.get(1)?, point)) +} + +struct ParsedQuery { + locality: String, + region: Option<String>, + country: Option<String>, +} + +fn parse_freeform_query(query: &str) -> ParsedQuery { + let parts = query + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::<Vec<_>>(); + match parts.as_slice() { + [] => ParsedQuery { + locality: String::new(), + region: None, + country: None, + }, + [locality] => ParsedQuery { + locality: (*locality).to_owned(), + region: None, + country: None, + }, + [locality, region] => ParsedQuery { + locality: (*locality).to_owned(), + region: Some((*region).to_owned()), + country: None, + }, + parts => ParsedQuery { + locality: parts[..parts.len() - 2].join(", "), + region: Some(parts[parts.len() - 2].to_owned()), + country: Some(parts[parts.len() - 1].to_owned()), + }, + } +} + +fn normalize_name(value: &str) -> String { + value + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") + .to_ascii_lowercase() +} + +fn normalize_region_code(value: &str) -> String { + value + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|character| character.to_ascii_uppercase()) + .collect() +} + +fn country_matches(candidate: &Candidate, expected: &str) -> bool { + normalize_name(candidate.country_id()) == expected + || candidate + .country_name() + .is_some_and(|name| normalize_name(name) == expected) +} + +fn region_matches(candidate: &Candidate, expected: &str) -> bool { + if candidate + .admin1_id() + .is_some_and(|id| normalize_name(id) == expected) + || candidate + .admin1_name() + .is_some_and(|name| normalize_name(name) == expected) + { + return true; + } + let expected_code = normalize_region_code(expected); + region_aliases(candidate.country_id()) + .iter() + .any(|(code, name)| { + normalize_region_code(code) == expected_code + && candidate + .admin1_name() + .is_some_and(|admin_name| normalize_name(admin_name) == normalize_name(name)) + }) +} + +fn region_aliases(country_id: &str) -> &'static [(&'static str, &'static str)] { + match country_id.to_ascii_uppercase().as_str() { + "CA" => &[ + ("AB", "Alberta"), + ("BC", "British Columbia"), + ("MB", "Manitoba"), + ("NB", "New Brunswick"), + ("NL", "Newfoundland and Labrador"), + ("NS", "Nova Scotia"), + ("NT", "Northwest Territories"), + ("NU", "Nunavut"), + ("ON", "Ontario"), + ("PE", "Prince Edward Island"), + ("QC", "Quebec"), + ("SK", "Saskatchewan"), + ("YT", "Yukon"), + ], + "US" => &[ + ("AL", "Alabama"), + ("AK", "Alaska"), + ("AZ", "Arizona"), + ("AR", "Arkansas"), + ("CA", "California"), + ("CO", "Colorado"), + ("CT", "Connecticut"), + ("DC", "District of Columbia"), + ("DE", "Delaware"), + ("FL", "Florida"), + ("GA", "Georgia"), + ("HI", "Hawaii"), + ("ID", "Idaho"), + ("IL", "Illinois"), + ("IN", "Indiana"), + ("IA", "Iowa"), + ("KS", "Kansas"), + ("KY", "Kentucky"), + ("LA", "Louisiana"), + ("ME", "Maine"), + ("MD", "Maryland"), + ("MA", "Massachusetts"), + ("MI", "Michigan"), + ("MN", "Minnesota"), + ("MS", "Mississippi"), + ("MO", "Missouri"), + ("MT", "Montana"), + ("NE", "Nebraska"), + ("NV", "Nevada"), + ("NH", "New Hampshire"), + ("NJ", "New Jersey"), + ("NM", "New Mexico"), + ("NY", "New York"), + ("NC", "North Carolina"), + ("ND", "North Dakota"), + ("OH", "Ohio"), + ("OK", "Oklahoma"), + ("OR", "Oregon"), + ("PA", "Pennsylvania"), + ("RI", "Rhode Island"), + ("SC", "South Carolina"), + ("SD", "South Dakota"), + ("TN", "Tennessee"), + ("TX", "Texas"), + ("UT", "Utah"), + ("VT", "Vermont"), + ("VA", "Virginia"), + ("WA", "Washington"), + ("WV", "West Virginia"), + ("WI", "Wisconsin"), + ("WY", "Wyoming"), + ], + _ => &[], + } } fn configure_connection(connection: &Connection) -> Result<(), Error> { @@ -185,8 +546,23 @@ mod tests { longitude REAL NOT NULL ); INSERT INTO geonames VALUES - (6174041, 'Victoria', 'BC', 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656); - INSERT INTO coordinates VALUES (6174041, 48.4284, -123.3656); + (6174041, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656), + (5815135, 'Victoria', 'WA', 'Washington', 'US', 'United States', 48.1000, -122.8000), + (10, 'Twin A', 'BC', 'British Columbia', 'CA', 'Canada', 49.0000, -124.0000), + (11, 'Twin B', 'BC', 'British Columbia', 'CA', 'Canada', 49.0000, -124.0000), + (20, 'Date East', NULL, NULL, 'FJ', 'Fiji', 0.0000, 179.9000), + (21, 'Date West', NULL, NULL, 'FJ', 'Fiji', 0.0000, -179.9000), + (30, 'Pole Prime', NULL, NULL, 'AQ', 'Antarctica', 89.9000, 0.0000), + (31, 'Pole East', NULL, NULL, 'AQ', 'Antarctica', 89.9000, 120.0000); + INSERT INTO coordinates VALUES + (6174041, 48.4284, -123.3656), + (5815135, 48.1000, -122.8000), + (10, 49.0000, -124.0000), + (11, 49.0000, -124.0000), + (20, 0.0000, 179.9000), + (21, 0.0000, -179.9000), + (30, 89.9000, 0.0000), + (31, 89.9000, 120.0000); " } @@ -200,7 +576,7 @@ mod tests { row.get::<_, i64>(0) }) .expect("query fixture"); - assert_eq!(count, 1); + assert_eq!(count, 8); assert!(matches!( connection.execute("DELETE FROM geonames", []), Err(rusqlite::Error::SqliteFailure(_, _)) @@ -210,6 +586,140 @@ mod tests { } #[test] + fn forward_and_feature_queries_preserve_text_ids_and_stable_order() { + let (_directory, path, spec) = database_fixture(governed_schema()); + let geocoder = Geocoder::open(path, &spec).expect("geocoder"); + + let structured = crate::Query::locality("Victoria") + .expect("locality") + .with_region("BC") + .expect("region") + .with_country("Canada") + .expect("country"); + let result = geocoder.query(&structured).expect("structured query"); + let candidates = result.as_candidates().expect("candidate result"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].feature_id(), 6_174_041); + assert_eq!(candidates[0].admin1_id(), Some("2")); + assert_eq!( + candidates[0].display_name(), + "Victoria, British Columbia, Canada" + ); + + let freeform = crate::Query::freeform("Victoria, BC, CA").expect("freeform"); + assert_eq!( + geocoder + .query(&freeform) + .expect("freeform query") + .as_candidates() + .expect("candidates")[0] + .feature_id(), + 6_174_041 + ); + + let ambiguous = crate::Query::locality("Victoria") + .expect("locality") + .with_limit(2) + .expect("limit"); + let candidates = geocoder + .query(&ambiguous) + .expect("ambiguous query") + .as_candidates() + .expect("candidates") + .to_vec(); + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.country_id()) + .collect::<Vec<_>>(), + vec!["CA", "US"] + ); + + let feature = crate::Query::feature_id(5_815_135).expect("feature query"); + assert_eq!( + geocoder + .query(&feature) + .expect("feature result") + .as_candidates() + .expect("candidates")[0] + .admin1_id(), + Some("WA") + ); + } + + #[test] + fn reverse_and_country_queries_are_bounded_and_deterministic() { + let (_directory, path, spec) = database_fixture(governed_schema()); + let geocoder = Geocoder::open(path, &spec).expect("geocoder"); + let reverse = crate::Query::reverse(crate::Point::new(49.0, -124.0).expect("point")) + .with_radius_degrees(0.1) + .expect("radius") + .with_limit(2) + .expect("limit"); + let candidates = geocoder + .query(&reverse) + .expect("reverse result") + .as_candidates() + .expect("candidates") + .to_vec(); + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.feature_id()) + .collect::<Vec<_>>(), + vec![10, 11] + ); + + let countries = geocoder + .query(&crate::Query::countries()) + .expect("country result"); + let countries = countries.as_countries().expect("countries"); + assert_eq!( + countries + .iter() + .map(|country| country.id()) + .collect::<Vec<_>>(), + vec!["AQ", "CA", "FJ", "US"] + ); + assert_eq!(countries[1].name(), Some("Canada")); + assert!(countries[0].center().latitude().is_finite()); + + let dateline = crate::Query::reverse(crate::Point::new(0.0, 180.0).expect("point")) + .with_radius_degrees(0.2) + .expect("radius") + .with_limit(2) + .expect("limit"); + assert_eq!( + geocoder + .query(&dateline) + .expect("dateline result") + .as_candidates() + .expect("candidates") + .iter() + .map(|candidate| candidate.feature_id()) + .collect::<Vec<_>>(), + vec![20, 21] + ); + + let pole = crate::Query::reverse(crate::Point::new(90.0, 0.0).expect("point")) + .with_radius_degrees(0.2) + .expect("radius") + .with_limit(2) + .expect("limit"); + assert_eq!( + geocoder + .query(&pole) + .expect("pole result") + .as_candidates() + .expect("candidates") + .iter() + .map(|candidate| candidate.feature_id()) + .collect::<Vec<_>>(), + vec![30, 31] + ); + } + + #[test] fn corrupt_bytes_and_incomplete_schema_fail_closed() { let directory = tempdir().expect("tempdir"); let path = directory.path().join("geonames-test.db"); diff --git a/crates/geonames/src/error.rs b/crates/geonames/src/error.rs @@ -58,6 +58,10 @@ pub enum Error { InvalidQueryText, /// A query limit was outside the supported range. InvalidQueryLimit, + /// A reverse-query radius was non-finite or out of bounds. + InvalidQueryRadius, + /// A feature identifier could not be represented by the provider database. + InvalidFeatureId, /// A locality-only option was applied to another query kind. QueryOptionNotApplicable, } @@ -109,7 +113,15 @@ impl fmt::Display for Error { Self::InvalidQueryText => { formatter.write_str("query text must be non-empty and normalized") } - Self::InvalidQueryLimit => formatter.write_str("query limit must be between 1 and 100"), + Self::InvalidQueryLimit => { + formatter.write_str("query limit must be between 1 and 1000") + } + Self::InvalidQueryRadius => { + formatter.write_str("reverse-query radius must be greater than 0 and at most 10") + } + Self::InvalidFeatureId => { + formatter.write_str("feature identifier exceeds the provider database range") + } Self::QueryOptionNotApplicable => { formatter.write_str("query option is not applicable to this query kind") } diff --git a/crates/geonames/src/model.rs b/crates/geonames/src/model.rs @@ -52,6 +52,32 @@ pub struct Candidate { } impl Candidate { + pub(crate) fn from_provider_row( + feature_id: u64, + name: String, + admin1_id: Option<String>, + admin1_name: Option<String>, + country_id: String, + country_name: Option<String>, + point: Point, + ) -> Self { + let mut display_parts = vec![name.clone()]; + if let Some(admin1_name) = &admin1_name { + display_parts.push(admin1_name.clone()); + } + display_parts.push(country_name.clone().unwrap_or_else(|| country_id.clone())); + Self { + feature_id, + name, + admin1_id, + admin1_name, + country_id, + country_name, + point, + display_name: display_parts.join(", "), + } + } + /// Returns the stable GeoNames feature identifier. #[must_use] pub const fn feature_id(&self) -> u64 { @@ -101,6 +127,38 @@ impl Candidate { } } +/// One country and its deterministic center point. +#[derive(Clone, Debug, PartialEq)] +pub struct Country { + id: String, + name: Option<String>, + center: Point, +} + +impl Country { + pub(crate) fn from_provider_row(id: String, name: Option<String>, center: Point) -> Self { + Self { id, name, center } + } + + /// Returns the country identifier stored by the asset. + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + /// Returns the country name when present. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Returns the average coordinate of the country's locality rows. + #[must_use] + pub const fn center(&self) -> Point { + self.center + } +} + #[cfg(test)] mod tests { use super::{Candidate, Point}; @@ -130,16 +188,15 @@ mod tests { #[test] fn candidates_expose_provider_values_without_public_fields() { let point = Point::new(48.4284, -123.3656).expect("valid point"); - let candidate = Candidate { - feature_id: 617_4041, - name: "Victoria".to_owned(), - admin1_id: Some("BC".to_owned()), - admin1_name: Some("British Columbia".to_owned()), - country_id: "CA".to_owned(), - country_name: Some("Canada".to_owned()), + let candidate = Candidate::from_provider_row( + 617_4041, + "Victoria".to_owned(), + Some("BC".to_owned()), + Some("British Columbia".to_owned()), + "CA".to_owned(), + Some("Canada".to_owned()), point, - display_name: "Victoria, British Columbia, Canada".to_owned(), - }; + ); assert_eq!(candidate.feature_id(), 617_4041); assert_eq!(candidate.name(), "Victoria"); assert_eq!(candidate.admin1_id(), Some("BC")); diff --git a/crates/geonames/src/query.rs b/crates/geonames/src/query.rs @@ -1,9 +1,13 @@ //! Validated forward and reverse locality queries. -use crate::{Error, Point}; +use crate::model::Country; +use crate::{Candidate, Error, Point}; const DEFAULT_LIMIT: usize = 10; -const MAX_LIMIT: usize = 100; +const DEFAULT_COUNTRY_LIMIT: usize = 300; +const DEFAULT_REVERSE_RADIUS_DEGREES: f64 = 0.5; +const MAX_LIMIT: usize = 1_000; +const MAX_REVERSE_RADIUS_DEGREES: f64 = 10.0; /// A validated GeoNames lookup request. #[derive(Clone, Debug, PartialEq)] @@ -20,11 +24,58 @@ pub(crate) enum QueryKind { country: Option<String>, }, Freeform(String), - FeatureId(u64), - Reverse(Point), + FeatureId(i64), + Reverse { + point: Point, + radius_degrees: f64, + }, Countries, } +/// Results from one [`Query`], with provider-owned storage kept private. +#[derive(Clone, Debug, PartialEq)] +pub struct QueryResult { + kind: QueryResultKind, +} + +#[derive(Clone, Debug, PartialEq)] +enum QueryResultKind { + Candidates(Vec<Candidate>), + Countries(Vec<Country>), +} + +impl QueryResult { + pub(crate) fn candidates(candidates: Vec<Candidate>) -> Self { + Self { + kind: QueryResultKind::Candidates(candidates), + } + } + + pub(crate) fn countries(countries: Vec<Country>) -> Self { + Self { + kind: QueryResultKind::Countries(countries), + } + } + + /// Returns locality candidates, or `None` for a country-list result. + #[must_use] + pub fn as_candidates(&self) -> Option<&[Candidate]> { + match &self.kind { + QueryResultKind::Candidates(candidates) => Some(candidates), + QueryResultKind::Countries(_) => None, + } + } + + /// Returns countries, or `None` for a locality result. + #[must_use] + pub fn as_countries(&self) -> Option<&[Country]> { + match &self.kind { + QueryResultKind::Candidates(_) => None, + QueryResultKind::Countries(countries) => Some(countries), + } + } +} + impl Query { /// Creates a structured locality query. pub fn locality(locality: impl Into<String>) -> Result<Self, Error> { @@ -47,19 +98,23 @@ impl Query { } /// Creates an exact GeoNames feature query. - #[must_use] - pub const fn feature_id(feature_id: u64) -> Self { - Self { - kind: QueryKind::FeatureId(feature_id), + pub fn feature_id(feature_id: u64) -> Result<Self, Error> { + Ok(Self { + kind: QueryKind::FeatureId( + i64::try_from(feature_id).map_err(|_| Error::InvalidFeatureId)?, + ), limit: 1, - } + }) } /// Creates a nearest-locality query around an explicit point. #[must_use] pub const fn reverse(point: Point) -> Self { Self { - kind: QueryKind::Reverse(point), + kind: QueryKind::Reverse { + point, + radius_degrees: DEFAULT_REVERSE_RADIUS_DEGREES, + }, limit: 1, } } @@ -69,7 +124,7 @@ impl Query { pub const fn countries() -> Self { Self { kind: QueryKind::Countries, - limit: DEFAULT_LIMIT, + limit: DEFAULT_COUNTRY_LIMIT, } } @@ -106,6 +161,25 @@ impl Query { Ok(self) } + /// Sets the square prefilter radius for a reverse query. + pub fn with_radius_degrees(mut self, radius_degrees: f64) -> Result<Self, Error> { + if !radius_degrees.is_finite() + || !(0.0..=MAX_REVERSE_RADIUS_DEGREES).contains(&radius_degrees) + || radius_degrees == 0.0 + { + return Err(Error::InvalidQueryRadius); + } + let QueryKind::Reverse { + radius_degrees: current, + .. + } = &mut self.kind + else { + return Err(Error::QueryOptionNotApplicable); + }; + *current = radius_degrees; + Ok(self) + } + /// Returns the maximum result count. #[must_use] pub const fn limit(&self) -> usize { @@ -138,7 +212,7 @@ impl Query { #[must_use] pub fn exact_feature_id(&self) -> Option<u64> { match &self.kind { - QueryKind::FeatureId(value) => Some(*value), + QueryKind::FeatureId(value) => u64::try_from(*value).ok(), _ => None, } } @@ -147,7 +221,7 @@ impl Query { #[must_use] pub fn reverse_point(&self) -> Option<Point> { match &self.kind { - QueryKind::Reverse(value) => Some(*value), + QueryKind::Reverse { point, .. } => Some(*point), _ => None, } } @@ -198,7 +272,7 @@ mod tests { assert_eq!(Query::locality(""), Err(Error::InvalidQueryText)); assert_eq!(Query::freeform(" Victoria "), Err(Error::InvalidQueryText)); assert_eq!( - Query::feature_id(1).with_country("CA"), + Query::feature_id(1).and_then(|query| query.with_country("CA")), Err(Error::QueryOptionNotApplicable) ); assert_eq!( @@ -206,16 +280,27 @@ mod tests { Err(Error::InvalidQueryLimit) ); assert_eq!( - Query::countries().with_limit(101), + Query::countries().with_limit(1_001), Err(Error::InvalidQueryLimit) ); + assert_eq!(Query::feature_id(u64::MAX), Err(Error::InvalidFeatureId)); } #[test] fn exact_reverse_and_country_queries_have_bounded_defaults() { let point = Point::new(48.4284, -123.3656).expect("point"); - assert_eq!(Query::feature_id(617_4041).limit(), 1); + assert_eq!(Query::feature_id(6_174_041).expect("feature").limit(), 1); assert_eq!(Query::reverse(point).limit(), 1); - assert_eq!(Query::countries().limit(), 10); + assert_eq!(Query::countries().limit(), 300); + assert_eq!( + Query::reverse(point).with_radius_degrees(0.0), + Err(Error::InvalidQueryRadius) + ); + assert_eq!( + Query::locality("Victoria") + .expect("locality") + .with_radius_degrees(1.0), + Err(Error::QueryOptionNotApplicable) + ); } }