LCOV - code coverage report
Current view: top level - antares-sql/src/compile - geo.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.6 % 515 513
Test Date: 2026-11-02 13:21:27 Functions: 95.2 % 63 60

            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              : //! 4. **Row-level security.** No PostGIS operator is leakproof, so under the
      31              : //!    tables' RLS the GIST index on `location` is never an index condition
      32              : //!    for the broker's role and every geoquery read its whole Tenant.
      33              : //!    Each predicate is therefore ANDed with a geohash cover of its
      34              : //!    region over `location_cell` (migration 0015), btree text comparisons
      35              : //!    that are leakproof; the cover is a superset (`cell_cover`), the PostGIS
      36              : //!    predicate still narrows it, and the evaluator still decides.
      37              : //!
      38              : //! The query geometry travels as bound GeoJSON text (`ST_GeomFromGeoJSON
      39              : //! ($n)`), never as SQL text, and so does every distance.
      40              : 
      41              : use serde_json::Value;
      42              : 
      43              : /// A compiled geoquery: a SQL boolean expression plus its binds, numbered
      44              : /// from the offset passed to [`compile_geo`].
      45              : pub struct CompiledGeo {
      46              :     pub sql: String,
      47              :     /// GeoJSON documents, in placeholder order.
      48              :     pub geo_binds: Vec<String>,
      49              :     /// Distances in metres, in placeholder order (after the geo binds).
      50              :     pub num_binds: Vec<f64>,
      51              :     /// Geohash cell bounds of the region's cover, in placeholder order
      52              :     /// (after the numeric binds); empty when no cover narrows the predicate.
      53              :     pub cell_binds: Vec<String>,
      54              : }
      55              : 
      56              : pub use antares_store::filter::{GeoSpec, Rel, LOCATION_IRI};
      57              : 
      58              : /// Compile a geoquery over `col` (a `geometry(Geometry,4326)`).
      59              : ///
      60              : /// A `geoproperty` other than the default `location` has no extracted column,
      61              : /// so it returns `None` and the evaluator does the work (the documented
      62              : /// fallback). `first_bind` numbers the geo binds; numeric binds follow them.
      63         2432 : pub fn compile_geo(
      64         2432 :     spec: &GeoSpec<'_>,
      65         2432 :     col: &'static str,
      66         2432 :     first_bind: usize,
      67         2432 : ) -> Option<CompiledGeo> {
      68         2432 :     if !spec.geoproperty_iri.is_empty() && spec.geoproperty_iri != LOCATION_IRI {
      69            2 :         return None;
      70         2430 :     }
      71              :     // The un-extractable row always survives (module docs, point 1) — via the
      72              :     // `location_ambiguous` column, not `location IS NULL`: rows WITHOUT any
      73              :     // default GeoProperty can never match and are excluded in SQL, and the OR
      74              :     // over two indexable conditions BitmapOrs (GIST + partial index) instead
      75              :     // of forcing a sequential scan.
      76         2430 :     let mut c = predicate(spec, col, first_bind)?;
      77              :     // The cover is the leakproof index path (module docs, point 4); the
      78              :     // PostGIS predicate stays beside it as the narrowing filter.
      79         2412 :     let pred = match cell_cover(spec) {
      80         1120 :         Some(cells) => {
      81         1120 :             let n = first_bind
      82         1120 :                 .saturating_add(c.geo_binds.len())
      83         1120 :                 .saturating_add(c.num_binds.len());
      84         1120 :             let (cover, binds) = cover_sql(&cells, &format!("{col}_cell"), n);
      85         1120 :             c.cell_binds = binds;
      86         1120 :             format!("{cover} AND ({})", c.sql)
      87              :         }
      88         1292 :         None => c.sql,
      89              :     };
      90         2412 :     c.sql = format!("(({pred}) OR location_ambiguous)");
      91         2412 :     Some(c)
      92         2432 : }
      93              : 
      94              : /// The same predicate over a PER-INSTANCE geometry column
      95              : /// (`attr_instances.geo_value`, 5.7.4.4 S3): each row is one instance, so
      96              : /// there is no ambiguity flag — a NULL `geo_value` (a value the extractor
      97              : /// could not take) is the "reaches the evaluator" arm instead. No geoproperty
      98              : /// restriction: the caller binds the attr IRI itself.
      99           33 : pub fn compile_geo_instance(
     100           33 :     spec: &GeoSpec<'_>,
     101           33 :     col: &'static str,
     102           33 :     first_bind: usize,
     103           33 : ) -> Option<CompiledGeo> {
     104           33 :     let mut c = predicate(spec, col, first_bind)?;
     105           31 :     c.sql = format!("(({}) OR {col} IS NULL)", c.sql);
     106           31 :     Some(c)
     107           33 : }
     108              : 
     109         2463 : fn predicate(spec: &GeoSpec<'_>, col: &str, first_bind: usize) -> Option<CompiledGeo> {
     110              :     let GeoSpec {
     111         2463 :         rel,
     112         2463 :         geometry,
     113         2463 :         coordinates,
     114              :         ..
     115         2463 :     } = spec;
     116         2463 :     let geojson = serde_json::to_string(&serde_json::json!({
     117         2463 :         "type": geometry, "coordinates": coordinates
     118         2463 :     }))
     119         2463 :     .ok()?;
     120              : 
     121         2463 :     let geo_binds = vec![geojson];
     122         2463 :     let mut num_binds: Vec<f64> = Vec::new();
     123              :     // placeholder for the single geometry bind
     124         2463 :     let g = format!("ST_SetSRID(ST_GeomFromGeoJSON(${first_bind}), 4326)");
     125              : 
     126         2463 :     let pred = match rel {
     127          650 :         Rel::Near { max, min } => {
     128          650 :             if *geometry != "Point" {
     129            8 :                 return None; // see module docs, point 3
     130          642 :             }
     131              :             // 4.10 PositiveNumber is an RFC 8259 Number, but `inf` and `NaN`
     132              :             // both parse as `f64`: inflating one gives a bound that EXCLUDES
     133              :             // every row rather than widening. Refuse instead of narrowing.
     134          904 :             if [max, min].into_iter().flatten().any(|d| !d.is_finite()) {
     135           10 :                 return None;
     136          632 :             }
     137          632 :             let mut parts = Vec::new();
     138              :             // Numeric binds are numbered after ALL geo binds; there is exactly
     139              :             // one geo bind here, hence the +1 base.
     140          632 :             if let Some(m) = max {
     141          468 :                 let n = first_bind.saturating_add(1).saturating_add(num_binds.len());
     142          468 :                 // widen: spheroid-vs-sphere slack, plus a metre of absolute
     143          468 :                 // slack so a 0 m radius still behaves
     144          468 :                 num_binds.push(m * 1.005 + 1.0);
     145          468 :                 parts.push(format!(
     146          468 :                     "ST_DWithin({col}::geography, {g}::geography, ${n})"
     147          468 :                 ));
     148          468 :             }
     149          632 :             if let Some(m) = min {
     150          424 :                 let n = first_bind.saturating_add(1).saturating_add(num_binds.len());
     151          424 :                 num_binds.push((m * 0.995 - 1.0).max(0.0));
     152          424 :                 parts.push(format!(
     153          424 :                     "ST_Distance({col}::geography, {g}::geography) >= ${n}"
     154          424 :                 ));
     155          437 :             }
     156          632 :             if parts.is_empty() {
     157            2 :                 return None; // `near` with neither bound is not a filter
     158          630 :             }
     159          630 :             parts.join(" AND ")
     160              :         }
     161          468 :         Rel::Within => format!("ST_Within({col}, {g})"),
     162          197 :         Rel::Contains => format!("ST_Contains({col}, {g})"),
     163          609 :         Rel::Intersects => format!("ST_Intersects({col}, {g})"),
     164          362 :         Rel::Disjoint => format!("ST_Disjoint({col}, {g})"),
     165            4 :         Rel::Overlaps => format!("ST_Overlaps({col}, {g})"),
     166          173 :         Rel::Equals => format!("ST_Equals({col}, {g})"),
     167              :     };
     168         2443 :     Some(CompiledGeo {
     169         2443 :         sql: pred,
     170         2443 :         geo_binds,
     171         2443 :         num_binds,
     172         2443 :         cell_binds: Vec::new(),
     173         2443 :     })
     174         2463 : }
     175              : 
     176              : /// Cells a cover may hold. Eight keeps the statement at most 8 range probes
     177              : /// plus one prefix probe; a region wider than eight cells of the coarsest
     178              : /// precision (45° x 45°) gets no cover and reads the Tenant, as before.
     179              : const MAX_COVER_CELLS: f64 = 8.0;
     180              : 
     181              : /// Degrees a cover reaches past the region on every side, so a point on the
     182              : /// region's closed edge also lies inside a half-open geohash cell of it.
     183              : const EDGE: f64 = 1e-7;
     184              : 
     185              : const BASE32: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
     186              : 
     187              : /// The geohash of a WGS84 point at `chars` characters, the encoding of
     188              : /// PostGIS `ST_GeoHash`: longitude bit first, a coordinate on a split goes
     189              : /// to the upper half.
     190         5480 : pub fn geohash(lon: f64, lat: f64, chars: usize) -> String {
     191         5480 :     let (mut x, mut y) = ((-180.0_f64, 180.0_f64), (-90.0_f64, 90.0_f64));
     192         5480 :     let mut out = String::with_capacity(chars);
     193         5480 :     let mut on_lon = true;
     194        43446 :     while out.len() < chars {
     195        37966 :         let mut ch = 0_usize;
     196        37966 :         for _ in 0..5 {
     197       189830 :             let (r, v) = if on_lon { (&mut x, lon) } else { (&mut y, lat) };
     198       189830 :             let mid = (r.0 + r.1) / 2.0;
     199       189830 :             ch <<= 1;
     200       189830 :             if v >= mid {
     201        70173 :                 ch |= 1;
     202        70173 :                 r.0 = mid;
     203       119657 :             } else {
     204       119657 :                 r.1 = mid;
     205       119657 :             }
     206       189830 :             on_lon = !on_lon;
     207              :         }
     208              :         // five bits: always inside the alphabet
     209        37966 :         if let Some(b) = BASE32.get(ch) {
     210        37966 :             out.push(char::from(*b));
     211        37966 :         }
     212              :     }
     213         5480 :     out
     214         5480 : }
     215              : 
     216              : /// The geohash cells of one precision that tile the region's bounding box,
     217              : /// or `None` when no cover can narrow (`disjoint`, `near` without a maximum,
     218              : /// a box across the antimeridian or a pole, more than `MAX_COVER_CELLS`).
     219              : /// A SUPERSET: a stored geometry the predicate can match touches the box,
     220              : /// and its `location_cell` (migration 0015: the cell holding its whole
     221              : /// bounding box, `''` when none does) then either lies inside one of these
     222              : /// cells or is a prefix of one.
     223         2426 : fn cell_cover(spec: &GeoSpec<'_>) -> Option<Vec<String>> {
     224         1130 :     let (x0, y0, x1, y1) = match &spec.rel {
     225          526 :         Rel::Disjoint | Rel::Near { max: None, .. } => return None,
     226          457 :         Rel::Near { max: Some(m), .. } => {
     227          457 :             let [x, y] = spec.coordinates.as_array().map(Vec::as_slice)? else {
     228            0 :                 return None;
     229              :             };
     230          457 :             let (x, y) = (x.as_f64()?, y.as_f64()?);
     231              :             // the radius `predicate` binds, as an angle on a sphere of
     232              :             // 110 km per degree: shorter than any WGS84 degree of latitude
     233          201 :             let r = ((m * 1.005 + 1.0) / 110_000.0).to_radians();
     234          201 :             let lat = y.to_radians();
     235              :             // the circle's widest longitude reach (spherical cap)
     236          201 :             if !r.is_finite() || r.sin() >= lat.cos() {
     237            2 :                 return None;
     238          199 :             }
     239          199 :             let dx = (r.sin() / lat.cos()).asin().to_degrees();
     240          199 :             let dy = r.to_degrees();
     241          199 :             (x - dx, y - dy, x + dx, y + dy)
     242              :         }
     243         1443 :         _ => bbox(spec.coordinates)?,
     244              :     };
     245         1130 :     let (x0, y0, x1, y1) = (x0 - EDGE, y0 - EDGE, x1 + EDGE, y1 + EDGE);
     246         1130 :     if !(x0 >= -180.0 && x1 < 180.0 && y0 >= -90.0 && y1 < 90.0) {
     247            2 :         return None;
     248         1128 :     }
     249              :     // the finest precision whose cells tile the box in at most eight
     250         1128 :     let mut best = None;
     251         7450 :     for chars in 1..=12_u32 {
     252         7450 :         let w = 360.0 / f64::from(1_u32 << (chars.saturating_mul(5).div_ceil(2)).min(31));
     253         7450 :         let h = 180.0 / f64::from(1_u32 << (chars.saturating_mul(5) / 2).min(31));
     254         7450 :         let span =
     255        14900 :             |a: f64, b: f64, o: f64, s: f64| ((b + o) / s).floor() - ((a + o) / s).floor() + 1.0;
     256         7450 :         if span(x0, x1, 180.0, w) * span(y0, y1, 90.0, h) > MAX_COVER_CELLS {
     257          765 :             break;
     258         6685 :         }
     259         6685 :         best = Some((chars as usize, w, h));
     260              :     }
     261         1128 :     let (chars, w, h) = best?;
     262         1124 :     let (i0, i1) = (((x0 + 180.0) / w).floor(), ((x1 + 180.0) / w).floor());
     263         1124 :     let (j0, j1) = (((y0 + 90.0) / h).floor(), ((y1 + 90.0) / h).floor());
     264         1124 :     let mut cells = Vec::new();
     265         1124 :     let mut i = i0;
     266         3269 :     while i <= i1 {
     267         2145 :         let mut j = j0;
     268         6591 :         while j <= j1 {
     269         4446 :             // each cell by its centre, away from every split
     270         4446 :             cells.push(geohash(
     271         4446 :                 -180.0 + (i + 0.5) * w,
     272         4446 :                 -90.0 + (j + 0.5) * h,
     273         4446 :                 chars,
     274         4446 :             ));
     275         4446 :             j += 1.0;
     276         4446 :         }
     277         2145 :         i += 1.0;
     278              :     }
     279         1124 :     cells.sort_unstable();
     280         1124 :     cells.dedup();
     281         1124 :     Some(cells)
     282         2426 : }
     283              : 
     284              : /// The bounding box of a GeoJSON `coordinates` member, any nesting depth.
     285         5377 : fn bbox(v: &Value) -> Option<(f64, f64, f64, f64)> {
     286         5377 :     match v.as_array()?.as_slice() {
     287         4303 :         [x, y, ..] if x.is_number() => {
     288         3215 :             let (x, y) = (x.as_f64()?, y.as_f64()?);
     289         3215 :             Some((x, y, x, y))
     290              :         }
     291         3934 :         parts => parts.iter().try_fold(None, |acc, p| {
     292         3934 :             let b = bbox(p)?;
     293         3422 :             Some(Some(match acc {
     294         1138 :                 None => b,
     295         2284 :                 Some((a0, b0, a1, b1)) => (b.0.min(a0), b.1.min(b0), b.2.max(a1), b.3.max(b1)),
     296              :             }))
     297         3934 :         })?,
     298              :     }
     299         5377 : }
     300              : 
     301              : /// The cover as leakproof btree conditions on `cell_col` (`text COLLATE
     302              : /// "C"`): a stored cell inside a cover cell (`>= c AND < c~`; `~` sorts
     303              : /// after every geohash character), or one that is a prefix of a cover cell
     304              : /// (a geometry too large for a finer cell). Binds numbered from `first`.
     305         1120 : fn cover_sql(cells: &[String], cell_col: &str, first: usize) -> (String, Vec<String>) {
     306         1120 :     let mut binds = Vec::new();
     307         1120 :     let mut ors = Vec::new();
     308         4432 :     for c in cells {
     309         4432 :         let n = first.saturating_add(binds.len());
     310         4432 :         let m = n.saturating_add(1);
     311         4432 :         ors.push(format!("({cell_col} >= ${n} AND {cell_col} < ${m})"));
     312         4432 :         binds.push(c.clone());
     313         4432 :         binds.push(format!("{c}~"));
     314         4432 :     }
     315         1120 :     let mut prefixes: Vec<&str> = cells
     316         1120 :         .iter()
     317        25526 :         .flat_map(|c| (0..c.len()).map(move |k| &c[..k]))
     318         1120 :         .collect();
     319         1120 :     prefixes.sort_unstable();
     320         1120 :     prefixes.dedup();
     321         1120 :     let list: Vec<String> = prefixes
     322         1120 :         .iter()
     323         1120 :         .enumerate()
     324         9082 :         .map(|(k, _)| format!("${}", first.saturating_add(binds.len()).saturating_add(k)))
     325         1120 :         .collect();
     326         1120 :     ors.push(format!("{cell_col} = ANY(ARRAY[{}])", list.join(", ")));
     327         1120 :     binds.extend(prefixes.into_iter().map(str::to_owned));
     328         1120 :     (format!("({})", ors.join(" OR ")), binds)
     329         1120 : }
     330              : 
     331              : /// Extract the geometry to store in `entities.location` at write time.
     332              : /// `Some(geojson)` only for exactly one default-GeoProperty instance carrying
     333              : /// a GeoJSON value — see module docs, point 1, for why more than one is
     334              : /// deliberately `None`.
     335        41808 : pub fn extract_location(doc: &Value) -> Option<String> {
     336        41808 :     let instances = doc.get(LOCATION_IRI)?;
     337         4241 :     let inst = match instances {
     338         4241 :         Value::Array(a) => match a.as_slice() {
     339         3891 :             [one] => one,
     340          350 :             _ => return None, // multi-instance: let the evaluator judge
     341              :         },
     342            0 :         v => v,
     343              :     };
     344         3891 :     let value = inst.get("value").or_else(|| inst.get("object"))?;
     345              :     // must look like a GeoJSON geometry; anything else is not indexable and
     346              :     // must not silently become a NULL that looks like "no location".
     347              :     // GeometryCollection is deliberately absent: the PostGIS relate
     348              :     // predicates refuse one, so an extracted collection turns every later
     349              :     // geoquery into a database error — and it can never match anyway, since
     350              :     // `GeoQuery::matches_geometry` reads a geometry's `coordinates` and a
     351              :     // collection has none. It is flagged ambiguous instead and judged by the
     352              :     // evaluator like any other unextractable geoproperty.
     353         3891 :     let t = value.get("type")?.as_str()?;
     354         3881 :     if !matches!(
     355         3885 :         t,
     356         3885 :         "Point" | "MultiPoint" | "LineString" | "MultiLineString" | "Polygon" | "MultiPolygon"
     357              :     ) {
     358            4 :         return None;
     359         3881 :     }
     360         3881 :     serde_json::to_string(value).ok()
     361        41808 : }
     362              : 
     363              : #[cfg(test)]
     364              : mod tests {
     365              :     use super::*;
     366              :     use serde_json::json;
     367              : 
     368           40 :     fn coords() -> Value {
     369           40 :         json!([2.29, 48.85])
     370           40 :     }
     371              : 
     372           60 :     fn spec<'a>(rel: Rel, geometry: &'a str, coordinates: &'a Value, gp: &'a str) -> GeoSpec<'a> {
     373           60 :         GeoSpec {
     374           60 :             rel,
     375           60 :             geometry,
     376           60 :             coordinates,
     377           60 :             geoproperty_iri: gp,
     378           60 :         }
     379           60 :     }
     380              : 
     381              :     #[test]
     382            2 :     fn relations_bind_the_geometry_and_never_splice_it() {
     383            2 :         let c = compile_geo(&spec(Rel::Within, "Point", &coords(), ""), "location", 4)
     384            2 :             .expect("compiles");
     385            2 :         assert!(!c.sql.contains("48.85"), "sql: {}", c.sql);
     386            2 :         assert!(c.sql.contains("ST_GeomFromGeoJSON($4)"), "sql: {}", c.sql);
     387            2 :         assert!(c.sql.contains("ST_Within(location,"));
     388            2 :         assert_eq!(c.geo_binds.len(), 1);
     389            2 :         assert!(c.geo_binds[0].contains("48.85"));
     390            2 :     }
     391              : 
     392              :     #[test]
     393            2 :     fn unextracted_rows_always_survive() {
     394           12 :         for rel in [
     395            2 :             Rel::Within,
     396            2 :             Rel::Contains,
     397            2 :             Rel::Intersects,
     398            2 :             Rel::Disjoint,
     399            2 :             Rel::Overlaps,
     400            2 :             Rel::Equals,
     401            2 :         ] {
     402           12 :             let c =
     403           12 :                 compile_geo(&spec(rel, "Point", &coords(), ""), "location", 1).expect("compiles");
     404           12 :             assert!(
     405           12 :                 c.sql.ends_with(" OR location_ambiguous)"),
     406              :                 "a row carrying an unextractable geoproperty must reach the evaluator: {}",
     407              :                 c.sql
     408              :             );
     409              :         }
     410            2 :     }
     411              : 
     412              :     #[test]
     413            2 :     fn near_widens_max_and_deflates_min() {
     414            2 :         let c = compile_geo(
     415            2 :             &spec(
     416            2 :                 Rel::Near {
     417            2 :                     max: Some(2000.0),
     418            2 :                     min: None,
     419            2 :                 },
     420            2 :                 "Point",
     421            2 :                 &coords(),
     422            2 :                 "",
     423            2 :             ),
     424              :             "location",
     425              :             1,
     426              :         )
     427            2 :         .expect("compiles");
     428            2 :         assert!(c.sql.contains("ST_DWithin(location::geography"));
     429            2 :         assert!(
     430            2 :             c.num_binds[0] > 2000.0,
     431              :             "radius must widen: {:?}",
     432              :             c.num_binds
     433              :         );
     434              : 
     435            2 :         let c = compile_geo(
     436            2 :             &spec(
     437            2 :                 Rel::Near {
     438            2 :                     max: None,
     439            2 :                     min: Some(2000.0),
     440            2 :                 },
     441            2 :                 "Point",
     442            2 :                 &coords(),
     443            2 :                 "",
     444            2 :             ),
     445              :             "location",
     446              :             1,
     447              :         )
     448            2 :         .expect("compiles");
     449            2 :         assert!(c.sql.contains("ST_Distance(location::geography"));
     450            2 :         assert!(
     451            2 :             c.num_binds[0] < 2000.0,
     452              :             "floor must deflate: {:?}",
     453              :             c.num_binds
     454              :         );
     455            2 :         assert_eq!(
     456            2 :             compile_geo(
     457            2 :                 &spec(
     458            2 :                     Rel::Near {
     459            2 :                         max: Some(0.0),
     460            2 :                         min: Some(0.0)
     461            2 :                     },
     462            2 :                     "Point",
     463            2 :                     &coords(),
     464            2 :                     ""
     465            2 :                 ),
     466            2 :                 "location",
     467            2 :                 1
     468            2 :             )
     469            2 :             .expect("compiles")
     470            2 :             .num_binds[1],
     471              :             0.0,
     472              :             "a deflated floor never goes negative"
     473              :         );
     474            2 :     }
     475              : 
     476              :     /// Both `near` bounds in one predicate: the geometry keeps the offset and
     477              :     /// the two distances take the next two placeholders, in the order the
     478              :     /// caller appends them (geo binds first, then the numbers).
     479              :     #[test]
     480            2 :     fn both_near_bounds_take_distinct_placeholders_after_the_geometry() {
     481            2 :         let c = compile_geo(
     482            2 :             &spec(
     483            2 :                 Rel::Near {
     484            2 :                     max: Some(2000.0),
     485            2 :                     min: Some(500.0),
     486            2 :                 },
     487            2 :                 "Point",
     488            2 :                 &coords(),
     489            2 :                 "",
     490            2 :             ),
     491              :             "location",
     492              :             1,
     493              :         )
     494            2 :         .expect("compiles");
     495            2 :         assert_eq!(c.geo_binds.len(), 1);
     496            2 :         assert_eq!(c.num_binds.len(), 2);
     497            2 :         assert!(c.sql.contains("ST_GeomFromGeoJSON($1)"), "{}", c.sql);
     498            2 :         assert!(c.sql.contains("$2)"), "maxDistance placeholder: {}", c.sql);
     499            2 :         assert!(
     500            2 :             c.sql.contains(">= $3"),
     501              :             "minDistance placeholder: {}",
     502              :             c.sql
     503              :         );
     504            2 :         let past = 4 + c.cell_binds.len();
     505            2 :         assert!(
     506            2 :             c.sql.contains("location_cell >= $4"),
     507              :             "cover after: {}",
     508              :             c.sql
     509              :         );
     510            2 :         assert!(!c.sql.contains(&format!("${past}")), "overshoot: {}", c.sql);
     511            2 :     }
     512              : 
     513              :     /// `maxDistance`/`minDistance` reach this compiler as `f64`, and `inf`
     514              :     /// parses as one. A non-finite bound would compile to a comparison that
     515              :     /// EXCLUDES every row rather than widening — refuse it instead.
     516              :     #[test]
     517            2 :     fn a_non_finite_distance_is_left_to_the_evaluator() {
     518           10 :         for (max, min) in [
     519            2 :             (Some(f64::INFINITY), None),
     520            2 :             (None, Some(f64::INFINITY)),
     521            2 :             (Some(f64::NAN), None),
     522            2 :             (None, Some(f64::NAN)),
     523            2 :             (Some(2000.0), Some(f64::INFINITY)),
     524            2 :         ] {
     525           10 :             assert!(
     526           10 :                 compile_geo(
     527           10 :                     &spec(Rel::Near { max, min }, "Point", &coords(), ""),
     528           10 :                     "location",
     529           10 :                     1
     530           10 :                 )
     531           10 :                 .is_none(),
     532              :                 "non-finite bound must not compile: {max:?}/{min:?}"
     533              :             );
     534              :         }
     535            2 :     }
     536              : 
     537              :     /// `geometry` is a client string. It is a JSON member of the bound
     538              :     /// document, never a fragment of the statement.
     539              :     #[test]
     540            2 :     fn the_query_geometry_type_travels_in_the_bound_geojson() {
     541            2 :         let c = compile_geo(
     542            2 :             &spec(
     543            2 :                 Rel::Within,
     544            2 :                 "Polygon'); DROP TABLE entities; --",
     545            2 :                 &coords(),
     546            2 :                 "",
     547            2 :             ),
     548              :             "location",
     549              :             1,
     550              :         )
     551            2 :         .expect("compiles");
     552            8 :         for needle in ["DROP", "TABLE", "--", "'"] {
     553            8 :             assert!(!c.sql.contains(needle), "{needle:?} leaked: {}", c.sql);
     554              :         }
     555            2 :         assert!(
     556            2 :             c.sql
     557            2 :                 .starts_with("((((location_cell >= $2 AND location_cell < $3)"),
     558              :             "{}",
     559              :             c.sql
     560              :         );
     561            2 :         assert!(
     562            2 :             c.sql.ends_with(
     563            2 :                 " AND (ST_Within(location, ST_SetSRID(ST_GeomFromGeoJSON($1), 4326)))) \
     564            2 :                  OR location_ambiguous)"
     565            2 :             ),
     566              :             "{}",
     567              :             c.sql
     568              :         );
     569            2 :         assert!(c.geo_binds[0].contains("DROP"), "{}", c.geo_binds[0]);
     570            2 :     }
     571              : 
     572              :     /// The encoding PostGIS `ST_GeoHash` stores (`SELECT ST_GeoHash(
     573              :     /// ST_MakePoint(10, 45))`, PostGIS 3.5), splits going to the upper cell.
     574              :     #[test]
     575            2 :     fn geohash_matches_postgis() {
     576            2 :         assert_eq!(geohash(10.0, 45.0, 12), "u0p0581b0bh2");
     577            2 :         assert_eq!(geohash(11.25, 46.0, 3), "u20");
     578            2 :         assert_eq!(geohash(11.249_999_9, 46.0, 3), "u0p");
     579            2 :     }
     580              : 
     581              :     /// the cover is a superset. Every point the region holds, its
     582              :     /// edges and corners included, has a stored cell (a 12-character
     583              :     /// geohash) inside one cover cell, and a geometry whose cell is a prefix
     584              :     /// of a cover cell is kept by the prefix list.
     585              :     #[test]
     586            2 :     fn the_cell_cover_keeps_every_point_of_the_region() {
     587         1032 :         let kept = |cells: &[String], stored: &str| {
     588         1032 :             cells
     589         1032 :                 .iter()
     590         4028 :                 .any(|c| stored.starts_with(c.as_str()) || c.starts_with(stored))
     591         1032 :         };
     592            2 :         let poly = json!([[
     593              :             [10.0, 45.0],
     594              :             [10.0, 45.05],
     595              :             [10.05, 45.05],
     596              :             [10.05, 45.0],
     597              :             [10.0, 45.0]
     598              :         ]]);
     599            2 :         let s = spec(Rel::Intersects, "Polygon", &poly, "");
     600            2 :         let cells = cell_cover(&s).expect("a small box has a cover");
     601            2 :         assert!(
     602           12 :             cells.len() <= 8 && cells.iter().all(|c| c.len() == 5),
     603              :             "{cells:?}"
     604              :         );
     605           42 :         for i in 0..=20 {
     606          882 :             for j in 0..=20 {
     607          882 :                 let (x, y) = (10.0 + 0.0025 * f64::from(i), 45.0 + 0.0025 * f64::from(j));
     608          882 :                 assert!(
     609          882 :                     kept(&cells, &geohash(x, y, 12)),
     610              :                     "({x}, {y}) lost: {cells:?}"
     611              :                 );
     612              :             }
     613              :         }
     614            2 :         assert!(kept(&cells, "u0p0") && kept(&cells, ""), "ancestors stay");
     615            2 :         assert!(!kept(&cells, &geohash(10.2, 45.0, 12)), "the cover narrows");
     616              : 
     617              :         // near: every point within the radius, measured on the sphere
     618            2 :         let centre = json!([16.37, 48.21]);
     619            2 :         let s = spec(
     620            2 :             Rel::Near {
     621            2 :                 max: Some(5000.0),
     622            2 :                 min: None,
     623            2 :             },
     624            2 :             "Point",
     625            2 :             &centre,
     626            2 :             "",
     627              :         );
     628            2 :         let cells = cell_cover(&s).expect("a 5 km circle has a cover");
     629          144 :         for k in 0..72 {
     630          144 :             let a = f64::from(k * 5).to_radians();
     631          144 :             let (lat, lon, d) = (
     632          144 :                 48.21_f64.to_radians(),
     633          144 :                 16.37_f64.to_radians(),
     634          144 :                 5000.0_f64 / 6_371_008.8,
     635          144 :             );
     636          144 :             let y = (lat.sin() * d.cos() + lat.cos() * d.sin() * a.cos()).asin();
     637          144 :             let x = lon + (a.sin() * d.sin() * lat.cos()).atan2(d.cos() - lat.sin() * y.sin());
     638          144 :             assert!(
     639          144 :                 kept(&cells, &geohash(x.to_degrees(), y.to_degrees(), 12)),
     640              :                 "bearing {k}"
     641              :             );
     642              :         }
     643            2 :     }
     644              : 
     645              :     /// No cover where one cannot narrow or would have to wrap.
     646              :     #[test]
     647            2 :     fn regions_without_a_cover_keep_the_plain_predicate() {
     648            2 :         let poly = json!([[[10.0, 45.0], [10.0, 46.0], [11.0, 46.0], [10.0, 45.0]]]);
     649            2 :         assert!(cell_cover(&spec(Rel::Disjoint, "Polygon", &poly, "")).is_none());
     650            2 :         let p = json!([179.99, 0.0]);
     651            4 :         let near = |max| {
     652            4 :             spec(
     653            4 :                 Rel::Near {
     654            4 :                     max,
     655            4 :                     min: Some(1.0),
     656            4 :                 },
     657            4 :                 "Point",
     658            4 :                 &p,
     659            4 :                 "",
     660              :             )
     661            4 :         };
     662            2 :         assert!(cell_cover(&near(None)).is_none(), "minDistance alone");
     663            2 :         assert!(
     664            2 :             cell_cover(&near(Some(5000.0))).is_none(),
     665              :             "across the antimeridian"
     666              :         );
     667            2 :         let pole = json!([0.0, 89.99]);
     668            2 :         let s = spec(
     669            2 :             Rel::Near {
     670            2 :                 max: Some(5000.0),
     671            2 :                 min: None,
     672            2 :             },
     673            2 :             "Point",
     674            2 :             &pole,
     675            2 :             "",
     676              :         );
     677            2 :         assert!(cell_cover(&s).is_none(), "around a pole");
     678            2 :         let world = json!([[
     679              :             [-170.0, -80.0],
     680              :             [-170.0, 80.0],
     681              :             [170.0, 80.0],
     682              :             [-170.0, -80.0]
     683              :         ]]);
     684            2 :         assert!(
     685            2 :             cell_cover(&spec(Rel::Within, "Polygon", &world, "")).is_none(),
     686              :             "too wide"
     687              :         );
     688            2 :         let c = compile_geo(&spec(Rel::Within, "Polygon", &world, ""), "location", 1).expect("sql");
     689            2 :         assert!(
     690            2 :             c.cell_binds.is_empty() && !c.sql.contains("location_cell"),
     691              :             "{}",
     692              :             c.sql
     693              :         );
     694            2 :     }
     695              : 
     696              :     #[test]
     697            2 :     fn refusals_leave_it_to_the_evaluator() {
     698              :         // near from an extended geometry: evaluator measures the first vertex,
     699              :         // PostGIS the nearest point — narrowing risk on minDistance
     700            2 :         assert!(compile_geo(
     701            2 :             &spec(
     702            2 :                 Rel::Near {
     703            2 :                     max: Some(10.0),
     704            2 :                     min: None
     705            2 :                 },
     706            2 :                 "Polygon",
     707            2 :                 &json!([[[0, 0], [1, 0], [1, 1], [0, 0]]]),
     708            2 :                 ""
     709            2 :             ),
     710            2 :             "location",
     711            2 :             1
     712            2 :         )
     713            2 :         .is_none());
     714              :         // a non-default geoproperty has no extracted column
     715            2 :         assert!(compile_geo(
     716            2 :             &spec(
     717            2 :                 Rel::Within,
     718            2 :                 "Point",
     719            2 :                 &coords(),
     720            2 :                 "https://example.org/observationSpace"
     721            2 :             ),
     722            2 :             "location",
     723            2 :             1
     724            2 :         )
     725            2 :         .is_none());
     726              :         // `near` with no bound is not a filter
     727            2 :         assert!(compile_geo(
     728            2 :             &spec(
     729            2 :                 Rel::Near {
     730            2 :                     max: None,
     731            2 :                     min: None
     732            2 :                 },
     733            2 :                 "Point",
     734            2 :                 &coords(),
     735            2 :                 ""
     736            2 :             ),
     737            2 :             "location",
     738            2 :             1
     739            2 :         )
     740            2 :         .is_none());
     741            2 :     }
     742              : 
     743              :     #[test]
     744            2 :     fn instance_variant_falls_back_to_null_and_takes_any_geoproperty() {
     745            2 :         let c = compile_geo_instance(
     746            2 :             &spec(Rel::Within, "Point", &coords(), ""),
     747              :             "gi.geo_value",
     748              :             2,
     749              :         )
     750            2 :         .expect("compiles");
     751            2 :         assert!(
     752            2 :             c.sql.ends_with(" OR gi.geo_value IS NULL)"),
     753              :             "an instance with an unextracted geometry must reach the evaluator: {}",
     754              :             c.sql
     755              :         );
     756            2 :         assert!(c.sql.contains("ST_Within(gi.geo_value,"), "{}", c.sql);
     757            2 :         assert!(!c.sql.contains("location_ambiguous"), "{}", c.sql);
     758              :         // the predicate core still refuses near-from-extended-geometry
     759            2 :         assert!(compile_geo_instance(
     760            2 :             &spec(
     761            2 :                 Rel::Near {
     762            2 :                     max: Some(1.0),
     763            2 :                     min: None
     764            2 :                 },
     765            2 :                 "Polygon",
     766            2 :                 &json!([[[0, 0], [1, 0], [1, 1], [0, 0]]]),
     767            2 :                 ""
     768            2 :             ),
     769            2 :             "gi.geo_value",
     770            2 :             1
     771            2 :         )
     772            2 :         .is_none());
     773            2 :     }
     774              : 
     775              :     #[test]
     776            2 :     fn location_extraction_takes_the_single_instance_only() {
     777            2 :         let one = json!({
     778            2 :             LOCATION_IRI: [{"type": "GeoProperty",
     779            2 :                             "value": {"type": "Point", "coordinates": [1, 2]}}]
     780              :         });
     781            2 :         assert_eq!(
     782            2 :             extract_location(&one).expect("extracted"),
     783              :             "{\"coordinates\":[1,2],\"type\":\"Point\"}"
     784              :         );
     785              : 
     786            2 :         let multi = json!({
     787            2 :             LOCATION_IRI: [
     788            2 :                 {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1, 2]}},
     789            2 :                 {"type": "GeoProperty", "datasetId": "urn:d", "value": {"type": "Point", "coordinates": [3, 4]}}
     790              :             ]
     791              :         });
     792            2 :         assert!(
     793            2 :             extract_location(&multi).is_none(),
     794              :             "multi-instance stays NULL so the guard hands the row to the evaluator"
     795              :         );
     796              : 
     797              :         // a non-geometry value must not be extracted as if it were one
     798            2 :         let bogus = json!({ LOCATION_IRI: [{"type": "Property", "value": "somewhere"}] });
     799            2 :         assert!(extract_location(&bogus).is_none());
     800            2 :         assert!(extract_location(&json!({"id": "urn:x"})).is_none());
     801              : 
     802              :         // a collection is a GeoJSON geometry, but the relate predicates
     803              :         // refuse one — it stays unextracted so the row is flagged ambiguous
     804            2 :         let collection = json!({
     805            2 :             LOCATION_IRI: [{"type": "GeoProperty", "value": {
     806            2 :                 "type": "GeometryCollection",
     807            2 :                 "geometries": [{"type": "Point", "coordinates": [1, 2]}]
     808              :             }}]
     809              :         });
     810            2 :         assert!(
     811            2 :             extract_location(&collection).is_none(),
     812              :             "a GeometryCollection must not reach a PostGIS relate predicate"
     813              :         );
     814            2 :     }
     815              : }
        

Generated by: LCOV version 2.0-1