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 }