Skip to main content

oxigdal_query/executor/
spatial_funcs.rs

1//! Spatial (`ST_*`) function evaluator.
2//!
3//! This module closes the eval gap between the query planner and the row-based
4//! filter interpreter. The parser turns SQL like
5//!
6//! ```sql
7//! WHERE ST_Intersects(geom, 'POLYGON((...))')
8//! ```
9//!
10//! into an [`Expr::Function`](crate::parser::ast::Expr::Function) node. The
11//! `Filter` operator now forwards every
12//! `Expr::Function` to [`evaluate_spatial_function`], which dispatches on the
13//! function name (case-insensitive) and returns either a typed
14//! [`Value`] or a typed
15//! [`QueryError`].
16//!
17//! # Value-variant rationale
18//!
19//! The runtime
20//! [`Value`] enum lives in
21//! `crates/oxigdal-query/src/executor/filter.rs` (it is the row-level value
22//! type used by the row-based interpreter). A grep of `Value::` across
23//! `crates/oxigdal-query/src` confirmed that *every* `match` on `Value` uses a
24//! `_ => …` catch-all (binary/unary operator dispatch, comparison fall-through,
25//! filter column extraction, etc.). Adding a new variant is therefore safe and
26//! does not regress any existing call site.
27//!
28//! Following that observation we chose **`Value::Geometry(geo::Geometry<f64>)`**
29//! over the alternative `Value::Wkt(String)`. Reasons:
30//!
31//! * Set-operation and constructor functions (`ST_Centroid`, `ST_Envelope`,
32//!   `ST_Buffer`, `ST_Intersection`, …) naturally produce a typed
33//!   `geo::Geometry<f64>`; round-tripping through a `String` would waste cycles
34//!   and lose precision via float-to-text conversion.
35//! * Downstream predicate evaluators (`ST_Intersects`, `ST_Contains`, …) keep
36//!   the geometry in its parsed form, so they avoid re-parsing WKT on every
37//!   row.
38//! * The variant is constructed by this module only; literal WKT strings
39//!   embedded in SQL stay as [`Value::String`] until parsed inside
40//!   `parse_geometry_arg`, so the SQL parser/optimizer surface is unchanged.
41//!
42//! # Function dispatch
43//!
44//! Names match case-insensitively (the SQL parser preserves the original
45//! casing). Supported operations:
46//!
47//! | Name                      | Arity | Result kind        |
48//! |---------------------------|-------|--------------------|
49//! | `ST_Intersects(a, b)`     | 2     | `Value::Boolean`   |
50//! | `ST_Contains(a, b)`       | 2     | `Value::Boolean`   |
51//! | `ST_Within(a, b)`         | 2     | `Value::Boolean`   |
52//! | `ST_Disjoint(a, b)`       | 2     | `Value::Boolean`   |
53//! | `ST_Equals(a, b)`         | 2     | `Value::Boolean`   |
54//! | `ST_Touches(a, b)`        | 2     | `Value::Boolean`   |
55//! | `ST_Overlaps(a, b)`       | 2     | `Value::Boolean`   |
56//! | `ST_Crosses(a, b)`        | 2     | `Value::Boolean`   |
57//! | `ST_Covers(a, b)`         | 2     | `Value::Boolean`   |
58//! | `ST_CoveredBy(a, b)`      | 2     | `Value::Boolean`   |
59//! | `ST_DWithin(a, b, dist)`  | 3     | `Value::Boolean`   |
60//! | `ST_Distance(a, b)`       | 2     | `Value::Float64`   |
61//! | `ST_Area(g)`              | 1     | `Value::Float64`   |
62//! | `ST_Length(g)`            | 1     | `Value::Float64`   |
63//! | `ST_Centroid(g)`          | 1     | `Value::Geometry`  |
64//! | `ST_Envelope(g)`          | 1     | `Value::Geometry`  |
65//! | `ST_Buffer(g, dist)`      | 2     | `Value::Geometry`  |
66//! | `ST_Intersection(a, b)`   | 2     | `Value::Geometry`  |
67//! | `ST_Union(a, b)`          | 2     | `Value::Geometry`  |
68//! | `ST_Difference(a, b)`     | 2     | `Value::Geometry`  |
69//!
70//! Unsupported geometry combinations (e.g. `BooleanOps` on a non-`Polygon`)
71//! return a typed [`QueryError::Unsupported`] rather than panicking.
72
73use crate::error::{QueryError, Result};
74use crate::executor::filter::Value;
75
76use geo::algorithm::{
77    area::Area, bool_ops::BooleanOps, bounding_rect::BoundingRect, buffer::Buffer,
78    centroid::Centroid, contains::Contains,
79};
80use geo::algorithm::{intersects::Intersects, relate::Relate, within::Within};
81use geo::line_measures::{Euclidean, Length};
82use geo_types::{Coord, Geometry, LineString, Polygon};
83use std::str::FromStr;
84use wkt::Wkt;
85
86/// Tolerance for coordinate-wise equality in `ST_Equals`.
87const EQUALS_EPS: f64 = f64::EPSILON;
88
89/// Evaluate a spatial (`ST_*`) function call.
90///
91/// The dispatch is case-insensitive on `name`. Returns a typed
92/// [`Value`] on success or a typed [`QueryError`] on failure (invalid
93/// arity, unparseable WKT, unsupported predicate for the given
94/// geometry types, …).
95///
96/// `_coordinate_dim` is reserved for a future 3-D/M extension; the
97/// current implementation operates strictly on 2-D `f64` geometries.
98pub fn evaluate_spatial_function(
99    name: &str,
100    args: &[Value],
101    _coordinate_dim: usize,
102) -> Result<Value> {
103    match name.to_ascii_uppercase().as_str() {
104        // ── Binary boolean predicates ───────────────────────────────────────
105        "ST_INTERSECTS" => binary_predicate(name, args, |a, b| a.intersects(b)),
106        "ST_CONTAINS" => binary_predicate(name, args, |a, b| a.contains(b)),
107        "ST_WITHIN" => binary_predicate(name, args, |a, b| a.is_within(b)),
108        "ST_DISJOINT" => binary_predicate(name, args, |a, b| !a.intersects(b)),
109        "ST_EQUALS" => binary_predicate(name, args, geom_equals_within_eps),
110        "ST_TOUCHES" => binary_predicate(name, args, |a, b| a.relate(b).is_touches()),
111        "ST_OVERLAPS" => binary_predicate(name, args, |a, b| a.relate(b).is_overlaps()),
112        "ST_CROSSES" => binary_predicate(name, args, |a, b| a.relate(b).is_crosses()),
113        "ST_COVERS" => binary_predicate(name, args, |a, b| a.relate(b).is_covers()),
114        "ST_COVEREDBY" => binary_predicate(name, args, |a, b| a.relate(b).is_coveredby()),
115
116        // ── Tolerance predicate ─────────────────────────────────────────────
117        "ST_DWITHIN" => st_dwithin(args),
118
119        // ── Metric / distance ───────────────────────────────────────────────
120        "ST_DISTANCE" => st_distance(args),
121        "ST_AREA" => st_area(args),
122        "ST_LENGTH" => st_length(args),
123
124        // ── Constructors ────────────────────────────────────────────────────
125        "ST_CENTROID" => st_centroid(args),
126        "ST_ENVELOPE" => st_envelope(args),
127        "ST_BUFFER" => st_buffer(args),
128
129        // ── Set operations ──────────────────────────────────────────────────
130        "ST_INTERSECTION" => st_bool_op(args, BoolOp::Intersection),
131        "ST_UNION" => st_bool_op(args, BoolOp::Union),
132        "ST_DIFFERENCE" => st_bool_op(args, BoolOp::Difference),
133
134        other => Err(QueryError::FunctionNotFound(other.to_string())),
135    }
136}
137
138// ─── Helpers ────────────────────────────────────────────────────────────────
139
140/// Arity check helper.
141fn check_arity(name: &str, args: &[Value], expected: usize) -> Result<()> {
142    if args.len() == expected {
143        Ok(())
144    } else {
145        Err(QueryError::InvalidArgument(format!(
146            "{} expects {} arg(s), got {}",
147            name,
148            expected,
149            args.len()
150        )))
151    }
152}
153
154/// Convert a [`Value`] to a [`Geometry<f64>`]. Strings are parsed as WKT.
155fn parse_geometry_arg(v: &Value) -> Result<Geometry<f64>> {
156    match v {
157        Value::Geometry(g) => Ok(g.clone()),
158        Value::String(s) => parse_wkt(s),
159        other => Err(QueryError::TypeMismatch {
160            expected: "geometry or WKT string".to_string(),
161            actual: format!("{:?}", other),
162        }),
163    }
164}
165
166/// Parse a WKT string into a [`Geometry<f64>`].
167fn parse_wkt(s: &str) -> Result<Geometry<f64>> {
168    let parsed = Wkt::<f64>::from_str(s)
169        .map_err(|e| QueryError::InvalidArgument(format!("wkt parse error: {}", e)))?;
170    Geometry::<f64>::try_from(parsed)
171        .map_err(|e| QueryError::InvalidArgument(format!("wkt -> geo: {}", e)))
172}
173
174/// Convert a [`Value`] to an `f64` numeric.
175fn parse_numeric_arg(v: &Value) -> Result<f64> {
176    match v {
177        Value::Float64(f) => Ok(*f),
178        Value::Float32(f) => Ok(*f as f64),
179        Value::Int32(i) => Ok(*i as f64),
180        Value::Int64(i) => Ok(*i as f64),
181        other => Err(QueryError::TypeMismatch {
182            expected: "numeric".to_string(),
183            actual: format!("{:?}", other),
184        }),
185    }
186}
187
188/// Evaluate a 2-arg boolean predicate that takes two geometries.
189fn binary_predicate<F>(name: &str, args: &[Value], pred: F) -> Result<Value>
190where
191    F: Fn(&Geometry<f64>, &Geometry<f64>) -> bool,
192{
193    check_arity(name, args, 2)?;
194    let a = parse_geometry_arg(&args[0])?;
195    let b = parse_geometry_arg(&args[1])?;
196    Ok(Value::Boolean(pred(&a, &b)))
197}
198
199/// Coordinate-wise equality between two geometries (within `EQUALS_EPS`).
200///
201/// This is OGC `ST_Equals` semantics relaxed by `f64::EPSILON`: the two
202/// geometries must share the same enum variant and produce identical coordinate
203/// sequences when iterated in order.
204fn geom_equals_within_eps(a: &Geometry<f64>, b: &Geometry<f64>) -> bool {
205    use geo_types::Geometry as G;
206    match (a, b) {
207        (G::Point(p1), G::Point(p2)) => coord_eq(p1.0, p2.0),
208        (G::Line(l1), G::Line(l2)) => coord_eq(l1.start, l2.start) && coord_eq(l1.end, l2.end),
209        (G::LineString(ls1), G::LineString(ls2)) => coords_eq(&ls1.0, &ls2.0),
210        (G::Polygon(p1), G::Polygon(p2)) => polygon_eq(p1, p2),
211        (G::MultiPoint(m1), G::MultiPoint(m2)) => {
212            m1.0.len() == m2.0.len() && m1.0.iter().zip(&m2.0).all(|(a, b)| coord_eq(a.0, b.0))
213        }
214        (G::MultiLineString(m1), G::MultiLineString(m2)) => {
215            m1.0.len() == m2.0.len() && m1.0.iter().zip(&m2.0).all(|(a, b)| coords_eq(&a.0, &b.0))
216        }
217        (G::MultiPolygon(m1), G::MultiPolygon(m2)) => {
218            m1.0.len() == m2.0.len() && m1.0.iter().zip(&m2.0).all(|(a, b)| polygon_eq(a, b))
219        }
220        (G::Rect(r1), G::Rect(r2)) => coord_eq(r1.min(), r2.min()) && coord_eq(r1.max(), r2.max()),
221        (G::Triangle(t1), G::Triangle(t2)) => {
222            coord_eq(t1.v1(), t2.v1()) && coord_eq(t1.v2(), t2.v2()) && coord_eq(t1.v3(), t2.v3())
223        }
224        _ => false,
225    }
226}
227
228fn coord_eq(a: Coord<f64>, b: Coord<f64>) -> bool {
229    (a.x - b.x).abs() <= EQUALS_EPS && (a.y - b.y).abs() <= EQUALS_EPS
230}
231
232fn coords_eq(a: &[Coord<f64>], b: &[Coord<f64>]) -> bool {
233    a.len() == b.len() && a.iter().zip(b).all(|(x, y)| coord_eq(*x, *y))
234}
235
236fn polygon_eq(a: &Polygon<f64>, b: &Polygon<f64>) -> bool {
237    coords_eq(&a.exterior().0, &b.exterior().0)
238        && a.interiors().len() == b.interiors().len()
239        && a.interiors()
240            .iter()
241            .zip(b.interiors())
242            .all(|(x, y)| coords_eq(&x.0, &y.0))
243}
244
245// ─── Predicate / metric functions ───────────────────────────────────────────
246
247fn st_dwithin(args: &[Value]) -> Result<Value> {
248    check_arity("ST_DWithin", args, 3)?;
249    let a = parse_geometry_arg(&args[0])?;
250    let b = parse_geometry_arg(&args[1])?;
251    let threshold = parse_numeric_arg(&args[2])?;
252    if threshold.is_nan() || threshold < 0.0 {
253        return Err(QueryError::InvalidArgument(format!(
254            "ST_DWithin threshold must be a non-negative finite number, got {}",
255            threshold
256        )));
257    }
258    let dist = euclidean_geometry_distance(&a, &b);
259    Ok(Value::Boolean(dist <= threshold))
260}
261
262fn st_distance(args: &[Value]) -> Result<Value> {
263    check_arity("ST_Distance", args, 2)?;
264    let a = parse_geometry_arg(&args[0])?;
265    let b = parse_geometry_arg(&args[1])?;
266    Ok(Value::Float64(euclidean_geometry_distance(&a, &b)))
267}
268
269fn st_area(args: &[Value]) -> Result<Value> {
270    check_arity("ST_Area", args, 1)?;
271    let g = parse_geometry_arg(&args[0])?;
272    Ok(Value::Float64(g.unsigned_area()))
273}
274
275fn st_length(args: &[Value]) -> Result<Value> {
276    check_arity("ST_Length", args, 1)?;
277    let g = parse_geometry_arg(&args[0])?;
278    Ok(Value::Float64(geometry_length(&g)))
279}
280
281/// Euclidean length of a geometry. Polygons / points return `0.0`.
282fn geometry_length(g: &Geometry<f64>) -> f64 {
283    use geo_types::Geometry as G;
284    match g {
285        G::Line(line) => Euclidean.length(line),
286        G::LineString(ls) => Euclidean.length(ls),
287        G::MultiLineString(mls) => Euclidean.length(mls),
288        G::Triangle(t) => {
289            let a = t.v1();
290            let b = t.v2();
291            let c = t.v3();
292            edge_len(a, b) + edge_len(b, c) + edge_len(c, a)
293        }
294        G::Rect(r) => {
295            let p = r.max() - r.min();
296            2.0 * (p.x.abs() + p.y.abs())
297        }
298        G::Polygon(p) => polygon_perimeter(p),
299        G::MultiPolygon(mp) => mp.0.iter().map(polygon_perimeter).sum(),
300        G::Point(_) | G::MultiPoint(_) => 0.0,
301        G::GeometryCollection(c) => c.0.iter().map(geometry_length).sum(),
302    }
303}
304
305fn edge_len(a: Coord<f64>, b: Coord<f64>) -> f64 {
306    let dx = a.x - b.x;
307    let dy = a.y - b.y;
308    dx.hypot(dy)
309}
310
311fn polygon_perimeter(p: &Polygon<f64>) -> f64 {
312    let ext = Euclidean.length(p.exterior());
313    let interior: f64 = p.interiors().iter().map(|r| Euclidean.length(r)).sum();
314    ext + interior
315}
316
317/// Minimum Euclidean distance between two arbitrary geometries.
318///
319/// Delegates to [`Euclidean::distance`] which is implemented for
320/// `(&Geometry<f64>, &Geometry<f64>)`.
321fn euclidean_geometry_distance(a: &Geometry<f64>, b: &Geometry<f64>) -> f64 {
322    use geo::line_measures::Distance;
323    Euclidean.distance(a, b)
324}
325
326// ─── Constructors ───────────────────────────────────────────────────────────
327
328fn st_centroid(args: &[Value]) -> Result<Value> {
329    check_arity("ST_Centroid", args, 1)?;
330    let g = parse_geometry_arg(&args[0])?;
331    let pt = g.centroid().ok_or_else(|| {
332        QueryError::execution("ST_Centroid: geometry has no centroid (empty geometry)")
333    })?;
334    Ok(Value::Geometry(Geometry::Point(pt)))
335}
336
337fn st_envelope(args: &[Value]) -> Result<Value> {
338    check_arity("ST_Envelope", args, 1)?;
339    let g = parse_geometry_arg(&args[0])?;
340    let rect = g.bounding_rect().ok_or_else(|| {
341        QueryError::execution("ST_Envelope: geometry has no bounding rectangle (empty geometry)")
342    })?;
343    // Materialise the AABB as a Polygon for OGC compatibility.
344    let mn = rect.min();
345    let mx = rect.max();
346    let ls = LineString::from(vec![
347        Coord { x: mn.x, y: mn.y },
348        Coord { x: mx.x, y: mn.y },
349        Coord { x: mx.x, y: mx.y },
350        Coord { x: mn.x, y: mx.y },
351        Coord { x: mn.x, y: mn.y },
352    ]);
353    Ok(Value::Geometry(Geometry::Polygon(Polygon::new(ls, vec![]))))
354}
355
356fn st_buffer(args: &[Value]) -> Result<Value> {
357    check_arity("ST_Buffer", args, 2)?;
358    let g = parse_geometry_arg(&args[0])?;
359    let dist = parse_numeric_arg(&args[1])?;
360    if !dist.is_finite() {
361        return Err(QueryError::InvalidArgument(format!(
362            "ST_Buffer distance must be finite, got {}",
363            dist
364        )));
365    }
366    // `geo::Buffer` is implemented for every concrete geometry type as well as
367    // `Geometry<f64>` (see geo-0.33 `algorithm/buffer.rs`). The result is a
368    // `MultiPolygon`.
369    let mp = g.buffer(dist);
370    Ok(Value::Geometry(Geometry::MultiPolygon(mp)))
371}
372
373// ─── Set operations ─────────────────────────────────────────────────────────
374
375enum BoolOp {
376    Intersection,
377    Union,
378    Difference,
379}
380
381fn st_bool_op(args: &[Value], op: BoolOp) -> Result<Value> {
382    let name = match op {
383        BoolOp::Intersection => "ST_Intersection",
384        BoolOp::Union => "ST_Union",
385        BoolOp::Difference => "ST_Difference",
386    };
387    check_arity(name, args, 2)?;
388    let a = parse_geometry_arg(&args[0])?;
389    let b = parse_geometry_arg(&args[1])?;
390    // `geo::BooleanOps` is implemented only for `Polygon<T>` and
391    // `MultiPolygon<T>` in geo 0.33; surface a typed error otherwise.
392    let multi_a = to_multi_polygon(&a, name)?;
393    let multi_b = to_multi_polygon(&b, name)?;
394    let result = match op {
395        BoolOp::Intersection => multi_a.intersection(&multi_b),
396        BoolOp::Union => multi_a.union(&multi_b),
397        BoolOp::Difference => multi_a.difference(&multi_b),
398    };
399    Ok(Value::Geometry(Geometry::MultiPolygon(result)))
400}
401
402fn to_multi_polygon(g: &Geometry<f64>, fn_name: &str) -> Result<geo_types::MultiPolygon<f64>> {
403    use geo_types::{Geometry as G, MultiPolygon};
404    match g {
405        G::Polygon(p) => Ok(MultiPolygon::new(vec![p.clone()])),
406        G::MultiPolygon(mp) => Ok(mp.clone()),
407        other => Err(QueryError::Unsupported(format!(
408            "{} requires Polygon/MultiPolygon operands, got {}",
409            fn_name,
410            geometry_kind(other)
411        ))),
412    }
413}
414
415fn geometry_kind(g: &Geometry<f64>) -> &'static str {
416    use geo_types::Geometry as G;
417    match g {
418        G::Point(_) => "Point",
419        G::Line(_) => "Line",
420        G::LineString(_) => "LineString",
421        G::Polygon(_) => "Polygon",
422        G::MultiPoint(_) => "MultiPoint",
423        G::MultiLineString(_) => "MultiLineString",
424        G::MultiPolygon(_) => "MultiPolygon",
425        G::Rect(_) => "Rect",
426        G::Triangle(_) => "Triangle",
427        G::GeometryCollection(_) => "GeometryCollection",
428    }
429}
430
431// ─── Unit tests for helpers ─────────────────────────────────────────────────
432
433#[cfg(test)]
434#[allow(clippy::panic)]
435#[allow(clippy::unwrap_used)]
436mod tests {
437    use super::*;
438
439    fn wkt(s: &str) -> Value {
440        Value::String(s.to_string())
441    }
442
443    #[test]
444    fn test_check_arity_ok_and_err() {
445        assert!(check_arity("F", &[Value::Null], 1).is_ok());
446        let err = check_arity("F", &[], 2).unwrap_err();
447        assert!(matches!(err, QueryError::InvalidArgument(_)));
448    }
449
450    #[test]
451    fn test_parse_geometry_from_wkt() {
452        let g = parse_geometry_arg(&wkt("POINT(1 2)")).expect("parse");
453        assert!(matches!(g, Geometry::Point(_)));
454    }
455
456    #[test]
457    fn test_parse_geometry_type_mismatch() {
458        let err = parse_geometry_arg(&Value::Int64(1)).unwrap_err();
459        assert!(matches!(err, QueryError::TypeMismatch { .. }));
460    }
461
462    #[test]
463    fn test_parse_numeric_int_and_float() {
464        assert_eq!(parse_numeric_arg(&Value::Int64(7)).unwrap(), 7.0);
465        assert_eq!(parse_numeric_arg(&Value::Float64(2.5)).unwrap(), 2.5);
466    }
467}