Skip to main content

nodedb_query/expr/
eval.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Row-scope evaluator for [`SqlExpr`].
4//!
5//! `eval()` resolves column references against a single document. `eval_with_old()`
6//! resolves `Column(..)` against the post-update ("new") document and `OldColumn(..)`
7//! against the pre-update ("old") document — this is the path used by TRANSITION
8//! CHECK and similar old/new diff predicates.
9
10use nodedb_types::Value;
11
12use crate::value_ops::{coerced_eq, is_truthy, to_value_number, value_to_f64};
13
14use super::binary::eval_binary_op;
15use super::types::{SqlExpr, WHOLE_ROW_COLUMN};
16
17/// Error type for row-scope `SqlExpr` evaluation.
18///
19/// Mirrors the [`WindowError`](crate::window::WindowError) idiom: a small,
20/// `thiserror`-derived enum living next to the evaluator it describes.
21/// Bad casts, wrong argument counts and unknown-value coercions fold to
22/// `Value::Null`. These faults fail the statement instead:
23/// - division or modulo by a zero divisor, SQLSTATE `22012`;
24/// - a call to a function no evaluator implements. It never evaluates to a
25///   silent `NULL`;
26/// - a function argument it cannot compute on: vectors of different
27///   dimensions, an argument of the wrong type, a malformed JSONPath.
28///   SQLSTATE `22000`. A `NULL` argument stays `NULL` instead;
29/// - an exact integer SUM past the `Decimal` range, SQLSTATE `22000`.
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
31pub enum EvalError {
32    #[error("division by zero")]
33    DivisionByZero,
34    #[error("function {name}() has no evaluator")]
35    UnknownFunction {
36        /// The function name as called.
37        name: String,
38    },
39    /// Two vector operands have different dimensions. Same message shape
40    /// as the vector engine's dimension error.
41    #[error("{function}(): vector dimension mismatch: expected {expected}, got {got}")]
42    VectorDimensionMismatch {
43        function: &'static str,
44        /// Dimension of the first operand.
45        expected: usize,
46        /// Dimension of the second operand.
47        got: usize,
48    },
49    /// An argument holds a value of a type the function cannot compute on.
50    #[error("{function}(): argument {position} must be {expected}, got {got}")]
51    ArgumentType {
52        function: &'static str,
53        /// 1-based argument position.
54        position: usize,
55        expected: &'static str,
56        /// `Value::type_name` of the argument received.
57        got: &'static str,
58    },
59    /// A path argument is not a supported JSONPath.
60    #[error("{function}(): invalid JSONPath {path:?}: {reason}")]
61    InvalidJsonPath {
62        function: &'static str,
63        path: String,
64        reason: String,
65    },
66    /// An exact integer aggregate total lies outside the `Decimal` range.
67    #[error("{function}(): integer total out of range")]
68    NumericOverflow { function: &'static str },
69}
70
71/// Row scope for `SqlExpr::eval_scope`: how `Column(..)` and `OldColumn(..)`
72/// resolve to `Value`s. The shared evaluator walks the AST once and calls
73/// into this scope for every leaf column reference — both `eval()` and
74/// `eval_with_old()` delegate here instead of duplicating the walk.
75struct RowScope<'a> {
76    new_doc: &'a Value,
77    /// Pre-update row, if this is an old/new evaluation (TRANSITION CHECK).
78    /// `None` means `OldColumn(..)` resolves to `Null`, matching plain `eval`.
79    old_doc: Option<&'a Value>,
80    /// Incoming `EXCLUDED.*` row for
81    /// `INSERT ... ON CONFLICT DO UPDATE SET col = EXCLUDED.col`. `None`
82    /// means `ExcludedColumn(..)` resolves to `Null`, matching plain `eval`.
83    excluded_doc: Option<&'a Value>,
84}
85
86impl<'a> RowScope<'a> {
87    fn column(&self, name: &str) -> Value {
88        row_field(self.new_doc, name)
89    }
90
91    fn old_column(&self, name: &str) -> Value {
92        match self.old_doc {
93            Some(old) => row_field(old, name),
94            None => Value::Null,
95        }
96    }
97
98    fn excluded_column(&self, name: &str) -> Value {
99        match self.excluded_doc {
100            Some(excluded) => row_field(excluded, name),
101            None => Value::Null,
102        }
103    }
104}
105
106/// The value `name` references in `doc`: the whole document for
107/// [`WHOLE_ROW_COLUMN`], else the field, else `Null`.
108fn row_field(doc: &Value, name: &str) -> Value {
109    if name == WHOLE_ROW_COLUMN {
110        return doc.clone();
111    }
112    doc.get(name).cloned().unwrap_or(Value::Null)
113}
114
115impl SqlExpr {
116    /// Evaluate this expression against a document.
117    ///
118    /// Column references look up fields in the document. Missing fields
119    /// return `Null`. Arithmetic on non-numeric values returns `Null`.
120    /// `OldColumn(..)` resolves to `Null` (use `eval_with_old` for the
121    /// TRANSITION CHECK path).
122    ///
123    /// Returns `Err(EvalError::DivisionByZero)` if evaluation reaches a
124    /// `/` or `%` with a zero divisor — every other
125    /// historically-`NULL`-folding case (bad casts, wrong arg counts,
126    /// unknown-value coercions) is unaffected and still evaluates to
127    /// `Ok(Value::Null)`.
128    pub fn eval(&self, doc: &Value) -> Result<Value, EvalError> {
129        self.eval_scope(&RowScope {
130            new_doc: doc,
131            old_doc: None,
132            excluded_doc: None,
133        })
134    }
135
136    /// Evaluate with access to both NEW and OLD documents, used by
137    /// TRANSITION CHECK predicates. `Column(name)` resolves against
138    /// `new_doc`; `OldColumn(name)` resolves against `old_doc`.
139    pub fn eval_with_old(&self, new_doc: &Value, old_doc: &Value) -> Result<Value, EvalError> {
140        self.eval_scope(&RowScope {
141            new_doc,
142            old_doc: Some(old_doc),
143            excluded_doc: None,
144        })
145    }
146
147    /// Evaluate with access to the incoming `EXCLUDED.*` row, used by
148    /// `INSERT ... ON CONFLICT DO UPDATE`. `Column(name)` resolves
149    /// against the existing (current) row `doc`; `ExcludedColumn(name)`
150    /// resolves against `excluded`.
151    pub fn eval_with_excluded(&self, doc: &Value, excluded: &Value) -> Result<Value, EvalError> {
152        self.eval_scope(&RowScope {
153            new_doc: doc,
154            old_doc: None,
155            excluded_doc: Some(excluded),
156        })
157    }
158
159    /// Shared walker: one match, one recursion scheme, parameterised by the
160    /// row-scope so `eval` and `eval_with_old` can't drift out of sync.
161    ///
162    /// CASE and COALESCE stay short-circuiting: an untaken CASE branch or an
163    /// already-satisfied COALESCE argument is never evaluated, so `CASE WHEN
164    /// false THEN 1/0 ELSE 0 END` and `COALESCE(1, 1/0)` never reach the
165    /// division and cannot raise `DivisionByZero`.
166    fn eval_scope(&self, scope: &RowScope<'_>) -> Result<Value, EvalError> {
167        match self {
168            SqlExpr::Column(name) => Ok(scope.column(name)),
169            SqlExpr::OldColumn(name) => Ok(scope.old_column(name)),
170            SqlExpr::ExcludedColumn(name) => Ok(scope.excluded_column(name)),
171
172            SqlExpr::Literal(v) => Ok(v.clone()),
173
174            SqlExpr::BinaryOp { left, op, right } => {
175                let l = left.eval_scope(scope)?;
176                let r = right.eval_scope(scope)?;
177                eval_binary_op(&l, *op, &r)
178            }
179
180            SqlExpr::Negate(inner) => {
181                let v = inner.eval_scope(scope)?;
182                if let Some(b) = v.as_bool() {
183                    Ok(Value::Bool(!b))
184                } else {
185                    match value_to_f64(&v, false) {
186                        Some(n) => Ok(to_value_number(-n)),
187                        None => Ok(Value::Null),
188                    }
189                }
190            }
191
192            SqlExpr::Function { name, args } => {
193                let evaluated: Vec<Value> = args
194                    .iter()
195                    .map(|a| a.eval_scope(scope))
196                    .collect::<Result<_, EvalError>>()?;
197                crate::functions::eval_function(name, &evaluated)
198            }
199
200            SqlExpr::Cast { expr, to_type } => {
201                let v = expr.eval_scope(scope)?;
202                Ok(crate::cast::eval_cast(&v, to_type))
203            }
204
205            SqlExpr::Case {
206                operand,
207                when_thens,
208                else_expr,
209            } => {
210                let op_val = match operand {
211                    Some(e) => Some(e.eval_scope(scope)?),
212                    None => None,
213                };
214                for (when_expr, then_expr) in when_thens {
215                    let when_val = when_expr.eval_scope(scope)?;
216                    let matches = match &op_val {
217                        Some(ov) => coerced_eq(ov, &when_val),
218                        None => is_truthy(&when_val),
219                    };
220                    if matches {
221                        return then_expr.eval_scope(scope);
222                    }
223                }
224                match else_expr {
225                    Some(e) => e.eval_scope(scope),
226                    None => Ok(Value::Null),
227                }
228            }
229
230            SqlExpr::Coalesce(exprs) => {
231                for expr in exprs {
232                    let v = expr.eval_scope(scope)?;
233                    if !v.is_null() {
234                        return Ok(v);
235                    }
236                }
237                Ok(Value::Null)
238            }
239
240            SqlExpr::NullIf(a, b) => {
241                let va = a.eval_scope(scope)?;
242                let vb = b.eval_scope(scope)?;
243                if coerced_eq(&va, &vb) {
244                    Ok(Value::Null)
245                } else {
246                    Ok(va)
247                }
248            }
249
250            SqlExpr::IsNull { expr, negated } => {
251                let v = expr.eval_scope(scope)?;
252                let is_null = v.is_null();
253                Ok(Value::Bool(if *negated { !is_null } else { is_null }))
254            }
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::super::types::BinaryOp;
262    use super::*;
263
264    fn doc() -> Value {
265        Value::Object(
266            [
267                ("name".to_string(), Value::String("Alice".into())),
268                ("age".to_string(), Value::Integer(30)),
269                ("price".to_string(), Value::Float(10.5)),
270                ("qty".to_string(), Value::Integer(4)),
271                ("active".to_string(), Value::Bool(true)),
272                ("email".to_string(), Value::Null),
273            ]
274            .into_iter()
275            .collect(),
276        )
277    }
278
279    #[test]
280    fn column_ref() {
281        let expr = SqlExpr::Column("name".into());
282        assert_eq!(expr.eval(&doc()).unwrap(), Value::String("Alice".into()));
283    }
284
285    #[test]
286    fn missing_column() {
287        let expr = SqlExpr::Column("missing".into());
288        assert_eq!(expr.eval(&doc()).unwrap(), Value::Null);
289    }
290
291    #[test]
292    fn whole_row_column_is_the_document() {
293        let expr = SqlExpr::Column(WHOLE_ROW_COLUMN.into());
294        assert_eq!(expr.eval(&doc()).unwrap(), doc());
295        let old = SqlExpr::OldColumn(WHOLE_ROW_COLUMN.into());
296        let before = Value::Object(Default::default());
297        assert_eq!(old.eval_with_old(&doc(), &before).unwrap(), before);
298    }
299
300    #[test]
301    fn to_jsonb_of_the_whole_row_keeps_every_field_type() {
302        let expr = SqlExpr::Function {
303            name: "to_jsonb".into(),
304            args: vec![SqlExpr::Column(WHOLE_ROW_COLUMN.into())],
305        };
306        assert_eq!(expr.eval(&doc()).unwrap(), doc());
307    }
308
309    #[test]
310    fn literal() {
311        let expr = SqlExpr::Literal(Value::Integer(42));
312        assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(42));
313    }
314
315    #[test]
316    fn add() {
317        let expr = SqlExpr::BinaryOp {
318            left: Box::new(SqlExpr::Column("price".into())),
319            op: BinaryOp::Add,
320            right: Box::new(SqlExpr::Literal(Value::Float(1.5))),
321        };
322        assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(12));
323    }
324
325    #[test]
326    fn multiply() {
327        let expr = SqlExpr::BinaryOp {
328            left: Box::new(SqlExpr::Column("price".into())),
329            op: BinaryOp::Mul,
330            right: Box::new(SqlExpr::Column("qty".into())),
331        };
332        assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(42));
333    }
334
335    #[test]
336    fn case_when() {
337        let expr = SqlExpr::Case {
338            operand: None,
339            when_thens: vec![(
340                SqlExpr::BinaryOp {
341                    left: Box::new(SqlExpr::Column("age".into())),
342                    op: BinaryOp::GtEq,
343                    right: Box::new(SqlExpr::Literal(Value::Integer(18))),
344                },
345                SqlExpr::Literal(Value::String("adult".into())),
346            )],
347            else_expr: Some(Box::new(SqlExpr::Literal(Value::String("minor".into())))),
348        };
349        assert_eq!(expr.eval(&doc()).unwrap(), Value::String("adult".into()));
350    }
351
352    #[test]
353    fn coalesce() {
354        let expr = SqlExpr::Coalesce(vec![
355            SqlExpr::Column("email".into()),
356            SqlExpr::Literal(Value::String("default@example.com".into())),
357        ]);
358        assert_eq!(
359            expr.eval(&doc()).unwrap(),
360            Value::String("default@example.com".into())
361        );
362    }
363
364    #[test]
365    fn is_null() {
366        let expr = SqlExpr::IsNull {
367            expr: Box::new(SqlExpr::Column("email".into())),
368            negated: false,
369        };
370        assert_eq!(expr.eval(&doc()).unwrap(), Value::Bool(true));
371    }
372
373    // ── Division/modulo by zero ──────────────────────────────────────────────
374
375    #[test]
376    fn integer_division_by_zero_errors() {
377        let expr = SqlExpr::BinaryOp {
378            left: Box::new(SqlExpr::Literal(Value::Integer(1))),
379            op: BinaryOp::Div,
380            right: Box::new(SqlExpr::Literal(Value::Integer(0))),
381        };
382        assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero));
383    }
384
385    #[test]
386    fn float_division_by_zero_errors() {
387        let expr = SqlExpr::BinaryOp {
388            left: Box::new(SqlExpr::Literal(Value::Float(1.5))),
389            op: BinaryOp::Div,
390            right: Box::new(SqlExpr::Literal(Value::Float(0.0))),
391        };
392        assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero));
393    }
394
395    #[test]
396    fn decimal_division_by_decimal_zero_errors() {
397        let expr = SqlExpr::BinaryOp {
398            left: Box::new(SqlExpr::Literal(Value::Decimal(
399                rust_decimal::Decimal::new(15, 1),
400            ))),
401            op: BinaryOp::Div,
402            right: Box::new(SqlExpr::Literal(Value::Decimal(
403                rust_decimal::Decimal::ZERO,
404            ))),
405        };
406        assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero));
407    }
408
409    #[test]
410    fn modulo_by_zero_errors() {
411        let expr = SqlExpr::BinaryOp {
412            left: Box::new(SqlExpr::Literal(Value::Integer(5))),
413            op: BinaryOp::Mod,
414            right: Box::new(SqlExpr::Literal(Value::Integer(0))),
415        };
416        assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero));
417    }
418
419    #[test]
420    fn scalar_mod_function_by_zero_errors() {
421        let expr = SqlExpr::Function {
422            name: "mod".into(),
423            args: vec![
424                SqlExpr::Literal(Value::Integer(5)),
425                SqlExpr::Literal(Value::Integer(0)),
426            ],
427        };
428        assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero));
429    }
430
431    /// An untaken CASE branch must never be evaluated — `1/0` in the THEN
432    /// arm of a WHEN that doesn't match must not raise `DivisionByZero`.
433    #[test]
434    fn case_laziness_untaken_branch_not_evaluated() {
435        let expr = SqlExpr::Case {
436            operand: None,
437            when_thens: vec![(
438                SqlExpr::Literal(Value::Bool(false)),
439                SqlExpr::BinaryOp {
440                    left: Box::new(SqlExpr::Literal(Value::Integer(1))),
441                    op: BinaryOp::Div,
442                    right: Box::new(SqlExpr::Literal(Value::Integer(0))),
443                },
444            )],
445            else_expr: Some(Box::new(SqlExpr::Literal(Value::Integer(0)))),
446        };
447        assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(0));
448    }
449
450    /// COALESCE stops at the first non-null argument — a later `1/0`
451    /// argument must never be evaluated once an earlier one is non-null.
452    #[test]
453    fn coalesce_laziness_short_circuits_before_division() {
454        let expr = SqlExpr::Coalesce(vec![
455            SqlExpr::Literal(Value::Integer(1)),
456            SqlExpr::BinaryOp {
457                left: Box::new(SqlExpr::Literal(Value::Integer(1))),
458                op: BinaryOp::Div,
459                right: Box::new(SqlExpr::Literal(Value::Integer(0))),
460            },
461        ]);
462        assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(1));
463    }
464
465    /// Control: `NULL + 1` still folds to `Ok(Null)` — non-division NULL
466    /// propagation is untouched by this fix.
467    #[test]
468    fn null_arithmetic_still_folds_to_null() {
469        let expr = SqlExpr::BinaryOp {
470            left: Box::new(SqlExpr::Literal(Value::Null)),
471            op: BinaryOp::Add,
472            right: Box::new(SqlExpr::Literal(Value::Integer(1))),
473        };
474        assert_eq!(expr.eval(&doc()).unwrap(), Value::Null);
475    }
476
477    /// Control: a valid (non-zero-divisor) division still succeeds.
478    #[test]
479    fn valid_division_still_succeeds() {
480        let expr = SqlExpr::BinaryOp {
481            left: Box::new(SqlExpr::Literal(Value::Integer(10))),
482            op: BinaryOp::Div,
483            right: Box::new(SqlExpr::Literal(Value::Integer(2))),
484        };
485        assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(5));
486    }
487}