LCOV - code coverage report
Current view: top level - antares-ql/src - geo.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.8 % 629 628
Test Date: 2026-11-02 13:21:27 Functions: 92.6 % 95 88

            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(&params).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(&params).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(&params).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(&params).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(&params), 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(&params).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(&params).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(&params),
     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(&params).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(&params).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(&params).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(&params) {
     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(&params)
     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(&params)
     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(&params(rel)).is_err(),
     876              :                 "{rel} must be rejected"
     877              :             );
     878              :         }
     879              :         // a positive number stays valid
     880            2 :         assert!(GeoQuery::from_params(&params("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(&params("near;maxDistance==5;minDistance==1")).is_err());
     889            2 :         assert!(GeoQuery::from_params(&params("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(&params("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(&params).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(&params(&[
    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              : }
        

Generated by: LCOV version 2.0-1