Skip to main content

mongreldb_kit_core/
check.rs

1//! Evaluation of table/column check constraints.
2//!
3//! Kit check constraints are stored as serializable expression strings (the
4//! TypeScript kit uses JS functions at the API layer, but the conformance
5//! fixtures and cross-language schema serialize a string grammar). This module
6//! implements a small, deterministic evaluator for that grammar so Rust,
7//! Python, and TypeScript enforce the same constraints.
8//!
9//! Supported grammar:
10//!
11//! ```text
12//! expr       := or_expr
13//! or_expr    := and_expr ( ("OR" | "||") and_expr )*
14//! and_expr   := not_expr ( ("AND" | "&&") not_expr )*
15//! not_expr   := ("NOT" | "!") not_expr | atom
16//! atom       := "(" or_expr ")" | comparison
17//! comparison := operand OP operand
18//! operand    := column | number | string | bool | null
19//! OP         := "=" | "==" | "!=" | "<>" | "<" | "<=" | ">" | ">="
20//! ```
21//!
22//! Evaluation follows SQL three-valued logic: a check is only *violated* when it
23//! evaluates to a definite `false`. `NULL`/unknown comparisons pass.
24
25use serde_json::{Map, Value};
26
27/// Error produced when a check expression cannot be parsed.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CheckParseError(pub String);
30
31/// Evaluate a check expression against a row.
32///
33/// Returns `Ok(true)` when the check holds (including unknown/NULL results) and
34/// `Ok(false)` only when the predicate is definitely violated.
35pub fn eval_check(expr: &str, row: &Map<String, Value>) -> Result<bool, CheckParseError> {
36    let tokens = tokenize(expr)?;
37    let mut parser = Parser { tokens, pos: 0 };
38    let result = parser.parse_or(row)?;
39    if parser.pos != parser.tokens.len() {
40        return Err(CheckParseError(format!(
41            "unexpected trailing tokens in check expression: {expr}"
42        )));
43    }
44    // SQL semantics: only a definite `false` is a violation.
45    Ok(result != Some(false))
46}
47
48#[derive(Debug, Clone, PartialEq)]
49enum Token {
50    Ident(String),
51    Number(f64),
52    Str(String),
53    Bool(bool),
54    Null,
55    Op(String),
56    And,
57    Or,
58    Not,
59    LParen,
60    RParen,
61}
62
63fn tokenize(expr: &str) -> Result<Vec<Token>, CheckParseError> {
64    let chars: Vec<char> = expr.chars().collect();
65    let mut tokens = Vec::new();
66    let mut i = 0;
67    while i < chars.len() {
68        let c = chars[i];
69        if c.is_whitespace() {
70            i += 1;
71            continue;
72        }
73        match c {
74            '(' => {
75                tokens.push(Token::LParen);
76                i += 1;
77            }
78            ')' => {
79                tokens.push(Token::RParen);
80                i += 1;
81            }
82            '\'' | '"' => {
83                let quote = c;
84                i += 1;
85                let mut s = String::new();
86                while i < chars.len() && chars[i] != quote {
87                    if chars[i] == '\\' && i + 1 < chars.len() {
88                        s.push(chars[i + 1]);
89                        i += 2;
90                    } else {
91                        s.push(chars[i]);
92                        i += 1;
93                    }
94                }
95                if i >= chars.len() {
96                    return Err(CheckParseError("unterminated string literal".into()));
97                }
98                i += 1; // closing quote
99                tokens.push(Token::Str(s));
100            }
101            // A bare `!` (not part of `!=`) is logical NOT, per the grammar.
102            '!' if !(i + 1 < chars.len() && chars[i + 1] == '=') => {
103                tokens.push(Token::Not);
104                i += 1;
105            }
106            '=' | '!' | '<' | '>' => {
107                let mut op = String::new();
108                op.push(c);
109                i += 1;
110                if i < chars.len() && (chars[i] == '=' || (c == '<' && chars[i] == '>')) {
111                    op.push(chars[i]);
112                    i += 1;
113                }
114                tokens.push(Token::Op(op));
115            }
116            '&' if i + 1 < chars.len() && chars[i + 1] == '&' => {
117                tokens.push(Token::And);
118                i += 2;
119            }
120            '|' if i + 1 < chars.len() && chars[i + 1] == '|' => {
121                tokens.push(Token::Or);
122                i += 2;
123            }
124            _ if c.is_ascii_digit()
125                || (c == '-' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit()) =>
126            {
127                let mut num = String::new();
128                num.push(c);
129                i += 1;
130                while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
131                    num.push(chars[i]);
132                    i += 1;
133                }
134                let value = num
135                    .parse::<f64>()
136                    .map_err(|_| CheckParseError(format!("invalid number: {num}")))?;
137                tokens.push(Token::Number(value));
138            }
139            _ if c.is_alphabetic() || c == '_' => {
140                let mut ident = String::new();
141                while i < chars.len()
142                    && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '.')
143                {
144                    ident.push(chars[i]);
145                    i += 1;
146                }
147                match ident.to_ascii_lowercase().as_str() {
148                    "and" => tokens.push(Token::And),
149                    "or" => tokens.push(Token::Or),
150                    "not" => tokens.push(Token::Not),
151                    "true" => tokens.push(Token::Bool(true)),
152                    "false" => tokens.push(Token::Bool(false)),
153                    "null" => tokens.push(Token::Null),
154                    _ => tokens.push(Token::Ident(ident)),
155                }
156            }
157            _ => return Err(CheckParseError(format!("unexpected character: {c}"))),
158        }
159    }
160    Ok(tokens)
161}
162
163struct Parser {
164    tokens: Vec<Token>,
165    pos: usize,
166}
167
168#[derive(Debug, Clone, PartialEq)]
169enum Operand {
170    Null,
171    Num(f64),
172    Str(String),
173    Bool(bool),
174}
175
176impl Parser {
177    fn peek(&self) -> Option<&Token> {
178        self.tokens.get(self.pos)
179    }
180
181    fn parse_or(&mut self, row: &Map<String, Value>) -> Result<Option<bool>, CheckParseError> {
182        let mut acc = self.parse_and(row)?;
183        while matches!(self.peek(), Some(Token::Or)) {
184            self.pos += 1;
185            let rhs = self.parse_and(row)?;
186            acc = or3(acc, rhs);
187        }
188        Ok(acc)
189    }
190
191    fn parse_and(&mut self, row: &Map<String, Value>) -> Result<Option<bool>, CheckParseError> {
192        let mut acc = self.parse_not(row)?;
193        while matches!(self.peek(), Some(Token::And)) {
194            self.pos += 1;
195            let rhs = self.parse_not(row)?;
196            acc = and3(acc, rhs);
197        }
198        Ok(acc)
199    }
200
201    fn parse_not(&mut self, row: &Map<String, Value>) -> Result<Option<bool>, CheckParseError> {
202        if matches!(self.peek(), Some(Token::Not)) {
203            self.pos += 1;
204            let inner = self.parse_not(row)?;
205            return Ok(not3(inner));
206        }
207        self.parse_atom(row)
208    }
209
210    fn parse_atom(&mut self, row: &Map<String, Value>) -> Result<Option<bool>, CheckParseError> {
211        if matches!(self.peek(), Some(Token::LParen)) {
212            self.pos += 1;
213            let inner = self.parse_or(row)?;
214            match self.peek() {
215                Some(Token::RParen) => {
216                    self.pos += 1;
217                    Ok(inner)
218                }
219                _ => Err(CheckParseError("expected closing parenthesis".into())),
220            }
221        } else {
222            self.parse_comparison(row)
223        }
224    }
225
226    fn parse_comparison(
227        &mut self,
228        row: &Map<String, Value>,
229    ) -> Result<Option<bool>, CheckParseError> {
230        let lhs = self.parse_operand(row)?;
231        let op = match self.peek() {
232            Some(Token::Op(op)) => op.clone(),
233            other => {
234                return Err(CheckParseError(format!(
235                    "expected comparison operator, found {other:?}"
236                )))
237            }
238        };
239        self.pos += 1;
240        let rhs = self.parse_operand(row)?;
241        Ok(compare(&lhs, &op, &rhs))
242    }
243
244    fn parse_operand(&mut self, row: &Map<String, Value>) -> Result<Operand, CheckParseError> {
245        let tok = self
246            .peek()
247            .cloned()
248            .ok_or_else(|| CheckParseError("unexpected end of expression".into()))?;
249        self.pos += 1;
250        Ok(match tok {
251            Token::Number(n) => Operand::Num(n),
252            Token::Str(s) => Operand::Str(s),
253            Token::Bool(b) => Operand::Bool(b),
254            Token::Null => Operand::Null,
255            Token::Ident(name) => value_to_operand(row.get(&name)),
256            other => {
257                return Err(CheckParseError(format!(
258                    "expected an operand, found {other:?}"
259                )))
260            }
261        })
262    }
263}
264
265fn value_to_operand(value: Option<&Value>) -> Operand {
266    match value {
267        None | Some(Value::Null) => Operand::Null,
268        Some(Value::Bool(b)) => Operand::Bool(*b),
269        Some(Value::Number(n)) => Operand::Num(n.as_f64().unwrap_or(f64::NAN)),
270        Some(Value::String(s)) => Operand::Str(s.clone()),
271        Some(other) => Operand::Str(other.to_string()),
272    }
273}
274
275/// Compare two operands, returning `None` for unknown (NULL-involving) results.
276fn compare(lhs: &Operand, op: &str, rhs: &Operand) -> Option<bool> {
277    if matches!(lhs, Operand::Null) || matches!(rhs, Operand::Null) {
278        return None;
279    }
280    let ord = match (lhs, rhs) {
281        (Operand::Num(a), Operand::Num(b)) => a.partial_cmp(b),
282        (Operand::Str(a), Operand::Str(b)) => Some(a.cmp(b)),
283        (Operand::Bool(a), Operand::Bool(b)) => Some(a.cmp(b)),
284        // Mixed types only support (in)equality and are otherwise unknown.
285        _ => None,
286    };
287    match op {
288        "=" | "==" => Some(lhs == rhs),
289        "!=" | "<>" => Some(lhs != rhs),
290        "<" => ord.map(|o| o.is_lt()),
291        "<=" => ord.map(|o| o.is_le()),
292        ">" => ord.map(|o| o.is_gt()),
293        ">=" => ord.map(|o| o.is_ge()),
294        _ => None,
295    }
296}
297
298fn not3(v: Option<bool>) -> Option<bool> {
299    v.map(|b| !b)
300}
301
302fn and3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
303    match (a, b) {
304        (Some(false), _) | (_, Some(false)) => Some(false),
305        (Some(true), Some(true)) => Some(true),
306        _ => None,
307    }
308}
309
310fn or3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
311    match (a, b) {
312        (Some(true), _) | (_, Some(true)) => Some(true),
313        (Some(false), Some(false)) => Some(false),
314        _ => None,
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use serde_json::json;
322
323    fn row(v: Value) -> Map<String, Value> {
324        v.as_object().unwrap().clone()
325    }
326
327    #[test]
328    fn passes_simple_comparison() {
329        let r = row(json!({ "age": 30 }));
330        assert!(eval_check("age >= 0", &r).unwrap());
331    }
332
333    #[test]
334    fn fails_simple_comparison() {
335        let r = row(json!({ "age": -1 }));
336        assert!(!eval_check("age >= 0", &r).unwrap());
337    }
338
339    #[test]
340    fn null_comparison_passes() {
341        let r = row(json!({ "age": null }));
342        assert!(eval_check("age >= 0", &r).unwrap());
343    }
344
345    #[test]
346    fn and_or_combination() {
347        let r = row(json!({ "a": 5, "b": 10 }));
348        assert!(eval_check("a > 0 AND b > 0", &r).unwrap());
349        assert!(!eval_check("a > 0 AND b < 0", &r).unwrap());
350        assert!(eval_check("a < 0 OR b > 0", &r).unwrap());
351        assert!(!eval_check("a < 0 OR b < 0", &r).unwrap());
352    }
353
354    #[test]
355    fn parentheses_and_not() {
356        let r = row(json!({ "a": 5, "b": 10 }));
357        assert!(eval_check("NOT (a > 100)", &r).unwrap());
358        assert!(eval_check("(a = 5 OR a = 6) AND b = 10", &r).unwrap());
359    }
360
361    #[test]
362    fn bang_is_logical_not() {
363        let r = row(json!({ "a": 5 }));
364        // `!` is logical NOT (distinct from `!=`).
365        assert!(eval_check("!(a > 100)", &r).unwrap());
366        assert!(!eval_check("!(a > 0)", &r).unwrap());
367        assert!(eval_check("a != 6", &r).unwrap());
368        assert!(!eval_check("a != 5", &r).unwrap());
369    }
370
371    #[test]
372    fn string_and_cross_column() {
373        let r = row(json!({ "lo": 1, "hi": 9, "kind": "x" }));
374        assert!(eval_check("lo < hi", &r).unwrap());
375        assert!(eval_check("kind = 'x'", &r).unwrap());
376        assert!(!eval_check("kind != 'x'", &r).unwrap());
377    }
378
379    #[test]
380    fn rejects_garbage() {
381        let r = row(json!({}));
382        assert!(eval_check("@@@", &r).is_err());
383    }
384}