Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Geoquery evaluation (CIM 009 4.10) — in-memory, over GeoJSON values,
3 : //! via `geo`'s DE-9IM relate (polygon holes, edge-crossing
4 : //! intersects, line/line, MultiPolygon, topological equals — the planar
5 : //! approximations this file used to carry are retired).
6 : //!
7 : //! The query geometry is parsed ONCE at construction; targets are parsed per
8 : //! evaluation. `near` is the metric minimum distance between the two
9 : //! geometries (`min_distance_m`: local equirectangular projection, so
10 : //! closest-point selection is metric and extended reference/target
11 : //! geometries measure from their true closest points); the small
12 : //! equirectangular residual vs PostGIS `ST_DWithin` on geography is the
13 : //! remaining documented ceiling (the SQL path is the metric authority).
14 : //! Known ceiling: per-call relate without a prepared edge index — a
15 : //! PreparedGeometry cache is the matcher lever when 10k subscriptions
16 : //! demand it.
17 :
18 : use antares_jsonld::Context;
19 : use antares_model::NgsiError;
20 : use geo::Relate;
21 : use serde_json::Value;
22 : use std::collections::HashMap;
23 :
24 : /// The default GeoProperty — the only one backends extract a column for.
25 : pub const LOCATION_IRI: &str = "https://uri.etsi.org/ngsi-ld/location";
26 :
27 : /// Vertices one query geometry may carry; above it the parse is refused
28 : /// (BadRequestData), so a relate never runs over an unbounded ring.
29 : pub const MAX_GEO_VERTICES: usize = 1024;
30 :
31 : /// `georel` as parsed from the request (4.10), distances in metres.
32 : #[derive(Debug, Clone, PartialEq)]
33 : pub enum Rel {
34 : /// `near;maxDistance==…` / `near;minDistance==…` — either bound may be absent.
35 : Near {
36 : /// `maxDistance` in metres.
37 : max: Option<f64>,
38 : /// `minDistance` in metres.
39 : min: Option<f64>,
40 : },
41 : /// `within`
42 : Within,
43 : /// `contains`
44 : Contains,
45 : /// `intersects`
46 : Intersects,
47 : /// `disjoint`
48 : Disjoint,
49 : /// `overlaps`
50 : Overlaps,
51 : /// `equals`
52 : Equals,
53 : }
54 :
55 : /// The parsed relation of a [`GeoQuery`].
56 : pub type Georel = Rel;
57 :
58 : /// A geoquery in the borrowed shape the SQL compilers take (4.10 params
59 : /// as the API already validated them).
60 : #[derive(Clone)]
61 : pub struct GeoSpec<'a> {
62 : /// The relation.
63 : pub rel: Rel,
64 : /// GeoJSON geometry type of the query geometry.
65 : pub geometry: &'a str,
66 : /// The query geometry's coordinates.
67 : pub coordinates: &'a Value,
68 : /// EXPANDED `geoproperty`; empty means the default (`location`).
69 : pub geoproperty_iri: &'a str,
70 : }
71 :
72 : /// A parsed 4.10 geoquery: the query geometry is parsed once at construction.
73 : #[derive(Debug, Clone, PartialEq)]
74 : pub struct GeoQuery {
75 : /// The relation.
76 : pub rel: Georel,
77 : /// GeoJSON geometry type of the query geometry.
78 : pub geometry: String,
79 : /// The query geometry's coordinates.
80 : pub coordinates: Value,
81 : /// The `geoproperty` term as sent (empty = default `location`).
82 : pub geoproperty: String,
83 : /// Parsed once at construction.
84 : query_geom: geo_types::Geometry<f64>,
85 : }
86 :
87 : /// Coordinate positions in a GeoJSON `coordinates` value — the leaves of its
88 : /// nested arrays, at whatever depth the geometry type nests them.
89 25595 : fn count_positions(v: &Value) -> usize {
90 25595 : match v.as_array() {
91 25593 : Some(a) if a.first().is_some_and(Value::is_array) => a.iter().map(count_positions).sum(),
92 22273 : Some(_) => 1,
93 2 : None => 0,
94 : }
95 25595 : }
96 :
97 : /// Every position of a geometry a REQUEST carries in is an edge walked once
98 : /// per candidate entity (`relate`, `min_distance_m`), so the count is bounded
99 : /// by `bounds::MAX_GEO_VERTICES` before the geometry is built. Geometries
100 : /// already stored as entity attributes are not capped — they are the targets,
101 : /// evaluated once each.
102 1879 : fn check_vertex_budget(coords: &Value) -> Result<(), String> {
103 1879 : let n = count_positions(coords);
104 1879 : if n > MAX_GEO_VERTICES {
105 8 : return Err(format!(
106 8 : "geometry has {n} coordinate positions (maximum {})",
107 8 : MAX_GEO_VERTICES
108 8 : ));
109 1871 : }
110 1871 : Ok(())
111 1879 : }
112 :
113 : /// 4.23: reference geometry for distance ordering (orderFrom/orderGeometry),
114 : /// also the 4.7 well-formedness check for a registration's geometries.
115 92 : pub fn parse_ref_geometry(gtype: &str, coords: &Value) -> Result<geo_types::Geometry<f64>, String> {
116 92 : check_vertex_budget(coords)?;
117 90 : parse_geometry(gtype, coords)
118 92 : }
119 :
120 : /// 4.23: metres from the reference geometry to a target GeoJSON value —
121 : /// the same metric minimum distance as `near` (4.10). None when the target
122 : /// is not a valid geometry.
123 106 : pub fn order_distance_m(refg: &geo_types::Geometry<f64>, target: &Value) -> Option<f64> {
124 92 : let (t, c) = (
125 106 : target.get("type").and_then(Value::as_str)?,
126 94 : target.get("coordinates")?,
127 : );
128 92 : let target = parse_geometry(t, c).ok()?;
129 90 : Some(min_distance_m(refg, &target))
130 106 : }
131 :
132 : /// metres per degree of latitude (WGS-84 mean)
133 : const DEG_M: f64 = 111_319.490_793;
134 :
135 : /// 4.10 near / 4.23 ordering: minimum distance in metres between two
136 : /// geometries. Both are projected into a local equirectangular plane
137 : /// (x = lon·cos(lat₀)), so closest-point selection is metric and EXTENDED
138 : /// reference and target geometries both measure from their true closest
139 : /// points; intersecting/containing pairs are distance 0.
140 : /// Known ceiling: equirectangular residual (<~0.5 % over sub-1000 km spans, no
141 : /// antimeridian wrap) — the PostGIS geography path stays the metric
142 : /// authority; geodesic segment distance if a geo TP ever demands exactness.
143 725 : fn min_distance_m(a: &geo_types::Geometry<f64>, b: &geo_types::Geometry<f64>) -> f64 {
144 : use geo::algorithm::line_measures::{Distance, Euclidean};
145 : use geo::algorithm::{BoundingRect, MapCoords};
146 725 : let mid_lat =
147 1450 : |g: &geo_types::Geometry<f64>| g.bounding_rect().map(|r| (r.min().y + r.max().y) / 2.0);
148 725 : let (Some(la), Some(lb)) = (mid_lat(a), mid_lat(b)) else {
149 : // A geometry with no position has no bounding box. RFC 7946 3.1:
150 : // "GeoJSON processors MAY interpret Geometry objects with empty
151 : // `coordinates` arrays as null objects" — nothing is near a null
152 : // object, and reading the absent box as the origin made an empty
153 : // stored geometry match every `near` query in the tenant.
154 10 : return f64::INFINITY;
155 : };
156 715 : let lat0 = (la + lb) / 2.0;
157 715 : let k = lat0.to_radians().cos().max(1e-9);
158 715 : let proj =
159 1526 : |g: &geo_types::Geometry<f64>| g.map_coords(|c| geo_types::Coord { x: c.x * k, y: c.y });
160 715 : Euclidean.distance(&proj(a), &proj(b)) * DEG_M
161 725 : }
162 :
163 : /// GeoJSON `{type, coordinates}` → geo_types. 4.7.2 admits a geometry only
164 : /// when it meets "the syntax and restrictions mandated by IETF RFC 7946 \[8\]
165 : /// when representing a valid Geometry of the type specified", so the shape
166 : /// and the WGS84 coordinate range are checked by the one validator the write
167 : /// path uses: a reference geometry the broker would not have stored is a 400,
168 : /// and a target that is not a geometry is a non-match.
169 4924 : fn parse_geometry(gtype: &str, coords: &Value) -> Result<geo_types::Geometry<f64>, String> {
170 4924 : antares_jsonld::expand::check_geometry(gtype, coords)?;
171 4878 : let gj = serde_json::json!({"type": gtype, "coordinates": coords});
172 4878 : let geom: geojson::Geometry =
173 4878 : serde_json::from_value(gj).map_err(|e| format!("invalid GeoJSON geometry: {e}"))?;
174 4878 : geo_types::Geometry::<f64>::try_from(geom).map_err(|e| format!("invalid geometry: {e}"))
175 4924 : }
176 :
177 : impl GeoQuery {
178 : /// Build from query params; `None` when no georel present. Validates per
179 : /// 4.10 (georel present ⇒ geometry+coordinates required and well-formed).
180 : /// The 4.11 twin is `antares_api::temporal::TemporalQ::from_params`: the
181 : /// name is the workspace convention for one query family parsed from its
182 : /// own parameters, and the two share no code.
183 10959 : pub fn from_params(params: &HashMap<String, String>) -> Result<Option<Self>, NgsiError> {
184 10959 : let Some(georel) = params.get("georel") else {
185 9034 : if params.contains_key("geometry") || params.contains_key("coordinates") {
186 12 : return Err(NgsiError::BadRequestData(
187 12 : "geometry/coordinates given without georel".into(),
188 12 : ));
189 9022 : }
190 9022 : return Ok(None);
191 : };
192 1925 : let bad = NgsiError::BadRequestData;
193 1925 : let mut parts = georel.split(';');
194 1925 : let base = parts.next().unwrap_or("").trim();
195 1925 : let mut max = None;
196 1925 : let mut min = None;
197 : // 4.10 PositiveNumber: RFC 8259 Number "excluding the 'minus' symbol
198 : // and excluding the number 0".
199 1925 : let positive = |v: &str, name: &str| -> Result<f64, NgsiError> {
200 657 : let n = v
201 657 : .parse::<f64>()
202 657 : .map_err(|_| bad(format!("invalid {name} {v:?}")))?;
203 : // NaN and the infinities parse as valid f64 but are not RFC 8259
204 : // Numbers; the Greater-only comparison rejects NaN as well.
205 655 : if !n.is_finite() || n.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
206 50 : return Err(bad(format!("{name} must be a positive non-zero number")));
207 605 : }
208 605 : Ok(n)
209 657 : };
210 1925 : for p in parts {
211 673 : let p = p.trim();
212 : // 4.10 nearRel = nearOp andOp distance equal PositiveNumber —
213 : // exactly one distance modifier is in the grammar.
214 673 : if max.is_some() || min.is_some() {
215 14 : return Err(bad("near takes a single distance modifier".into()));
216 659 : }
217 659 : if let Some(v) = p.strip_prefix("maxDistance==") {
218 533 : max = Some(positive(v, "maxDistance")?);
219 126 : } else if let Some(v) = p.strip_prefix("minDistance==") {
220 124 : min = Some(positive(v, "minDistance")?);
221 : } else {
222 2 : return Err(bad(format!("invalid georel modifier {p:?}")));
223 : }
224 : }
225 1857 : if base != "near" && (max.is_some() || min.is_some()) {
226 6 : return Err(bad(format!("georel {base} takes no distance (4.10)")));
227 1851 : }
228 1851 : let rel = match base {
229 1851 : "near" => {
230 603 : if max.is_none() && min.is_none() {
231 18 : return Err(bad("near requires maxDistance or minDistance".into()));
232 585 : }
233 585 : Georel::Near { max, min }
234 : }
235 1248 : "within" => Georel::Within,
236 894 : "contains" => Georel::Contains,
237 736 : "intersects" => Georel::Intersects,
238 445 : "equals" => Georel::Equals,
239 298 : "disjoint" => Georel::Disjoint,
240 40 : "overlaps" => Georel::Overlaps,
241 26 : other => return Err(bad(format!("invalid georel {other:?}"))),
242 : };
243 1807 : let geometry = params
244 1807 : .get("geometry")
245 1807 : .ok_or_else(|| bad("georel requires geometry".into()))?
246 1801 : .clone();
247 : const GEOMETRIES: &[&str] = &[
248 : "Point",
249 : "MultiPoint",
250 : "LineString",
251 : "MultiLineString",
252 : "Polygon",
253 : "MultiPolygon",
254 : ];
255 1801 : if !GEOMETRIES.contains(&geometry.as_str()) {
256 8 : return Err(bad(format!("invalid geometry {geometry:?}")));
257 1793 : }
258 1793 : let coords_raw = params
259 1793 : .get("coordinates")
260 1793 : .ok_or_else(|| bad("georel requires coordinates".into()))?;
261 1791 : let coordinates: Value = serde_json::from_str(coords_raw)
262 1791 : .map_err(|_| bad(format!("invalid coordinates {coords_raw:?}")))?;
263 1789 : if !coordinates.is_array() {
264 2 : return Err(bad("coordinates must be a JSON array".into()));
265 1787 : }
266 1787 : check_vertex_budget(&coordinates).map_err(bad)?;
267 1781 : let query_geom = parse_geometry(&geometry, &coordinates).map_err(bad)?;
268 1751 : Ok(Some(Self {
269 1751 : rel,
270 1751 : geometry,
271 1751 : coordinates,
272 1751 : geoproperty: params.get("geoproperty").cloned().unwrap_or_default(),
273 1751 : query_geom,
274 1751 : }))
275 10959 : }
276 :
277 : /// The entity against the query: every instance of the target GeoProperty
278 : /// (the default `location`, or the expanded `geoproperty`) is a candidate.
279 4157 : pub fn matches(&self, doc: &Value, ctx: &Context) -> bool {
280 4157 : let iri = if self.geoproperty.is_empty() {
281 4105 : LOCATION_IRI.to_owned()
282 : } else {
283 52 : ctx.expand_key(&self.geoproperty)
284 : };
285 4157 : let Some(instances) = doc.get(&iri).and_then(Value::as_array) else {
286 1386 : return false;
287 : };
288 2783 : instances.iter().any(|inst| {
289 2773 : inst.get("value")
290 2773 : .is_some_and(|geo| self.matches_geometry(geo))
291 2773 : })
292 4157 : }
293 :
294 : /// The same query, in the shape `antares-sql` compiles to PostGIS.
295 : /// `geoproperty` is expanded here because the compiler only pushes down
296 : /// the DEFAULT GeoProperty — the one with an extracted column.
297 1196 : pub fn to_sql_spec<'a>(&'a self, ctx: &antares_jsonld::Context) -> Option<GeoSpec<'a>> {
298 1196 : let (spec, iri) = self.to_instance_spec(ctx);
299 1196 : (iri == LOCATION_IRI).then_some(spec)
300 1196 : }
301 :
302 : /// 5.7.4.4 S3: the prefilter shape for the temporal store — like
303 : /// `to_sql_spec` but for the per-instance `attr_instances.geo_value`
304 : /// rows, where EVERY geoproperty has extracted geometries; returns the
305 : /// spec plus the expanded IRI the windowed EXISTS binds as `attr_id`.
306 1252 : pub fn to_instance_spec<'a>(&'a self, ctx: &antares_jsonld::Context) -> (GeoSpec<'a>, String) {
307 1252 : let rel = self.rel.clone();
308 1252 : let iri = if self.geoproperty.is_empty() {
309 1234 : LOCATION_IRI.to_owned()
310 : } else {
311 18 : ctx.expand_key(&self.geoproperty)
312 : };
313 1252 : (
314 1252 : GeoSpec {
315 1252 : rel,
316 1252 : geometry: &self.geometry,
317 1252 : coordinates: &self.coordinates,
318 1252 : geoproperty_iri: "",
319 1252 : },
320 1252 : iri,
321 1252 : )
322 1252 : }
323 :
324 : /// One target GeoJSON value against the query. A malformed TARGET is a
325 : /// non-match (queries 400 at parse; stored data must never 500 a read).
326 2965 : pub fn matches_geometry(&self, geo: &Value) -> bool {
327 2961 : let (Some(t), Some(c)) = (
328 2965 : geo.get("type").and_then(Value::as_str),
329 2965 : geo.get("coordinates"),
330 : ) else {
331 4 : return false;
332 : };
333 2961 : let Ok(target) = parse_geometry(t, c) else {
334 14 : return false;
335 : };
336 2947 : let q = &self.query_geom;
337 2947 : match &self.rel {
338 635 : Georel::Near { max, min } => {
339 : // 4.10: metric minimum distance between the geometries;
340 : // maxDistance = within the (closed) buffer => d <= max;
341 : // minDistance = DISJOINT with the buffer — boundary contact
342 : // is not disjoint => strictly d > min.
343 635 : let d = min_distance_m(q, &target);
344 635 : max.is_none_or(|m| d <= m) && min.is_none_or(|m| d > m)
345 : }
346 : Georel::Equals => {
347 : // literal-identical geometry is equal even when the ring is
348 : // technically invalid (self-intersecting fixtures exist in
349 : // the wild — DE-9IM is undefined there); topo-equal covers
350 : // reordered-but-equivalent rings.
351 194 : (t == self.geometry && *c == self.coordinates) || target.relate(q).is_equal_topo()
352 : }
353 937 : Georel::Within => target.relate(q).is_within(),
354 243 : Georel::Contains => target.relate(q).is_contains(),
355 460 : Georel::Intersects => target.relate(q).is_intersects(),
356 458 : Georel::Disjoint => !target.relate(q).is_intersects(),
357 20 : Georel::Overlaps => target.relate(q).is_overlaps(),
358 : }
359 2965 : }
360 : }
361 :
362 : #[cfg(test)]
363 : mod tests {
364 : use super::*;
365 : use serde_json::json;
366 :
367 66 : fn q(rel: &str, gtype: &str, coords: &str) -> GeoQuery {
368 66 : let mut params = HashMap::new();
369 66 : params.insert("georel".to_owned(), rel.to_owned());
370 66 : params.insert("geometry".to_owned(), gtype.to_owned());
371 66 : params.insert("coordinates".to_owned(), coords.to_owned());
372 66 : GeoQuery::from_params(¶ms).unwrap().unwrap()
373 66 : }
374 :
375 84 : fn geoval(gtype: &str, coords: Value) -> Value {
376 84 : json!({"type": gtype, "coordinates": coords})
377 84 : }
378 :
379 : /// 4.10 minDistance: farther than the minimum matches, nearer does not.
380 : #[test]
381 2 : fn min_distance_keeps_only_the_farther_side() {
382 2 : let g = q("near;minDistance==1000", "Point", "[10,60]");
383 2 : assert!(g.matches_geometry(&geoval("Point", json!([10.0, 61.0]))));
384 2 : assert!(!g.matches_geometry(&geoval("Point", json!([10.0, 60.001]))));
385 2 : }
386 :
387 : /// A self-intersecting (bow-tie) query ring is evaluated, not refused: ETSI `019_11_06`
388 : /// queries `within` such a polygon and expects its match. Every area relation answers
389 : /// without a panic, and a point inside one lobe is `within` it.
390 : #[test]
391 2 : fn an_area_relation_evaluates_a_self_intersecting_ring() {
392 2 : let ring = "[[[0,0],[2,2],[2,0],[0,2],[0,0]]]";
393 2 : let inside = geoval("Point", json!([1.8, 1.0]));
394 10 : for rel in [
395 2 : "contains",
396 2 : "intersects",
397 2 : "overlaps",
398 2 : "disjoint",
399 2 : "near;maxDistance==10",
400 10 : ] {
401 10 : let _ = q(rel, "Polygon", ring).matches_geometry(&inside);
402 10 : }
403 2 : assert!(q("within", "Polygon", ring).matches_geometry(&inside));
404 2 : }
405 :
406 : /// equals: a literally identical geometry is equal even where DE-9IM is
407 : /// undefined, as for a self-intersecting (bow-tie) ring.
408 : #[test]
409 2 : fn equals_holds_for_a_literally_identical_invalid_ring() {
410 2 : let ring = "[[[0,0],[2,2],[2,0],[0,2],[0,0]]]";
411 2 : let g = q("equals", "Polygon", ring);
412 2 : let same: Value = serde_json::from_str(ring).expect("ring");
413 2 : assert!(g.matches_geometry(&geoval("Polygon", same)));
414 2 : }
415 :
416 : /// 4.10 near with an EXTENDED reference geometry: "near;maxDistance==x
417 : /// (in meters)" is the distance to the reference GEOMETRY — measured
418 : /// from its closest point, not from its first coordinate.
419 : #[test]
420 2 : fn clause_4_10_near_extended_reference() {
421 : // LineString [0,60]→[10,60]; the target sits ~1 km north of the
422 : // EAST end (~557 km from the first coordinate)
423 2 : let g = q("near;maxDistance==2000", "LineString", "[[0,60],[10,60]]");
424 2 : assert!(
425 2 : g.matches_geometry(&geoval("Point", json!([10.0, 60.009]))),
426 : "distance must be measured from the closest point of the line"
427 : );
428 : // ~111 km north of the line must NOT match
429 2 : assert!(!g.matches_geometry(&geoval("Point", json!([10.0, 61.0]))));
430 2 : }
431 :
432 : /// 4.10 near: closest-point selection on an extended TARGET is METRIC —
433 : /// at lat 85 a 1°-longitude offset (~9.7 km) is closer than a
434 : /// 0.6°-latitude offset (~67 km), though planar lon/lat says otherwise.
435 : #[test]
436 2 : fn clause_4_10_near_metric_closest_point_high_latitude() {
437 2 : let target = geoval("LineString", json!([[0.0, 85.6], [1.0, 85.0]]));
438 2 : let g = q("near;maxDistance==20000", "Point", "[0,85]");
439 2 : assert!(
440 2 : g.matches_geometry(&target),
441 : "metric closest point is the lon-offset end (~9.7 km)"
442 : );
443 : // ...but it is farther than 5 km — must NOT match
444 2 : let g5 = q("near;maxDistance==5000", "Point", "[0,85]");
445 2 : assert!(!g5.matches_geometry(&target));
446 2 : }
447 :
448 : #[test]
449 2 : fn near_point() {
450 2 : let g = q("near;maxDistance==2000", "Point", "[2.29,48.85]");
451 2 : let ctx = crate::test_core();
452 2 : let doc = json!({
453 2 : "https://uri.etsi.org/ngsi-ld/location": [
454 2 : {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [2.30, 48.86]}}
455 : ]
456 : });
457 2 : assert!(g.matches(&doc, &ctx));
458 2 : let far = json!({
459 2 : "https://uri.etsi.org/ngsi-ld/location": [
460 2 : {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [10.0, 50.0]}}
461 : ]
462 : });
463 2 : assert!(!g.matches(&far, &ctx));
464 2 : }
465 :
466 : #[test]
467 2 : fn within_polygon_and_holes() {
468 : // A polygon HOLE excludes points — the planar approximation
469 : // this replaces got this wrong by only reading outer rings.
470 2 : let g = q(
471 2 : "within",
472 2 : "Polygon",
473 2 : "[[[0,0],[10,0],[10,10],[0,10],[0,0]],[[4,4],[6,4],[6,6],[4,6],[4,4]]]",
474 : );
475 2 : assert!(g.matches_geometry(&geoval("Point", json!([2, 2]))));
476 2 : assert!(
477 2 : !g.matches_geometry(&geoval("Point", json!([5, 5]))),
478 : "point in the hole is NOT within"
479 : );
480 2 : }
481 :
482 : #[test]
483 2 : fn edge_crossing_intersects_and_line_line() {
484 : // two lines crossing mid-edge share no vertex — the old
485 : // shared-point approximation missed this
486 2 : let g = q("intersects", "LineString", "[[0,0],[10,10]]");
487 2 : assert!(g.matches_geometry(&geoval("LineString", json!([[0, 10], [10, 0]]))));
488 2 : assert!(!g.matches_geometry(&geoval("LineString", json!([[20, 20], [30, 30]]))));
489 : // polygon edge crossing without contained vertices
490 2 : let g = q("intersects", "Polygon", "[[[0,0],[4,0],[4,4],[0,4],[0,0]]]");
491 2 : assert!(g.matches_geometry(&geoval(
492 2 : "Polygon",
493 2 : json!([[[-1, 1], [5, 1], [5, 3], [-1, 3], [-1, 1]]])
494 2 : )));
495 2 : }
496 :
497 : #[test]
498 2 : fn multipolygon_and_topological_equals() {
499 2 : let g = q(
500 2 : "within",
501 2 : "MultiPolygon",
502 2 : "[[[[0,0],[4,0],[4,4],[0,4],[0,0]]],[[[10,10],[14,10],[14,14],[10,14],[10,10]]]]",
503 : );
504 2 : assert!(g.matches_geometry(&geoval("Point", json!([12, 12]))));
505 2 : assert!(!g.matches_geometry(&geoval("Point", json!([7, 7]))));
506 : // equals is topological, not literal-coordinate order
507 2 : let g = q("equals", "Polygon", "[[[0,0],[4,0],[4,4],[0,4],[0,0]]]");
508 2 : assert!(g.matches_geometry(&geoval(
509 2 : "Polygon",
510 2 : json!([[[4, 0], [4, 4], [0, 4], [0, 0], [4, 0]]])
511 2 : )));
512 2 : }
513 :
514 : #[test]
515 2 : fn malformed_rings_are_400_on_query_and_nonmatch_on_target() {
516 2 : let mut params = HashMap::new();
517 2 : params.insert("georel".to_owned(), "within".to_owned());
518 2 : params.insert("geometry".to_owned(), "Polygon".to_owned());
519 2 : params.insert("coordinates".to_owned(), "[[[0,0],[4,0],[4,4]]]".to_owned());
520 2 : assert!(
521 2 : GeoQuery::from_params(¶ms).is_err(),
522 : "3-position ring must 400"
523 : );
524 2 : params.insert(
525 2 : "coordinates".to_owned(),
526 2 : "[[[0,0],[4,0],[4,4],[0,4]]]".to_owned(),
527 : );
528 2 : assert!(
529 2 : GeoQuery::from_params(¶ms).is_err(),
530 : "unclosed ring must 400"
531 : );
532 : // stored (target) data malformed: non-match, never an error
533 2 : let g = q("within", "Polygon", "[[[0,0],[4,0],[4,4],[0,4],[0,0]]]");
534 2 : assert!(!g.matches_geometry(&geoval("Polygon", json!([[[0, 0], [4, 0]]]))));
535 2 : }
536 :
537 : #[test]
538 2 : fn rejects_bad_params() {
539 2 : let mut params = HashMap::new();
540 2 : params.insert("georel".to_owned(), "nearish".to_owned());
541 2 : assert!(GeoQuery::from_params(¶ms).is_err());
542 2 : }
543 :
544 : /// 4.10 leaves the size of the reference geometry open, and a POST query
545 : /// carries its coordinates in the body, not the URI. Every position is an
546 : /// edge `relate` walks once per candidate entity, so an over-cap geometry
547 : /// is BadRequestData and NO entity is evaluated.
548 : #[test]
549 : #[cfg_attr(miri, ignore)] // a cap-sized input: past the weekly Miri job's 90 min
550 2 : fn oversized_query_geometry_is_rejected_before_any_entity_is_scanned() {
551 2 : let cap = MAX_GEO_VERTICES;
552 : // a closed ring of n positions on the unit circle
553 4 : let ring = |n: usize| {
554 4 : let pts: Vec<String> = (0..n - 1)
555 4094 : .map(|i| {
556 4094 : let a = std::f64::consts::TAU * i as f64 / (n - 1) as f64;
557 4094 : format!("[{},{}]", a.cos(), a.sin())
558 4094 : })
559 4 : .collect();
560 4 : format!("[[{},{}]]", pts.join(","), pts[0])
561 4 : };
562 2 : let ctx = crate::test_core();
563 2 : let corpus: Vec<Value> = (0..8)
564 16 : .map(|i| {
565 16 : json!({"https://uri.etsi.org/ngsi-ld/location": [
566 16 : {"type": "GeoProperty",
567 16 : "value": {"type": "Point", "coordinates": [i as f64 / 1e3, 0.0]}}
568 : ]})
569 16 : })
570 2 : .collect();
571 : // counts the entities the query actually touches — it can only run
572 : // once a geometry was accepted
573 2 : let scanned = std::cell::Cell::new(0usize);
574 4 : let scan = |params: &HashMap<String, String>| -> Result<(), NgsiError> {
575 4 : let g = GeoQuery::from_params(params)?.expect("georel present");
576 16 : for doc in &corpus {
577 16 : scanned.set(scanned.get() + 1);
578 16 : let _ = g.matches(doc, &ctx);
579 16 : }
580 2 : Ok(())
581 4 : };
582 2 : let mut params = HashMap::new();
583 2 : params.insert("georel".to_owned(), "within".to_owned());
584 2 : params.insert("geometry".to_owned(), "Polygon".to_owned());
585 2 : params.insert("coordinates".to_owned(), ring(cap + 1));
586 2 : assert!(
587 2 : matches!(scan(¶ms), Err(NgsiError::BadRequestData(_))),
588 : "over the cap must be rejected as BadRequestData"
589 : );
590 2 : assert_eq!(
591 2 : scanned.get(),
592 : 0,
593 : "the rejection lands before any entity is evaluated"
594 : );
595 : // …and the ceiling itself still parses — which also proves the
596 : // counter above is not vacuous
597 2 : params.insert("coordinates".to_owned(), ring(cap));
598 2 : assert!(scan(¶ms).is_ok(), "exactly at the cap is accepted");
599 2 : assert_eq!(scanned.get(), corpus.len());
600 : // a MultiPoint spends the same budget across its members
601 2 : params.insert("georel".to_owned(), "intersects".to_owned());
602 2 : params.insert("geometry".to_owned(), "MultiPoint".to_owned());
603 2 : let pts: Vec<String> = (0..=cap)
604 2050 : .map(|i| format!("[{},0]", i as f64 / 1e6))
605 2 : .collect();
606 2 : params.insert("coordinates".to_owned(), format!("[{}]", pts.join(",")));
607 2 : assert!(
608 2 : GeoQuery::from_params(¶ms).is_err(),
609 : "the cap counts leaves, not rings"
610 : );
611 2 : }
612 :
613 : /// A MultiPolygon nests its positions three levels down, so a length
614 : /// check on the top-level array sees a couple of hundred members and
615 : /// passes while the geometry spends more than the whole budget. The cap
616 : /// counts leaves, at whatever depth the geometry type puts them.
617 : #[test]
618 2 : fn nested_multipolygon_cannot_smuggle_vertices_past_the_cap() {
619 2 : let cap = MAX_GEO_VERTICES;
620 : // the squares march along a meridian band, so 205 of them still sit
621 : // inside the WGS84 longitude range a reference geometry must respect
622 410 : let square = |i: usize| {
623 410 : let x = i as f64 * 0.7 - 100.0;
624 410 : json!([[[x, 0.0], [x + 0.5, 0.0], [x + 0.5, 0.5], [x, 0.5], [x, 0.0]]])
625 410 : };
626 2 : let members = cap / 5 + 1; // 5 positions per member => one over the cap
627 2 : let coords: Vec<Value> = (0..members).map(square).collect();
628 2 : assert!(
629 2 : members <= cap,
630 : "a top-level length check would see {members} members and pass"
631 : );
632 2 : let mut params = HashMap::new();
633 2 : params.insert("georel".to_owned(), "intersects".to_owned());
634 2 : params.insert("geometry".to_owned(), "MultiPolygon".to_owned());
635 2 : params.insert("coordinates".to_owned(), json!(coords).to_string());
636 2 : assert!(
637 0 : matches!(
638 2 : GeoQuery::from_params(¶ms),
639 : Err(NgsiError::BadRequestData(_))
640 : ),
641 : "nested positions count against the same budget"
642 : );
643 : // one member fewer is inside the budget and still parses
644 2 : params.insert(
645 2 : "coordinates".to_owned(),
646 2 : json!(coords[..members - 1]).to_string(),
647 : );
648 2 : assert!(GeoQuery::from_params(¶ms).is_ok());
649 2 : }
650 :
651 : /// 4.23 ordering: the reference geometry is measured against every result
652 : /// row, so it carries the same vertex ceiling as a geoquery geometry.
653 : #[test]
654 2 : fn ordering_reference_geometry_carries_the_same_vertex_cap() {
655 2 : let n = MAX_GEO_VERTICES + 1;
656 2050 : let pts: Vec<Value> = (0..n).map(|i| json!([i as f64 / 1e6, 0.0])).collect();
657 2 : assert!(
658 2 : parse_ref_geometry("MultiPoint", &json!(pts)).is_err(),
659 : "an oversized ordering reference must be rejected"
660 : );
661 2 : assert!(parse_ref_geometry("Point", &json!([1.0, 2.0])).is_ok());
662 2 : }
663 :
664 : /// 4.10: only nearRel takes a distance (`withinRel = "within"`, and so on
665 : /// for every other relation); Coraine query_entities_geo_errors.
666 : #[test]
667 2 : fn a_distance_belongs_to_near_only() {
668 6 : for rel in [
669 2 : "within;maxDistance==100",
670 2 : "intersects;minDistance==1",
671 2 : "disjoint;maxDistance==5",
672 2 : ] {
673 6 : let mut params = HashMap::new();
674 6 : params.insert("georel".to_owned(), rel.to_owned());
675 6 : params.insert("geometry".to_owned(), "Point".to_owned());
676 6 : params.insert("coordinates".to_owned(), "[8,40]".to_owned());
677 6 : assert!(
678 6 : GeoQuery::from_params(¶ms).is_err(),
679 : "{rel} must be rejected"
680 : );
681 : }
682 2 : }
683 :
684 : /// 4.10 PositiveNumber is an RFC 8259 Number — "inf"/"Infinity" and an
685 : /// overflowing literal are not numbers, however happily f64 parses them.
686 : #[test]
687 2 : fn a_non_finite_distance_is_not_a_positive_number() {
688 10 : for rel in [
689 2 : "near;maxDistance==inf",
690 2 : "near;maxDistance==infinity",
691 2 : "near;minDistance==inf",
692 2 : "near;maxDistance==1e400",
693 2 : "near;maxDistance==NaN",
694 2 : ] {
695 10 : let mut params = HashMap::new();
696 10 : params.insert("georel".to_owned(), rel.to_owned());
697 10 : params.insert("geometry".to_owned(), "Point".to_owned());
698 10 : params.insert("coordinates".to_owned(), "[8,40]".to_owned());
699 10 : assert!(
700 10 : GeoQuery::from_params(¶ms).is_err(),
701 : "{rel} must be rejected"
702 : );
703 : }
704 2 : }
705 :
706 : /// Degenerate but well-formed GeoJSON (empty rings, empty coordinate
707 : /// arrays) reaches the predicates from both sides — a query 400s or
708 : /// evaluates, a stored target is a non-match. Neither may panic.
709 : #[test]
710 2 : fn degenerate_geometries_never_panic() {
711 10 : for gtype in [
712 2 : "Point",
713 2 : "LineString",
714 2 : "Polygon",
715 2 : "MultiPoint",
716 2 : "MultiPolygon",
717 2 : ] {
718 10 : let mut params = HashMap::new();
719 10 : params.insert("georel".to_owned(), "intersects".to_owned());
720 10 : params.insert("geometry".to_owned(), gtype.to_owned());
721 10 : params.insert("coordinates".to_owned(), "[]".to_owned());
722 10 : if let Ok(Some(g)) = GeoQuery::from_params(¶ms) {
723 6 : let _ = g.matches_geometry(&geoval("Point", json!([1, 1])));
724 6 : let _ = g.matches_geometry(&geoval("Polygon", json!([])));
725 6 : }
726 : }
727 : // degenerate TARGETS against an ordinary query
728 2 : let g = q("within", "Polygon", "[[[0,0],[4,0],[4,4],[0,4],[0,0]]]");
729 12 : for target in [
730 2 : geoval("Polygon", json!([])),
731 2 : geoval("LineString", json!([])),
732 2 : geoval("MultiPoint", json!([])),
733 2 : geoval("Point", json!([])),
734 2 : json!({"type": "Point"}),
735 2 : json!("not a geometry"),
736 12 : ] {
737 12 : let _ = g.matches_geometry(&target);
738 12 : }
739 2 : }
740 :
741 : /// 4.23: distance ordering skips rows whose ordering value is not a
742 : /// geometry rather than failing the query.
743 : #[test]
744 2 : fn order_distance_is_none_for_a_non_geometry() {
745 2 : let refg = parse_ref_geometry("Point", &json!([0.0, 0.0])).expect("ref");
746 2 : assert!(order_distance_m(&refg, &json!({"type": "Point"})).is_none());
747 2 : assert!(order_distance_m(&refg, &json!({"coordinates": [1, 1]})).is_none());
748 2 : assert!(order_distance_m(&refg, &json!(42)).is_none());
749 2 : assert!(
750 2 : order_distance_m(&refg, &geoval("Polygon", json!([[[0, 0], [1, 0]]]))).is_none(),
751 : "a malformed ring is not a distance"
752 : );
753 2 : let d = order_distance_m(&refg, &geoval("Point", json!([0.0, 1.0]))).expect("distance");
754 2 : assert!((d - DEG_M).abs() < 1.0, "one degree of latitude, got {d}");
755 2 : }
756 :
757 : /// The PostGIS push-down only owns the DEFAULT GeoProperty column: a
758 : /// geoquery on any other geoproperty must NOT produce a SQL spec (it is
759 : /// evaluated in memory instead), while the per-instance spec carries the
760 : /// expanded IRI for every geoproperty.
761 : #[test]
762 2 : fn only_the_default_geoproperty_is_pushed_down_to_sql() {
763 2 : let ctx = crate::test_core();
764 2 : let mut params = HashMap::new();
765 2 : params.insert("georel".to_owned(), "within".to_owned());
766 2 : params.insert("geometry".to_owned(), "Polygon".to_owned());
767 2 : params.insert(
768 2 : "coordinates".to_owned(),
769 2 : "[[[0,0],[4,0],[4,4],[0,4],[0,0]]]".to_owned(),
770 : );
771 2 : let g = GeoQuery::from_params(¶ms)
772 2 : .expect("parse")
773 2 : .expect("some");
774 2 : assert!(
775 2 : g.to_sql_spec(&ctx).is_some(),
776 : "default location pushes down"
777 : );
778 2 : assert_eq!(g.to_instance_spec(&ctx).1, LOCATION_IRI);
779 :
780 2 : params.insert("geoproperty".to_owned(), "operationSpace".to_owned());
781 2 : let g = GeoQuery::from_params(¶ms)
782 2 : .expect("parse")
783 2 : .expect("some");
784 2 : assert!(
785 2 : g.to_sql_spec(&ctx).is_none(),
786 : "a non-default geoproperty has no extracted column — no push-down"
787 : );
788 2 : let (_, iri) = g.to_instance_spec(&ctx);
789 2 : assert_ne!(iri, LOCATION_IRI);
790 2 : assert!(
791 2 : iri.starts_with("https://uri.etsi.org/ngsi-ld/"),
792 : "expanded, got {iri}"
793 : );
794 2 : }
795 :
796 : /// A stored TARGET that is not a valid geometry is a non-match, not a
797 : /// panic and not a match: reads over data written before the check must
798 : /// still answer.
799 : #[test]
800 2 : fn a_malformed_target_geometry_is_a_nonmatch() {
801 2 : let g = q("intersects", "Polygon", "[[[0,0],[2,0],[2,2],[0,2],[0,0]]]");
802 8 : for target in [
803 2 : geoval("Point", json!([1, 999])),
804 2 : geoval("Point", json!([1])),
805 2 : geoval("Polygon", json!([[[0, 0], [1, 0], [1, 1]]])),
806 2 : geoval("GeometryCollection", json!([])),
807 2 : ] {
808 8 : assert!(!g.matches_geometry(&target), "{target}");
809 : }
810 2 : }
811 :
812 : /// RFC 7946 3.1 puts no minimum on a multi-geometry, so an empty one is a
813 : /// geometry and reaches both sides of every relation. Neither the DE-9IM
814 : /// relate nor the metric distance may panic on it: an empty geometry
815 : /// touches nothing, so every relation but `disjoint` is a non-match.
816 : #[test]
817 2 : fn an_empty_geometry_relates_without_panicking() {
818 2 : let empties = [
819 2 : geoval("MultiPoint", json!([])),
820 2 : geoval("MultiLineString", json!([])),
821 2 : geoval("Polygon", json!([])),
822 2 : geoval("MultiPolygon", json!([])),
823 2 : ];
824 12 : for rel in [
825 2 : "intersects",
826 2 : "within",
827 2 : "contains",
828 2 : "overlaps",
829 2 : "equals",
830 2 : "near;maxDistance==1000000",
831 2 : ] {
832 12 : let g = q(rel, "Polygon", "[[[0,0],[2,0],[2,2],[0,2],[0,0]]]");
833 48 : for target in &empties {
834 48 : assert!(!g.matches_geometry(target), "{rel} vs {target}");
835 : }
836 : // and with the empty geometry as the REFERENCE side
837 12 : let g = q(rel, "MultiPoint", "[]");
838 12 : assert!(
839 12 : !g.matches_geometry(&geoval("Point", json!([1.0, 1.0]))),
840 : "{rel} with an empty reference"
841 : );
842 : }
843 : // disjoint is the complement: nothing intersects an empty geometry
844 2 : let g = q("disjoint", "Polygon", "[[[0,0],[2,0],[2,2],[0,2],[0,0]]]");
845 8 : for target in &empties {
846 8 : assert!(g.matches_geometry(target), "disjoint vs {target}");
847 : }
848 2 : }
849 : }
850 :
851 : #[cfg(test)]
852 : mod clause_4_10_grammar {
853 : use super::*;
854 : use serde_json::json;
855 :
856 16 : fn params(rel: &str) -> HashMap<String, String> {
857 16 : let mut p = HashMap::new();
858 16 : p.insert("georel".to_owned(), rel.to_owned());
859 16 : p.insert("geometry".to_owned(), "Point".to_owned());
860 16 : p.insert("coordinates".to_owned(), "[8,40]".to_owned());
861 16 : p
862 16 : }
863 :
864 : /// 4.10 PositiveNumber: "excluding the 'minus' symbol and excluding the
865 : /// number 0" — a zero or negative distance is a grammar violation, 400.
866 : #[test]
867 2 : fn distance_must_be_a_positive_nonzero_number() {
868 8 : for rel in [
869 2 : "near;maxDistance==0",
870 2 : "near;maxDistance==-100",
871 2 : "near;minDistance==0",
872 2 : "near;minDistance==-0.5",
873 2 : ] {
874 8 : assert!(
875 8 : GeoQuery::from_params(¶ms(rel)).is_err(),
876 : "{rel} must be rejected"
877 : );
878 : }
879 : // a positive number stays valid
880 2 : assert!(GeoQuery::from_params(¶ms("near;maxDistance==0.5")).is_ok());
881 2 : }
882 :
883 : /// 4.10 nearRel = nearOp andOp distance equal PositiveNumber — exactly
884 : /// ONE distance modifier; a second (or duplicate) one is not in the
885 : /// grammar.
886 : #[test]
887 2 : fn near_takes_exactly_one_distance_modifier() {
888 2 : assert!(GeoQuery::from_params(¶ms("near;maxDistance==5;minDistance==1")).is_err());
889 2 : assert!(GeoQuery::from_params(¶ms("near;maxDistance==5;maxDistance==7")).is_err());
890 2 : }
891 :
892 : /// 4.10: "Entities which do not convey the target GeoProperty of the
893 : /// query shall be considered as non-matching."
894 : #[test]
895 2 : fn missing_target_geoproperty_is_a_nonmatch() {
896 2 : let ctx = crate::test_core();
897 2 : let g = GeoQuery::from_params(¶ms("near;maxDistance==2000"))
898 2 : .unwrap()
899 2 : .unwrap();
900 2 : let doc = json!({
901 2 : "https://uri.etsi.org/ngsi-ld/default-context/temperature": [
902 2 : {"type": "Property", "value": 20}
903 : ]
904 : });
905 2 : assert!(!g.matches(&doc, &ctx), "no location => non-matching");
906 2 : }
907 :
908 : /// 4.7.1 through 4.10: `coordinates` expresses "the reference geometry",
909 : /// and a reference geometry is a GeoJSON Geometry — "meeting the syntax
910 : /// and restrictions mandated by IETF RFC 7946 \[8\] when representing a
911 : /// valid Geometry of the type specified" (4.7.2). A geometry outside the
912 : /// WGS84 range the format fixes is not one, and reaching PostGIS it is a
913 : /// `::geography` cast that errors rather than a 400.
914 : #[test]
915 2 : fn a_reference_geometry_is_a_valid_rfc_7946_geometry() {
916 36 : let ask = |gtype: &str, coords: &str| {
917 36 : let mut p = HashMap::new();
918 36 : p.insert("georel".to_owned(), "near;maxDistance==100".to_owned());
919 36 : p.insert("geometry".to_owned(), gtype.to_owned());
920 36 : p.insert("coordinates".to_owned(), coords.to_owned());
921 36 : GeoQuery::from_params(&p)
922 36 : };
923 22 : for (why, gtype, coords) in [
924 2 : ("latitude past the pole", "Point", "[0, 999]"),
925 2 : ("longitude past the antimeridian", "Point", "[181, 0]"),
926 2 : ("latitude just past the pole", "Point", "[0, -90.5]"),
927 2 : ("one-element position", "Point", "[1]"),
928 2 : ("position of strings", "Point", r#"["1", "2"]"#),
929 2 : ("nested where a position belongs", "Point", "[[1, 2]]"),
930 2 : ("one-position LineString", "LineString", "[[1, 2]]"),
931 2 : ("open ring", "Polygon", "[[[0,0],[1,0],[1,1]]]"),
932 2 : ("short ring", "Polygon", "[[[0,0],[1,0],[0,0]]]"),
933 2 : (
934 2 : "ring out of range",
935 2 : "Polygon",
936 2 : "[[[0,0],[1,0],[1,91],[0,0]]]",
937 2 : ),
938 2 : (
939 2 : "MultiPolygon nested one level short",
940 2 : "MultiPolygon",
941 2 : "[[[0,0],[1,0],[1,1],[0,0]]]",
942 2 : ),
943 2 : ] {
944 22 : let err = ask(gtype, coords).expect_err(why);
945 22 : assert!(
946 22 : matches!(err, NgsiError::BadRequestData(_)),
947 : "{why}: {err:?}"
948 : );
949 : }
950 : // the shapes RFC 7946 allows stay valid, edges of the range included
951 14 : for (gtype, coords) in [
952 2 : ("Point", "[17.1, 48.7]"),
953 2 : ("Point", "[-180, -90]"),
954 2 : ("Point", "[180, 90]"),
955 2 : ("Point", "[1, 2, 300]"),
956 2 : ("LineString", "[[1,2],[3,4]]"),
957 2 : ("Polygon", "[[[0,0],[1,0],[1,1],[0,0]]]"),
958 2 : ("MultiPolygon", "[[[[0,0],[1,0],[1,1],[0,0]]]]"),
959 2 : ] {
960 14 : assert!(ask(gtype, coords).is_ok(), "{gtype} {coords}");
961 : }
962 2 : }
963 :
964 : /// 4.10: no georel is no geo query, unless geometry or coordinates
965 : /// came without it.
966 : #[test]
967 2 : fn geometry_without_georel_is_refused() {
968 2 : assert!(matches!(GeoQuery::from_params(&HashMap::new()), Ok(None)));
969 4 : for k in ["geometry", "coordinates"] {
970 4 : let params = HashMap::from([(k.to_owned(), "Point".to_owned())]);
971 4 : assert!(GeoQuery::from_params(¶ms).is_err(), "{k}");
972 : }
973 2 : }
974 : }
975 :
976 : /// Every refusal of `from_params` once, and a `geoproperty` other than `location`.
977 : #[cfg(test)]
978 : mod edges {
979 : use super::*;
980 :
981 20 : fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
982 20 : pairs
983 20 : .iter()
984 38 : .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
985 20 : .collect()
986 20 : }
987 :
988 : #[test]
989 2 : fn each_refusal_names_what_is_wrong() {
990 2 : let point = [("geometry", "Point"), ("coordinates", "[1,2]")];
991 18 : for (georel, extra, said) in [
992 2 : ("near;maxDistance==far", &point[..], "invalid maxDistance"),
993 2 : ("near;radius==1", &point[..], "invalid georel modifier"),
994 2 : (
995 2 : "near;minDistance==1;maxDistance==2",
996 2 : &point[..],
997 2 : "a single distance modifier",
998 2 : ),
999 2 : ("near", &point[..], "near requires"),
1000 2 : ("within", &[][..], "requires geometry"),
1001 2 : (
1002 2 : "within",
1003 2 : &[("geometry", "Circle"), ("coordinates", "[1,2]")][..],
1004 2 : "invalid geometry",
1005 2 : ),
1006 2 : (
1007 2 : "within",
1008 2 : &[("geometry", "Point")][..],
1009 2 : "requires coordinates",
1010 2 : ),
1011 2 : (
1012 2 : "within",
1013 2 : &[("geometry", "Point"), ("coordinates", "[1,")][..],
1014 2 : "invalid coordinates",
1015 2 : ),
1016 2 : (
1017 2 : "within",
1018 2 : &[("geometry", "Point"), ("coordinates", "{}")][..],
1019 2 : "must be a JSON array",
1020 2 : ),
1021 2 : ] {
1022 18 : let mut p = params(extra);
1023 18 : p.insert("georel".into(), georel.into());
1024 18 : let err = GeoQuery::from_params(&p)
1025 18 : .err()
1026 18 : .map(|e| e.to_string())
1027 18 : .unwrap_or_default();
1028 18 : assert!(err.contains(said), "{georel} {extra:?}: {err}");
1029 : }
1030 2 : assert_eq!(count_positions(&serde_json::json!("x")), 0);
1031 2 : }
1032 :
1033 : #[test]
1034 2 : fn a_named_geoproperty_is_the_one_read() {
1035 2 : let gq = GeoQuery::from_params(¶ms(&[
1036 2 : ("georel", "near;maxDistance==10"),
1037 2 : ("geometry", "Point"),
1038 2 : ("coordinates", "[1,2]"),
1039 2 : ("geoproperty", "position"),
1040 2 : ]))
1041 2 : .expect("valid")
1042 2 : .expect("a query");
1043 2 : let ctx = crate::test_core();
1044 4 : let at = |key: &str| {
1045 4 : serde_json::json!({ ctx.expand_key(key): [{"type": "GeoProperty",
1046 4 : "value": {"type": "Point", "coordinates": [1.0, 2.0]}}] })
1047 4 : };
1048 2 : assert!(gq.matches(&at("position"), &ctx));
1049 2 : assert!(!gq.matches(&at("location"), &ctx));
1050 2 : }
1051 : }
|