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 : }
|