Skip to content

Commit 88023f0

Browse files
emanzxfarhan-syah
authored andcommitted
fix(sql): reject procedural division/modulo by zero (#227)
The procedural executor's constant-expression evaluator — a separate, sqlparser-based tree-walk evaluator used by trigger bodies and EXCEPTION blocks, with no dependency on the DataFusion-backed row-expression evaluator — still folded `/` and `%` by a zero divisor to NULL instead of raising an error. A trigger computing `NEW.ratio := NEW.numerator / NEW.denominator` would silently write NULL when the denominator was zero instead of failing the insert. try_eval_constant, eval_expr, and eval_binary_op now return Result<Option<Value>, ConstEvalError> instead of Option<Value>, and raise a division-by-zero signal only when a `/` or `%` reaches a zero divisor; every other historically-NULL-folding case (unknown identifiers, unsupported operators, overflow, non-finite float results) keeps folding to None as before. The signal converts to a new crate::Error::DivisionByZero variant, which pgwire renders as SQLSTATE 22012 (division_by_zero), so trigger bodies can also match it with EXCEPTION WHEN DIVISION_BY_ZERO. Add the supporting DivisionByZero plumbing at the nodedb-types layer (ErrorCode, ErrorDetails, msgpack wire tag, SQLSTATE constant, NodeDbError constructor) and e2e coverage: a BEFORE INSERT trigger dividing by zero rejects the insert without persisting the row, a non-zero divisor still computes and persists normally, and an EXCEPTION WHEN DIVISION_BY_ZERO handler catches the signal and lets the insert proceed with its fallback assignment.
1 parent 9621131 commit 88023f0

2 files changed

Lines changed: 282 additions & 53 deletions

File tree

nodedb/src/control/planner/procedural/executor/eval.rs

Lines changed: 140 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@
1212
use crate::control::state::SharedState;
1313
use crate::types::TenantId;
1414

15+
/// Signal from constant-expression evaluation that a `/` or `%` reached a
16+
/// zero divisor (nodedb issue #227).
17+
///
18+
/// Kept local to this module — the procedural executor's constant folder is
19+
/// a separate, sqlparser-based tree-walk evaluator with no dependency on
20+
/// `nodedb_query` (whose own `EvalError::DivisionByZero`, from the
21+
/// DataFusion-backed row-expression evaluator fixed under issue #216,
22+
/// covers a different code path entirely). Every other historically
23+
/// `None`-folding case (unknown identifiers, unsupported operators,
24+
/// overflow, non-finite float results) is unaffected and keeps folding to
25+
/// `Ok(None)` — see `try_eval_constant`, `evaluate_to_value`.
26+
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
27+
enum ConstEvalError {
28+
#[error("division by zero")]
29+
DivisionByZero,
30+
}
31+
32+
impl From<ConstEvalError> for crate::Error {
33+
fn from(e: ConstEvalError) -> Self {
34+
match e {
35+
ConstEvalError::DivisionByZero => Self::DivisionByZero,
36+
}
37+
}
38+
}
39+
1540
/// Evaluate a SQL boolean condition.
1641
///
1742
/// Fast path for constant TRUE/FALSE/1/0/NULL. Falls back to constant
@@ -25,7 +50,7 @@ pub async fn evaluate_condition(
2550
return Ok(result);
2651
}
2752

28-
match try_eval_constant(condition_sql) {
53+
match try_eval_constant(condition_sql)? {
2954
Some(nodedb_types::Value::Bool(b)) => Ok(b),
3055
Some(nodedb_types::Value::Integer(i)) => Ok(i != 0),
3156
Some(nodedb_types::Value::Float(f)) => Ok(f != 0.0),
@@ -48,7 +73,7 @@ pub async fn evaluate_int(
4873
return Ok(v);
4974
}
5075

51-
match try_eval_constant(sql) {
76+
match try_eval_constant(sql)? {
5277
Some(nodedb_types::Value::Integer(i)) => Ok(i),
5378
Some(nodedb_types::Value::Float(f)) => Ok(f as i64),
5479
_ => Err(crate::Error::PlanError {
@@ -87,7 +112,7 @@ pub async fn evaluate_to_value(
87112
return Ok(nodedb_types::Value::String(inner.replace("''", "'")));
88113
}
89114

90-
match try_eval_constant(trimmed) {
115+
match try_eval_constant(trimmed)? {
91116
Some(val) => Ok(val),
92117
None => Ok(nodedb_types::Value::Null),
93118
}
@@ -97,7 +122,13 @@ pub async fn evaluate_to_value(
97122
///
98123
/// Handles basic arithmetic (+, -, *, /), comparisons, boolean logic,
99124
/// and string concatenation on literal values.
100-
fn try_eval_constant(sql: &str) -> Option<nodedb_types::Value> {
125+
///
126+
/// Returns `Ok(None)` for anything this mini-evaluator doesn't handle
127+
/// (unparseable input, unknown identifiers, unsupported operators,
128+
/// overflow) — callers fold that to `Value::Null`, matching historical
129+
/// behavior. Returns `Err(ConstEvalError::DivisionByZero)` only when
130+
/// evaluation reaches a `/` or `%` with a zero divisor (nodedb issue #227).
131+
fn try_eval_constant(sql: &str) -> Result<Option<nodedb_types::Value>, ConstEvalError> {
101132
let trimmed = sql.trim();
102133
let expr_str = if trimmed.to_uppercase().starts_with("SELECT ") {
103134
trimmed[7..].trim()
@@ -108,24 +139,28 @@ fn try_eval_constant(sql: &str) -> Option<nodedb_types::Value> {
108139
let dialect = sqlparser::dialect::PostgreSqlDialect {};
109140
let select_sql = format!("SELECT {expr_str}");
110141
// reconstructed-sql: parser-only evaluates one constant-expression AST without dispatch
111-
let stmts = sqlparser::parser::Parser::parse_sql(&dialect, &select_sql).ok()?;
112-
let stmt = stmts.first()?;
142+
let Ok(stmts) = sqlparser::parser::Parser::parse_sql(&dialect, &select_sql) else {
143+
return Ok(None);
144+
};
145+
let Some(stmt) = stmts.first() else {
146+
return Ok(None);
147+
};
113148

114149
if let sqlparser::ast::Statement::Query(query) = stmt
115150
&& let sqlparser::ast::SetExpr::Select(select) = &*query.body
116151
&& let Some(sqlparser::ast::SelectItem::UnnamedExpr(expr)) = select.projection.first()
117152
{
118153
return eval_expr(expr);
119154
}
120-
None
155+
Ok(None)
121156
}
122157

123158
/// Recursively evaluate a sqlparser expression tree.
124-
fn eval_expr(expr: &sqlparser::ast::Expr) -> Option<nodedb_types::Value> {
159+
fn eval_expr(expr: &sqlparser::ast::Expr) -> Result<Option<nodedb_types::Value>, ConstEvalError> {
125160
use sqlparser::ast::{Expr, UnaryOperator, Value};
126161

127162
match expr {
128-
Expr::Value(v) => match &v.value {
163+
Expr::Value(v) => Ok(match &v.value {
129164
Value::Number(n, _) => {
130165
if let Ok(i) = n.parse::<i64>() {
131166
Some(nodedb_types::Value::Integer(i))
@@ -139,94 +174,146 @@ fn eval_expr(expr: &sqlparser::ast::Expr) -> Option<nodedb_types::Value> {
139174
Value::Boolean(b) => Some(nodedb_types::Value::Bool(*b)),
140175
Value::Null => Some(nodedb_types::Value::Null),
141176
_ => None,
142-
},
177+
}),
143178
Expr::UnaryOp {
144179
op: UnaryOperator::Minus,
145180
expr: inner,
146-
} => match eval_expr(inner)? {
147-
nodedb_types::Value::Integer(i) => Some(nodedb_types::Value::Integer(-i)),
148-
nodedb_types::Value::Float(f) => Some(nodedb_types::Value::Float(-f)),
181+
} => Ok(match eval_expr(inner)? {
182+
Some(nodedb_types::Value::Integer(i)) => Some(nodedb_types::Value::Integer(-i)),
183+
Some(nodedb_types::Value::Float(f)) => Some(nodedb_types::Value::Float(-f)),
149184
_ => None,
150-
},
185+
}),
151186
Expr::UnaryOp {
152187
op: UnaryOperator::Not,
153188
expr: inner,
154-
} => match eval_expr(inner)? {
155-
nodedb_types::Value::Bool(b) => Some(nodedb_types::Value::Bool(!b)),
189+
} => Ok(match eval_expr(inner)? {
190+
Some(nodedb_types::Value::Bool(b)) => Some(nodedb_types::Value::Bool(!b)),
156191
_ => None,
192+
}),
193+
Expr::BinaryOp { left, op, right } => match (eval_expr(left)?, eval_expr(right)?) {
194+
(Some(l), Some(r)) => eval_binary_op(&l, op, &r),
195+
_ => Ok(None),
157196
},
158-
Expr::BinaryOp { left, op, right } => {
159-
let l = eval_expr(left)?;
160-
let r = eval_expr(right)?;
161-
eval_binary_op(&l, op, &r)
162-
}
163197
Expr::Nested(inner) => eval_expr(inner),
164-
_ => None,
198+
_ => Ok(None),
165199
}
166200
}
167201

168202
fn eval_binary_op(
169203
l: &nodedb_types::Value,
170204
op: &sqlparser::ast::BinaryOperator,
171205
r: &nodedb_types::Value,
172-
) -> Option<nodedb_types::Value> {
206+
) -> Result<Option<nodedb_types::Value>, ConstEvalError> {
173207
use nodedb_types::Value;
174208
use sqlparser::ast::BinaryOperator::*;
175209

176210
match (l, r) {
177211
(Value::Integer(a), Value::Integer(b)) => match op {
178-
Plus => Some(Value::Integer(a.checked_add(*b)?)),
179-
Minus => Some(Value::Integer(a.checked_sub(*b)?)),
180-
Multiply => Some(Value::Integer(a.checked_mul(*b)?)),
181-
Divide if *b != 0 => Some(Value::Integer(a.checked_div(*b)?)),
182-
Modulo if *b != 0 => Some(Value::Integer(a.checked_rem(*b)?)),
183-
Gt => Some(Value::Bool(a > b)),
184-
GtEq => Some(Value::Bool(a >= b)),
185-
Lt => Some(Value::Bool(a < b)),
186-
LtEq => Some(Value::Bool(a <= b)),
187-
Eq => Some(Value::Bool(a == b)),
188-
NotEq => Some(Value::Bool(a != b)),
189-
_ => None,
212+
Plus => Ok(a.checked_add(*b).map(Value::Integer)),
213+
Minus => Ok(a.checked_sub(*b).map(Value::Integer)),
214+
Multiply => Ok(a.checked_mul(*b).map(Value::Integer)),
215+
Divide if *b != 0 => Ok(a.checked_div(*b).map(Value::Integer)),
216+
Divide => Err(ConstEvalError::DivisionByZero),
217+
Modulo if *b != 0 => Ok(a.checked_rem(*b).map(Value::Integer)),
218+
Modulo => Err(ConstEvalError::DivisionByZero),
219+
Gt => Ok(Some(Value::Bool(a > b))),
220+
GtEq => Ok(Some(Value::Bool(a >= b))),
221+
Lt => Ok(Some(Value::Bool(a < b))),
222+
LtEq => Ok(Some(Value::Bool(a <= b))),
223+
Eq => Ok(Some(Value::Bool(a == b))),
224+
NotEq => Ok(Some(Value::Bool(a != b))),
225+
_ => Ok(None),
190226
},
191227
(Value::Float(a), Value::Float(b)) => match op {
192-
Plus => Some(Value::Float(a + b)),
193-
Minus => Some(Value::Float(a - b)),
194-
Multiply => Some(Value::Float(a * b)),
195-
Divide if *b != 0.0 && b.is_finite() && *b != -0.0 => {
228+
Plus => Ok(Some(Value::Float(a + b))),
229+
Minus => Ok(Some(Value::Float(a - b))),
230+
Multiply => Ok(Some(Value::Float(a * b))),
231+
// Non-finite divisor (NaN/Infinity) is unsupported input, not a
232+
// zero-divisor — keeps folding to `None` as before.
233+
Divide if !b.is_finite() => Ok(None),
234+
Divide if *b == 0.0 => Err(ConstEvalError::DivisionByZero),
235+
Divide => {
196236
let result = a / b;
197237
if result.is_finite() {
198-
Some(Value::Float(result))
238+
Ok(Some(Value::Float(result)))
199239
} else {
200-
None
240+
Ok(None)
201241
}
202242
}
203-
Gt => Some(Value::Bool(a > b)),
204-
GtEq => Some(Value::Bool(a >= b)),
205-
Lt => Some(Value::Bool(a < b)),
206-
LtEq => Some(Value::Bool(a <= b)),
207-
Eq => Some(Value::Bool(a == b)),
208-
NotEq => Some(Value::Bool(a != b)),
209-
_ => None,
243+
Gt => Ok(Some(Value::Bool(a > b))),
244+
GtEq => Ok(Some(Value::Bool(a >= b))),
245+
Lt => Ok(Some(Value::Bool(a < b))),
246+
LtEq => Ok(Some(Value::Bool(a <= b))),
247+
Eq => Ok(Some(Value::Bool(a == b))),
248+
NotEq => Ok(Some(Value::Bool(a != b))),
249+
_ => Ok(None),
210250
},
211251
(Value::Integer(a), Value::Float(b)) => {
212252
eval_binary_op(&Value::Float(*a as f64), op, &Value::Float(*b))
213253
}
214254
(Value::Float(a), Value::Integer(b)) => {
215255
eval_binary_op(&Value::Float(*a), op, &Value::Float(*b as f64))
216256
}
217-
(Value::Bool(a), Value::Bool(b)) => match op {
257+
(Value::Bool(a), Value::Bool(b)) => Ok(match op {
218258
And => Some(Value::Bool(*a && *b)),
219259
Or => Some(Value::Bool(*a || *b)),
220260
Eq => Some(Value::Bool(a == b)),
221261
NotEq => Some(Value::Bool(a != b)),
222262
_ => None,
223-
},
224-
(Value::String(a), Value::String(b)) => match op {
263+
}),
264+
(Value::String(a), Value::String(b)) => Ok(match op {
225265
StringConcat => Some(Value::String(format!("{a}{b}"))),
226266
Eq => Some(Value::Bool(a == b)),
227267
NotEq => Some(Value::Bool(a != b)),
228268
_ => None,
229-
},
230-
_ => None,
269+
}),
270+
_ => Ok(None),
271+
}
272+
}
273+
274+
#[cfg(test)]
275+
mod tests {
276+
use super::*;
277+
278+
// ── Division/modulo by zero (nodedb issue #227) ─────────────────────────
279+
280+
#[test]
281+
fn integer_division_by_zero_signals_error() {
282+
assert_eq!(
283+
try_eval_constant("10 / 0"),
284+
Err(ConstEvalError::DivisionByZero)
285+
);
286+
}
287+
288+
#[test]
289+
fn integer_modulo_by_zero_signals_error() {
290+
assert_eq!(
291+
try_eval_constant("10 % 0"),
292+
Err(ConstEvalError::DivisionByZero)
293+
);
294+
}
295+
296+
#[test]
297+
fn float_division_by_zero_signals_error() {
298+
assert_eq!(
299+
try_eval_constant("10.0 / 0.0"),
300+
Err(ConstEvalError::DivisionByZero)
301+
);
302+
}
303+
304+
/// Control: a non-zero divisor still computes normally.
305+
#[test]
306+
fn integer_division_by_nonzero_still_computes() {
307+
assert_eq!(
308+
try_eval_constant("10 / 2"),
309+
Ok(Some(nodedb_types::Value::Integer(5)))
310+
);
311+
}
312+
313+
/// Control: an unrelated `None` case (unknown identifier) is unaffected
314+
/// by the division-by-zero signal and still folds to `Ok(None)`.
315+
#[test]
316+
fn unknown_identifier_still_folds_to_none() {
317+
assert_eq!(try_eval_constant("some_unbound_identifier"), Ok(None));
231318
}
232319
}

0 commit comments

Comments
 (0)