Skip to main content

reddb_rql/
geo_predicate.rs

1//! Shape recognition for the composable `GEO_WITHIN` geo predicate.
2//!
3//! `GEO_WITHIN(<geo-column>, POLYGON((lat lon), …))` parses into an
4//! ordinary `Expr::FunctionCall`, which the filter layer wraps as
5//! `<call> = TRUE`. Both the table executor (which turns a constant
6//! polygon over an H3-indexed column into a covering-cell candidate
7//! set) and `EXPLAIN` (which reports whether that route was taken)
8//! must agree, byte for byte, on which calls qualify. They agree by
9//! calling the recognizers here rather than re-deriving the shape.
10//!
11//! Nothing in this module decides *membership* — the inside-test lives
12//! with the `SEARCH SPATIAL WITHIN POLYGON` verb's exact even-odd
13//! point-in-polygon authority. These functions only answer "is this
14//! call a constant-polygon GEO_WITHIN over column X, and if so, what
15//! are the vertices?".
16
17use crate::ast::{CompareOp, Expr, FieldRef};
18use reddb_types::types::Value;
19
20/// A recognised `GEO_WITHIN(<column>, POLYGON(<constant vertices>))`.
21pub struct GeoWithinPredicate<'a> {
22    /// The geo column named by the call's first argument.
23    pub column: &'a str,
24    /// `(lat, lon)` vertices in the order the user wrote them.
25    pub vertices: Vec<(f64, f64)>,
26}
27
28/// The `(lat, lon)` pairs of a `POLYGON(...)` argument list, when every
29/// coordinate is a numeric literal. A polygon assembled from columns,
30/// parameters, or arithmetic is *not* constant: it can differ per row,
31/// so no single covering-cell set describes it and callers fall back to
32/// evaluating the predicate over a full scan.
33pub fn literal_polygon_vertices(args: &[Expr]) -> Option<Vec<(f64, f64)>> {
34    if args.len() < 6 || !args.len().is_multiple_of(2) {
35        return None;
36    }
37    let mut vertices = Vec::with_capacity(args.len() / 2);
38    for pair in args.chunks_exact(2) {
39        vertices.push((literal_f64(&pair[0])?, literal_f64(&pair[1])?));
40    }
41    Some(vertices)
42}
43
44/// Recognise a bare `GEO_WITHIN(column, POLYGON(constant…))` call.
45///
46/// `None` for a non-constant polygon, a first argument that is not a
47/// plain column reference, or any other function.
48pub fn geo_within_call(expr: &Expr) -> Option<GeoWithinPredicate<'_>> {
49    let Expr::FunctionCall { name, args, .. } = expr else {
50        return None;
51    };
52    if !name.eq_ignore_ascii_case("GEO_WITHIN") {
53        return None;
54    }
55    let [Expr::Column { field, .. }, polygon] = args.as_slice() else {
56        return None;
57    };
58    let FieldRef::TableColumn { column, .. } = field else {
59        return None;
60    };
61    let Expr::FunctionCall {
62        name: polygon_name,
63        args: polygon_args,
64        ..
65    } = polygon
66    else {
67        return None;
68    };
69    if !polygon_name.eq_ignore_ascii_case("POLYGON") {
70        return None;
71    }
72    Some(GeoWithinPredicate {
73        column: column.as_str(),
74        vertices: literal_polygon_vertices(polygon_args)?,
75    })
76}
77
78/// Recognise the truth test the filter layer builds for a bare boolean
79/// predicate: `GEO_WITHIN(...) = TRUE`.
80///
81/// Callers pass the comparison's two sides in both orders (flipping the
82/// operator) so `TRUE = GEO_WITHIN(...)` is recognised too. A negated
83/// test is deliberately not recognised: the covering cells bound where
84/// matches *can* be, which says nothing about where non-matches are.
85pub fn geo_within_truth_test<'a>(
86    lhs: &'a Expr,
87    op: CompareOp,
88    rhs: &Expr,
89) -> Option<GeoWithinPredicate<'a>> {
90    if !matches!(op, CompareOp::Eq) {
91        return None;
92    }
93    if !matches!(
94        rhs,
95        Expr::Literal {
96            value: Value::Boolean(true),
97            ..
98        }
99    ) {
100        return None;
101    }
102    geo_within_call(lhs)
103}
104
105fn literal_f64(expr: &Expr) -> Option<f64> {
106    match expr {
107        Expr::Literal {
108            value: Value::Float(value),
109            ..
110        } => Some(*value),
111        Expr::Literal {
112            value: Value::Integer(value),
113            ..
114        } => Some(*value as f64),
115        Expr::Literal {
116            value: Value::UnsignedInteger(value),
117            ..
118        } => Some(*value as f64),
119        _ => None,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::ast::Span;
127
128    fn lit(value: f64) -> Expr {
129        Expr::Literal {
130            value: Value::Float(value),
131            span: Span::synthetic(),
132        }
133    }
134
135    fn polygon(coords: &[f64]) -> Expr {
136        Expr::FunctionCall {
137            name: "POLYGON".to_string(),
138            args: coords.iter().copied().map(lit).collect(),
139            span: Span::synthetic(),
140        }
141    }
142
143    fn geo_within(column: &str, polygon: Expr) -> Expr {
144        Expr::FunctionCall {
145            name: "GEO_WITHIN".to_string(),
146            args: vec![
147                Expr::Column {
148                    field: FieldRef::TableColumn {
149                        table: String::new(),
150                        column: column.to_string(),
151                    },
152                    span: Span::synthetic(),
153                },
154                polygon,
155            ],
156            span: Span::synthetic(),
157        }
158    }
159
160    #[test]
161    fn recognises_constant_polygon_call() {
162        let call = geo_within("loc", polygon(&[0.0, 0.0, 1.0, 0.0, 1.0, 1.0]));
163        let predicate = geo_within_call(&call).expect("constant polygon is recognised");
164        assert_eq!(predicate.column, "loc");
165        assert_eq!(predicate.vertices, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]);
166    }
167
168    #[test]
169    fn rejects_non_constant_polygon() {
170        let mut args: Vec<Expr> = [0.0, 0.0, 1.0, 0.0, 1.0].iter().copied().map(lit).collect();
171        args.push(Expr::Column {
172            field: FieldRef::TableColumn {
173                table: String::new(),
174                column: "lon".to_string(),
175            },
176            span: Span::synthetic(),
177        });
178        let call = geo_within(
179            "loc",
180            Expr::FunctionCall {
181                name: "POLYGON".to_string(),
182                args,
183                span: Span::synthetic(),
184            },
185        );
186        assert!(geo_within_call(&call).is_none());
187    }
188
189    #[test]
190    fn rejects_degenerate_vertex_count() {
191        assert!(literal_polygon_vertices(&[lit(0.0), lit(0.0), lit(1.0), lit(1.0)]).is_none());
192        assert!(literal_polygon_vertices(&[lit(0.0), lit(0.0), lit(1.0)]).is_none());
193    }
194
195    #[test]
196    fn truth_test_only_matches_equals_true() {
197        let call = geo_within("loc", polygon(&[0.0, 0.0, 1.0, 0.0, 1.0, 1.0]));
198        let yes = Expr::Literal {
199            value: Value::Boolean(true),
200            span: Span::synthetic(),
201        };
202        let no = Expr::Literal {
203            value: Value::Boolean(false),
204            span: Span::synthetic(),
205        };
206        assert!(geo_within_truth_test(&call, CompareOp::Eq, &yes).is_some());
207        assert!(geo_within_truth_test(&call, CompareOp::Eq, &no).is_none());
208        assert!(geo_within_truth_test(&call, CompareOp::Ne, &yes).is_none());
209    }
210}