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#[derive(Debug, Clone, PartialEq)]
32pub enum CheckExpression {
33    Compare {
34        left: CheckOperand,
35        op: CheckOperator,
36        right: CheckOperand,
37    },
38    And(Box<CheckExpression>, Box<CheckExpression>),
39    Or(Box<CheckExpression>, Box<CheckExpression>),
40    Not(Box<CheckExpression>),
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub enum CheckOperand {
45    Column(String),
46    Number(f64),
47    String(String),
48    Bool(bool),
49    Null,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum CheckOperator {
54    Eq,
55    Ne,
56    Lt,
57    Le,
58    Gt,
59    Ge,
60}
61
62/// Evaluate a check expression against a row.
63///
64/// Returns `Ok(true)` when the check holds (including unknown/NULL results) and
65/// `Ok(false)` only when the predicate is definitely violated.
66pub fn eval_check(expr: &str, row: &Map<String, Value>) -> Result<bool, CheckParseError> {
67    Ok(parse_check(expr)?.eval(row) != Some(false))
68}
69
70pub fn parse_check(expr: &str) -> Result<CheckExpression, CheckParseError> {
71    let tokens = tokenize(expr)?;
72    let mut parser = Parser { tokens, pos: 0 };
73    let result = parser.parse_or()?;
74    if parser.pos != parser.tokens.len() {
75        return Err(CheckParseError(format!(
76            "unexpected trailing tokens in check expression: {expr}"
77        )));
78    }
79    Ok(result)
80}
81
82#[derive(Debug, Clone, PartialEq)]
83enum Token {
84    Ident(String),
85    Number(f64),
86    Str(String),
87    Bool(bool),
88    Null,
89    Op(String),
90    And,
91    Or,
92    Not,
93    LParen,
94    RParen,
95}
96
97fn tokenize(expr: &str) -> Result<Vec<Token>, CheckParseError> {
98    let chars: Vec<char> = expr.chars().collect();
99    let mut tokens = Vec::new();
100    let mut i = 0;
101    while i < chars.len() {
102        let c = chars[i];
103        if c.is_whitespace() {
104            i += 1;
105            continue;
106        }
107        match c {
108            '(' => {
109                tokens.push(Token::LParen);
110                i += 1;
111            }
112            ')' => {
113                tokens.push(Token::RParen);
114                i += 1;
115            }
116            '\'' | '"' => {
117                let quote = c;
118                i += 1;
119                let mut s = String::new();
120                while i < chars.len() && chars[i] != quote {
121                    if chars[i] == '\\' && i + 1 < chars.len() {
122                        s.push(chars[i + 1]);
123                        i += 2;
124                    } else {
125                        s.push(chars[i]);
126                        i += 1;
127                    }
128                }
129                if i >= chars.len() {
130                    return Err(CheckParseError("unterminated string literal".into()));
131                }
132                i += 1; // closing quote
133                tokens.push(Token::Str(s));
134            }
135            // A bare `!` (not part of `!=`) is logical NOT, per the grammar.
136            '!' if !(i + 1 < chars.len() && chars[i + 1] == '=') => {
137                tokens.push(Token::Not);
138                i += 1;
139            }
140            '=' | '!' | '<' | '>' => {
141                let mut op = String::new();
142                op.push(c);
143                i += 1;
144                if i < chars.len() && (chars[i] == '=' || (c == '<' && chars[i] == '>')) {
145                    op.push(chars[i]);
146                    i += 1;
147                }
148                tokens.push(Token::Op(op));
149            }
150            '&' if i + 1 < chars.len() && chars[i + 1] == '&' => {
151                tokens.push(Token::And);
152                i += 2;
153            }
154            '|' if i + 1 < chars.len() && chars[i + 1] == '|' => {
155                tokens.push(Token::Or);
156                i += 2;
157            }
158            _ if c.is_ascii_digit()
159                || (c == '-' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit()) =>
160            {
161                let mut num = String::new();
162                num.push(c);
163                i += 1;
164                while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
165                    num.push(chars[i]);
166                    i += 1;
167                }
168                let value = num
169                    .parse::<f64>()
170                    .map_err(|_| CheckParseError(format!("invalid number: {num}")))?;
171                tokens.push(Token::Number(value));
172            }
173            _ if c.is_alphabetic() || c == '_' => {
174                let mut ident = String::new();
175                while i < chars.len()
176                    && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '.')
177                {
178                    ident.push(chars[i]);
179                    i += 1;
180                }
181                match ident.to_ascii_lowercase().as_str() {
182                    "and" => tokens.push(Token::And),
183                    "or" => tokens.push(Token::Or),
184                    "not" => tokens.push(Token::Not),
185                    "true" => tokens.push(Token::Bool(true)),
186                    "false" => tokens.push(Token::Bool(false)),
187                    "null" => tokens.push(Token::Null),
188                    _ => tokens.push(Token::Ident(ident)),
189                }
190            }
191            _ => return Err(CheckParseError(format!("unexpected character: {c}"))),
192        }
193    }
194    Ok(tokens)
195}
196
197struct Parser {
198    tokens: Vec<Token>,
199    pos: usize,
200}
201
202#[derive(Debug, Clone, PartialEq)]
203enum Operand {
204    Null,
205    Num(f64),
206    Str(String),
207    Bool(bool),
208}
209
210impl Parser {
211    fn peek(&self) -> Option<&Token> {
212        self.tokens.get(self.pos)
213    }
214
215    fn parse_or(&mut self) -> Result<CheckExpression, CheckParseError> {
216        let mut acc = self.parse_and()?;
217        while matches!(self.peek(), Some(Token::Or)) {
218            self.pos += 1;
219            let rhs = self.parse_and()?;
220            acc = CheckExpression::Or(Box::new(acc), Box::new(rhs));
221        }
222        Ok(acc)
223    }
224
225    fn parse_and(&mut self) -> Result<CheckExpression, CheckParseError> {
226        let mut acc = self.parse_not()?;
227        while matches!(self.peek(), Some(Token::And)) {
228            self.pos += 1;
229            let rhs = self.parse_not()?;
230            acc = CheckExpression::And(Box::new(acc), Box::new(rhs));
231        }
232        Ok(acc)
233    }
234
235    fn parse_not(&mut self) -> Result<CheckExpression, CheckParseError> {
236        if matches!(self.peek(), Some(Token::Not)) {
237            self.pos += 1;
238            return Ok(CheckExpression::Not(Box::new(self.parse_not()?)));
239        }
240        self.parse_atom()
241    }
242
243    fn parse_atom(&mut self) -> Result<CheckExpression, CheckParseError> {
244        if matches!(self.peek(), Some(Token::LParen)) {
245            self.pos += 1;
246            let inner = self.parse_or()?;
247            match self.peek() {
248                Some(Token::RParen) => {
249                    self.pos += 1;
250                    Ok(inner)
251                }
252                _ => Err(CheckParseError("expected closing parenthesis".into())),
253            }
254        } else {
255            self.parse_comparison()
256        }
257    }
258
259    fn parse_comparison(&mut self) -> Result<CheckExpression, CheckParseError> {
260        let left = self.parse_operand()?;
261        let op = match self.peek() {
262            Some(Token::Op(op)) => op.clone(),
263            other => {
264                return Err(CheckParseError(format!(
265                    "expected comparison operator, found {other:?}"
266                )))
267            }
268        };
269        self.pos += 1;
270        let right = self.parse_operand()?;
271        let op = match op.as_str() {
272            "=" | "==" => CheckOperator::Eq,
273            "!=" | "<>" => CheckOperator::Ne,
274            "<" => CheckOperator::Lt,
275            "<=" => CheckOperator::Le,
276            ">" => CheckOperator::Gt,
277            ">=" => CheckOperator::Ge,
278            _ => {
279                return Err(CheckParseError(format!(
280                    "unsupported comparison operator: {op}"
281                )))
282            }
283        };
284        Ok(CheckExpression::Compare { left, op, right })
285    }
286
287    fn parse_operand(&mut self) -> Result<CheckOperand, CheckParseError> {
288        let tok = self
289            .peek()
290            .cloned()
291            .ok_or_else(|| CheckParseError("unexpected end of expression".into()))?;
292        self.pos += 1;
293        Ok(match tok {
294            Token::Number(n) => CheckOperand::Number(n),
295            Token::Str(s) => CheckOperand::String(s),
296            Token::Bool(b) => CheckOperand::Bool(b),
297            Token::Null => CheckOperand::Null,
298            Token::Ident(name) => CheckOperand::Column(name),
299            other => {
300                return Err(CheckParseError(format!(
301                    "expected an operand, found {other:?}"
302                )))
303            }
304        })
305    }
306}
307
308impl CheckExpression {
309    fn eval(&self, row: &Map<String, Value>) -> Option<bool> {
310        match self {
311            CheckExpression::Compare { left, op, right } => {
312                compare(&left.resolve(row), *op, &right.resolve(row))
313            }
314            CheckExpression::And(left, right) => and3(left.eval(row), right.eval(row)),
315            CheckExpression::Or(left, right) => or3(left.eval(row), right.eval(row)),
316            CheckExpression::Not(expression) => not3(expression.eval(row)),
317        }
318    }
319}
320
321impl CheckOperand {
322    fn resolve(&self, row: &Map<String, Value>) -> Operand {
323        match self {
324            CheckOperand::Column(name) => value_to_operand(row.get(name)),
325            CheckOperand::Number(value) => Operand::Num(*value),
326            CheckOperand::String(value) => Operand::Str(value.clone()),
327            CheckOperand::Bool(value) => Operand::Bool(*value),
328            CheckOperand::Null => Operand::Null,
329        }
330    }
331}
332
333fn value_to_operand(value: Option<&Value>) -> Operand {
334    match value {
335        None | Some(Value::Null) => Operand::Null,
336        Some(Value::Bool(b)) => Operand::Bool(*b),
337        Some(Value::Number(n)) => Operand::Num(n.as_f64().unwrap_or(f64::NAN)),
338        Some(Value::String(s)) => Operand::Str(s.clone()),
339        Some(other) => Operand::Str(other.to_string()),
340    }
341}
342
343/// Compare two operands, returning `None` for unknown (NULL-involving) results.
344fn compare(lhs: &Operand, op: CheckOperator, rhs: &Operand) -> Option<bool> {
345    if matches!(lhs, Operand::Null) || matches!(rhs, Operand::Null) {
346        return None;
347    }
348    let ord = match (lhs, rhs) {
349        (Operand::Num(a), Operand::Num(b)) => a.partial_cmp(b),
350        (Operand::Str(a), Operand::Str(b)) => Some(a.cmp(b)),
351        (Operand::Bool(a), Operand::Bool(b)) => Some(a.cmp(b)),
352        // Mixed types only support (in)equality and are otherwise unknown.
353        _ => None,
354    };
355    match op {
356        CheckOperator::Eq => Some(lhs == rhs),
357        CheckOperator::Ne => Some(lhs != rhs),
358        CheckOperator::Lt => ord.map(|o| o.is_lt()),
359        CheckOperator::Le => ord.map(|o| o.is_le()),
360        CheckOperator::Gt => ord.map(|o| o.is_gt()),
361        CheckOperator::Ge => ord.map(|o| o.is_ge()),
362    }
363}
364
365fn not3(v: Option<bool>) -> Option<bool> {
366    v.map(|b| !b)
367}
368
369fn and3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
370    match (a, b) {
371        (Some(false), _) | (_, Some(false)) => Some(false),
372        (Some(true), Some(true)) => Some(true),
373        _ => None,
374    }
375}
376
377fn or3(a: Option<bool>, b: Option<bool>) -> Option<bool> {
378    match (a, b) {
379        (Some(true), _) | (_, Some(true)) => Some(true),
380        (Some(false), Some(false)) => Some(false),
381        _ => None,
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    fn row(v: Value) -> Map<String, Value> {
391        v.as_object().unwrap().clone()
392    }
393
394    #[test]
395    fn passes_simple_comparison() {
396        let r = row(json!({ "age": 30 }));
397        assert!(eval_check("age >= 0", &r).unwrap());
398    }
399
400    #[test]
401    fn fails_simple_comparison() {
402        let r = row(json!({ "age": -1 }));
403        assert!(!eval_check("age >= 0", &r).unwrap());
404    }
405
406    #[test]
407    fn null_comparison_passes() {
408        let r = row(json!({ "age": null }));
409        assert!(eval_check("age >= 0", &r).unwrap());
410    }
411
412    #[test]
413    fn and_or_combination() {
414        let r = row(json!({ "a": 5, "b": 10 }));
415        assert!(eval_check("a > 0 AND b > 0", &r).unwrap());
416        assert!(!eval_check("a > 0 AND b < 0", &r).unwrap());
417        assert!(eval_check("a < 0 OR b > 0", &r).unwrap());
418        assert!(!eval_check("a < 0 OR b < 0", &r).unwrap());
419    }
420
421    #[test]
422    fn parentheses_and_not() {
423        let r = row(json!({ "a": 5, "b": 10 }));
424        assert!(eval_check("NOT (a > 100)", &r).unwrap());
425        assert!(eval_check("(a = 5 OR a = 6) AND b = 10", &r).unwrap());
426    }
427
428    #[test]
429    fn bang_is_logical_not() {
430        let r = row(json!({ "a": 5 }));
431        // `!` is logical NOT (distinct from `!=`).
432        assert!(eval_check("!(a > 100)", &r).unwrap());
433        assert!(!eval_check("!(a > 0)", &r).unwrap());
434        assert!(eval_check("a != 6", &r).unwrap());
435        assert!(!eval_check("a != 5", &r).unwrap());
436    }
437
438    #[test]
439    fn string_and_cross_column() {
440        let r = row(json!({ "lo": 1, "hi": 9, "kind": "x" }));
441        assert!(eval_check("lo < hi", &r).unwrap());
442        assert!(eval_check("kind = 'x'", &r).unwrap());
443        assert!(!eval_check("kind != 'x'", &r).unwrap());
444    }
445
446    #[test]
447    fn rejects_garbage() {
448        let r = row(json!({}));
449        assert!(eval_check("@@@", &r).is_err());
450    }
451}