Skip to main content

oxigdal_services/ogc_features/
cql.rs

1//! CQL2 minimal parser and evaluator.
2
3use super::error::FeaturesError;
4
5/// A CQL2 scalar value
6#[derive(Debug, Clone, PartialEq)]
7pub enum CqlValue {
8    /// Text string
9    String(String),
10    /// Numeric value
11    Number(f64),
12    /// Boolean
13    Bool(bool),
14}
15
16/// A CQL2 expression node
17#[derive(Debug, Clone, PartialEq)]
18pub enum CqlExpr {
19    /// Equality comparison
20    Eq {
21        /// Property name
22        property: String,
23        /// Comparison value
24        value: CqlValue,
25    },
26    /// Less-than comparison
27    Lt {
28        /// Property name
29        property: String,
30        /// Threshold
31        value: f64,
32    },
33    /// Less-than-or-equal comparison
34    Lte {
35        /// Property name
36        property: String,
37        /// Threshold
38        value: f64,
39    },
40    /// Greater-than comparison
41    Gt {
42        /// Property name
43        property: String,
44        /// Threshold
45        value: f64,
46    },
47    /// Greater-than-or-equal comparison
48    Gte {
49        /// Property name
50        property: String,
51        /// Threshold
52        value: f64,
53    },
54    /// SQL LIKE pattern match (`%` and `_` wildcards)
55    Like {
56        /// Property name
57        property: String,
58        /// Pattern string
59        pattern: String,
60    },
61    /// BETWEEN range check (inclusive)
62    Between {
63        /// Property name
64        property: String,
65        /// Lower bound
66        low: f64,
67        /// Upper bound
68        high: f64,
69    },
70    /// Logical AND of two expressions
71    And(Box<CqlExpr>, Box<CqlExpr>),
72    /// Logical OR of two expressions
73    Or(Box<CqlExpr>, Box<CqlExpr>),
74    /// Logical NOT of an expression
75    Not(Box<CqlExpr>),
76}
77
78/// Minimal CQL2-text parser and evaluator
79pub struct CqlParser;
80
81impl CqlParser {
82    /// Parse a simple CQL2-text expression string into a `CqlExpr`.
83    ///
84    /// Supported forms (case-insensitive keywords):
85    /// - `name = 'London'`
86    /// - `population > 1000000`
87    /// - `name LIKE '%city%'`
88    /// - `age BETWEEN 18 AND 65`
89    /// - `a > 5 AND b < 10`
90    /// - `NOT (a > 5)`
91    pub fn parse(input: &str) -> Result<CqlExpr, FeaturesError> {
92        let trimmed = input.trim();
93        Self::parse_or(trimmed)
94    }
95
96    // ── recursive descent ───────────────────────────────────────────────────
97
98    fn parse_or(input: &str) -> Result<CqlExpr, FeaturesError> {
99        // Split on " OR " (case-insensitive, top level)
100        if let Some(idx) = Self::find_keyword_boundary(input, " OR ") {
101            let left = Self::parse_and(&input[..idx])?;
102            let right = Self::parse_or(&input[idx + 4..])?;
103            return Ok(CqlExpr::Or(Box::new(left), Box::new(right)));
104        }
105        Self::parse_and(input)
106    }
107
108    fn parse_and(input: &str) -> Result<CqlExpr, FeaturesError> {
109        // Split on " AND " but NOT inside "BETWEEN … AND …"
110        if let Some(idx) = Self::find_and_not_between(input) {
111            let left = Self::parse_not(&input[..idx])?;
112            let right = Self::parse_and(&input[idx + 5..])?;
113            return Ok(CqlExpr::And(Box::new(left), Box::new(right)));
114        }
115        Self::parse_not(input)
116    }
117
118    fn parse_not(input: &str) -> Result<CqlExpr, FeaturesError> {
119        let s = input.trim();
120        let upper = s.to_ascii_uppercase();
121        if upper.starts_with("NOT ") {
122            let inner = s[4..].trim();
123            // strip optional parentheses
124            let inner = Self::strip_parens(inner);
125            return Ok(CqlExpr::Not(Box::new(Self::parse_or(inner)?)));
126        }
127        // Strip surrounding parentheses then retry
128        if s.starts_with('(') && s.ends_with(')') {
129            let inner = &s[1..s.len() - 1];
130            return Self::parse_or(inner.trim());
131        }
132        Self::parse_atom(s)
133    }
134
135    fn parse_atom(input: &str) -> Result<CqlExpr, FeaturesError> {
136        let s = input.trim();
137        let upper = s.to_ascii_uppercase();
138
139        // BETWEEN
140        if let Some(between_idx) = upper.find(" BETWEEN ") {
141            let property = s[..between_idx].trim().to_string();
142            let rest = &s[between_idx + 9..];
143            let upper_rest = rest.to_ascii_uppercase();
144            if let Some(and_idx) = upper_rest.find(" AND ") {
145                let low: f64 = rest[..and_idx].trim().parse().map_err(|_| {
146                    FeaturesError::CqlParseError(format!(
147                        "BETWEEN low bound not numeric: {}",
148                        &rest[..and_idx]
149                    ))
150                })?;
151                let high: f64 = rest[and_idx + 5..].trim().parse().map_err(|_| {
152                    FeaturesError::CqlParseError(format!(
153                        "BETWEEN high bound not numeric: {}",
154                        &rest[and_idx + 5..]
155                    ))
156                })?;
157                return Ok(CqlExpr::Between {
158                    property,
159                    low,
160                    high,
161                });
162            }
163        }
164
165        // LIKE
166        if let Some(like_idx) = upper.find(" LIKE ") {
167            let property = s[..like_idx].trim().to_string();
168            let pattern_raw = s[like_idx + 6..].trim();
169            let pattern = Self::unquote(pattern_raw)?;
170            return Ok(CqlExpr::Like { property, pattern });
171        }
172
173        // Comparison operators — longest first to avoid ambiguity
174        for (op_str, builder) in &[
175            (
176                ">=",
177                Self::build_gte as fn(&str, &str) -> Result<CqlExpr, FeaturesError>,
178            ),
179            ("<=", Self::build_lte),
180            ("!=", Self::build_neq_placeholder),
181            (">", Self::build_gt),
182            ("<", Self::build_lt),
183            ("=", Self::build_eq),
184        ] {
185            if let Some(op_idx) = s.find(op_str) {
186                let property = s[..op_idx].trim().to_string();
187                let value_str = s[op_idx + op_str.len()..].trim();
188                return builder(&property, value_str);
189            }
190        }
191
192        Err(FeaturesError::CqlParseError(format!(
193            "Cannot parse atom: {s}"
194        )))
195    }
196
197    // ── operator builders ────────────────────────────────────────────────────
198
199    fn build_eq(property: &str, value_str: &str) -> Result<CqlExpr, FeaturesError> {
200        let value = Self::parse_value(value_str)?;
201        Ok(CqlExpr::Eq {
202            property: property.to_string(),
203            value,
204        })
205    }
206
207    fn build_lt(property: &str, value_str: &str) -> Result<CqlExpr, FeaturesError> {
208        let v: f64 = value_str.parse().map_err(|_| {
209            FeaturesError::CqlParseError(format!("Expected number after '<': {value_str}"))
210        })?;
211        Ok(CqlExpr::Lt {
212            property: property.to_string(),
213            value: v,
214        })
215    }
216
217    fn build_lte(property: &str, value_str: &str) -> Result<CqlExpr, FeaturesError> {
218        let v: f64 = value_str.parse().map_err(|_| {
219            FeaturesError::CqlParseError(format!("Expected number after '<=': {value_str}"))
220        })?;
221        Ok(CqlExpr::Lte {
222            property: property.to_string(),
223            value: v,
224        })
225    }
226
227    fn build_gt(property: &str, value_str: &str) -> Result<CqlExpr, FeaturesError> {
228        let v: f64 = value_str.parse().map_err(|_| {
229            FeaturesError::CqlParseError(format!("Expected number after '>': {value_str}"))
230        })?;
231        Ok(CqlExpr::Gt {
232            property: property.to_string(),
233            value: v,
234        })
235    }
236
237    fn build_gte(property: &str, value_str: &str) -> Result<CqlExpr, FeaturesError> {
238        let v: f64 = value_str.parse().map_err(|_| {
239            FeaturesError::CqlParseError(format!("Expected number after '>=': {value_str}"))
240        })?;
241        Ok(CqlExpr::Gte {
242            property: property.to_string(),
243            value: v,
244        })
245    }
246
247    fn build_neq_placeholder(_property: &str, _value_str: &str) -> Result<CqlExpr, FeaturesError> {
248        Err(FeaturesError::CqlParseError(
249            "!= operator is not yet supported".to_string(),
250        ))
251    }
252
253    // ── value parsing helpers ────────────────────────────────────────────────
254
255    fn parse_value(s: &str) -> Result<CqlValue, FeaturesError> {
256        let s = s.trim();
257        // Quoted string
258        if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
259            return Ok(CqlValue::String(Self::unquote(s)?));
260        }
261        // Boolean
262        match s.to_ascii_uppercase().as_str() {
263            "TRUE" => return Ok(CqlValue::Bool(true)),
264            "FALSE" => return Ok(CqlValue::Bool(false)),
265            _ => {}
266        }
267        // Number
268        if let Ok(n) = s.parse::<f64>() {
269            return Ok(CqlValue::Number(n));
270        }
271        // Bare string (unquoted identifier value)
272        Ok(CqlValue::String(s.to_string()))
273    }
274
275    fn unquote(s: &str) -> Result<String, FeaturesError> {
276        let s = s.trim();
277        if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
278            Ok(s[1..s.len() - 1].to_string())
279        } else {
280            Ok(s.to_string())
281        }
282    }
283
284    fn strip_parens(s: &str) -> &str {
285        let s = s.trim();
286        if s.starts_with('(') && s.ends_with(')') {
287            &s[1..s.len() - 1]
288        } else {
289            s
290        }
291    }
292
293    /// Find the byte offset of the first top-level occurrence of `keyword`
294    /// (case-insensitive), ignoring occurrences inside parentheses.
295    fn find_keyword_boundary(input: &str, keyword: &str) -> Option<usize> {
296        let upper = input.to_ascii_uppercase();
297        let kw_upper = keyword.to_ascii_uppercase();
298        let mut depth = 0usize;
299        let bytes = input.as_bytes();
300        let kw_len = kw_upper.len();
301        let kw_bytes = kw_upper.as_bytes();
302
303        let mut i = 0;
304        while i + kw_len <= bytes.len() {
305            match bytes[i] {
306                b'(' => {
307                    depth += 1;
308                    i += 1;
309                }
310                b')' => {
311                    depth = depth.saturating_sub(1);
312                    i += 1;
313                }
314                b'\'' | b'"' => {
315                    // skip quoted string
316                    let quote = bytes[i];
317                    i += 1;
318                    while i < bytes.len() && bytes[i] != quote {
319                        i += 1;
320                    }
321                    i += 1; // closing quote
322                }
323                _ => {
324                    if depth == 0 && upper.as_bytes()[i..].starts_with(kw_bytes) {
325                        return Some(i);
326                    }
327                    i += 1;
328                }
329            }
330        }
331        None
332    }
333
334    /// Find ` AND ` that is NOT part of a `BETWEEN … AND …` construct.
335    fn find_and_not_between(input: &str) -> Option<usize> {
336        let upper = input.to_ascii_uppercase();
337        let mut search_start = 0;
338
339        while let Some(rel) = upper[search_start..].find(" AND ") {
340            let abs = search_start + rel;
341            // Check if this AND is part of BETWEEN … AND
342            let prefix = &upper[..abs];
343            // Find nearest BETWEEN before abs that is not already paired
344            if Self::is_between_and(prefix) {
345                search_start = abs + 5;
346                continue;
347            }
348            return Some(abs);
349        }
350        None
351    }
352
353    /// Heuristic: is the upcoming " AND " completing a BETWEEN expression?
354    ///
355    /// We look at whether the token before this AND has a BETWEEN in the same
356    /// clause that hasn't been closed yet.
357    fn is_between_and(prefix: &str) -> bool {
358        // Count BETWEEN occurrences and AND occurrences in the prefix.
359        // If betweens > ands already consumed, this AND closes a BETWEEN.
360        let p = prefix.to_ascii_uppercase();
361        let between_count = p.matches(" BETWEEN ").count();
362        // Count ANDs that are already present in the prefix (closing previous BETWEENs)
363        let and_count = p.matches(" AND ").count();
364        between_count > and_count
365    }
366
367    // ─────────────────────────────────────────────────────────────────────────
368    // Evaluator
369    // ─────────────────────────────────────────────────────────────────────────
370
371    /// Evaluate a `CqlExpr` against a JSON properties object.
372    ///
373    /// Returns `true` if the expression holds for these properties.
374    pub fn evaluate(expr: &CqlExpr, properties: &serde_json::Value) -> bool {
375        match expr {
376            CqlExpr::Eq { property, value } => {
377                let prop = Self::get_prop(properties, property);
378                match (value, &prop) {
379                    (CqlValue::String(s), serde_json::Value::String(ps)) => s == ps,
380                    (CqlValue::Number(n), serde_json::Value::Number(pn)) => {
381                        pn.as_f64().is_some_and(|v| (v - n).abs() < f64::EPSILON)
382                    }
383                    (CqlValue::Bool(b), serde_json::Value::Bool(pb)) => b == pb,
384                    _ => false,
385                }
386            }
387
388            CqlExpr::Lt { property, value } => {
389                Self::numeric_prop(properties, property).is_some_and(|v| v < *value)
390            }
391            CqlExpr::Lte { property, value } => {
392                Self::numeric_prop(properties, property).is_some_and(|v| v <= *value)
393            }
394            CqlExpr::Gt { property, value } => {
395                Self::numeric_prop(properties, property).is_some_and(|v| v > *value)
396            }
397            CqlExpr::Gte { property, value } => {
398                Self::numeric_prop(properties, property).is_some_and(|v| v >= *value)
399            }
400
401            CqlExpr::Like { property, pattern } => {
402                if let serde_json::Value::String(s) = Self::get_prop(properties, property) {
403                    Self::like_match(&s, pattern)
404                } else {
405                    false
406                }
407            }
408
409            CqlExpr::Between {
410                property,
411                low,
412                high,
413            } => Self::numeric_prop(properties, property).is_some_and(|v| v >= *low && v <= *high),
414
415            CqlExpr::And(a, b) => Self::evaluate(a, properties) && Self::evaluate(b, properties),
416            CqlExpr::Or(a, b) => Self::evaluate(a, properties) || Self::evaluate(b, properties),
417            CqlExpr::Not(inner) => !Self::evaluate(inner, properties),
418        }
419    }
420
421    fn get_prop(properties: &serde_json::Value, key: &str) -> serde_json::Value {
422        match properties {
423            serde_json::Value::Object(map) => {
424                map.get(key).cloned().unwrap_or(serde_json::Value::Null)
425            }
426            _ => serde_json::Value::Null,
427        }
428    }
429
430    fn numeric_prop(properties: &serde_json::Value, key: &str) -> Option<f64> {
431        match Self::get_prop(properties, key) {
432            serde_json::Value::Number(n) => n.as_f64(),
433            _ => None,
434        }
435    }
436
437    /// SQL LIKE matching with `%` (any sequence) and `_` (any single char).
438    fn like_match(value: &str, pattern: &str) -> bool {
439        Self::like_recursive(value.as_bytes(), pattern.as_bytes())
440    }
441
442    fn like_recursive(value: &[u8], pattern: &[u8]) -> bool {
443        if pattern.is_empty() {
444            return value.is_empty();
445        }
446        match pattern[0] {
447            b'%' => {
448                // Match zero or more characters
449                for i in 0..=value.len() {
450                    if Self::like_recursive(&value[i..], &pattern[1..]) {
451                        return true;
452                    }
453                }
454                false
455            }
456            b'_' => {
457                // Match exactly one character
458                if value.is_empty() {
459                    false
460                } else {
461                    Self::like_recursive(&value[1..], &pattern[1..])
462                }
463            }
464            ch => {
465                if value.is_empty() || value[0] != ch {
466                    false
467                } else {
468                    Self::like_recursive(&value[1..], &pattern[1..])
469                }
470            }
471        }
472    }
473}