Skip to main content

nodedb_query/expr/
eval.rs

1//! Row-scope evaluator for [`SqlExpr`].
2//!
3//! `eval()` resolves column references against a single document. `eval_with_old()`
4//! resolves `Column(..)` against the post-update ("new") document and `OldColumn(..)`
5//! against the pre-update ("old") document — this is the path used by TRANSITION
6//! CHECK and similar old/new diff predicates.
7
8use nodedb_types::Value;
9
10use crate::value_ops::{coerced_eq, is_truthy, to_value_number, value_to_f64};
11
12use super::binary::eval_binary_op;
13use super::types::SqlExpr;
14
15/// Row scope for `SqlExpr::eval_scope`: how `Column(..)` and `OldColumn(..)`
16/// resolve to `Value`s. The shared evaluator walks the AST once and calls
17/// into this scope for every leaf column reference — both `eval()` and
18/// `eval_with_old()` delegate here instead of duplicating the walk.
19struct RowScope<'a> {
20    new_doc: &'a Value,
21    /// Pre-update row, if this is an old/new evaluation (TRANSITION CHECK).
22    /// `None` means `OldColumn(..)` resolves to `Null`, matching plain `eval`.
23    old_doc: Option<&'a Value>,
24}
25
26impl<'a> RowScope<'a> {
27    fn column(&self, name: &str) -> Value {
28        self.new_doc.get(name).cloned().unwrap_or(Value::Null)
29    }
30
31    fn old_column(&self, name: &str) -> Value {
32        match self.old_doc {
33            Some(old) => old.get(name).cloned().unwrap_or(Value::Null),
34            None => Value::Null,
35        }
36    }
37}
38
39impl SqlExpr {
40    /// Evaluate this expression against a document.
41    ///
42    /// Column references look up fields in the document. Missing fields
43    /// return `Null`. Arithmetic on non-numeric values returns `Null`.
44    /// `OldColumn(..)` resolves to `Null` (use `eval_with_old` for the
45    /// TRANSITION CHECK path).
46    pub fn eval(&self, doc: &Value) -> Value {
47        self.eval_scope(&RowScope {
48            new_doc: doc,
49            old_doc: None,
50        })
51    }
52
53    /// Evaluate with access to both NEW and OLD documents, used by
54    /// TRANSITION CHECK predicates. `Column(name)` resolves against
55    /// `new_doc`; `OldColumn(name)` resolves against `old_doc`.
56    pub fn eval_with_old(&self, new_doc: &Value, old_doc: &Value) -> Value {
57        self.eval_scope(&RowScope {
58            new_doc,
59            old_doc: Some(old_doc),
60        })
61    }
62
63    /// Shared walker: one match, one recursion scheme, parameterised by the
64    /// row-scope so `eval` and `eval_with_old` can't drift out of sync.
65    fn eval_scope(&self, scope: &RowScope<'_>) -> Value {
66        match self {
67            SqlExpr::Column(name) => scope.column(name),
68            SqlExpr::OldColumn(name) => scope.old_column(name),
69
70            SqlExpr::Literal(v) => v.clone(),
71
72            SqlExpr::BinaryOp { left, op, right } => {
73                let l = left.eval_scope(scope);
74                let r = right.eval_scope(scope);
75                eval_binary_op(&l, *op, &r)
76            }
77
78            SqlExpr::Negate(inner) => {
79                let v = inner.eval_scope(scope);
80                if let Some(b) = v.as_bool() {
81                    Value::Bool(!b)
82                } else {
83                    match value_to_f64(&v, false) {
84                        Some(n) => to_value_number(-n),
85                        None => Value::Null,
86                    }
87                }
88            }
89
90            SqlExpr::Function { name, args } => {
91                let evaluated: Vec<Value> = args.iter().map(|a| a.eval_scope(scope)).collect();
92                crate::functions::eval_function(name, &evaluated)
93            }
94
95            SqlExpr::Cast { expr, to_type } => {
96                let v = expr.eval_scope(scope);
97                crate::cast::eval_cast(&v, to_type)
98            }
99
100            SqlExpr::Case {
101                operand,
102                when_thens,
103                else_expr,
104            } => {
105                let op_val = operand.as_ref().map(|e| e.eval_scope(scope));
106                for (when_expr, then_expr) in when_thens {
107                    let when_val = when_expr.eval_scope(scope);
108                    let matches = match &op_val {
109                        Some(ov) => coerced_eq(ov, &when_val),
110                        None => is_truthy(&when_val),
111                    };
112                    if matches {
113                        return then_expr.eval_scope(scope);
114                    }
115                }
116                match else_expr {
117                    Some(e) => e.eval_scope(scope),
118                    None => Value::Null,
119                }
120            }
121
122            SqlExpr::Coalesce(exprs) => {
123                for expr in exprs {
124                    let v = expr.eval_scope(scope);
125                    if !v.is_null() {
126                        return v;
127                    }
128                }
129                Value::Null
130            }
131
132            SqlExpr::NullIf(a, b) => {
133                let va = a.eval_scope(scope);
134                let vb = b.eval_scope(scope);
135                if coerced_eq(&va, &vb) {
136                    Value::Null
137                } else {
138                    va
139                }
140            }
141
142            SqlExpr::IsNull { expr, negated } => {
143                let v = expr.eval_scope(scope);
144                let is_null = v.is_null();
145                Value::Bool(if *negated { !is_null } else { is_null })
146            }
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::super::types::BinaryOp;
154    use super::*;
155
156    fn doc() -> Value {
157        Value::Object(
158            [
159                ("name".to_string(), Value::String("Alice".into())),
160                ("age".to_string(), Value::Integer(30)),
161                ("price".to_string(), Value::Float(10.5)),
162                ("qty".to_string(), Value::Integer(4)),
163                ("active".to_string(), Value::Bool(true)),
164                ("email".to_string(), Value::Null),
165            ]
166            .into_iter()
167            .collect(),
168        )
169    }
170
171    #[test]
172    fn column_ref() {
173        let expr = SqlExpr::Column("name".into());
174        assert_eq!(expr.eval(&doc()), Value::String("Alice".into()));
175    }
176
177    #[test]
178    fn missing_column() {
179        let expr = SqlExpr::Column("missing".into());
180        assert_eq!(expr.eval(&doc()), Value::Null);
181    }
182
183    #[test]
184    fn literal() {
185        let expr = SqlExpr::Literal(Value::Integer(42));
186        assert_eq!(expr.eval(&doc()), Value::Integer(42));
187    }
188
189    #[test]
190    fn add() {
191        let expr = SqlExpr::BinaryOp {
192            left: Box::new(SqlExpr::Column("price".into())),
193            op: BinaryOp::Add,
194            right: Box::new(SqlExpr::Literal(Value::Float(1.5))),
195        };
196        assert_eq!(expr.eval(&doc()), Value::Integer(12));
197    }
198
199    #[test]
200    fn multiply() {
201        let expr = SqlExpr::BinaryOp {
202            left: Box::new(SqlExpr::Column("price".into())),
203            op: BinaryOp::Mul,
204            right: Box::new(SqlExpr::Column("qty".into())),
205        };
206        assert_eq!(expr.eval(&doc()), Value::Integer(42));
207    }
208
209    #[test]
210    fn case_when() {
211        let expr = SqlExpr::Case {
212            operand: None,
213            when_thens: vec![(
214                SqlExpr::BinaryOp {
215                    left: Box::new(SqlExpr::Column("age".into())),
216                    op: BinaryOp::GtEq,
217                    right: Box::new(SqlExpr::Literal(Value::Integer(18))),
218                },
219                SqlExpr::Literal(Value::String("adult".into())),
220            )],
221            else_expr: Some(Box::new(SqlExpr::Literal(Value::String("minor".into())))),
222        };
223        assert_eq!(expr.eval(&doc()), Value::String("adult".into()));
224    }
225
226    #[test]
227    fn coalesce() {
228        let expr = SqlExpr::Coalesce(vec![
229            SqlExpr::Column("email".into()),
230            SqlExpr::Literal(Value::String("default@example.com".into())),
231        ]);
232        assert_eq!(
233            expr.eval(&doc()),
234            Value::String("default@example.com".into())
235        );
236    }
237
238    #[test]
239    fn is_null() {
240        let expr = SqlExpr::IsNull {
241            expr: Box::new(SqlExpr::Column("email".into())),
242            negated: false,
243        };
244        assert_eq!(expr.eval(&doc()), Value::Bool(true));
245    }
246}