LCOV - code coverage report
Current view: top level - antares-sql/src/compile - geo.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.7 % 306 305
Test Date: 2026-09-21 10:31:06 Functions: 86.2 % 29 25

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Geoquery Language (CIM 009 clause 4.10) compiled to PostGIS
       3              : //! over the extracted `entities.location` column (GIST-indexed).
       4              : //!
       5              : //! Same one-directional contract as the other compilers: this may only
       6              : //! NARROW, and `antares_ql::geo::GeoQuery::matches` stays the arbiter. Three
       7              : //! places where the two engines could disagree are handled by deliberately
       8              : //! widening rather than by hoping they agree:
       9              : //!
      10              : //! 1. **Rows without an extracted geometry.** `location` holds the DEFAULT
      11              : //!    GeoProperty and only when the entity carries exactly one instance of it
      12              : //!    (clause 4.5.5 multi-instance sets have no single-geometry spelling, and a
      13              : //!    GEOMETRYCOLLECTION would make `within` mean "all of them", which is
      14              : //!    stricter). Rows that CARRY the geoproperty but defeated extraction are
      15              : //!    flagged `location_ambiguous` at write time; every predicate ORs that
      16              : //!    flag, so those rows reach the evaluator, while rows with no geoproperty
      17              : //!    at all (which can never match) are excluded in SQL. Both OR arms are
      18              : //!    index-shaped (GIST + partial index → BitmapOr).
      19              : //! 2. **`near` metric.** The evaluator measures haversine on a sphere;
      20              : //!    PostGIS `geography` measures on the WGS84 spheroid. They differ by up
      21              : //!    to ~0.5 %, which at the boundary of a radius is the difference between
      22              : //!    keeping and losing a matching row — so the compiled radius is inflated
      23              : //!    and a compiled `minDistance` floor is deflated.
      24              : //! 3. **`near` against an extended query geometry.** The evaluator measures
      25              : //!    from the query geometry's FIRST vertex; PostGIS measures from its
      26              : //!    nearest point. For `maxDistance` that only widens, but for
      27              : //!    `minDistance` it narrows — so `near` compiles only for a Point query
      28              : //!    geometry, where the two are the same point by definition.
      29              : //!
      30              : //! The query geometry travels as bound GeoJSON text (`ST_GeomFromGeoJSON
      31              : //! ($n)`), never as SQL text, and so does every distance.
      32              : 
      33              : use serde_json::Value;
      34              : 
      35              : /// A compiled geoquery: a SQL boolean expression plus its binds, numbered
      36              : /// from the offset passed to [`compile_geo`].
      37              : pub struct CompiledGeo {
      38              :     pub sql: String,
      39              :     /// GeoJSON documents, in placeholder order.
      40              :     pub geo_binds: Vec<String>,
      41              :     /// Distances in metres, in placeholder order (after the geo binds).
      42              :     pub num_binds: Vec<f64>,
      43              : }
      44              : 
      45              : pub use antares_store::filter::{GeoSpec, Rel, LOCATION_IRI};
      46              : 
      47              : /// Compile a geoquery over `col` (a `geometry(Geometry,4326)`).
      48              : ///
      49              : /// A `geoproperty` other than the default `location` has no extracted column,
      50              : /// so it returns `None` and the evaluator does the work (the documented
      51              : /// fallback). `first_bind` numbers the geo binds; numeric binds follow them.
      52           58 : pub fn compile_geo(spec: &GeoSpec<'_>, col: &str, first_bind: usize) -> Option<CompiledGeo> {
      53           58 :     if !spec.geoproperty_iri.is_empty() && spec.geoproperty_iri != LOCATION_IRI {
      54            2 :         return None;
      55           56 :     }
      56              :     // The un-extractable row always survives (module docs, point 1) — via the
      57              :     // `location_ambiguous` column, not `location IS NULL`: rows WITHOUT any
      58              :     // default GeoProperty can never match and are excluded in SQL, and the OR
      59              :     // over two indexable conditions BitmapOrs (GIST + partial index) instead
      60              :     // of forcing a sequential scan.
      61           56 :     let mut c = predicate(spec, col, first_bind)?;
      62           42 :     c.sql = format!("(({}) OR location_ambiguous)", c.sql);
      63           42 :     Some(c)
      64           58 : }
      65              : 
      66              : /// The same predicate over a PER-INSTANCE geometry column
      67              : /// (`attr_instances.geo_value`, 5.7.4.4 S3): each row is one instance, so
      68              : /// there is no ambiguity flag — a NULL `geo_value` (a value the extractor
      69              : /// could not take) is the "reaches the evaluator" arm instead. No geoproperty
      70              : /// restriction: the caller binds the attr IRI itself.
      71           29 : pub fn compile_geo_instance(
      72           29 :     spec: &GeoSpec<'_>,
      73           29 :     col: &str,
      74           29 :     first_bind: usize,
      75           29 : ) -> Option<CompiledGeo> {
      76           29 :     let mut c = predicate(spec, col, first_bind)?;
      77           27 :     c.sql = format!("(({}) OR {col} IS NULL)", c.sql);
      78           27 :     Some(c)
      79           29 : }
      80              : 
      81           85 : fn predicate(spec: &GeoSpec<'_>, col: &str, first_bind: usize) -> Option<CompiledGeo> {
      82              :     let GeoSpec {
      83           85 :         rel,
      84           85 :         geometry,
      85           85 :         coordinates,
      86              :         ..
      87           85 :     } = spec;
      88           85 :     let geojson = serde_json::to_string(&serde_json::json!({
      89           85 :         "type": geometry, "coordinates": coordinates
      90           85 :     }))
      91           85 :     .ok()?;
      92              : 
      93           85 :     let geo_binds = vec![geojson];
      94           85 :     let mut num_binds: Vec<f64> = Vec::new();
      95              :     // placeholder for the single geometry bind
      96           85 :     let g = format!("ST_SetSRID(ST_GeomFromGeoJSON(${first_bind}), 4326)");
      97              : 
      98           85 :     let pred = match rel {
      99           47 :         Rel::Near { max, min } => {
     100           47 :             if *geometry != "Point" {
     101            4 :                 return None; // see module docs, point 3
     102           43 :             }
     103              :             // 4.10 PositiveNumber is an RFC 8259 Number, but `inf` and `NaN`
     104              :             // both parse as `f64`: inflating one gives a bound that EXCLUDES
     105              :             // every row rather than widening. Refuse instead of narrowing.
     106           49 :             if [max, min].into_iter().flatten().any(|d| !d.is_finite()) {
     107           10 :                 return None;
     108           33 :             }
     109           33 :             let mut parts = Vec::new();
     110              :             // Numeric binds are numbered after ALL geo binds; there is exactly
     111              :             // one geo bind here, hence the +1 base.
     112           33 :             if let Some(m) = max {
     113           27 :                 let n = first_bind + 1 + num_binds.len();
     114           27 :                 // widen: spheroid-vs-sphere slack, plus a metre of absolute
     115           27 :                 // slack so a 0 m radius still behaves
     116           27 :                 num_binds.push(m * 1.005 + 1.0);
     117           27 :                 parts.push(format!(
     118           27 :                     "ST_DWithin({col}::geography, {g}::geography, ${n})"
     119           27 :                 ));
     120           27 :             }
     121           33 :             if let Some(m) = min {
     122           10 :                 let n = first_bind + 1 + num_binds.len();
     123           10 :                 num_binds.push((m * 0.995 - 1.0).max(0.0));
     124           10 :                 parts.push(format!(
     125           10 :                     "ST_Distance({col}::geography, {g}::geography) >= ${n}"
     126           10 :                 ));
     127           23 :             }
     128           33 :             if parts.is_empty() {
     129            2 :                 return None; // `near` with neither bound is not a filter
     130           31 :             }
     131           31 :             parts.join(" AND ")
     132              :         }
     133           14 :         Rel::Within => format!("ST_Within({col}, {g})"),
     134            4 :         Rel::Contains => format!("ST_Contains({col}, {g})"),
     135            6 :         Rel::Intersects => format!("ST_Intersects({col}, {g})"),
     136            6 :         Rel::Disjoint => format!("ST_Disjoint({col}, {g})"),
     137            2 :         Rel::Overlaps => format!("ST_Overlaps({col}, {g})"),
     138            6 :         Rel::Equals => format!("ST_Equals({col}, {g})"),
     139              :     };
     140           69 :     Some(CompiledGeo {
     141           69 :         sql: pred,
     142           69 :         geo_binds,
     143           69 :         num_binds,
     144           69 :     })
     145           85 : }
     146              : 
     147              : /// Extract the geometry to store in `entities.location` at write time.
     148              : /// `Some(geojson)` only for exactly one default-GeoProperty instance carrying
     149              : /// a GeoJSON value — see module docs, point 1, for why more than one is
     150              : /// deliberately `None`.
     151         3286 : pub fn extract_location(doc: &Value) -> Option<String> {
     152         3286 :     let instances = doc.get(LOCATION_IRI)?;
     153           46 :     let inst = match instances {
     154           46 :         Value::Array(a) if a.len() == 1 => &a[0],
     155            6 :         Value::Array(_) => return None, // multi-instance: let the evaluator judge
     156            0 :         v => v,
     157              :     };
     158           40 :     let value = inst.get("value").or_else(|| inst.get("object"))?;
     159              :     // must look like a GeoJSON geometry; anything else is not indexable and
     160              :     // must not silently become a NULL that looks like "no location".
     161              :     // GeometryCollection is deliberately absent: the PostGIS relate
     162              :     // predicates refuse one, so an extracted collection turns every later
     163              :     // geoquery into a database error — and it can never match anyway, since
     164              :     // `GeoQuery::matches_geometry` reads a geometry's `coordinates` and a
     165              :     // collection has none. It is flagged ambiguous instead and judged by the
     166              :     // evaluator like any other unextractable geoproperty.
     167           40 :     let t = value.get("type")?.as_str()?;
     168           30 :     if !matches!(
     169           34 :         t,
     170           34 :         "Point" | "MultiPoint" | "LineString" | "MultiLineString" | "Polygon" | "MultiPolygon"
     171              :     ) {
     172            4 :         return None;
     173           30 :     }
     174           30 :     serde_json::to_string(value).ok()
     175         3286 : }
     176              : 
     177              : #[cfg(test)]
     178              : mod tests {
     179              :     use super::*;
     180              :     use serde_json::json;
     181              : 
     182           40 :     fn coords() -> Value {
     183           40 :         json!([2.29, 48.85])
     184           40 :     }
     185              : 
     186           44 :     fn spec<'a>(rel: Rel, geometry: &'a str, coordinates: &'a Value, gp: &'a str) -> GeoSpec<'a> {
     187           44 :         GeoSpec {
     188           44 :             rel,
     189           44 :             geometry,
     190           44 :             coordinates,
     191           44 :             geoproperty_iri: gp,
     192           44 :         }
     193           44 :     }
     194              : 
     195              :     #[test]
     196            2 :     fn relations_bind_the_geometry_and_never_splice_it() {
     197            2 :         let c = compile_geo(&spec(Rel::Within, "Point", &coords(), ""), "location", 4)
     198            2 :             .expect("compiles");
     199            2 :         assert!(!c.sql.contains("48.85"), "sql: {}", c.sql);
     200            2 :         assert!(c.sql.contains("ST_GeomFromGeoJSON($4)"), "sql: {}", c.sql);
     201            2 :         assert!(c.sql.contains("ST_Within(location,"));
     202            2 :         assert_eq!(c.geo_binds.len(), 1);
     203            2 :         assert!(c.geo_binds[0].contains("48.85"));
     204            2 :     }
     205              : 
     206              :     #[test]
     207            2 :     fn unextracted_rows_always_survive() {
     208           12 :         for rel in [
     209            2 :             Rel::Within,
     210            2 :             Rel::Contains,
     211            2 :             Rel::Intersects,
     212            2 :             Rel::Disjoint,
     213            2 :             Rel::Overlaps,
     214            2 :             Rel::Equals,
     215            2 :         ] {
     216           12 :             let c =
     217           12 :                 compile_geo(&spec(rel, "Point", &coords(), ""), "location", 1).expect("compiles");
     218           12 :             assert!(
     219           12 :                 c.sql.ends_with(" OR location_ambiguous)"),
     220              :                 "a row carrying an unextractable geoproperty must reach the evaluator: {}",
     221              :                 c.sql
     222              :             );
     223              :         }
     224            2 :     }
     225              : 
     226              :     #[test]
     227            2 :     fn near_widens_max_and_deflates_min() {
     228            2 :         let c = compile_geo(
     229            2 :             &spec(
     230            2 :                 Rel::Near {
     231            2 :                     max: Some(2000.0),
     232            2 :                     min: None,
     233            2 :                 },
     234            2 :                 "Point",
     235            2 :                 &coords(),
     236            2 :                 "",
     237            2 :             ),
     238            2 :             "location",
     239              :             1,
     240              :         )
     241            2 :         .expect("compiles");
     242            2 :         assert!(c.sql.contains("ST_DWithin(location::geography"));
     243            2 :         assert!(
     244            2 :             c.num_binds[0] > 2000.0,
     245              :             "radius must widen: {:?}",
     246              :             c.num_binds
     247              :         );
     248              : 
     249            2 :         let c = compile_geo(
     250            2 :             &spec(
     251            2 :                 Rel::Near {
     252            2 :                     max: None,
     253            2 :                     min: Some(2000.0),
     254            2 :                 },
     255            2 :                 "Point",
     256            2 :                 &coords(),
     257            2 :                 "",
     258            2 :             ),
     259            2 :             "location",
     260              :             1,
     261              :         )
     262            2 :         .expect("compiles");
     263            2 :         assert!(c.sql.contains("ST_Distance(location::geography"));
     264            2 :         assert!(
     265            2 :             c.num_binds[0] < 2000.0,
     266              :             "floor must deflate: {:?}",
     267              :             c.num_binds
     268              :         );
     269            2 :         assert_eq!(
     270            2 :             compile_geo(
     271            2 :                 &spec(
     272            2 :                     Rel::Near {
     273            2 :                         max: Some(0.0),
     274            2 :                         min: Some(0.0)
     275            2 :                     },
     276            2 :                     "Point",
     277            2 :                     &coords(),
     278            2 :                     ""
     279            2 :                 ),
     280            2 :                 "location",
     281            2 :                 1
     282            2 :             )
     283            2 :             .expect("compiles")
     284            2 :             .num_binds[1],
     285              :             0.0,
     286              :             "a deflated floor never goes negative"
     287              :         );
     288            2 :     }
     289              : 
     290              :     /// Both `near` bounds in one predicate: the geometry keeps the offset and
     291              :     /// the two distances take the next two placeholders, in the order the
     292              :     /// caller appends them (geo binds first, then the numbers).
     293              :     #[test]
     294            2 :     fn both_near_bounds_take_distinct_placeholders_after_the_geometry() {
     295            2 :         let c = compile_geo(
     296            2 :             &spec(
     297            2 :                 Rel::Near {
     298            2 :                     max: Some(2000.0),
     299            2 :                     min: Some(500.0),
     300            2 :                 },
     301            2 :                 "Point",
     302            2 :                 &coords(),
     303            2 :                 "",
     304            2 :             ),
     305            2 :             "location",
     306              :             1,
     307              :         )
     308            2 :         .expect("compiles");
     309            2 :         assert_eq!(c.geo_binds.len(), 1);
     310            2 :         assert_eq!(c.num_binds.len(), 2);
     311            2 :         assert!(c.sql.contains("ST_GeomFromGeoJSON($1)"), "{}", c.sql);
     312            2 :         assert!(c.sql.contains("$2)"), "maxDistance placeholder: {}", c.sql);
     313            2 :         assert!(
     314            2 :             c.sql.contains(">= $3"),
     315              :             "minDistance placeholder: {}",
     316              :             c.sql
     317              :         );
     318            2 :         assert!(!c.sql.contains("$4"), "overshoot: {}", c.sql);
     319            2 :     }
     320              : 
     321              :     /// `maxDistance`/`minDistance` reach this compiler as `f64`, and `inf`
     322              :     /// parses as one. A non-finite bound would compile to a comparison that
     323              :     /// EXCLUDES every row rather than widening — refuse it instead.
     324              :     #[test]
     325            2 :     fn a_non_finite_distance_is_left_to_the_evaluator() {
     326           10 :         for (max, min) in [
     327            2 :             (Some(f64::INFINITY), None),
     328            2 :             (None, Some(f64::INFINITY)),
     329            2 :             (Some(f64::NAN), None),
     330            2 :             (None, Some(f64::NAN)),
     331            2 :             (Some(2000.0), Some(f64::INFINITY)),
     332            2 :         ] {
     333           10 :             assert!(
     334           10 :                 compile_geo(
     335           10 :                     &spec(Rel::Near { max, min }, "Point", &coords(), ""),
     336           10 :                     "location",
     337           10 :                     1
     338           10 :                 )
     339           10 :                 .is_none(),
     340              :                 "non-finite bound must not compile: {max:?}/{min:?}"
     341              :             );
     342              :         }
     343            2 :     }
     344              : 
     345              :     /// `geometry` is a client string. It is a JSON member of the bound
     346              :     /// document, never a fragment of the statement.
     347              :     #[test]
     348            2 :     fn the_query_geometry_type_travels_in_the_bound_geojson() {
     349            2 :         let c = compile_geo(
     350            2 :             &spec(
     351            2 :                 Rel::Within,
     352            2 :                 "Polygon'); DROP TABLE entities; --",
     353            2 :                 &coords(),
     354            2 :                 "",
     355            2 :             ),
     356            2 :             "location",
     357              :             1,
     358              :         )
     359            2 :         .expect("compiles");
     360            8 :         for needle in ["DROP", "TABLE", "--", "'"] {
     361            8 :             assert!(!c.sql.contains(needle), "{needle:?} leaked: {}", c.sql);
     362              :         }
     363            2 :         assert_eq!(
     364              :             c.sql,
     365              :             "((ST_Within(location, ST_SetSRID(ST_GeomFromGeoJSON($1), 4326))) OR location_ambiguous)"
     366              :         );
     367            2 :         assert!(c.geo_binds[0].contains("DROP"), "{}", c.geo_binds[0]);
     368            2 :     }
     369              : 
     370              :     #[test]
     371            2 :     fn refusals_leave_it_to_the_evaluator() {
     372              :         // near from an extended geometry: evaluator measures the first vertex,
     373              :         // PostGIS the nearest point — narrowing risk on minDistance
     374            2 :         assert!(compile_geo(
     375            2 :             &spec(
     376            2 :                 Rel::Near {
     377            2 :                     max: Some(10.0),
     378            2 :                     min: None
     379            2 :                 },
     380            2 :                 "Polygon",
     381            2 :                 &json!([[[0, 0], [1, 0], [1, 1], [0, 0]]]),
     382            2 :                 ""
     383            2 :             ),
     384            2 :             "location",
     385            2 :             1
     386            2 :         )
     387            2 :         .is_none());
     388              :         // a non-default geoproperty has no extracted column
     389            2 :         assert!(compile_geo(
     390            2 :             &spec(
     391            2 :                 Rel::Within,
     392            2 :                 "Point",
     393            2 :                 &coords(),
     394            2 :                 "https://example.org/observationSpace"
     395            2 :             ),
     396            2 :             "location",
     397            2 :             1
     398            2 :         )
     399            2 :         .is_none());
     400              :         // `near` with no bound is not a filter
     401            2 :         assert!(compile_geo(
     402            2 :             &spec(
     403            2 :                 Rel::Near {
     404            2 :                     max: None,
     405            2 :                     min: None
     406            2 :                 },
     407            2 :                 "Point",
     408            2 :                 &coords(),
     409            2 :                 ""
     410            2 :             ),
     411            2 :             "location",
     412            2 :             1
     413            2 :         )
     414            2 :         .is_none());
     415            2 :     }
     416              : 
     417              :     #[test]
     418            2 :     fn instance_variant_falls_back_to_null_and_takes_any_geoproperty() {
     419            2 :         let c = compile_geo_instance(
     420            2 :             &spec(Rel::Within, "Point", &coords(), ""),
     421            2 :             "gi.geo_value",
     422              :             2,
     423              :         )
     424            2 :         .expect("compiles");
     425            2 :         assert!(
     426            2 :             c.sql.ends_with(" OR gi.geo_value IS NULL)"),
     427              :             "an instance with an unextracted geometry must reach the evaluator: {}",
     428              :             c.sql
     429              :         );
     430            2 :         assert!(c.sql.contains("ST_Within(gi.geo_value,"), "{}", c.sql);
     431            2 :         assert!(!c.sql.contains("location_ambiguous"), "{}", c.sql);
     432              :         // the predicate core still refuses near-from-extended-geometry
     433            2 :         assert!(compile_geo_instance(
     434            2 :             &spec(
     435            2 :                 Rel::Near {
     436            2 :                     max: Some(1.0),
     437            2 :                     min: None
     438            2 :                 },
     439            2 :                 "Polygon",
     440            2 :                 &json!([[[0, 0], [1, 0], [1, 1], [0, 0]]]),
     441            2 :                 ""
     442            2 :             ),
     443            2 :             "gi.geo_value",
     444            2 :             1
     445            2 :         )
     446            2 :         .is_none());
     447            2 :     }
     448              : 
     449              :     #[test]
     450            2 :     fn location_extraction_takes_the_single_instance_only() {
     451            2 :         let one = json!({
     452            2 :             LOCATION_IRI: [{"type": "GeoProperty",
     453            2 :                             "value": {"type": "Point", "coordinates": [1, 2]}}]
     454              :         });
     455            2 :         assert_eq!(
     456            2 :             extract_location(&one).expect("extracted"),
     457              :             "{\"coordinates\":[1,2],\"type\":\"Point\"}"
     458              :         );
     459              : 
     460            2 :         let multi = json!({
     461            2 :             LOCATION_IRI: [
     462            2 :                 {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1, 2]}},
     463            2 :                 {"type": "GeoProperty", "datasetId": "urn:d", "value": {"type": "Point", "coordinates": [3, 4]}}
     464              :             ]
     465              :         });
     466            2 :         assert!(
     467            2 :             extract_location(&multi).is_none(),
     468              :             "multi-instance stays NULL so the guard hands the row to the evaluator"
     469              :         );
     470              : 
     471              :         // a non-geometry value must not be extracted as if it were one
     472            2 :         let bogus = json!({ LOCATION_IRI: [{"type": "Property", "value": "somewhere"}] });
     473            2 :         assert!(extract_location(&bogus).is_none());
     474            2 :         assert!(extract_location(&json!({"id": "urn:x"})).is_none());
     475              : 
     476              :         // a collection is a GeoJSON geometry, but the relate predicates
     477              :         // refuse one — it stays unextracted so the row is flagged ambiguous
     478            2 :         let collection = json!({
     479            2 :             LOCATION_IRI: [{"type": "GeoProperty", "value": {
     480            2 :                 "type": "GeometryCollection",
     481            2 :                 "geometries": [{"type": "Point", "coordinates": [1, 2]}]
     482              :             }}]
     483              :         });
     484            2 :         assert!(
     485            2 :             extract_location(&collection).is_none(),
     486              :             "a GeometryCollection must not reach a PostGIS relate predicate"
     487              :         );
     488            2 :     }
     489              : }
        

Generated by: LCOV version 2.0-1