Skip to content

Commit a8eb896

Browse files
committed
fix(sql): surface division-by-zero and undefined-function errors (#216)
Two related classes of expression error silently evaluated to NULL instead of failing the statement, letting bad queries return plausible-looking wrong results: - A `/` or `%` with a zero divisor folded to NULL in both the f64 and Decimal arithmetic paths (`SqlExpr::eval`'s row-scope evaluator) and in the `mod` scalar function. It now raises SQLSTATE 22012 (division_by_zero) at the point the divisor value is evaluated. `SqlExpr::eval`/`eval_with_old`/`eval_with_excluded` and `eval_function` now return `Result<Value, EvalError>` instead of `Value`, threaded through every caller: the query evaluator's own scan/filter/window/aggregate paths, CRDT CHECK-constraint and transition-check validation, and every executor handler, columnar scan, and cold-storage predicate pushdown in `nodedb` that evaluates a row expression. CASE and COALESCE stay short-circuiting, so an untaken branch or an already-satisfied argument never raises. - A call to a scalar/aggregate/window function with no registered definition fell through `eval_function`'s final `unwrap_or(Value::Null)` and never surfaced as an error. The SQL resolver now rejects any function name absent from `FunctionRegistry` at plan time (SQLSTATE 42883, undefined_function) instead of letting the call flow through to evaluate as NULL on every row. This gate exposed collection registry gaps for function families the runtime evaluator already supported (array-element, ID-generation, and legacy JSON functions, plus several math, datetime, misc, spatial, and string builtins) that would otherwise have been wrongly rejected; those registrations are added alongside the gate. Adds pgwire integration coverage for both SQLSTATEs across the document and columnar engines.
1 parent 3c398eb commit a8eb896

101 files changed

Lines changed: 4005 additions & 529 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

nodedb-crdt/src/constraint_checks.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,37 @@ impl Validator {
8585
}
8686
};
8787

88+
// A division/modulo-by-zero inside the predicate (nodedb issue #216)
89+
// is neither a PASS nor an ordinary FAIL — it's an evaluation
90+
// error. `Validator::validate`/`ValidationOutcome` (this crate's
91+
// public API, consumed cross-crate by `nodedb`) has no error
92+
// channel distinct from `Rejected(Vec<Violation>)`, so — exactly
93+
// like the unparseable-expression case above — it fails closed as
94+
// a `Violation` with a reason that names the real cause, rather
95+
// than silently treating it as a pass. Giving eval errors a first-
96+
// class outcome distinct from an ordinary CHECK failure would mean
97+
// widening `ValidationOutcome` and its ~5 cross-crate call sites; a
98+
// deliberate follow-up, not a mechanical part of this fix.
8899
match parsed.eval(&row_value) {
89-
nodedb_types::Value::Bool(true) => None,
90-
nodedb_types::Value::Null => None,
91-
_ => Some(Violation {
100+
Ok(nodedb_types::Value::Bool(true)) => None,
101+
Ok(nodedb_types::Value::Null) => None,
102+
Ok(_) => Some(Violation {
92103
constraint_name: constraint.name.clone(),
93104
reason: format!("CHECK `{}` failed: {expr}", constraint.name),
94105
hint: CompensationHint::ManualIntervention {
95106
reason: format!("row violates CHECK predicate `{expr}`"),
96107
},
97108
}),
109+
Err(e) => Some(Violation {
110+
constraint_name: constraint.name.clone(),
111+
reason: format!(
112+
"CHECK `{}` failed to evaluate: {expr}: {e}",
113+
constraint.name
114+
),
115+
hint: CompensationHint::ManualIntervention {
116+
reason: format!("CHECK predicate `{expr}` raised an evaluation error: {e}"),
117+
},
118+
}),
98119
}
99120
}
100121

nodedb-query/src/expr/binary.rs

Lines changed: 73 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::value_ops::{
1010
coerced_eq, compare_values, is_truthy, to_value_number, value_to_display_string, value_to_f64,
1111
};
1212

13+
use super::eval::EvalError;
1314
use super::types::BinaryOp;
1415

1516
/// Coerce a `Value` to `Decimal` for precise arithmetic.
@@ -28,86 +29,119 @@ fn value_to_decimal(v: &Value) -> Option<Decimal> {
2829
}
2930
}
3031

31-
/// Apply a Decimal arithmetic operation, returning `Value::Null` on overflow/div-zero.
32-
fn decimal_arith(a: Decimal, op: BinaryOp, b: Decimal) -> Value {
32+
/// Apply a Decimal arithmetic operation, returning `Value::Null` on overflow
33+
/// and `Err(EvalError::DivisionByZero)` when `/` or `%` see a zero divisor
34+
/// (nodedb issue #216). The zero check happens before `checked_div`/
35+
/// `checked_rem` are called because those methods return `None` for both
36+
/// div-by-zero *and* overflow, and only the former is a `22012` error —
37+
/// overflow keeps folding to `Value::Null` exactly as before.
38+
fn decimal_arith(a: Decimal, op: BinaryOp, b: Decimal) -> Result<Value, EvalError> {
3339
let result = match op {
3440
BinaryOp::Add => a.checked_add(b),
3541
BinaryOp::Sub => a.checked_sub(b),
3642
BinaryOp::Mul => a.checked_mul(b),
37-
BinaryOp::Div => a.checked_div(b),
38-
BinaryOp::Mod => a.checked_rem(b),
39-
_ => return Value::Null,
43+
BinaryOp::Div => {
44+
if b.is_zero() {
45+
return Err(EvalError::DivisionByZero);
46+
}
47+
a.checked_div(b)
48+
}
49+
BinaryOp::Mod => {
50+
if b.is_zero() {
51+
return Err(EvalError::DivisionByZero);
52+
}
53+
a.checked_rem(b)
54+
}
55+
_ => return Ok(Value::Null),
4056
};
41-
result.map(Value::Decimal).unwrap_or(Value::Null)
57+
Ok(result.map(Value::Decimal).unwrap_or(Value::Null))
4258
}
4359

44-
pub(super) fn eval_binary_op(left: &Value, op: BinaryOp, right: &Value) -> Value {
60+
pub(super) fn eval_binary_op(
61+
left: &Value,
62+
op: BinaryOp,
63+
right: &Value,
64+
) -> Result<Value, EvalError> {
4565
match op {
4666
// Arithmetic: prefer Decimal when either operand is Decimal to avoid f64 drift.
4767
BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
4868
let left_is_decimal = matches!(left, Value::Decimal(_));
4969
let right_is_decimal = matches!(right, Value::Decimal(_));
5070
if left_is_decimal || right_is_decimal {
51-
match (value_to_decimal(left), value_to_decimal(right)) {
52-
(Some(a), Some(b)) => return decimal_arith(a, op, b),
53-
_ => return Value::Null,
54-
}
71+
return match (value_to_decimal(left), value_to_decimal(right)) {
72+
(Some(a), Some(b)) => decimal_arith(a, op, b),
73+
_ => Ok(Value::Null),
74+
};
5575
}
5676
// Both operands are non-Decimal: use f64 path.
5777
match op {
58-
BinaryOp::Add => match (value_to_f64(left, true), value_to_f64(right, true)) {
59-
(Some(a), Some(b)) => to_value_number(a + b),
60-
_ => Value::Null,
61-
},
62-
BinaryOp::Sub => match (value_to_f64(left, true), value_to_f64(right, true)) {
63-
(Some(a), Some(b)) => to_value_number(a - b),
64-
_ => Value::Null,
65-
},
66-
BinaryOp::Mul => match (value_to_f64(left, true), value_to_f64(right, true)) {
67-
(Some(a), Some(b)) => to_value_number(a * b),
68-
_ => Value::Null,
69-
},
78+
BinaryOp::Add => Ok(
79+
match (value_to_f64(left, true), value_to_f64(right, true)) {
80+
(Some(a), Some(b)) => to_value_number(a + b),
81+
_ => Value::Null,
82+
},
83+
),
84+
BinaryOp::Sub => Ok(
85+
match (value_to_f64(left, true), value_to_f64(right, true)) {
86+
(Some(a), Some(b)) => to_value_number(a - b),
87+
_ => Value::Null,
88+
},
89+
),
90+
BinaryOp::Mul => Ok(
91+
match (value_to_f64(left, true), value_to_f64(right, true)) {
92+
(Some(a), Some(b)) => to_value_number(a * b),
93+
_ => Value::Null,
94+
},
95+
),
7096
BinaryOp::Div => match (value_to_f64(left, true), value_to_f64(right, true)) {
7197
(Some(a), Some(b)) => {
7298
if b == 0.0 {
73-
Value::Null
99+
Err(EvalError::DivisionByZero)
74100
} else {
75-
to_value_number(a / b)
101+
Ok(to_value_number(a / b))
76102
}
77103
}
78-
_ => Value::Null,
104+
_ => Ok(Value::Null),
79105
},
80106
BinaryOp::Mod => match (value_to_f64(left, true), value_to_f64(right, true)) {
81107
(Some(a), Some(b)) => {
82108
if b == 0.0 {
83-
Value::Null
109+
Err(EvalError::DivisionByZero)
84110
} else {
85-
to_value_number(a % b)
111+
Ok(to_value_number(a % b))
86112
}
87113
}
88-
_ => Value::Null,
114+
_ => Ok(Value::Null),
89115
},
90-
_ => Value::Null,
116+
_ => Ok(Value::Null),
91117
}
92118
}
93119
BinaryOp::Concat => {
94120
let ls = value_to_display_string(left);
95121
let rs = value_to_display_string(right);
96-
Value::String(format!("{ls}{rs}"))
122+
Ok(Value::String(format!("{ls}{rs}")))
97123
}
98-
BinaryOp::Eq => Value::Bool(coerced_eq(left, right)),
99-
BinaryOp::NotEq => Value::Bool(!coerced_eq(left, right)),
100-
BinaryOp::Gt => Value::Bool(compare_values(left, right) == std::cmp::Ordering::Greater),
124+
BinaryOp::Eq => Ok(Value::Bool(coerced_eq(left, right))),
125+
BinaryOp::NotEq => Ok(Value::Bool(!coerced_eq(left, right))),
126+
BinaryOp::Gt => Ok(Value::Bool(
127+
compare_values(left, right) == std::cmp::Ordering::Greater,
128+
)),
101129
BinaryOp::GtEq => {
102130
let c = compare_values(left, right);
103-
Value::Bool(c == std::cmp::Ordering::Greater || c == std::cmp::Ordering::Equal)
131+
Ok(Value::Bool(
132+
c == std::cmp::Ordering::Greater || c == std::cmp::Ordering::Equal,
133+
))
104134
}
105-
BinaryOp::Lt => Value::Bool(compare_values(left, right) == std::cmp::Ordering::Less),
135+
BinaryOp::Lt => Ok(Value::Bool(
136+
compare_values(left, right) == std::cmp::Ordering::Less,
137+
)),
106138
BinaryOp::LtEq => {
107139
let c = compare_values(left, right);
108-
Value::Bool(c == std::cmp::Ordering::Less || c == std::cmp::Ordering::Equal)
140+
Ok(Value::Bool(
141+
c == std::cmp::Ordering::Less || c == std::cmp::Ordering::Equal,
142+
))
109143
}
110-
BinaryOp::And => Value::Bool(is_truthy(left) && is_truthy(right)),
111-
BinaryOp::Or => Value::Bool(is_truthy(left) || is_truthy(right)),
144+
BinaryOp::And => Ok(Value::Bool(is_truthy(left) && is_truthy(right))),
145+
BinaryOp::Or => Ok(Value::Bool(is_truthy(left) || is_truthy(right))),
112146
}
113147
}

0 commit comments

Comments
 (0)