lib

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

query.rs (11328B)


      1 //! Validated forward and reverse locality queries.
      2 
      3 use crate::model::Country;
      4 use crate::{Candidate, Error, Point};
      5 
      6 const DEFAULT_LIMIT: usize = 10;
      7 const DEFAULT_COUNTRY_LIMIT: usize = 300;
      8 const DEFAULT_REVERSE_RADIUS_DEGREES: f64 = 0.5;
      9 const MAX_LIMIT: usize = 1_000;
     10 const MAX_REVERSE_RADIUS_DEGREES: f64 = 10.0;
     11 
     12 /// A validated GeoNames lookup request.
     13 #[derive(Clone, Debug, PartialEq)]
     14 pub struct Query {
     15     pub(crate) kind: QueryKind,
     16     limit: usize,
     17 }
     18 
     19 #[derive(Clone, Debug, PartialEq)]
     20 pub(crate) enum QueryKind {
     21     Locality {
     22         locality: String,
     23         region: Option<String>,
     24         country: Option<String>,
     25     },
     26     Freeform(String),
     27     FeatureId(i64),
     28     Reverse {
     29         point: Point,
     30         radius_degrees: f64,
     31     },
     32     Countries,
     33 }
     34 
     35 /// Results from one [`Query`], with provider-owned storage kept private.
     36 #[derive(Clone, Debug, PartialEq)]
     37 pub struct QueryResult {
     38     kind: QueryResultKind,
     39 }
     40 
     41 #[derive(Clone, Debug, PartialEq)]
     42 enum QueryResultKind {
     43     Candidates(Vec<Candidate>),
     44     Countries(Vec<Country>),
     45 }
     46 
     47 impl QueryResult {
     48     pub(crate) fn candidates(candidates: Vec<Candidate>) -> Self {
     49         Self {
     50             kind: QueryResultKind::Candidates(candidates),
     51         }
     52     }
     53 
     54     pub(crate) fn countries(countries: Vec<Country>) -> Self {
     55         Self {
     56             kind: QueryResultKind::Countries(countries),
     57         }
     58     }
     59 
     60     /// Returns locality candidates, or `None` for a country-list result.
     61     #[must_use]
     62     pub fn as_candidates(&self) -> Option<&[Candidate]> {
     63         match &self.kind {
     64             QueryResultKind::Candidates(candidates) => Some(candidates),
     65             QueryResultKind::Countries(_) => None,
     66         }
     67     }
     68 
     69     /// Returns countries, or `None` for a locality result.
     70     #[must_use]
     71     pub fn as_countries(&self) -> Option<&[Country]> {
     72         match &self.kind {
     73             QueryResultKind::Candidates(_) => None,
     74             QueryResultKind::Countries(countries) => Some(countries),
     75         }
     76     }
     77 }
     78 
     79 impl Query {
     80     /// Creates a structured locality query.
     81     pub fn locality(locality: impl Into<String>) -> Result<Self, Error> {
     82         Ok(Self {
     83             kind: QueryKind::Locality {
     84                 locality: normalized_query_text(locality)?,
     85                 region: None,
     86                 country: None,
     87             },
     88             limit: DEFAULT_LIMIT,
     89         })
     90     }
     91 
     92     /// Creates a free-form locality query.
     93     pub fn freeform(query: impl Into<String>) -> Result<Self, Error> {
     94         Ok(Self {
     95             kind: QueryKind::Freeform(normalized_query_text(query)?),
     96             limit: DEFAULT_LIMIT,
     97         })
     98     }
     99 
    100     /// Creates an exact GeoNames feature query.
    101     pub fn feature_id(feature_id: u64) -> Result<Self, Error> {
    102         Ok(Self {
    103             kind: QueryKind::FeatureId(
    104                 i64::try_from(feature_id).map_err(|_| Error::InvalidFeatureId)?,
    105             ),
    106             limit: 1,
    107         })
    108     }
    109 
    110     /// Creates a nearest-locality query around an explicit point.
    111     #[must_use]
    112     pub const fn reverse(point: Point) -> Self {
    113         Self {
    114             kind: QueryKind::Reverse {
    115                 point,
    116                 radius_degrees: DEFAULT_REVERSE_RADIUS_DEGREES,
    117             },
    118             limit: 1,
    119         }
    120     }
    121 
    122     /// Creates a deterministic country-list query.
    123     #[must_use]
    124     pub const fn countries() -> Self {
    125         Self {
    126             kind: QueryKind::Countries,
    127             limit: DEFAULT_COUNTRY_LIMIT,
    128         }
    129     }
    130 
    131     /// Narrows a structured locality query by administrative region.
    132     pub fn with_region(mut self, region: impl Into<String>) -> Result<Self, Error> {
    133         let QueryKind::Locality {
    134             region: current, ..
    135         } = &mut self.kind
    136         else {
    137             return Err(Error::QueryOptionNotApplicable);
    138         };
    139         *current = Some(normalized_query_text(region)?);
    140         Ok(self)
    141     }
    142 
    143     /// Narrows a structured locality query by country identifier or name.
    144     pub fn with_country(mut self, country: impl Into<String>) -> Result<Self, Error> {
    145         let QueryKind::Locality {
    146             country: current, ..
    147         } = &mut self.kind
    148         else {
    149             return Err(Error::QueryOptionNotApplicable);
    150         };
    151         *current = Some(normalized_query_text(country)?);
    152         Ok(self)
    153     }
    154 
    155     /// Sets the maximum result count.
    156     pub fn with_limit(mut self, limit: usize) -> Result<Self, Error> {
    157         if !(1..=MAX_LIMIT).contains(&limit) {
    158             return Err(Error::InvalidQueryLimit);
    159         }
    160         self.limit = limit;
    161         Ok(self)
    162     }
    163 
    164     /// Sets the square prefilter radius for a reverse query.
    165     pub fn with_radius_degrees(mut self, radius_degrees: f64) -> Result<Self, Error> {
    166         if !radius_degrees.is_finite()
    167             || !(0.0..=MAX_REVERSE_RADIUS_DEGREES).contains(&radius_degrees)
    168             || radius_degrees == 0.0
    169         {
    170             return Err(Error::InvalidQueryRadius);
    171         }
    172         let QueryKind::Reverse {
    173             radius_degrees: current,
    174             ..
    175         } = &mut self.kind
    176         else {
    177             return Err(Error::QueryOptionNotApplicable);
    178         };
    179         *current = radius_degrees;
    180         Ok(self)
    181     }
    182 
    183     /// Returns the maximum result count.
    184     #[must_use]
    185     pub const fn limit(&self) -> usize {
    186         self.limit
    187     }
    188 
    189     /// Returns structured locality fields when this is a locality query.
    190     #[must_use]
    191     pub fn locality_fields(&self) -> Option<(&str, Option<&str>, Option<&str>)> {
    192         match &self.kind {
    193             QueryKind::Locality {
    194                 locality,
    195                 region,
    196                 country,
    197             } => Some((locality, region.as_deref(), country.as_deref())),
    198             _ => None,
    199         }
    200     }
    201 
    202     /// Returns free-form text when this is a free-form query.
    203     #[must_use]
    204     pub fn freeform_text(&self) -> Option<&str> {
    205         match &self.kind {
    206             QueryKind::Freeform(value) => Some(value),
    207             _ => None,
    208         }
    209     }
    210 
    211     /// Returns the feature identifier when this is an exact feature query.
    212     #[must_use]
    213     pub fn exact_feature_id(&self) -> Option<u64> {
    214         match &self.kind {
    215             QueryKind::FeatureId(value) => u64::try_from(*value).ok(),
    216             _ => None,
    217         }
    218     }
    219 
    220     /// Returns the point when this is a reverse query.
    221     #[must_use]
    222     pub fn reverse_point(&self) -> Option<Point> {
    223         match &self.kind {
    224             QueryKind::Reverse { point, .. } => Some(*point),
    225             _ => None,
    226         }
    227     }
    228 
    229     /// Returns whether this query requests the country list.
    230     #[must_use]
    231     pub fn is_country_list(&self) -> bool {
    232         matches!(&self.kind, QueryKind::Countries)
    233     }
    234 }
    235 
    236 fn normalized_query_text(value: impl Into<String>) -> Result<String, Error> {
    237     let value = value.into();
    238     if value.is_empty() || value.trim() != value {
    239         return Err(Error::InvalidQueryText);
    240     }
    241     Ok(value)
    242 }
    243 
    244 #[cfg(test)]
    245 mod tests {
    246     use super::{Query, QueryKind, QueryResult};
    247     use crate::model::Country;
    248     use crate::{Candidate, Error, Point};
    249 
    250     #[test]
    251     fn structured_queries_keep_normalized_filters_private() {
    252         let query = Query::locality("Victoria")
    253             .expect("locality")
    254             .with_region("British Columbia")
    255             .expect("region")
    256             .with_country("CA")
    257             .expect("country")
    258             .with_limit(7)
    259             .expect("limit");
    260         assert_eq!(query.limit(), 7);
    261         assert_eq!(
    262             query.kind,
    263             QueryKind::Locality {
    264                 locality: "Victoria".to_owned(),
    265                 region: Some("British Columbia".to_owned()),
    266                 country: Some("CA".to_owned()),
    267             }
    268         );
    269     }
    270 
    271     #[test]
    272     fn query_constructors_reject_ambiguous_or_unbounded_input() {
    273         assert_eq!(Query::locality(""), Err(Error::InvalidQueryText));
    274         assert_eq!(Query::freeform(" Victoria "), Err(Error::InvalidQueryText));
    275         assert_eq!(
    276             Query::feature_id(1).and_then(|query| query.with_country("CA")),
    277             Err(Error::QueryOptionNotApplicable)
    278         );
    279         assert_eq!(
    280             Query::countries().with_limit(0),
    281             Err(Error::InvalidQueryLimit)
    282         );
    283         assert_eq!(
    284             Query::countries().with_limit(1_001),
    285             Err(Error::InvalidQueryLimit)
    286         );
    287         assert_eq!(Query::feature_id(u64::MAX), Err(Error::InvalidFeatureId));
    288     }
    289 
    290     #[test]
    291     fn exact_reverse_and_country_queries_have_bounded_defaults() {
    292         let point = Point::new(48.4284, -123.3656).expect("point");
    293         assert_eq!(Query::feature_id(6_174_041).expect("feature").limit(), 1);
    294         assert_eq!(Query::reverse(point).limit(), 1);
    295         assert_eq!(Query::countries().limit(), 300);
    296         assert_eq!(
    297             Query::reverse(point).with_radius_degrees(0.0),
    298             Err(Error::InvalidQueryRadius)
    299         );
    300         assert_eq!(
    301             Query::locality("Victoria")
    302                 .expect("locality")
    303                 .with_radius_degrees(1.0),
    304             Err(Error::QueryOptionNotApplicable)
    305         );
    306         assert_eq!(
    307             Query::reverse(point).with_radius_degrees(f64::NAN),
    308             Err(Error::InvalidQueryRadius)
    309         );
    310         assert_eq!(
    311             Query::reverse(point).with_radius_degrees(10.1),
    312             Err(Error::InvalidQueryRadius)
    313         );
    314     }
    315 
    316     #[test]
    317     fn query_and_result_accessors_distinguish_every_kind() {
    318         let point = Point::new(48.4284, -123.3656).expect("point");
    319         let locality = Query::locality("Victoria").expect("locality");
    320         assert_eq!(locality.locality_fields(), Some(("Victoria", None, None)));
    321         assert_eq!(locality.freeform_text(), None);
    322         assert_eq!(locality.exact_feature_id(), None);
    323         assert_eq!(locality.reverse_point(), None);
    324         assert!(!locality.is_country_list());
    325 
    326         let freeform = Query::freeform("Victoria, BC, CA").expect("freeform");
    327         assert_eq!(freeform.locality_fields(), None);
    328         assert_eq!(freeform.freeform_text(), Some("Victoria, BC, CA"));
    329 
    330         let feature = Query::feature_id(42).expect("feature");
    331         assert_eq!(feature.exact_feature_id(), Some(42));
    332         assert_eq!(feature.freeform_text(), None);
    333 
    334         let reverse = Query::reverse(point);
    335         assert_eq!(reverse.reverse_point(), Some(point));
    336         assert_eq!(reverse.exact_feature_id(), None);
    337 
    338         let countries = Query::countries();
    339         assert!(countries.is_country_list());
    340         assert_eq!(countries.reverse_point(), None);
    341 
    342         let candidate = Candidate::from_provider_row(
    343             1,
    344             "Victoria".to_owned(),
    345             None,
    346             None,
    347             "CA".to_owned(),
    348             None,
    349             point,
    350         );
    351         let candidate_result = QueryResult::candidates(vec![candidate]);
    352         assert_eq!(candidate_result.as_candidates().map(<[_]>::len), Some(1));
    353         assert_eq!(candidate_result.as_countries(), None);
    354 
    355         let country = Country::from_provider_row("CA".to_owned(), None, point);
    356         let country_result = QueryResult::countries(vec![country]);
    357         assert_eq!(country_result.as_candidates(), None);
    358         assert_eq!(country_result.as_countries().map(<[_]>::len), Some(1));
    359     }
    360 }