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 : ¢re,
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 : }
|