Skip to main content

architect_sdk/sql/
rsql.rs

1//! RSQL filter and sort parser.
2//!
3//! Syntax: `field<op>value` joined by `;` (AND) or `,` (OR), grouped with `()`.
4//!
5//! Operators: `==` `!=` `=gt=` `=ge=` `=lt=` `=le=` `=in=` `=out=`
6//!            `=like=` `=ilike=` `=contains=` `=starts=` `=ends=`
7//!            `=between=` `=null=`
8//!
9//! Sort: `?sort=-created_at,name`  (`-` prefix = descending)
10
11use crate::AppError;
12
13// ─── Public types ─────────────────────────────────────────────────────────────
14
15#[derive(Debug, Clone, PartialEq)]
16pub enum RsqlOp {
17    Eq,
18    Neq,
19    Gt,
20    Ge,
21    Lt,
22    Le,
23    In,
24    Out,
25    Like,
26    Ilike,
27    Contains,
28    Starts,
29    Ends,
30    Between,
31    /// `=null=true` → IS NULL; `=null=false` → IS NOT NULL
32    Null(bool),
33}
34
35impl RsqlOp {
36    pub fn display(&self) -> &'static str {
37        match self {
38            RsqlOp::Eq => "==",
39            RsqlOp::Neq => "!=",
40            RsqlOp::Gt => "=gt=",
41            RsqlOp::Ge => "=ge=",
42            RsqlOp::Lt => "=lt=",
43            RsqlOp::Le => "=le=",
44            RsqlOp::In => "=in=",
45            RsqlOp::Out => "=out=",
46            RsqlOp::Like => "=like=",
47            RsqlOp::Ilike => "=ilike=",
48            RsqlOp::Contains => "=contains=",
49            RsqlOp::Starts => "=starts=",
50            RsqlOp::Ends => "=ends=",
51            RsqlOp::Between => "=between=",
52            RsqlOp::Null(_) => "=null=",
53        }
54    }
55}
56
57#[derive(Debug, Clone)]
58pub enum FilterNode {
59    And(Vec<FilterNode>),
60    Or(Vec<FilterNode>),
61    Leaf {
62        field: String,
63        op: RsqlOp,
64        /// Parsed string values (empty for Null; one for scalar ops; ≥1 for In/Out/Between).
65        values: Vec<String>,
66    },
67}
68
69#[derive(Debug, Clone)]
70pub struct SortSpec {
71    pub field: String,
72    pub desc: bool,
73}
74
75// ─── Public entry points ───────────────────────────────────────────────────────
76
77/// Parse an RSQL expression string into a `FilterNode` tree.
78/// Returns `AppError::Validation` (HTTP 422) on any parse error.
79pub fn parse_rsql(input: &str) -> Result<FilterNode, AppError> {
80    let trimmed = input.trim();
81    if trimmed.is_empty() {
82        return Err(AppError::Validation("empty RSQL expression".into()));
83    }
84    let mut p = Parser::new(trimmed);
85    let node = p
86        .parse_expression()
87        .map_err(|e| AppError::Validation(format!("RSQL parse error: {}", e)))?;
88    if !p.at_end() {
89        return Err(AppError::Validation(format!(
90            "RSQL parse error: unexpected token at position {}",
91            p.pos
92        )));
93    }
94    Ok(node)
95}
96
97/// Parse a `sort` query parameter into a list of `SortSpec`.
98/// Format: `field1,-field2,field3`  (`-` prefix = descending).
99/// Silently skips empty tokens.
100pub fn parse_sort(input: &str) -> Vec<SortSpec> {
101    input
102        .split(',')
103        .filter_map(|part| {
104            let part = part.trim();
105            if part.is_empty() {
106                return None;
107            }
108            if let Some(field) = part.strip_prefix('-') {
109                if field.is_empty() {
110                    return None;
111                }
112                Some(SortSpec {
113                    field: field.to_string(),
114                    desc: true,
115                })
116            } else {
117                Some(SortSpec {
118                    field: part.to_string(),
119                    desc: false,
120                })
121            }
122        })
123        .collect()
124}
125
126// ─── Parser ───────────────────────────────────────────────────────────────────
127
128struct Parser {
129    chars: Vec<char>,
130    pub pos: usize,
131}
132
133impl Parser {
134    fn new(input: &str) -> Self {
135        Parser {
136            chars: input.chars().collect(),
137            pos: 0,
138        }
139    }
140
141    fn peek(&self) -> Option<char> {
142        self.chars.get(self.pos).copied()
143    }
144
145    fn at_end(&self) -> bool {
146        self.pos >= self.chars.len()
147    }
148
149    // expression = or_expr
150    fn parse_expression(&mut self) -> Result<FilterNode, String> {
151        self.parse_or()
152    }
153
154    // or_expr = and_expr (',' and_expr)*
155    // NOTE: ',' inside =in=(...) / =out=(...) / =between=(...) is consumed by
156    // parse_arguments before we return here, so no ambiguity.
157    fn parse_or(&mut self) -> Result<FilterNode, String> {
158        let first = self.parse_and()?;
159        let mut parts = vec![first];
160        while self.peek() == Some(',') {
161            self.pos += 1;
162            parts.push(self.parse_and()?);
163        }
164        if parts.len() == 1 {
165            Ok(parts.remove(0))
166        } else {
167            Ok(FilterNode::Or(parts))
168        }
169    }
170
171    // and_expr = atom (';' atom)*
172    fn parse_and(&mut self) -> Result<FilterNode, String> {
173        let first = self.parse_atom()?;
174        let mut parts = vec![first];
175        while self.peek() == Some(';') {
176            self.pos += 1;
177            parts.push(self.parse_atom()?);
178        }
179        if parts.len() == 1 {
180            Ok(parts.remove(0))
181        } else {
182            Ok(FilterNode::And(parts))
183        }
184    }
185
186    // atom = '(' expression ')' | leaf
187    fn parse_atom(&mut self) -> Result<FilterNode, String> {
188        if self.peek() == Some('(') {
189            self.pos += 1;
190            let node = self.parse_expression()?;
191            if self.peek() != Some(')') {
192                return Err(format!("expected ')' at position {}", self.pos));
193            }
194            self.pos += 1;
195            Ok(node)
196        } else {
197            self.parse_leaf()
198        }
199    }
200
201    fn parse_leaf(&mut self) -> Result<FilterNode, String> {
202        let field = self.parse_selector()?;
203        let (op_raw, op_name) = self.parse_operator()?;
204
205        // Special handling for =null=: parse true/false value and bake into op
206        if op_name == "null" {
207            let raw = self.parse_value()?;
208            let is_null = match raw.to_lowercase().as_str() {
209                "true" => true,
210                "false" => false,
211                other => return Err(format!("=null= expects true or false, got '{}'", other)),
212            };
213            return Ok(FilterNode::Leaf {
214                field,
215                op: RsqlOp::Null(is_null),
216                values: vec![],
217            });
218        }
219
220        let values = self.parse_arguments(&op_raw)?;
221        Ok(FilterNode::Leaf {
222            field,
223            op: op_raw,
224            values,
225        })
226    }
227
228    fn parse_selector(&mut self) -> Result<String, String> {
229        let start = self.pos;
230        while let Some(c) = self.peek() {
231            if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
232                self.pos += 1;
233            } else {
234                break;
235            }
236        }
237        if self.pos == start {
238            return Err(format!("expected field name at position {}", self.pos));
239        }
240        let field: String = self.chars[start..self.pos].iter().collect();
241        if field.chars().filter(|&c| c == '.').count() > 1 {
242            return Err(format!(
243                "nested dotted field '{}' is not supported; only one level allowed (e.g. include_name.field)",
244                field
245            ));
246        }
247        Ok(field)
248    }
249
250    /// Returns (RsqlOp, op_name_string).
251    fn parse_operator(&mut self) -> Result<(RsqlOp, String), String> {
252        // Two-char operators: == and !=
253        let two: String = self
254            .chars
255            .get(self.pos..self.pos + 2)
256            .map(|s| s.iter().collect())
257            .unwrap_or_default();
258        if two == "==" {
259            self.pos += 2;
260            return Ok((RsqlOp::Eq, "==".into()));
261        }
262        if two == "!=" {
263            self.pos += 2;
264            return Ok((RsqlOp::Neq, "!=".into()));
265        }
266
267        // Named operators: =name=
268        if self.peek() == Some('=') {
269            self.pos += 1; // skip opening =
270            let start = self.pos;
271            while let Some(c) = self.peek() {
272                if c == '=' {
273                    break;
274                }
275                self.pos += 1;
276            }
277            if self.peek() != Some('=') {
278                return Err(format!("unterminated operator at position {}", self.pos));
279            }
280            let name: String = self.chars[start..self.pos].iter().collect();
281            self.pos += 1; // skip closing =
282            let op = match name.as_str() {
283                "gt" => RsqlOp::Gt,
284                "ge" => RsqlOp::Ge,
285                "lt" => RsqlOp::Lt,
286                "le" => RsqlOp::Le,
287                "in" => RsqlOp::In,
288                "out" => RsqlOp::Out,
289                "like" => RsqlOp::Like,
290                "ilike" => RsqlOp::Ilike,
291                "contains" => RsqlOp::Contains,
292                "starts" => RsqlOp::Starts,
293                "ends" => RsqlOp::Ends,
294                "between" => RsqlOp::Between,
295                "null" => RsqlOp::Null(true), // placeholder; fixed in parse_leaf
296                _ => {
297                    return Err(format!(
298                        "unknown operator '={}=' at position {}",
299                        name, self.pos
300                    ))
301                }
302            };
303            return Ok((op, name));
304        }
305
306        Err(format!("expected operator at position {}", self.pos))
307    }
308
309    fn parse_arguments(&mut self, op: &RsqlOp) -> Result<Vec<String>, String> {
310        match op {
311            RsqlOp::In | RsqlOp::Out | RsqlOp::Between => {
312                // Expect (val1,val2,...)
313                if self.peek() != Some('(') {
314                    return Err(format!(
315                        "expected '(' after operator at position {}",
316                        self.pos
317                    ));
318                }
319                self.pos += 1;
320                let mut values = Vec::new();
321                loop {
322                    values.push(self.parse_value()?);
323                    match self.peek() {
324                        Some(',') => {
325                            self.pos += 1;
326                        }
327                        Some(')') => {
328                            self.pos += 1;
329                            break;
330                        }
331                        _ => return Err(format!("expected ',' or ')' at position {}", self.pos)),
332                    }
333                }
334                Ok(values)
335            }
336            _ => Ok(vec![self.parse_value()?]),
337        }
338    }
339
340    fn parse_value(&mut self) -> Result<String, String> {
341        if self.peek() == Some('"') {
342            // Quoted value — read until closing unescaped "
343            self.pos += 1;
344            let mut val = String::new();
345            loop {
346                match self.peek() {
347                    None => {
348                        return Err(format!(
349                            "unterminated quoted value at position {}",
350                            self.pos
351                        ))
352                    }
353                    Some('"') => {
354                        self.pos += 1;
355                        break;
356                    }
357                    Some(c) => {
358                        val.push(c);
359                        self.pos += 1;
360                    }
361                }
362            }
363            Ok(val)
364        } else {
365            // Unquoted — read until , ; ( ) or end
366            let start = self.pos;
367            while let Some(c) = self.peek() {
368                if ",;()".contains(c) {
369                    break;
370                }
371                self.pos += 1;
372            }
373            Ok(self.chars[start..self.pos].iter().collect())
374        }
375    }
376}
377
378// ─── Tests ────────────────────────────────────────────────────────────────────
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_simple_eq() {
386        let node = parse_rsql("status==active").unwrap();
387        assert!(matches!(node, FilterNode::Leaf { op: RsqlOp::Eq, .. }));
388    }
389
390    #[test]
391    fn test_and() {
392        let node = parse_rsql("status==active;age=ge=18").unwrap();
393        assert!(matches!(node, FilterNode::And(_)));
394    }
395
396    #[test]
397    fn test_or() {
398        let node = parse_rsql("status==active,status==pending").unwrap();
399        assert!(matches!(node, FilterNode::Or(_)));
400    }
401
402    #[test]
403    fn test_in_does_not_split_on_comma() {
404        // The , inside (active,pending) must NOT be treated as OR
405        let node = parse_rsql("status=in=(active,pending);age=gt=0").unwrap();
406        assert!(matches!(node, FilterNode::And(_)));
407        if let FilterNode::And(ref parts) = node {
408            assert_eq!(parts.len(), 2);
409            if let FilterNode::Leaf {
410                op: RsqlOp::In,
411                values,
412                ..
413            } = &parts[0]
414            {
415                assert_eq!(values, &["active", "pending"]);
416            } else {
417                panic!("expected In leaf");
418            }
419        }
420    }
421
422    #[test]
423    fn test_null_true() {
424        let node = parse_rsql("deleted_at=null=true").unwrap();
425        assert!(matches!(
426            node,
427            FilterNode::Leaf {
428                op: RsqlOp::Null(true),
429                ..
430            }
431        ));
432    }
433
434    #[test]
435    fn test_null_false() {
436        let node = parse_rsql("email=null=false").unwrap();
437        assert!(matches!(
438            node,
439            FilterNode::Leaf {
440                op: RsqlOp::Null(false),
441                ..
442            }
443        ));
444    }
445
446    #[test]
447    fn test_between() {
448        let node = parse_rsql("age=between=(18,65)").unwrap();
449        if let FilterNode::Leaf {
450            op: RsqlOp::Between,
451            values,
452            ..
453        } = node
454        {
455            assert_eq!(values, &["18", "65"]);
456        } else {
457            panic!("expected Between leaf");
458        }
459    }
460
461    #[test]
462    fn test_grouped_or_inside_and() {
463        let node = parse_rsql("status==active;(role==admin,role==moderator)").unwrap();
464        assert!(matches!(node, FilterNode::And(_)));
465    }
466
467    #[test]
468    fn test_quoted_value() {
469        let node = parse_rsql(r#"name=="John Doe""#).unwrap();
470        if let FilterNode::Leaf { values, .. } = node {
471            assert_eq!(values[0], "John Doe");
472        }
473    }
474
475    #[test]
476    fn test_sort_parse() {
477        let specs = parse_sort("-created_at,name");
478        assert_eq!(specs.len(), 2);
479        assert!(specs[0].desc);
480        assert_eq!(specs[0].field, "created_at");
481        assert!(!specs[1].desc);
482        assert_eq!(specs[1].field, "name");
483    }
484
485    #[test]
486    fn test_unknown_op_errors() {
487        assert!(parse_rsql("age=foo=5").is_err());
488    }
489
490    #[test]
491    fn test_null_bad_value_errors() {
492        assert!(parse_rsql("deleted_at=null=yes").is_err());
493    }
494
495    #[test]
496    fn test_dotted_field() {
497        let node = parse_rsql("transport_unit.bay=contains=bay23").unwrap();
498        if let FilterNode::Leaf {
499            field,
500            op: RsqlOp::Contains,
501            ..
502        } = node
503        {
504            assert_eq!(field, "transport_unit.bay");
505        } else {
506            panic!("expected Contains leaf with dotted field");
507        }
508    }
509
510    #[test]
511    fn test_nested_dot_errors() {
512        assert!(parse_rsql("a.b.c==value").is_err());
513    }
514}