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:
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)
+ );
}
}