diff --git a/nodedb-crdt/src/constraint_checks.rs b/nodedb-crdt/src/constraint_checks.rs index 1e12d66b6..100cad89e 100644 --- a/nodedb-crdt/src/constraint_checks.rs +++ b/nodedb-crdt/src/constraint_checks.rs @@ -28,14 +28,20 @@ fn render_value(value: &LoroValue) -> String { } impl Validator { + /// + /// Returns `Err(EvalError)` when a CHECK predicate cannot be *evaluated* + /// (division/modulo by zero). This is distinct from an + /// ordinary constraint failure (`Ok(Some(Violation))`): the caller lifts it + /// to [`ValidationOutcome::EvalError`], a hard statement failure that + /// declarative conflict policies must never "resolve". pub(crate) fn check_constraint( &self, state: &impl RowLookup, change: &ProposedChange, constraint: &Constraint, - ) -> Option { + ) -> Result, nodedb_query::EvalError> { match &constraint.kind { - ConstraintKind::Unique => self.check_unique(state, change, constraint), + ConstraintKind::Unique => Ok(self.check_unique(state, change, constraint)), ConstraintKind::ForeignKey { ref_collection, ref_key, @@ -43,8 +49,8 @@ impl Validator { | ConstraintKind::BiTemporalFK { ref_collection, ref_key, - } => self.check_foreign_key(state, change, constraint, ref_collection, ref_key), - ConstraintKind::NotNull => self.check_not_null(change, constraint), + } => Ok(self.check_foreign_key(state, change, constraint, ref_collection, ref_key)), + ConstraintKind::NotNull => Ok(self.check_not_null(change, constraint)), ConstraintKind::Check { expr, .. } => self.check_expr(change, constraint, expr), } } @@ -60,7 +66,7 @@ impl Validator { change: &ProposedChange, constraint: &Constraint, expr: &str, - ) -> Option { + ) -> Result, nodedb_query::EvalError> { // Build a row Value::Object from the proposed change's fields so the // expression evaluator can resolve column references by name. let mut row = std::collections::HashMap::with_capacity(change.fields.len()); @@ -72,7 +78,10 @@ impl Validator { let parsed = match nodedb_query::expr_parse::parse_generated_expr(expr) { Ok((expr, _deps)) => expr, Err(e) => { - return Some(Violation { + // A malformed *stored* predicate is a catalog-integrity failure, + // not an evaluation error — it stays an ordinary Violation + // (fails closed) so a corrupt entry can't bypass its invariant. + return Ok(Some(Violation { constraint_name: constraint.name.clone(), reason: format!("invalid CHECK expression `{expr}`: {e}"), hint: CompensationHint::ManualIntervention { @@ -81,20 +90,27 @@ impl Validator { constraint.name ), }, - }); + })); } }; + // A division/modulo-by-zero inside the predicate + // is neither a PASS nor an ordinary FAIL — it's an evaluation error, + // propagated as `Err(EvalError)` and lifted by `validate` to + // `ValidationOutcome::EvalError`. That keeps it out of the conflict- + // policy machinery: an unevaluable predicate is a hard SQLSTATE-22012 + // failure, never something LastWriterWins et al. may "resolve". match parsed.eval(&row_value) { - nodedb_types::Value::Bool(true) => None, - nodedb_types::Value::Null => None, - _ => Some(Violation { + Ok(nodedb_types::Value::Bool(true)) => Ok(None), + Ok(nodedb_types::Value::Null) => Ok(None), + Ok(_) => Ok(Some(Violation { constraint_name: constraint.name.clone(), reason: format!("CHECK `{}` failed: {expr}", constraint.name), hint: CompensationHint::ManualIntervention { reason: format!("row violates CHECK predicate `{expr}`"), }, - }), + })), + Err(e) => Err(e), } } diff --git a/nodedb-crdt/src/pre_validate.rs b/nodedb-crdt/src/pre_validate.rs index deeb86fc3..07272e71c 100644 --- a/nodedb-crdt/src/pre_validate.rs +++ b/nodedb-crdt/src/pre_validate.rs @@ -45,6 +45,16 @@ pub fn pre_validate( reason: v.reason.clone(), } } + // An unevaluable predicate (division/modulo by zero) + // is rejected before it ever reaches Raft — same terminal outcome as a + // violation, so the delta never commits. + ValidationOutcome::EvalError { + constraint_name, + error, + } => PreValidationResult::FastReject { + constraint: constraint_name, + reason: format!("CHECK predicate failed to evaluate: {error}"), + }, } } diff --git a/nodedb-crdt/src/validator/policy_dispatch.rs b/nodedb-crdt/src/validator/policy_dispatch.rs index c9719622b..0fa963146 100644 --- a/nodedb-crdt/src/validator/policy_dispatch.rs +++ b/nodedb-crdt/src/validator/policy_dispatch.rs @@ -4,12 +4,13 @@ use crate::CrdtAuthContext; use crate::constraint::{Constraint, ConstraintKind}; +use crate::dead_letter::CompensationHint; use crate::error::Result; use crate::policy::{ConflictPolicy, PolicyResolution, ResolvedAction}; use crate::row_lookup::RowLookup; use super::core::Validator; -use super::types::{ProposedChange, ValidationOutcome}; +use super::types::{ProposedChange, ValidationOutcome, Violation}; impl Validator { /// Validate with declarative policy resolution. @@ -195,6 +196,56 @@ impl Validator { } } } + ValidationOutcome::EvalError { + constraint_name, + error, + } => { + // An unevaluable predicate (division/modulo by zero) + // is NOT a resolvable conflict — it must never be + // handed to a declarative policy, which would "resolve" a delta + // the server cannot actually evaluate. Escalate straight to the + // DLQ (fails closed) regardless of the collection's configured + // policy, mirroring `EscalateToDlq` but for an eval error. + let constraint = self + .constraints + .all() + .iter() + .find(|c| c.name == constraint_name) + .cloned() + .unwrap_or_else(|| Constraint { + name: constraint_name.clone(), + collection: change.collection.clone(), + field: String::new(), + kind: ConstraintKind::NotNull, + }); + let reason = format!("CHECK `{constraint_name}` failed to evaluate: {error}"); + let hint = CompensationHint::ManualIntervention { + reason: reason.clone(), + }; + self.dlq + .enqueue(crate::dead_letter::EnqueueDeadLetterArgs { + peer_id, + user_id: auth.user_id, + tenant_id: auth.tenant_id, + delta: delta_bytes, + constraint: &constraint, + reason: reason.clone(), + hint: hint.clone(), + })?; + tracing::warn!( + constraint = %constraint_name, + collection = %change.collection, + %error, + "CRDT CHECK predicate raised an evaluation error; escalated to DLQ" + ); + Ok(PolicyResolution::Escalate { + violations: vec![Violation { + constraint_name, + reason, + hint, + }], + }) + } } } } diff --git a/nodedb-crdt/src/validator/tests.rs b/nodedb-crdt/src/validator/tests.rs index 8551033f0..1c448bcdb 100644 --- a/nodedb-crdt/src/validator/tests.rs +++ b/nodedb-crdt/src/validator/tests.rs @@ -415,3 +415,41 @@ fn check_constraint_rejects_malformed_expr() { _ => panic!("expected rejection for malformed CHECK expression"), } } + +/// A CHECK predicate that divides by zero is an *evaluation* error, not an +/// ordinary violation: `validate` returns `ValidationOutcome::EvalError` so the +/// downstream conflict-policy machinery never "resolves" an unevaluable +/// predicate. Distinct from both `Rejected` (a genuine violation) and +/// `Accepted`. +#[test] +fn check_constraint_division_by_zero_surfaces_eval_error() { + let state = CrdtState::new(1).unwrap(); + let mut cs = ConstraintSet::new(); + // `100 / age` is unevaluable when age = 0. + cs.add_check( + "people_ratio_check", + "people", + "age", + "100 / age > 0", + "100 / age > 0", + ); + let validator = Validator::new(cs, 100); + + let change = ProposedChange { + collection: "people".into(), + row_id: "p1".into(), + surrogate: nodedb_types::Surrogate::ZERO, + fields: vec![("age".into(), LoroValue::I64(0))], + }; + + match validator.validate(&state, &change) { + ValidationOutcome::EvalError { + constraint_name, + error, + } => { + assert_eq!(constraint_name, "people_ratio_check"); + assert_eq!(error, nodedb_query::EvalError::DivisionByZero); + } + other => panic!("expected ValidationOutcome::EvalError, got {other:?}"), + } +} diff --git a/nodedb-crdt/src/validator/types.rs b/nodedb-crdt/src/validator/types.rs index a847a41cf..ae06f1870 100644 --- a/nodedb-crdt/src/validator/types.rs +++ b/nodedb-crdt/src/validator/types.rs @@ -13,6 +13,19 @@ pub enum ValidationOutcome { Accepted, /// One or more constraints violated — delta rejected. Rejected(Vec), + /// A CHECK predicate could not be *evaluated* — division/modulo by zero. + /// Kept distinct from [`ValidationOutcome::Rejected`] + /// because an eval error is a hard statement failure (SQLSTATE `22012`), + /// NOT a resolvable conflict: declarative conflict policies + /// (`LastWriterWins`, `RenameSuffix`, …) must never "resolve" an + /// unevaluable predicate the way they resolve a genuine violation. It fails + /// closed — escalated straight to the dead-letter queue. + EvalError { + /// The CHECK constraint whose predicate failed to evaluate. + constraint_name: String, + /// The underlying evaluation error (currently only `DivisionByZero`). + error: nodedb_query::EvalError, + }, } /// A single constraint violation. diff --git a/nodedb-crdt/src/validator/validate.rs b/nodedb-crdt/src/validator/validate.rs index 59aeb8123..48671a97c 100644 --- a/nodedb-crdt/src/validator/validate.rs +++ b/nodedb-crdt/src/validator/validate.rs @@ -83,8 +83,19 @@ impl Validator { let mut violations = Vec::new(); for constraint in constraints { - if let Some(violation) = self.check_constraint(state, change, constraint) { - violations.push(violation); + match self.check_constraint(state, change, constraint) { + Ok(Some(violation)) => violations.push(violation), + Ok(None) => {} + // A predicate that could not be evaluated (division/modulo by + // zero) is a hard failure, not a violation to + // be batched with the others — surface it immediately so it + // bypasses conflict-policy resolution downstream. + Err(error) => { + return ValidationOutcome::EvalError { + constraint_name: constraint.name.clone(), + error, + }; + } } } diff --git a/nodedb-query/src/expr/binary.rs b/nodedb-query/src/expr/binary.rs index 225d55e31..281d96f10 100644 --- a/nodedb-query/src/expr/binary.rs +++ b/nodedb-query/src/expr/binary.rs @@ -10,6 +10,7 @@ use crate::value_ops::{ coerced_eq, compare_values, is_truthy, to_value_number, value_to_display_string, value_to_f64, }; +use super::eval::EvalError; use super::types::BinaryOp; /// Coerce a `Value` to `Decimal` for precise arithmetic. @@ -28,86 +29,119 @@ fn value_to_decimal(v: &Value) -> Option { } } -/// Apply a Decimal arithmetic operation, returning `Value::Null` on overflow/div-zero. -fn decimal_arith(a: Decimal, op: BinaryOp, b: Decimal) -> Value { +/// Apply a Decimal arithmetic operation, returning `Value::Null` on overflow +/// and `Err(EvalError::DivisionByZero)` when `/` or `%` see a zero divisor. +/// The zero check happens before `checked_div`/ +/// `checked_rem` are called because those methods return `None` for both +/// div-by-zero *and* overflow, and only the former is a `22012` error — +/// overflow keeps folding to `Value::Null` exactly as before. +fn decimal_arith(a: Decimal, op: BinaryOp, b: Decimal) -> Result { let result = match op { BinaryOp::Add => a.checked_add(b), BinaryOp::Sub => a.checked_sub(b), BinaryOp::Mul => a.checked_mul(b), - BinaryOp::Div => a.checked_div(b), - BinaryOp::Mod => a.checked_rem(b), - _ => return Value::Null, + BinaryOp::Div => { + if b.is_zero() { + return Err(EvalError::DivisionByZero); + } + a.checked_div(b) + } + BinaryOp::Mod => { + if b.is_zero() { + return Err(EvalError::DivisionByZero); + } + a.checked_rem(b) + } + _ => return Ok(Value::Null), }; - result.map(Value::Decimal).unwrap_or(Value::Null) + Ok(result.map(Value::Decimal).unwrap_or(Value::Null)) } -pub(super) fn eval_binary_op(left: &Value, op: BinaryOp, right: &Value) -> Value { +pub(super) fn eval_binary_op( + left: &Value, + op: BinaryOp, + right: &Value, +) -> Result { match op { // Arithmetic: prefer Decimal when either operand is Decimal to avoid f64 drift. BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { let left_is_decimal = matches!(left, Value::Decimal(_)); let right_is_decimal = matches!(right, Value::Decimal(_)); if left_is_decimal || right_is_decimal { - match (value_to_decimal(left), value_to_decimal(right)) { - (Some(a), Some(b)) => return decimal_arith(a, op, b), - _ => return Value::Null, - } + return match (value_to_decimal(left), value_to_decimal(right)) { + (Some(a), Some(b)) => decimal_arith(a, op, b), + _ => Ok(Value::Null), + }; } // Both operands are non-Decimal: use f64 path. match op { - BinaryOp::Add => match (value_to_f64(left, true), value_to_f64(right, true)) { - (Some(a), Some(b)) => to_value_number(a + b), - _ => Value::Null, - }, - BinaryOp::Sub => match (value_to_f64(left, true), value_to_f64(right, true)) { - (Some(a), Some(b)) => to_value_number(a - b), - _ => Value::Null, - }, - BinaryOp::Mul => match (value_to_f64(left, true), value_to_f64(right, true)) { - (Some(a), Some(b)) => to_value_number(a * b), - _ => Value::Null, - }, + BinaryOp::Add => Ok( + match (value_to_f64(left, true), value_to_f64(right, true)) { + (Some(a), Some(b)) => to_value_number(a + b), + _ => Value::Null, + }, + ), + BinaryOp::Sub => Ok( + match (value_to_f64(left, true), value_to_f64(right, true)) { + (Some(a), Some(b)) => to_value_number(a - b), + _ => Value::Null, + }, + ), + BinaryOp::Mul => Ok( + match (value_to_f64(left, true), value_to_f64(right, true)) { + (Some(a), Some(b)) => to_value_number(a * b), + _ => Value::Null, + }, + ), BinaryOp::Div => match (value_to_f64(left, true), value_to_f64(right, true)) { (Some(a), Some(b)) => { if b == 0.0 { - Value::Null + Err(EvalError::DivisionByZero) } else { - to_value_number(a / b) + Ok(to_value_number(a / b)) } } - _ => Value::Null, + _ => Ok(Value::Null), }, BinaryOp::Mod => match (value_to_f64(left, true), value_to_f64(right, true)) { (Some(a), Some(b)) => { if b == 0.0 { - Value::Null + Err(EvalError::DivisionByZero) } else { - to_value_number(a % b) + Ok(to_value_number(a % b)) } } - _ => Value::Null, + _ => Ok(Value::Null), }, - _ => Value::Null, + _ => Ok(Value::Null), } } BinaryOp::Concat => { let ls = value_to_display_string(left); let rs = value_to_display_string(right); - Value::String(format!("{ls}{rs}")) + Ok(Value::String(format!("{ls}{rs}"))) } - BinaryOp::Eq => Value::Bool(coerced_eq(left, right)), - BinaryOp::NotEq => Value::Bool(!coerced_eq(left, right)), - BinaryOp::Gt => Value::Bool(compare_values(left, right) == std::cmp::Ordering::Greater), + BinaryOp::Eq => Ok(Value::Bool(coerced_eq(left, right))), + BinaryOp::NotEq => Ok(Value::Bool(!coerced_eq(left, right))), + BinaryOp::Gt => Ok(Value::Bool( + compare_values(left, right) == std::cmp::Ordering::Greater, + )), BinaryOp::GtEq => { let c = compare_values(left, right); - Value::Bool(c == std::cmp::Ordering::Greater || c == std::cmp::Ordering::Equal) + Ok(Value::Bool( + c == std::cmp::Ordering::Greater || c == std::cmp::Ordering::Equal, + )) } - BinaryOp::Lt => Value::Bool(compare_values(left, right) == std::cmp::Ordering::Less), + BinaryOp::Lt => Ok(Value::Bool( + compare_values(left, right) == std::cmp::Ordering::Less, + )), BinaryOp::LtEq => { let c = compare_values(left, right); - Value::Bool(c == std::cmp::Ordering::Less || c == std::cmp::Ordering::Equal) + Ok(Value::Bool( + c == std::cmp::Ordering::Less || c == std::cmp::Ordering::Equal, + )) } - BinaryOp::And => Value::Bool(is_truthy(left) && is_truthy(right)), - BinaryOp::Or => Value::Bool(is_truthy(left) || is_truthy(right)), + BinaryOp::And => Ok(Value::Bool(is_truthy(left) && is_truthy(right))), + BinaryOp::Or => Ok(Value::Bool(is_truthy(left) || is_truthy(right))), } } diff --git a/nodedb-query/src/expr/eval.rs b/nodedb-query/src/expr/eval.rs index 5eb59a91a..04f60e2d6 100644 --- a/nodedb-query/src/expr/eval.rs +++ b/nodedb-query/src/expr/eval.rs @@ -14,6 +14,21 @@ use crate::value_ops::{coerced_eq, is_truthy, to_value_number, value_to_f64}; use super::binary::eval_binary_op; use super::types::SqlExpr; +/// Error type for row-scope `SqlExpr` evaluation. +/// +/// Mirrors the [`WindowError`](crate::window::WindowError) idiom: a small, +/// `thiserror`-derived enum living next to the evaluator it describes. +/// Everything that historically folded to `Value::Null` (bad casts, wrong +/// arg counts, unknown-value coercions) keeps doing so — this type exists +/// solely for division/modulo by a zero divisor, which must surface as +/// SQLSTATE `22012` (`division_by_zero`) instead of silently evaluating to +/// `NULL`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum EvalError { + #[error("division by zero")] + DivisionByZero, +} + /// Row scope for `SqlExpr::eval_scope`: how `Column(..)` and `OldColumn(..)` /// resolve to `Value`s. The shared evaluator walks the AST once and calls /// into this scope for every leaf column reference — both `eval()` and @@ -56,7 +71,13 @@ impl SqlExpr { /// return `Null`. Arithmetic on non-numeric values returns `Null`. /// `OldColumn(..)` resolves to `Null` (use `eval_with_old` for the /// TRANSITION CHECK path). - pub fn eval(&self, doc: &Value) -> Value { + /// + /// Returns `Err(EvalError::DivisionByZero)` if evaluation reaches a + /// `/` or `%` with a zero divisor — every other + /// historically-`NULL`-folding case (bad casts, wrong arg counts, + /// unknown-value coercions) is unaffected and still evaluates to + /// `Ok(Value::Null)`. + pub fn eval(&self, doc: &Value) -> Result { self.eval_scope(&RowScope { new_doc: doc, old_doc: None, @@ -67,7 +88,7 @@ impl SqlExpr { /// Evaluate with access to both NEW and OLD documents, used by /// TRANSITION CHECK predicates. `Column(name)` resolves against /// `new_doc`; `OldColumn(name)` resolves against `old_doc`. - pub fn eval_with_old(&self, new_doc: &Value, old_doc: &Value) -> Value { + pub fn eval_with_old(&self, new_doc: &Value, old_doc: &Value) -> Result { self.eval_scope(&RowScope { new_doc, old_doc: Some(old_doc), @@ -79,7 +100,7 @@ impl SqlExpr { /// `INSERT ... ON CONFLICT DO UPDATE`. `Column(name)` resolves /// against the existing (current) row `doc`; `ExcludedColumn(name)` /// resolves against `excluded`. - pub fn eval_with_excluded(&self, doc: &Value, excluded: &Value) -> Value { + pub fn eval_with_excluded(&self, doc: &Value, excluded: &Value) -> Result { self.eval_scope(&RowScope { new_doc: doc, old_doc: None, @@ -89,40 +110,48 @@ impl SqlExpr { /// Shared walker: one match, one recursion scheme, parameterised by the /// row-scope so `eval` and `eval_with_old` can't drift out of sync. - fn eval_scope(&self, scope: &RowScope<'_>) -> Value { + /// + /// CASE and COALESCE stay short-circuiting: an untaken CASE branch or an + /// already-satisfied COALESCE argument is never evaluated, so `CASE WHEN + /// false THEN 1/0 ELSE 0 END` and `COALESCE(1, 1/0)` never reach the + /// division and cannot raise `DivisionByZero`. + fn eval_scope(&self, scope: &RowScope<'_>) -> Result { match self { - SqlExpr::Column(name) => scope.column(name), - SqlExpr::OldColumn(name) => scope.old_column(name), - SqlExpr::ExcludedColumn(name) => scope.excluded_column(name), + SqlExpr::Column(name) => Ok(scope.column(name)), + SqlExpr::OldColumn(name) => Ok(scope.old_column(name)), + SqlExpr::ExcludedColumn(name) => Ok(scope.excluded_column(name)), - SqlExpr::Literal(v) => v.clone(), + SqlExpr::Literal(v) => Ok(v.clone()), SqlExpr::BinaryOp { left, op, right } => { - let l = left.eval_scope(scope); - let r = right.eval_scope(scope); + let l = left.eval_scope(scope)?; + let r = right.eval_scope(scope)?; eval_binary_op(&l, *op, &r) } SqlExpr::Negate(inner) => { - let v = inner.eval_scope(scope); + let v = inner.eval_scope(scope)?; if let Some(b) = v.as_bool() { - Value::Bool(!b) + Ok(Value::Bool(!b)) } else { match value_to_f64(&v, false) { - Some(n) => to_value_number(-n), - None => Value::Null, + Some(n) => Ok(to_value_number(-n)), + None => Ok(Value::Null), } } } SqlExpr::Function { name, args } => { - let evaluated: Vec = args.iter().map(|a| a.eval_scope(scope)).collect(); + let evaluated: Vec = args + .iter() + .map(|a| a.eval_scope(scope)) + .collect::>()?; crate::functions::eval_function(name, &evaluated) } SqlExpr::Cast { expr, to_type } => { - let v = expr.eval_scope(scope); - crate::cast::eval_cast(&v, to_type) + let v = expr.eval_scope(scope)?; + Ok(crate::cast::eval_cast(&v, to_type)) } SqlExpr::Case { @@ -130,9 +159,12 @@ impl SqlExpr { when_thens, else_expr, } => { - let op_val = operand.as_ref().map(|e| e.eval_scope(scope)); + let op_val = match operand { + Some(e) => Some(e.eval_scope(scope)?), + None => None, + }; for (when_expr, then_expr) in when_thens { - let when_val = when_expr.eval_scope(scope); + let when_val = when_expr.eval_scope(scope)?; let matches = match &op_val { Some(ov) => coerced_eq(ov, &when_val), None => is_truthy(&when_val), @@ -143,34 +175,34 @@ impl SqlExpr { } match else_expr { Some(e) => e.eval_scope(scope), - None => Value::Null, + None => Ok(Value::Null), } } SqlExpr::Coalesce(exprs) => { for expr in exprs { - let v = expr.eval_scope(scope); + let v = expr.eval_scope(scope)?; if !v.is_null() { - return v; + return Ok(v); } } - Value::Null + Ok(Value::Null) } SqlExpr::NullIf(a, b) => { - let va = a.eval_scope(scope); - let vb = b.eval_scope(scope); + let va = a.eval_scope(scope)?; + let vb = b.eval_scope(scope)?; if coerced_eq(&va, &vb) { - Value::Null + Ok(Value::Null) } else { - va + Ok(va) } } SqlExpr::IsNull { expr, negated } => { - let v = expr.eval_scope(scope); + let v = expr.eval_scope(scope)?; let is_null = v.is_null(); - Value::Bool(if *negated { !is_null } else { is_null }) + Ok(Value::Bool(if *negated { !is_null } else { is_null })) } } } @@ -199,19 +231,19 @@ mod tests { #[test] fn column_ref() { let expr = SqlExpr::Column("name".into()); - assert_eq!(expr.eval(&doc()), Value::String("Alice".into())); + assert_eq!(expr.eval(&doc()).unwrap(), Value::String("Alice".into())); } #[test] fn missing_column() { let expr = SqlExpr::Column("missing".into()); - assert_eq!(expr.eval(&doc()), Value::Null); + assert_eq!(expr.eval(&doc()).unwrap(), Value::Null); } #[test] fn literal() { let expr = SqlExpr::Literal(Value::Integer(42)); - assert_eq!(expr.eval(&doc()), Value::Integer(42)); + assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(42)); } #[test] @@ -221,7 +253,7 @@ mod tests { op: BinaryOp::Add, right: Box::new(SqlExpr::Literal(Value::Float(1.5))), }; - assert_eq!(expr.eval(&doc()), Value::Integer(12)); + assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(12)); } #[test] @@ -231,7 +263,7 @@ mod tests { op: BinaryOp::Mul, right: Box::new(SqlExpr::Column("qty".into())), }; - assert_eq!(expr.eval(&doc()), Value::Integer(42)); + assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(42)); } #[test] @@ -248,7 +280,7 @@ mod tests { )], else_expr: Some(Box::new(SqlExpr::Literal(Value::String("minor".into())))), }; - assert_eq!(expr.eval(&doc()), Value::String("adult".into())); + assert_eq!(expr.eval(&doc()).unwrap(), Value::String("adult".into())); } #[test] @@ -258,7 +290,7 @@ mod tests { SqlExpr::Literal(Value::String("default@example.com".into())), ]); assert_eq!( - expr.eval(&doc()), + expr.eval(&doc()).unwrap(), Value::String("default@example.com".into()) ); } @@ -269,6 +301,121 @@ mod tests { expr: Box::new(SqlExpr::Column("email".into())), negated: false, }; - assert_eq!(expr.eval(&doc()), Value::Bool(true)); + assert_eq!(expr.eval(&doc()).unwrap(), Value::Bool(true)); + } + + // ── Division/modulo by zero ────────────────────────────────────────────── + + #[test] + fn integer_division_by_zero_errors() { + let expr = SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Integer(1))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Literal(Value::Integer(0))), + }; + assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero)); + } + + #[test] + fn float_division_by_zero_errors() { + let expr = SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Float(1.5))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Literal(Value::Float(0.0))), + }; + assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero)); + } + + #[test] + fn decimal_division_by_decimal_zero_errors() { + let expr = SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Decimal( + rust_decimal::Decimal::new(15, 1), + ))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Literal(Value::Decimal( + rust_decimal::Decimal::ZERO, + ))), + }; + assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero)); + } + + #[test] + fn modulo_by_zero_errors() { + let expr = SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Integer(5))), + op: BinaryOp::Mod, + right: Box::new(SqlExpr::Literal(Value::Integer(0))), + }; + assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero)); + } + + #[test] + fn scalar_mod_function_by_zero_errors() { + let expr = SqlExpr::Function { + name: "mod".into(), + args: vec![ + SqlExpr::Literal(Value::Integer(5)), + SqlExpr::Literal(Value::Integer(0)), + ], + }; + assert_eq!(expr.eval(&doc()), Err(EvalError::DivisionByZero)); + } + + /// An untaken CASE branch must never be evaluated — `1/0` in the THEN + /// arm of a WHEN that doesn't match must not raise `DivisionByZero`. + #[test] + fn case_laziness_untaken_branch_not_evaluated() { + let expr = SqlExpr::Case { + operand: None, + when_thens: vec![( + SqlExpr::Literal(Value::Bool(false)), + SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Integer(1))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Literal(Value::Integer(0))), + }, + )], + else_expr: Some(Box::new(SqlExpr::Literal(Value::Integer(0)))), + }; + assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(0)); + } + + /// COALESCE stops at the first non-null argument — a later `1/0` + /// argument must never be evaluated once an earlier one is non-null. + #[test] + fn coalesce_laziness_short_circuits_before_division() { + let expr = SqlExpr::Coalesce(vec![ + SqlExpr::Literal(Value::Integer(1)), + SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Integer(1))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Literal(Value::Integer(0))), + }, + ]); + assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(1)); + } + + /// Control: `NULL + 1` still folds to `Ok(Null)` — non-division NULL + /// propagation is untouched by this fix. + #[test] + fn null_arithmetic_still_folds_to_null() { + let expr = SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Null)), + op: BinaryOp::Add, + right: Box::new(SqlExpr::Literal(Value::Integer(1))), + }; + assert_eq!(expr.eval(&doc()).unwrap(), Value::Null); + } + + /// Control: a valid (non-zero-divisor) division still succeeds. + #[test] + fn valid_division_still_succeeds() { + let expr = SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(Value::Integer(10))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Literal(Value::Integer(2))), + }; + assert_eq!(expr.eval(&doc()).unwrap(), Value::Integer(5)); } } diff --git a/nodedb-query/src/expr/mod.rs b/nodedb-query/src/expr/mod.rs index 30445c62c..b2c25fe79 100644 --- a/nodedb-query/src/expr/mod.rs +++ b/nodedb-query/src/expr/mod.rs @@ -12,4 +12,5 @@ pub mod codec; pub mod eval; pub mod types; +pub use eval::EvalError; pub use types::{BinaryOp, CastType, ComputedColumn, GroupKeySpec, SqlExpr}; diff --git a/nodedb-query/src/expr_parse/parser.rs b/nodedb-query/src/expr_parse/parser.rs index 867485817..a312765d5 100644 --- a/nodedb-query/src/expr_parse/parser.rs +++ b/nodedb-query/src/expr_parse/parser.rs @@ -511,7 +511,7 @@ mod tests { let (expr, deps) = parse_ok("price * (1 + tax_rate)"); assert_eq!(deps, vec!["price", "tax_rate"]); let doc = Value::from(serde_json::json!({"price": 100.0, "tax_rate": 0.08})); - let result = expr.eval(&doc); + let result = expr.eval(&doc).unwrap(); assert_eq!(result.as_f64(), Some(108.0)); } @@ -520,7 +520,7 @@ mod tests { let (expr, deps) = parse_ok("ROUND(price * (1 + tax_rate), 2)"); assert_eq!(deps, vec!["price", "tax_rate"]); let doc = Value::from(serde_json::json!({"price": 99.99, "tax_rate": 0.08})); - let result = expr.eval(&doc); + let result = expr.eval(&doc).unwrap(); assert_eq!(result, Value::Float(107.99)); } @@ -529,14 +529,14 @@ mod tests { let (expr, deps) = parse_ok("CONCAT(name, ' ', brand)"); assert_eq!(deps, vec!["brand", "name"]); let doc = Value::from(serde_json::json!({"name": "Shoe", "brand": "Nike"})); - assert_eq!(expr.eval(&doc), Value::String("Shoe Nike".into())); + assert_eq!(expr.eval(&doc).unwrap(), Value::String("Shoe Nike".into())); } #[test] fn coalesce() { let (expr, _) = parse_ok("COALESCE(description, '')"); let doc = Value::from(serde_json::json!({"description": null})); - assert_eq!(expr.eval(&doc), Value::String("".into())); + assert_eq!(expr.eval(&doc).unwrap(), Value::String("".into())); } #[test] @@ -547,10 +547,10 @@ mod tests { assert!(deps.contains(&"price".to_string())); let doc = Value::from(serde_json::json!({"price": 100.0, "discount": 0.2})); - assert_eq!(expr.eval(&doc).as_f64(), Some(80.0)); + assert_eq!(expr.eval(&doc).unwrap().as_f64(), Some(80.0)); let doc2 = Value::from(serde_json::json!({"price": 100.0, "discount": 0})); - assert_eq!(expr.eval(&doc2).as_f64(), Some(100.0)); + assert_eq!(expr.eval(&doc2).unwrap().as_f64(), Some(100.0)); } #[test] @@ -572,21 +572,24 @@ mod tests { fn string_literal() { let (expr, _) = parse_ok("CONCAT(name, ' - ', 'default')"); let doc = Value::from(serde_json::json!({"name": "Product"})); - assert_eq!(expr.eval(&doc), Value::String("Product - default".into())); + assert_eq!( + expr.eval(&doc).unwrap(), + Value::String("Product - default".into()) + ); } #[test] fn null_literal() { let (expr, _) = parse_ok("COALESCE(x, NULL, 0)"); let doc = Value::from(serde_json::json!({"x": null})); - assert_eq!(expr.eval(&doc), Value::Integer(0)); + assert_eq!(expr.eval(&doc).unwrap(), Value::Integer(0)); } #[test] fn nested_functions() { let (expr, _) = parse_ok("ROUND(price * (1 - COALESCE(discount, 0)), 2)"); let doc = Value::from(serde_json::json!({"price": 49.99})); - assert_eq!(expr.eval(&doc), Value::Float(49.99)); + assert_eq!(expr.eval(&doc).unwrap(), Value::Float(49.99)); } #[test] @@ -604,7 +607,7 @@ mod tests { fn cjk_string_in_concat() { let (expr, _) = parse_ok("CONCAT('你好', name)"); let doc = Value::from(serde_json::json!({"name": "world"})); - assert_eq!(expr.eval(&doc), Value::String("你好world".into())); + assert_eq!(expr.eval(&doc).unwrap(), Value::String("你好world".into())); } #[test] @@ -612,6 +615,6 @@ mod tests { let (expr, deps) = parse_ok("name != '禁止'"); assert_eq!(deps, vec!["name"]); let doc = Value::from(serde_json::json!({"name": "allowed"})); - assert_eq!(expr.eval(&doc), Value::Bool(true)); + assert_eq!(expr.eval(&doc).unwrap(), Value::Bool(true)); } } diff --git a/nodedb-query/src/functions/math.rs b/nodedb-query/src/functions/math.rs index 0f020b96c..72644ca66 100644 --- a/nodedb-query/src/functions/math.rs +++ b/nodedb-query/src/functions/math.rs @@ -3,15 +3,25 @@ //! Math scalar functions. use super::shared::num_arg; +use crate::expr::EvalError; use crate::value_ops::to_value_number; use nodedb_types::Value; -pub(super) fn try_eval(name: &str, args: &[Value]) -> Option { +/// Dispatch a math scalar function call. +/// +/// Every function here returns `Ok(Value::Null)` on invalid/missing +/// arguments, matching the crate-wide NULL-propagation convention — except +/// `mod`'s zero-modulus arm, which returns `Err(EvalError::DivisionByZero)` +/// instead of folding to `NULL`. This is the one +/// function in the scalar-function table that can fail, so `try_eval` +/// carries a `Result` inside the `Option` rather than making every sibling +/// module in `functions/` fallible for a single arm. +pub(super) fn try_eval(name: &str, args: &[Value]) -> Option> { let v = match name { "abs" => num_arg(args, 0).map_or(Value::Null, |n| to_value_number(n.abs())), "round" => { let Some(n) = num_arg(args, 0) else { - return Some(Value::Null); + return Some(Ok(Value::Null)); }; let decimals = num_arg(args, 1).unwrap_or(0.0) as u32; let mode_str = super::shared::str_arg(args, 2).unwrap_or_default(); @@ -36,7 +46,7 @@ pub(super) fn try_eval(name: &str, args: &[Value]) -> Option { "floor" => num_arg(args, 0).map_or(Value::Null, |n| to_value_number(n.floor())), "power" | "pow" => { let Some(base) = num_arg(args, 0) else { - return Some(Value::Null); + return Some(Ok(Value::Null)); }; let exp = num_arg(args, 1).unwrap_or(1.0); to_value_number(base.powf(exp)) @@ -44,14 +54,15 @@ pub(super) fn try_eval(name: &str, args: &[Value]) -> Option { "sqrt" => num_arg(args, 0).map_or(Value::Null, |n| to_value_number(n.sqrt())), "mod" => { let Some(a) = num_arg(args, 0) else { - return Some(Value::Null); + return Some(Ok(Value::Null)); }; let b = num_arg(args, 1).unwrap_or(1.0); if b == 0.0 { - Value::Null - } else { - to_value_number(a % b) + // Zero modulus raises SQLSTATE 22012 instead of folding to + // NULL. + return Some(Err(EvalError::DivisionByZero)); } + to_value_number(a % b) } "sign" => num_arg(args, 0).map_or(Value::Null, |n| to_value_number(n.signum())), "log" | "ln" => num_arg(args, 0).map_or(Value::Null, |n| to_value_number(n.ln())), @@ -60,5 +71,5 @@ pub(super) fn try_eval(name: &str, args: &[Value]) -> Option { "exp" => num_arg(args, 0).map_or(Value::Null, |n| to_value_number(n.exp())), _ => return None, }; - Some(v) + Some(Ok(v)) } diff --git a/nodedb-query/src/functions/mod.rs b/nodedb-query/src/functions/mod.rs index d4e9441e0..855c69919 100644 --- a/nodedb-query/src/functions/mod.rs +++ b/nodedb-query/src/functions/mod.rs @@ -19,40 +19,52 @@ mod types; use nodedb_types::Value; +use crate::expr::EvalError; + /// Evaluate a scalar function call. -pub fn eval_function(name: &str, args: &[Value]) -> Value { +/// +/// Every function returns `Ok(Value::Null)` on invalid/missing arguments +/// (SQL NULL propagation semantics) — the sole exception is `mod`'s +/// zero-modulus arm (`math::try_eval`), which returns +/// `Err(EvalError::DivisionByZero)`. Every other +/// sibling module here stays `Option`-shaped internally; only the +/// `math` arm is threaded as `Option>` and the +/// rest are wrapped in `Ok` at this dispatch boundary, so a single +/// fallible arm doesn't force every scalar-function module to carry a +/// `Result` it can never actually produce. +pub fn eval_function(name: &str, args: &[Value]) -> Result { if let Some(v) = string::try_eval(name, args) { - return v; + return Ok(v); } - if let Some(v) = math::try_eval(name, args) { - return v; + if let Some(r) = math::try_eval(name, args) { + return r; } if let Some(v) = conditional::try_eval(name, args) { - return v; + return Ok(v); } if let Some(v) = id::try_eval(name, args) { - return v; + return Ok(v); } if let Some(v) = datetime::try_eval(name, args) { - return v; + return Ok(v); } if let Some(v) = json::try_eval(name, args) { - return v; + return Ok(v); } if let Some(v) = types::try_eval(name, args) { - return v; + return Ok(v); } if let Some(v) = array::try_eval(name, args) { - return v; + return Ok(v); } if let Some(v) = fts::try_eval_fts(name, args) { - return v; + return Ok(v); } if let Some(v) = system::try_eval(name, args) { - return v; + return Ok(v); } // Geo / Spatial functions — delegated to geo_functions module. - crate::geo_functions::eval_geo_function(name, args).unwrap_or(Value::Null) + Ok(crate::geo_functions::eval_geo_function(name, args).unwrap_or(Value::Null)) } #[cfg(test)] @@ -61,7 +73,13 @@ mod tests { use crate::expr::SqlExpr; fn eval_fn(name: &str, args: Vec) -> Value { - eval_function(name, &args) + eval_function(name, &args).unwrap() + } + + #[test] + fn mod_by_zero_errors() { + let err = eval_function("mod", &[Value::Integer(5), Value::Integer(0)]).unwrap_err(); + assert_eq!(err, EvalError::DivisionByZero); } #[test] @@ -119,7 +137,7 @@ mod tests { .into_iter() .collect(), ); - assert_eq!(expr.eval(&doc), Value::String("ALICE".into())); + assert_eq!(expr.eval(&doc).unwrap(), Value::String("ALICE".into())); } #[test] diff --git a/nodedb-query/src/lib.rs b/nodedb-query/src/lib.rs index fa299a764..85785b1f0 100644 --- a/nodedb-query/src/lib.rs +++ b/nodedb-query/src/lib.rs @@ -30,7 +30,7 @@ pub mod value_ops; pub mod window; pub use chunk_text::{ChunkError, ChunkStrategy, TextChunk, chunk_text}; -pub use expr::{BinaryOp, CastType, ComputedColumn, SqlExpr}; +pub use expr::{BinaryOp, CastType, ComputedColumn, EvalError, SqlExpr}; pub use fusion::{ DEFAULT_RRF_K, FusedResult, RankedResult, reciprocal_rank_fusion, reciprocal_rank_fusion_linear, reciprocal_rank_fusion_weighted, diff --git a/nodedb-query/src/msgpack_scan/aggregate.rs b/nodedb-query/src/msgpack_scan/aggregate.rs index 590b1aa4e..e101b3661 100644 --- a/nodedb-query/src/msgpack_scan/aggregate.rs +++ b/nodedb-query/src/msgpack_scan/aggregate.rs @@ -12,6 +12,7 @@ use std::collections::HashSet; use nodedb_types::Value; +use crate::expr::EvalError; use crate::msgpack_scan::compare::compare_field_bytes; use crate::msgpack_scan::field::extract_field; use crate::msgpack_scan::reader::{read_f64, read_null, read_str}; @@ -27,15 +28,18 @@ pub fn compute_aggregate_binary( field: &str, expr: Option<&crate::expr::SqlExpr>, docs: &[&[u8]], -) -> Value { - match op { +) -> Result { + Ok(match op { "count" => { if field == "*" && expr.is_none() { Value::Integer(docs.len() as i64) } else { let count = docs .iter() - .filter_map(|d| extract_as_value(d, field, expr)) + .map(|d| extract_as_value(d, field, expr)) + .collect::, _>>()? + .into_iter() + .flatten() .filter(|v| !v.is_null()) .count(); Value::Integer(count as i64) @@ -45,7 +49,10 @@ pub fn compute_aggregate_binary( "sum" => { let total: f64 = docs .iter() - .filter_map(|d| extract_f64_val(d, field, expr)) + .map(|d| extract_f64_val(d, field, expr)) + .collect::, _>>()? + .into_iter() + .flatten() .sum(); Value::Float(total) } @@ -53,7 +60,10 @@ pub fn compute_aggregate_binary( "avg" => { let (sum, count) = docs .iter() - .filter_map(|d| extract_f64_val(d, field, expr)) + .map(|d| extract_f64_val(d, field, expr)) + .collect::, _>>()? + .into_iter() + .flatten() .fold((0.0f64, 0u64), |(s, c), v| (s + v, c + 1)); if count == 0 { Value::Null @@ -62,13 +72,13 @@ pub fn compute_aggregate_binary( } } - "min" => find_minmax(docs, field, expr, false), - "max" => find_minmax(docs, field, expr, true), + "min" => find_minmax(docs, field, expr, false)?, + "max" => find_minmax(docs, field, expr, true)?, "count_distinct" => { let mut seen = HashSet::new(); for doc in docs { - if let Some(bytes) = extract_value_bytes(doc, field, expr) + if let Some(bytes) = extract_value_bytes(doc, field, expr)? && !value_bytes_are_null(&bytes) { seen.insert(bytes); @@ -78,19 +88,22 @@ pub fn compute_aggregate_binary( } "stddev" | "stddev_pop" => { - stat_aggregate(docs, field, expr, |variance, _n| variance.sqrt(), true) + stat_aggregate(docs, field, expr, |variance, _n| variance.sqrt(), true)? } - "stddev_samp" => stat_aggregate(docs, field, expr, |variance, _n| variance.sqrt(), false), + "stddev_samp" => stat_aggregate(docs, field, expr, |variance, _n| variance.sqrt(), false)?, - "variance" | "var_pop" => stat_aggregate(docs, field, expr, |variance, _n| variance, true), + "variance" | "var_pop" => stat_aggregate(docs, field, expr, |variance, _n| variance, true)?, - "var_samp" => stat_aggregate(docs, field, expr, |variance, _n| variance, false), + "var_samp" => stat_aggregate(docs, field, expr, |variance, _n| variance, false)?, "array_agg" => { let values: Vec = docs .iter() - .filter_map(|d| extract_as_value(d, field, expr)) + .map(|d| extract_as_value(d, field, expr)) + .collect::, _>>()? + .into_iter() + .flatten() .filter(|v| !v.is_null()) .collect(); Value::Array(values) @@ -103,7 +116,7 @@ pub fn compute_aggregate_binary( // When expr is present, evaluate once and derive both bytes and value // from the result to avoid double-decoding the document. if let Some(expr) = expr { - let Some(val) = eval_expr_on_doc(doc, expr) else { + let Some(val) = eval_expr_on_doc(doc, expr)? else { continue; }; if val.is_null() { @@ -113,7 +126,7 @@ pub fn compute_aggregate_binary( if seen_bytes.insert(bytes) { values.push(val); } - } else if let Some(bytes) = extract_value_bytes(doc, field, None) + } else if let Some(bytes) = extract_value_bytes(doc, field, None)? && !value_bytes_are_null(&bytes) && seen_bytes.insert(bytes) && let Some(v) = value_from_field(doc, field) @@ -127,7 +140,10 @@ pub fn compute_aggregate_binary( "string_agg" | "group_concat" => { let values: Vec = docs .iter() - .filter_map(|d| extract_str_val(d, field, expr)) + .map(|d| extract_str_val(d, field, expr)) + .collect::, _>>()? + .into_iter() + .flatten() .collect(); Value::String(values.join(",")) } @@ -135,7 +151,7 @@ pub fn compute_aggregate_binary( "approx_count_distinct" => { let mut hll = nodedb_types::approx::HyperLogLog::new(); for doc in docs { - if let Some(bytes) = extract_value_bytes(doc, field, expr) + if let Some(bytes) = extract_value_bytes(doc, field, expr)? && !value_bytes_are_null(&bytes) { // Hash the raw bytes for HLL. @@ -151,14 +167,14 @@ pub fn compute_aggregate_binary( let (pct, actual_field) = if let Some(idx) = field.find(':') { match field[..idx].parse::() { Ok(p) => (p, &field[idx + 1..]), - Err(_) => return Value::Null, // invalid quantile + Err(_) => return Ok(Value::Null), // invalid quantile } } else { (0.5, field) }; let mut digest = nodedb_types::approx::TDigest::new(); for doc in docs { - if let Some(v) = extract_f64_val(doc, actual_field, expr) { + if let Some(v) = extract_f64_val(doc, actual_field, expr)? { digest.add(v); } } @@ -175,14 +191,14 @@ pub fn compute_aggregate_binary( let (k, actual_field) = if let Some(idx) = field.find(':') { match field[..idx].parse::() { Ok(k) => (k, &field[idx + 1..]), - Err(_) => return Value::Null, // invalid k + Err(_) => return Ok(Value::Null), // invalid k } } else { (10, field) }; let mut ss = nodedb_types::approx::SpaceSaving::new(k); for doc in docs { - if let Some(bytes) = extract_value_bytes(doc, actual_field, expr) + if let Some(bytes) = extract_value_bytes(doc, actual_field, expr)? && !value_bytes_are_null(&bytes) { ss.add(hash_bytes(&bytes)); @@ -211,17 +227,20 @@ pub fn compute_aggregate_binary( let (pct, actual_field) = if let Some(idx) = field.find(':') { match field[..idx].parse::() { Ok(p) => (p, &field[idx + 1..]), - Err(_) => return Value::Null, // invalid quantile + Err(_) => return Ok(Value::Null), // invalid quantile } } else { (0.5, field) }; let mut values: Vec = docs .iter() - .filter_map(|d| extract_f64_val(d, actual_field, expr)) + .map(|d| extract_f64_val(d, actual_field, expr)) + .collect::, _>>()? + .into_iter() + .flatten() .collect(); if values.is_empty() { - return Value::Null; + return Ok(Value::Null); } values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); let idx = (pct * (values.len() - 1) as f64).clamp(0.0, (values.len() - 1) as f64); @@ -233,47 +252,68 @@ pub fn compute_aggregate_binary( } _ => Value::Null, - } + }) } // ── Internal helpers ─────────────────────────────────────────────────── /// Decode a msgpack document directly to `nodedb_types::Value` and evaluate /// the expression. No JSON intermediate — msgpack → Value → eval → Value. +/// +/// `Ok(None)` means the row is skipped (document could not be decoded); +/// `Err(EvalError::DivisionByZero)` means the expression divided/modded by +/// zero and must propagate as a statement failure rather than being folded +/// to `None`. #[inline] -fn eval_expr_on_doc(doc: &[u8], expr: &crate::expr::SqlExpr) -> Option { - let doc_val = nodedb_types::json_msgpack::value_from_msgpack(doc).ok()?; - Some(expr.eval(&doc_val)) +fn eval_expr_on_doc(doc: &[u8], expr: &crate::expr::SqlExpr) -> Result, EvalError> { + let Ok(doc_val) = nodedb_types::json_msgpack::value_from_msgpack(doc) else { + return Ok(None); + }; + Ok(Some(expr.eval(&doc_val)?)) } /// Extract a numeric value from a field or expression result. #[inline] -fn extract_f64_val(doc: &[u8], field: &str, expr: Option<&crate::expr::SqlExpr>) -> Option { +fn extract_f64_val( + doc: &[u8], + field: &str, + expr: Option<&crate::expr::SqlExpr>, +) -> Result, EvalError> { if let Some(expr) = expr { - return value_ops::value_to_f64(&eval_expr_on_doc(doc, expr)?, false); + return Ok(eval_expr_on_doc(doc, expr)?.and_then(|v| value_ops::value_to_f64(&v, false))); } - let (start, _end) = extract_field(doc, 0, field)?; - read_f64(doc, start) + let Some((start, _end)) = extract_field(doc, 0, field) else { + return Ok(None); + }; + Ok(read_f64(doc, start)) } /// Extract a string from a field or expression result. -fn extract_str_val(doc: &[u8], field: &str, expr: Option<&crate::expr::SqlExpr>) -> Option { +fn extract_str_val( + doc: &[u8], + field: &str, + expr: Option<&crate::expr::SqlExpr>, +) -> Result, EvalError> { if let Some(expr) = expr { - return Some(value_ops::value_to_display_string(&eval_expr_on_doc( - doc, expr, - )?)); + return Ok(eval_expr_on_doc(doc, expr)?.map(|v| value_ops::value_to_display_string(&v))); } - let (start, _end) = extract_field(doc, 0, field)?; - read_str(doc, start).map(|s| s.to_string()) + let Some((start, _end)) = extract_field(doc, 0, field) else { + return Ok(None); + }; + Ok(read_str(doc, start).map(|s| s.to_string())) } /// Extract a field as `Value`. Uses direct msgpack→Value for scalars; /// falls back to full decode only for complex types. -fn extract_as_value(doc: &[u8], field: &str, expr: Option<&crate::expr::SqlExpr>) -> Option { +fn extract_as_value( + doc: &[u8], + field: &str, + expr: Option<&crate::expr::SqlExpr>, +) -> Result, EvalError> { if let Some(expr) = expr { return eval_expr_on_doc(doc, expr); } - value_from_field(doc, field) + Ok(value_from_field(doc, field)) } #[inline] @@ -294,13 +334,13 @@ fn find_minmax( field: &str, expr: Option<&crate::expr::SqlExpr>, want_max: bool, -) -> Value { +) -> Result { if let Some(expr) = expr { // Evaluate expression once per doc; compare on Value // since the result may be any type (not a raw field). let mut best: Option = None; for doc in docs { - let Some(value) = eval_expr_on_doc(doc, expr) else { + let Some(value) = eval_expr_on_doc(doc, expr)? else { continue; }; if value.is_null() { @@ -321,7 +361,7 @@ fn find_minmax( best = Some(value); } } - return best.unwrap_or(Value::Null); + return Ok(best.unwrap_or(Value::Null)); } let mut best_doc: Option<&[u8]> = None; @@ -354,16 +394,16 @@ fn find_minmax( } } - match (best_doc, best_range) { + Ok(match (best_doc, best_range) { (Some(doc), Some((start, end))) => { if let Some(v) = crate::msgpack_scan::reader::read_value(doc, start) { - return v; + return Ok(v); } let bytes = &doc[start..end]; nodedb_types::json_msgpack::value_from_msgpack(bytes).unwrap_or(Value::Null) } _ => Value::Null, - } + }) } /// Compute stddev or variance. `population` = true for population variant. @@ -374,13 +414,16 @@ fn stat_aggregate( expr: Option<&crate::expr::SqlExpr>, finalize: fn(f64, usize) -> f64, population: bool, -) -> Value { +) -> Result { let values: Vec = docs .iter() - .filter_map(|d| extract_f64_val(d, field, expr)) + .map(|d| extract_f64_val(d, field, expr)) + .collect::, _>>()? + .into_iter() + .flatten() .collect(); if values.len() < 2 { - return Value::Null; + return Ok(Value::Null); } let mean = values.iter().sum::() / values.len() as f64; let divisor = if population { @@ -389,20 +432,24 @@ fn stat_aggregate( (values.len() - 1) as f64 }; let variance = values.iter().map(|v| (v - mean).powi(2)).sum::() / divisor; - Value::Float(finalize(variance, values.len())) + Ok(Value::Float(finalize(variance, values.len()))) } fn extract_value_bytes( doc: &[u8], field: &str, expr: Option<&crate::expr::SqlExpr>, -) -> Option> { +) -> Result>, EvalError> { if let Some(expr) = expr { - let val = eval_expr_on_doc(doc, expr)?; - return nodedb_types::json_msgpack::value_to_msgpack(&val).ok(); + let Some(val) = eval_expr_on_doc(doc, expr)? else { + return Ok(None); + }; + return Ok(nodedb_types::json_msgpack::value_to_msgpack(&val).ok()); } - let (start, end) = extract_field(doc, 0, field)?; - Some(doc[start..end].to_vec()) + let Some((start, end)) = extract_field(doc, 0, field) else { + return Ok(None); + }; + Ok(Some(doc[start..end].to_vec())) } /// Check if msgpack bytes represent null. Msgpack null is the single byte 0xc0. @@ -436,7 +483,7 @@ mod tests { let d3 = encode(&json!({"x": 3})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; assert_eq!( - compute_aggregate_binary("count", "x", None, &docs), + compute_aggregate_binary("count", "x", None, &docs).unwrap(), Value::Integer(3) ); } @@ -448,7 +495,7 @@ mod tests { let d3 = encode(&json!({"v": 30})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; assert_eq!( - compute_aggregate_binary("sum", "v", None, &docs), + compute_aggregate_binary("sum", "v", None, &docs).unwrap(), Value::Float(60.0) ); } @@ -459,7 +506,7 @@ mod tests { let d2 = encode(&json!({"v": 20})); let docs: Vec<&[u8]> = vec![&d1, &d2]; assert_eq!( - compute_aggregate_binary("avg", "v", None, &docs), + compute_aggregate_binary("avg", "v", None, &docs).unwrap(), Value::Float(15.0) ); } @@ -469,7 +516,7 @@ mod tests { let d1 = encode(&json!({"other": 1})); let docs: Vec<&[u8]> = vec![&d1]; assert_eq!( - compute_aggregate_binary("avg", "v", None, &docs), + compute_aggregate_binary("avg", "v", None, &docs).unwrap(), Value::Null ); } @@ -481,8 +528,8 @@ mod tests { let d3 = encode(&json!({"v": 9})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; - let min = compute_aggregate_binary("min", "v", None, &docs); - let max = compute_aggregate_binary("max", "v", None, &docs); + let min = compute_aggregate_binary("min", "v", None, &docs).unwrap(); + let max = compute_aggregate_binary("max", "v", None, &docs).unwrap(); assert_eq!(min, Value::Integer(1)); assert_eq!(max, Value::Integer(9)); } @@ -494,7 +541,7 @@ mod tests { let d3 = encode(&json!({"v": "a"})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; assert_eq!( - compute_aggregate_binary("count_distinct", "v", None, &docs), + compute_aggregate_binary("count_distinct", "v", None, &docs).unwrap(), Value::Integer(2) ); } @@ -505,7 +552,7 @@ mod tests { let d2 = encode(&json!({"n": "bob"})); let docs: Vec<&[u8]> = vec![&d1, &d2]; assert_eq!( - compute_aggregate_binary("string_agg", "n", None, &docs), + compute_aggregate_binary("string_agg", "n", None, &docs).unwrap(), Value::String("alice,bob".into()) ); } @@ -515,7 +562,7 @@ mod tests { let d1 = encode(&json!({"v": 1})); let d2 = encode(&json!({"v": 2})); let docs: Vec<&[u8]> = vec![&d1, &d2]; - let result = compute_aggregate_binary("array_agg", "v", None, &docs); + let result = compute_aggregate_binary("array_agg", "v", None, &docs).unwrap(); assert_eq!( result, Value::Array(vec![Value::Integer(1), Value::Integer(2),]) @@ -533,7 +580,7 @@ mod tests { let d7 = encode(&json!({"v": 7.0})); let d8 = encode(&json!({"v": 9.0})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3, &d4, &d5, &d6, &d7, &d8]; - let result = compute_aggregate_binary("stddev_pop", "v", None, &docs); + let result = compute_aggregate_binary("stddev_pop", "v", None, &docs).unwrap(); if let Value::Float(v) = result { assert!((v - 2.0).abs() < 0.01); } else { @@ -548,7 +595,7 @@ mod tests { let d3 = encode(&json!({"v": 3.0})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; assert_eq!( - compute_aggregate_binary("percentile_cont", "v", None, &docs), + compute_aggregate_binary("percentile_cont", "v", None, &docs).unwrap(), Value::Float(2.0) ); } @@ -560,7 +607,7 @@ mod tests { let d3 = encode(&json!({"v": 30})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; assert_eq!( - compute_aggregate_binary("sum", "v", None, &docs), + compute_aggregate_binary("sum", "v", None, &docs).unwrap(), Value::Float(40.0) ); } @@ -572,7 +619,7 @@ mod tests { let d3 = encode(&json!({"v": "a"})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; assert_eq!( - compute_aggregate_binary("count_distinct", "v", None, &docs), + compute_aggregate_binary("count_distinct", "v", None, &docs).unwrap(), Value::Integer(1) ); } @@ -583,7 +630,7 @@ mod tests { let d2 = encode(&json!({"v": 2})); let d3 = encode(&json!({"v": 1})); let docs: Vec<&[u8]> = vec![&d1, &d2, &d3]; - let result = compute_aggregate_binary("array_agg_distinct", "v", None, &docs); + let result = compute_aggregate_binary("array_agg_distinct", "v", None, &docs).unwrap(); assert_eq!( result, Value::Array(vec![Value::Integer(1), Value::Integer(2),]) @@ -610,7 +657,7 @@ mod tests { }; assert_eq!( - compute_aggregate_binary("sum", "*", Some(&expr), &docs), + compute_aggregate_binary("sum", "*", Some(&expr), &docs).unwrap(), Value::Float(2.0) ); } @@ -624,7 +671,8 @@ mod tests { encode(&json!({"region": "ap"})), ]; let refs: Vec<&[u8]> = docs.iter().map(|d| d.as_slice()).collect(); - let result = compute_aggregate_binary("approx_count_distinct", "region", None, &refs); + let result = + compute_aggregate_binary("approx_count_distinct", "region", None, &refs).unwrap(); // HLL may not be exactly 3 but should be close. if let Value::Integer(n) = result { assert!((2..=4).contains(&n), "expected ~3 distinct, got {n}"); @@ -637,7 +685,7 @@ mod tests { fn approx_percentile_basic() { let docs: Vec> = (1..=100).map(|i| encode(&json!({"val": i}))).collect(); let refs: Vec<&[u8]> = docs.iter().map(|d| d.as_slice()).collect(); - let result = compute_aggregate_binary("approx_percentile", "0.5:val", None, &refs); + let result = compute_aggregate_binary("approx_percentile", "0.5:val", None, &refs).unwrap(); if let Value::Float(f) = result { assert!( (f - 50.0).abs() < 10.0, @@ -661,11 +709,27 @@ mod tests { docs.push(encode(&json!({"cat": "c"}))); } let refs: Vec<&[u8]> = docs.iter().map(|d| d.as_slice()).collect(); - let result = compute_aggregate_binary("approx_topk", "3:cat", None, &refs); + let result = compute_aggregate_binary("approx_topk", "3:cat", None, &refs).unwrap(); if let Value::Array(arr) = result { assert!(!arr.is_empty(), "should have top-k results"); } else { panic!("expected Array, got {result:?}"); } } + + #[test] + fn division_by_zero_in_expr_propagates() { + // `SUM(a / b)` over a document with `b = 0` must surface + // `EvalError::DivisionByZero` rather than folding the offending row + // to NULL and silently continuing the aggregate. + let d1 = encode(&json!({"a": 10, "b": 0})); + let docs: Vec<&[u8]> = vec![&d1]; + let expr = crate::expr::SqlExpr::BinaryOp { + left: Box::new(crate::expr::SqlExpr::Column("a".into())), + op: crate::expr::BinaryOp::Div, + right: Box::new(crate::expr::SqlExpr::Column("b".into())), + }; + let err = compute_aggregate_binary("sum", "*", Some(&expr), &docs).unwrap_err(); + assert_eq!(err, EvalError::DivisionByZero); + } } diff --git a/nodedb-query/src/msgpack_scan/aggregate_helpers.rs b/nodedb-query/src/msgpack_scan/aggregate_helpers.rs index 112642506..3d82f37c6 100644 --- a/nodedb-query/src/msgpack_scan/aggregate_helpers.rs +++ b/nodedb-query/src/msgpack_scan/aggregate_helpers.rs @@ -10,71 +10,117 @@ use nodedb_types::Value; -use crate::expr::SqlExpr; +use crate::expr::{EvalError, SqlExpr}; use crate::msgpack_scan::field::extract_field; use crate::msgpack_scan::reader::{read_f64, read_str, read_value}; use crate::value_ops; // ── Expression evaluator ─────────────────────────────────────────────────── +/// Evaluate `expr` against a decoded document. +/// +/// The `Result, EvalError>` shape distinguishes the two ways a +/// per-row aggregate argument can decline to contribute a value: +/// - `Ok(None)` — the row is *skipped* (document could not be decoded, or the +/// expression legitimately yields no value). Matches SQL aggregate semantics +/// where a NULL/absent argument is excluded from the accumulation. +/// - `Err(EvalError::DivisionByZero)` — the expression divided or took a +/// modulus by zero. This is a statement failure that +/// surfaces as SQLSTATE `22012`, exactly like a WHERE/projection expression, +/// rather than silently dropping the row from the aggregate. #[inline] -fn eval_expr(doc: &[u8], expr: &SqlExpr) -> Option { - let doc_val = nodedb_types::json_msgpack::value_from_msgpack(doc).ok()?; - Some(expr.eval(&doc_val)) +fn eval_expr(doc: &[u8], expr: &SqlExpr) -> Result, EvalError> { + let Ok(doc_val) = nodedb_types::json_msgpack::value_from_msgpack(doc) else { + return Ok(None); + }; + Ok(Some(expr.eval(&doc_val)?)) } // ── Public extraction helpers ────────────────────────────────────────────── /// Extract a numeric (f64) value from `field`, or evaluate `expr` if provided. -/// Returns `None` when the field is absent or cannot be converted to f64. +/// Returns `Ok(None)` when the field is absent or cannot be converted to f64, +/// and `Err(EvalError::DivisionByZero)` when `expr` divides/mods by zero. #[inline] -pub fn extract_f64(doc: &[u8], field: &str, expr: Option<&SqlExpr>) -> Option { +pub fn extract_f64( + doc: &[u8], + field: &str, + expr: Option<&SqlExpr>, +) -> Result, EvalError> { if let Some(expr) = expr { - return value_ops::value_to_f64(&eval_expr(doc, expr)?, false); + return Ok(eval_expr(doc, expr)?.and_then(|v| value_ops::value_to_f64(&v, false))); } - let (start, _end) = extract_field(doc, 0, field)?; - read_f64(doc, start) + let Some((start, _end)) = extract_field(doc, 0, field) else { + return Ok(None); + }; + Ok(read_f64(doc, start)) } /// Extract a display string from `field`, or evaluate `expr` if provided. -/// Returns `None` when the field is absent. -pub fn extract_str(doc: &[u8], field: &str, expr: Option<&SqlExpr>) -> Option { +/// Returns `Ok(None)` when the field is absent. +pub fn extract_str( + doc: &[u8], + field: &str, + expr: Option<&SqlExpr>, +) -> Result, EvalError> { if let Some(expr) = expr { - return Some(value_ops::value_to_display_string(&eval_expr(doc, expr)?)); + return Ok(eval_expr(doc, expr)?.map(|v| value_ops::value_to_display_string(&v))); } - let (start, _end) = extract_field(doc, 0, field)?; - read_str(doc, start).map(|s| s.to_string()) + let Some((start, _end)) = extract_field(doc, 0, field) else { + return Ok(None); + }; + Ok(read_str(doc, start).map(|s| s.to_string())) } /// Extract a field as `Value`. Uses direct msgpack→Value for scalars; /// falls back to full document decode only for complex types. -pub fn extract_value(doc: &[u8], field: &str, expr: Option<&SqlExpr>) -> Option { +pub fn extract_value( + doc: &[u8], + field: &str, + expr: Option<&SqlExpr>, +) -> Result, EvalError> { if let Some(expr) = expr { return eval_expr(doc, expr); } - let (start, end) = extract_field(doc, 0, field)?; + let Some((start, end)) = extract_field(doc, 0, field) else { + return Ok(None); + }; if let Some(v) = read_value(doc, start) { - return Some(v); + return Ok(Some(v)); } let field_bytes = &doc[start..end]; - nodedb_types::json_msgpack::value_from_msgpack(field_bytes).ok() + Ok(nodedb_types::json_msgpack::value_from_msgpack(field_bytes).ok()) } /// Extract a field or expression result as raw msgpack bytes. /// Used by `count_distinct`, `approx_count_distinct`, `approx_topk`, etc. -pub fn extract_bytes(doc: &[u8], field: &str, expr: Option<&SqlExpr>) -> Option> { +pub fn extract_bytes( + doc: &[u8], + field: &str, + expr: Option<&SqlExpr>, +) -> Result>, EvalError> { if let Some(expr) = expr { - let val = eval_expr(doc, expr)?; - return nodedb_types::json_msgpack::value_to_msgpack(&val).ok(); + let Some(val) = eval_expr(doc, expr)? else { + return Ok(None); + }; + return Ok(nodedb_types::json_msgpack::value_to_msgpack(&val).ok()); } - let (start, end) = extract_field(doc, 0, field)?; - Some(doc[start..end].to_vec()) + let Some((start, end)) = extract_field(doc, 0, field) else { + return Ok(None); + }; + Ok(Some(doc[start..end].to_vec())) } -/// Returns `Some(())` when the field is present and non-null. +/// Returns `Ok(Some(()))` when the field is present and non-null. /// Used by `count(field)` accumulator to count non-null values. #[inline] -pub fn extract_non_null(doc: &[u8], field: &str, expr: Option<&SqlExpr>) -> Option<()> { - let v = extract_value(doc, field, expr)?; - if v.is_null() { None } else { Some(()) } +pub fn extract_non_null( + doc: &[u8], + field: &str, + expr: Option<&SqlExpr>, +) -> Result, EvalError> { + let Some(v) = extract_value(doc, field, expr)? else { + return Ok(None); + }; + Ok(if v.is_null() { None } else { Some(()) }) } diff --git a/nodedb-query/src/msgpack_scan/filter.rs b/nodedb-query/src/msgpack_scan/filter.rs index 3c03e0d05..45a4547bc 100644 --- a/nodedb-query/src/msgpack_scan/filter.rs +++ b/nodedb-query/src/msgpack_scan/filter.rs @@ -9,6 +9,7 @@ use std::cmp::Ordering; +use crate::expr::EvalError; use crate::msgpack_scan::field::extract_field; use crate::msgpack_scan::index::FieldIndex; use crate::msgpack_scan::reader::{ @@ -17,23 +18,65 @@ use crate::msgpack_scan::reader::{ use crate::scan_filter::like::sql_like_match; use crate::scan_filter::{FilterOp, ScanFilter}; +impl ScanFilter { + /// Evaluate an AND-group of filters, short-circuiting on the first + /// `false` or the first evaluation error — same semantics as + /// `group.iter().all(|f| f.matches_binary(doc))` had before filter + /// evaluation could fail. A `pub` associated + /// function (rather than a private free function) so the many call + /// sites across the `nodedb` crate that used to write + /// `filters.iter().all(|f| f.matches_binary(doc))` have a single + /// drop-in replacement instead of each hand-rolling the same + /// short-circuit loop. + pub fn all_match_binary(group: &[ScanFilter], doc: &[u8]) -> Result { + for f in group { + if !f.matches_binary(doc)? { + return Ok(false); + } + } + Ok(true) + } + + /// Indexed counterpart of [`ScanFilter::all_match_binary`]. + pub fn all_match_binary_indexed( + group: &[ScanFilter], + doc: &[u8], + idx: &FieldIndex, + ) -> Result { + for f in group { + if !f.matches_binary_indexed(doc, idx)? { + return Ok(false); + } + } + Ok(true) + } +} + impl ScanFilter { /// Evaluate this filter against a raw MessagePack document. /// /// Zero deserialization — extracts only the needed field bytes. - pub fn matches_binary(&self, doc: &[u8]) -> bool { + /// + /// Returns `Err(EvalError::DivisionByZero)` when this is (or contains, + /// via an `OR` group) a `FilterOp::Expr` predicate that divides or + /// takes a modulus by zero — see + /// `scan_filter::ScanFilter::matches_value` for the WHERE-clause + /// behavior-flip rationale, which applies identically here. + pub fn matches_binary(&self, doc: &[u8]) -> Result { match self.op { - FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return true, + FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return Ok(true), FilterOp::Or => { - return self - .clauses - .iter() - .any(|clause| clause.iter().all(|f| f.matches_binary(doc))); + for clause in &self.clauses { + if Self::all_match_binary(clause, doc)? { + return Ok(true); + } + } + return Ok(false); } FilterOp::Expr => { return match (self.expr.as_ref(), nodedb_types::value_from_msgpack(doc)) { - (Some(expr), Ok(value)) => crate::value_ops::is_truthy(&expr.eval(&value)), - _ => false, + (Some(expr), Ok(value)) => Ok(crate::value_ops::is_truthy(&expr.eval(&value)?)), + _ => Ok(false), }; } _ => {} @@ -46,30 +89,32 @@ impl ScanFilter { let suffix = format!(".{}", self.field); match find_field_by_suffix(doc, &suffix) { Some(r) => r, - None => return self.op == FilterOp::IsNull, + None => return Ok(self.op == FilterOp::IsNull), } } }; - eval_op(self, doc, start, end) + Ok(eval_op(self, doc, start, end)) } /// Evaluate using a pre-built `FieldIndex` for O(1) field lookup. /// /// Use when evaluating multiple predicates on the same document. - pub fn matches_binary_indexed(&self, doc: &[u8], idx: &FieldIndex) -> bool { + pub fn matches_binary_indexed(&self, doc: &[u8], idx: &FieldIndex) -> Result { match self.op { - FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return true, + FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return Ok(true), FilterOp::Or => { - return self - .clauses - .iter() - .any(|clause| clause.iter().all(|f| f.matches_binary_indexed(doc, idx))); + for clause in &self.clauses { + if Self::all_match_binary_indexed(clause, doc, idx)? { + return Ok(true); + } + } + return Ok(false); } FilterOp::Expr => { return match (self.expr.as_ref(), nodedb_types::value_from_msgpack(doc)) { - (Some(expr), Ok(value)) => crate::value_ops::is_truthy(&expr.eval(&value)), - _ => false, + (Some(expr), Ok(value)) => Ok(crate::value_ops::is_truthy(&expr.eval(&value)?)), + _ => Ok(false), }; } _ => {} @@ -77,10 +122,10 @@ impl ScanFilter { let (start, end) = match idx.get(&self.field) { Some(r) => r, - None => return self.op == FilterOp::IsNull, + None => return Ok(self.op == FilterOp::IsNull), }; - eval_op(self, doc, start, end) + Ok(eval_op(self, doc, start, end)) } } @@ -284,79 +329,161 @@ mod tests { #[test] fn eq_integer() { let doc = encode(&json!({"age": 25})); - assert!(filter("age", "eq", nodedb_types::Value::Integer(25)).matches_binary(&doc)); - assert!(!filter("age", "eq", nodedb_types::Value::Integer(30)).matches_binary(&doc)); + assert!( + filter("age", "eq", nodedb_types::Value::Integer(25)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + !filter("age", "eq", nodedb_types::Value::Integer(30)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn eq_coerces_string_to_integer() { let doc = encode(&json!({"age": 25})); - assert!(filter("age", "eq", nodedb_types::Value::String("25".into())).matches_binary(&doc)); + assert!( + filter("age", "eq", nodedb_types::Value::String("25".into())) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn gt_coerces_string_to_integer() { let doc = encode(&json!({"score": "90"})); - assert!(filter("score", "gt", nodedb_types::Value::Integer(80)).matches_binary(&doc)); + assert!( + filter("score", "gt", nodedb_types::Value::Integer(80)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn eq_string() { let doc = encode(&json!({"name": "alice"})); assert!( - filter("name", "eq", nodedb_types::Value::String("alice".into())).matches_binary(&doc) + filter("name", "eq", nodedb_types::Value::String("alice".into())) + .matches_binary(&doc) + .unwrap() ); } #[test] fn eq_coercion_int_vs_string() { let doc = encode(&json!({"age": 25})); - assert!(filter("age", "eq", nodedb_types::Value::String("25".into())).matches_binary(&doc)); + assert!( + filter("age", "eq", nodedb_types::Value::String("25".into())) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn eq_coercion_string_vs_int() { let doc = encode(&json!({"score": "90"})); - assert!(filter("score", "eq", nodedb_types::Value::Integer(90)).matches_binary(&doc)); + assert!( + filter("score", "eq", nodedb_types::Value::Integer(90)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn ne() { let doc = encode(&json!({"x": 1})); - assert!(filter("x", "ne", nodedb_types::Value::Integer(2)).matches_binary(&doc)); - assert!(!filter("x", "ne", nodedb_types::Value::Integer(1)).matches_binary(&doc)); + assert!( + filter("x", "ne", nodedb_types::Value::Integer(2)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + !filter("x", "ne", nodedb_types::Value::Integer(1)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn gt_lt() { let doc = encode(&json!({"v": 10})); - assert!(filter("v", "gt", nodedb_types::Value::Integer(5)).matches_binary(&doc)); - assert!(!filter("v", "gt", nodedb_types::Value::Integer(15)).matches_binary(&doc)); - assert!(filter("v", "lt", nodedb_types::Value::Integer(15)).matches_binary(&doc)); - assert!(!filter("v", "lt", nodedb_types::Value::Integer(5)).matches_binary(&doc)); + assert!( + filter("v", "gt", nodedb_types::Value::Integer(5)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + !filter("v", "gt", nodedb_types::Value::Integer(15)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + filter("v", "lt", nodedb_types::Value::Integer(15)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + !filter("v", "lt", nodedb_types::Value::Integer(5)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn gte_lte() { let doc = encode(&json!({"v": 10})); - assert!(filter("v", "gte", nodedb_types::Value::Integer(10)).matches_binary(&doc)); - assert!(filter("v", "gte", nodedb_types::Value::Integer(5)).matches_binary(&doc)); - assert!(!filter("v", "gte", nodedb_types::Value::Integer(15)).matches_binary(&doc)); - assert!(filter("v", "lte", nodedb_types::Value::Integer(10)).matches_binary(&doc)); + assert!( + filter("v", "gte", nodedb_types::Value::Integer(10)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + filter("v", "gte", nodedb_types::Value::Integer(5)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + !filter("v", "gte", nodedb_types::Value::Integer(15)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + filter("v", "lte", nodedb_types::Value::Integer(10)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn is_null_not_null() { let doc = encode(&json!({"a": null, "b": 1})); - assert!(filter("a", "is_null", nodedb_types::Value::Null).matches_binary(&doc)); - assert!(!filter("b", "is_null", nodedb_types::Value::Null).matches_binary(&doc)); - assert!(filter("b", "is_not_null", nodedb_types::Value::Null).matches_binary(&doc)); + assert!( + filter("a", "is_null", nodedb_types::Value::Null) + .matches_binary(&doc) + .unwrap() + ); + assert!( + !filter("b", "is_null", nodedb_types::Value::Null) + .matches_binary(&doc) + .unwrap() + ); + assert!( + filter("b", "is_not_null", nodedb_types::Value::Null) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn missing_field_is_null() { let doc = encode(&json!({"x": 1})); - assert!(filter("missing", "is_null", nodedb_types::Value::Null).matches_binary(&doc)); + assert!( + filter("missing", "is_null", nodedb_types::Value::Null) + .matches_binary(&doc) + .unwrap() + ); } #[test] @@ -369,6 +496,7 @@ mod tests { nodedb_types::Value::String("world".into()) ) .matches_binary(&doc) + .unwrap() ); } @@ -376,15 +504,19 @@ mod tests { fn like_ilike() { let doc = encode(&json!({"name": "Alice"})); assert!( - filter("name", "like", nodedb_types::Value::String("Ali%".into())).matches_binary(&doc) + filter("name", "like", nodedb_types::Value::String("Ali%".into())) + .matches_binary(&doc) + .unwrap() ); assert!( !filter("name", "like", nodedb_types::Value::String("ali%".into())) .matches_binary(&doc) + .unwrap() ); assert!( filter("name", "ilike", nodedb_types::Value::String("ali%".into())) .matches_binary(&doc) + .unwrap() ); assert!( filter( @@ -393,6 +525,7 @@ mod tests { nodedb_types::Value::String("Bob%".into()) ) .matches_binary(&doc) + .unwrap() ); } @@ -412,6 +545,7 @@ mod tests { expr: None } .matches_binary(&doc) + .unwrap() ); let doc2 = encode(&json!({"status": "deleted"})); @@ -424,6 +558,7 @@ mod tests { expr: None } .matches_binary(&doc2) + .unwrap() ); } @@ -437,6 +572,7 @@ mod tests { nodedb_types::Value::String("rust".into()) ) .matches_binary(&doc) + .unwrap() ); assert!( !filter( @@ -445,6 +581,7 @@ mod tests { nodedb_types::Value::String("slow".into()) ) .matches_binary(&doc) + .unwrap() ); } @@ -464,6 +601,7 @@ mod tests { expr: None } .matches_binary(&doc) + .unwrap() ); } @@ -483,6 +621,7 @@ mod tests { expr: None } .matches_binary(&doc) + .unwrap() ); } @@ -499,33 +638,57 @@ mod tests { ], expr: None, }; - assert!(f.matches_binary(&doc)); + assert!(f.matches_binary(&doc).unwrap()); } #[test] fn match_all() { let doc = encode(&json!({"any": "thing"})); - assert!(filter("", "match_all", nodedb_types::Value::Null).matches_binary(&doc)); + assert!( + filter("", "match_all", nodedb_types::Value::Null) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn float_comparison() { let doc = encode(&json!({"temp": 36.6})); - assert!(filter("temp", "gt", nodedb_types::Value::Float(30.0)).matches_binary(&doc)); - assert!(filter("temp", "lt", nodedb_types::Value::Float(40.0)).matches_binary(&doc)); + assert!( + filter("temp", "gt", nodedb_types::Value::Float(30.0)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + filter("temp", "lt", nodedb_types::Value::Float(40.0)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn bool_eq() { let doc = encode(&json!({"active": true})); - assert!(filter("active", "eq", nodedb_types::Value::Bool(true)).matches_binary(&doc)); - assert!(!filter("active", "eq", nodedb_types::Value::Bool(false)).matches_binary(&doc)); + assert!( + filter("active", "eq", nodedb_types::Value::Bool(true)) + .matches_binary(&doc) + .unwrap() + ); + assert!( + !filter("active", "eq", nodedb_types::Value::Bool(false)) + .matches_binary(&doc) + .unwrap() + ); } #[test] fn gt_coercion_string_field() { let doc = encode(&json!({"score": "90"})); - assert!(filter("score", "gt", nodedb_types::Value::Integer(80)).matches_binary(&doc)); + assert!( + filter("score", "gt", nodedb_types::Value::Integer(80)) + .matches_binary(&doc) + .unwrap() + ); } #[test] @@ -538,6 +701,7 @@ mod tests { nodedb_types::Value::String("2026-07-02 14:00:00".into()) ) .matches_binary(&doc) + .unwrap() ); assert!( filter( @@ -546,6 +710,7 @@ mod tests { nodedb_types::Value::String("2026-07-02 14:00:00".into()) ) .matches_binary(&doc) + .unwrap() ); assert!( filter( @@ -554,6 +719,7 @@ mod tests { nodedb_types::Value::String("2026-07-02 12:00:00".into()) ) .matches_binary(&doc) + .unwrap() ); assert!( filter( @@ -562,6 +728,7 @@ mod tests { nodedb_types::Value::String("2026-07-02 13:00:00".into()) ) .matches_binary(&doc) + .unwrap() ); } @@ -582,8 +749,8 @@ mod tests { for f in &filters { assert_eq!( - f.matches_binary(&doc), - f.matches_binary_indexed(&doc, &idx), + f.matches_binary(&doc).unwrap(), + f.matches_binary_indexed(&doc, &idx).unwrap(), "mismatch for field={} op={:?}", f.field, f.op diff --git a/nodedb-query/src/msgpack_scan/group_key.rs b/nodedb-query/src/msgpack_scan/group_key.rs index 632e3d256..e3af3408c 100644 --- a/nodedb-query/src/msgpack_scan/group_key.rs +++ b/nodedb-query/src/msgpack_scan/group_key.rs @@ -5,7 +5,7 @@ //! Builds a deterministic string key from field values extracted directly //! from msgpack bytes, avoiding full document decode. -use crate::expr::{GroupKeySpec, SqlExpr}; +use crate::expr::{EvalError, GroupKeySpec, SqlExpr}; use crate::msgpack_scan::field::extract_field; use crate::msgpack_scan::index::FieldIndex; use crate::msgpack_scan::reader::{read_f64, read_i64, read_null, read_str}; @@ -23,9 +23,15 @@ use crate::msgpack_scan::reader::{read_f64, read_i64, read_null, read_str}; /// document bytes, so the same spec on the same bytes yields a byte-identical /// slot on producer and consumer. A spec with neither `field` nor `expr` /// contributes no slot. -pub fn build_group_key(doc: &[u8], group_keys: &[GroupKeySpec]) -> String { +/// +/// Returns `Err(EvalError::DivisionByZero)` when a computed key expression +/// divides or takes a modulus by zero: `GROUP BY a/b` with +/// `b = 0` fails the whole statement with SQLSTATE `22012`, exactly like a +/// WHERE/projection expression, instead of silently grouping the row under a +/// `null` key. +pub fn build_group_key(doc: &[u8], group_keys: &[GroupKeySpec]) -> Result { if group_keys.is_empty() { - return "__all__".to_string(); + return Ok("__all__".to_string()); } let mut key_buf = String::new(); @@ -42,22 +48,24 @@ pub fn build_group_key(doc: &[u8], group_keys: &[GroupKeySpec]) -> String { if written > 0 { key_buf.push(','); } - append_computed_value(&mut key_buf, doc, expr); + append_computed_value(&mut key_buf, doc, expr)?; written += 1; } } key_buf.push(']'); - key_buf + Ok(key_buf) } /// Build a GROUP BY key using a pre-built `FieldIndex` for O(1) lookups. +/// +/// Shares [`build_group_key`]'s computed-key error contract. pub fn build_group_key_indexed( doc: &[u8], group_keys: &[GroupKeySpec], idx: &FieldIndex, -) -> String { +) -> Result { if group_keys.is_empty() { - return "__all__".to_string(); + return Ok("__all__".to_string()); } let mut key_buf = String::new(); @@ -77,12 +85,12 @@ pub fn build_group_key_indexed( if written > 0 { key_buf.push(','); } - append_computed_value(&mut key_buf, doc, expr); + append_computed_value(&mut key_buf, doc, expr)?; written += 1; } } key_buf.push(']'); - key_buf + Ok(key_buf) } /// Append the msgpack value at `doc[start..end]` to the key buffer as a JSON @@ -131,16 +139,22 @@ fn append_field_value_range(buf: &mut String, doc: &[u8], range: Option<(usize, /// slot is byte-identical wherever the same expression meets the same document. /// A document that cannot be decoded, or a value that cannot be re-encoded, /// contributes `null` — mirroring the missing-field handling above. -fn append_computed_value(buf: &mut String, doc: &[u8], expr: &SqlExpr) { +/// +/// An expression that fails to evaluate (division/modulo by zero) +/// returns `Err(EvalError::DivisionByZero)`: a computed GROUP BY +/// key gets the same `22012` statement-failure treatment as a WHERE/projection +/// expression rather than silently grouping the row under a `null` key. +fn append_computed_value(buf: &mut String, doc: &[u8], expr: &SqlExpr) -> Result<(), EvalError> { let Ok(doc_val) = nodedb_types::json_msgpack::value_from_msgpack(doc) else { buf.push_str("null"); - return; + return Ok(()); }; - let val = expr.eval(&doc_val); + let val = expr.eval(&doc_val)?; match nodedb_types::json_msgpack::value_to_msgpack(&val) { Ok(vb) => append_value_at(buf, &vb, 0, vb.len()), Err(_) => buf.push_str("null"), } + Ok(()) } #[cfg(test)] @@ -159,49 +173,49 @@ mod tests { #[test] fn single_string_field() { let doc = encode(&json!({"name": "alice", "age": 30})); - let key = build_group_key(&doc, &keys(&["name"])); + let key = build_group_key(&doc, &keys(&["name"])).unwrap(); assert_eq!(key, r#"["alice"]"#); } #[test] fn single_int_field() { let doc = encode(&json!({"status": 200})); - let key = build_group_key(&doc, &keys(&["status"])); + let key = build_group_key(&doc, &keys(&["status"])).unwrap(); assert_eq!(key, "[200]"); } #[test] fn multiple_fields() { let doc = encode(&json!({"city": "ny", "year": 2024})); - let key = build_group_key(&doc, &keys(&["city", "year"])); + let key = build_group_key(&doc, &keys(&["city", "year"])).unwrap(); assert_eq!(key, r#"["ny",2024]"#); } #[test] fn missing_field_is_null() { let doc = encode(&json!({"x": 1})); - let key = build_group_key(&doc, &keys(&["missing"])); + let key = build_group_key(&doc, &keys(&["missing"])).unwrap(); assert_eq!(key, "[null]"); } #[test] fn empty_group_fields() { let doc = encode(&json!({"x": 1})); - let key = build_group_key(&doc, &[]); + let key = build_group_key(&doc, &[]).unwrap(); assert_eq!(key, "__all__"); } #[test] fn null_field_value() { let doc = encode(&json!({"v": null})); - let key = build_group_key(&doc, &keys(&["v"])); + let key = build_group_key(&doc, &keys(&["v"])).unwrap(); assert_eq!(key, "[null]"); } #[test] fn float_field() { let doc = encode(&json!({"temp": 36.6})); - let key = build_group_key(&doc, &keys(&["temp"])); + let key = build_group_key(&doc, &keys(&["temp"])).unwrap(); assert_eq!(key, "[36.6]"); } @@ -223,23 +237,49 @@ mod tests { let lower = encode(&json!({"label": "alpha", "score": 7})); let upper = encode(&json!({"label": "ALPHA", "score": 3})); assert_eq!( - build_group_key(&lower, &[upper_label_spec()]), + build_group_key(&lower, &[upper_label_spec()]).unwrap(), r#"["ALPHA"]"# ); assert_eq!( - build_group_key(&upper, &[upper_label_spec()]), + build_group_key(&upper, &[upper_label_spec()]).unwrap(), r#"["ALPHA"]"# ); } + /// A computed GROUP BY key that divides by zero fails the statement with a + /// division-by-zero error rather than grouping the row under a `null` + /// key. + #[test] + fn computed_key_division_by_zero_errors() { + let doc = encode(&json!({"n": 10, "d": 0})); + let spec = GroupKeySpec { + output_name: "q".to_string(), + field: None, + expr: Some(SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Column("n".to_string())), + op: crate::expr::BinaryOp::Div, + right: Box::new(SqlExpr::Column("d".to_string())), + }), + }; + assert_eq!( + build_group_key(&doc, std::slice::from_ref(&spec)), + Err(EvalError::DivisionByZero) + ); + let idx = FieldIndex::build(&doc, 0).unwrap_or_else(FieldIndex::empty); + assert_eq!( + build_group_key_indexed(&doc, std::slice::from_ref(&spec), &idx), + Err(EvalError::DivisionByZero) + ); + } + #[test] fn computed_key_indexed_matches_unindexed() { let doc = encode(&json!({"label": "beta", "score": 5})); let specs = [upper_label_spec()]; let idx = FieldIndex::build(&doc, 0).unwrap_or_else(FieldIndex::empty); assert_eq!( - build_group_key(&doc, &specs), - build_group_key_indexed(&doc, &specs, &idx), + build_group_key(&doc, &specs).unwrap(), + build_group_key_indexed(&doc, &specs, &idx).unwrap(), ); } @@ -247,6 +287,6 @@ mod tests { fn mixed_column_and_computed_keys() { let doc = encode(&json!({"region": "us", "label": "west"})); let specs = [GroupKeySpec::column("region"), upper_label_spec()]; - assert_eq!(build_group_key(&doc, &specs), r#"["us","WEST"]"#); + assert_eq!(build_group_key(&doc, &specs).unwrap(), r#"["us","WEST"]"#); } } diff --git a/nodedb-query/src/scan_filter/types.rs b/nodedb-query/src/scan_filter/types.rs index 5684eb3e8..c9ef8a6b0 100644 --- a/nodedb-query/src/scan_filter/types.rs +++ b/nodedb-query/src/scan_filter/types.rs @@ -3,7 +3,7 @@ //! `ScanFilter` record, its wire codec, and per-row evaluation against a //! `nodedb_types::Value` document. -use crate::expr::SqlExpr; +use crate::expr::{EvalError, SqlExpr}; use super::like; use super::op::FilterOp; @@ -69,23 +69,52 @@ impl<'a> zerompk::FromMessagePack<'a> for ScanFilter { } impl ScanFilter { + /// Evaluate an AND-group of filters, short-circuiting on the first + /// `false` or the first evaluation error — same semantics as + /// `group.iter().all(|f| f.matches_value(doc))` had before filter + /// evaluation could fail. `pub` so the many call + /// sites across the `nodedb` crate that used to write + /// `filters.iter().all(|f| f.matches_value(doc))` have a drop-in + /// replacement instead of each hand-rolling the same short-circuit loop. + pub fn all_match_value( + group: &[ScanFilter], + doc: &nodedb_types::Value, + ) -> Result { + for f in group { + if !f.matches_value(doc)? { + return Ok(false); + } + } + Ok(true) + } + /// Evaluate this filter against a `nodedb_types::Value` document. /// /// Same semantics as `matches()` but operates on the native Value type /// instead of serde_json::Value, avoiding lossy JSON roundtrips. - pub fn matches_value(&self, doc: &nodedb_types::Value) -> bool { + /// + /// Returns `Err(EvalError::DivisionByZero)` when the filter is (or + /// contains, via an `OR` group) a `FilterOp::Expr` predicate whose + /// expression divides or takes a modulus by zero. + /// This is the deliberate WHERE-clause behavior flip: a predicate like + /// `10 / denom > 1` used to evaluate to `Value::Null`/`false` (silently + /// filtering the row out) when `denom` was `0`; it now fails the whole + /// query with SQLSTATE `22012`, matching Postgres. + pub fn matches_value(&self, doc: &nodedb_types::Value) -> Result { match self.op { - FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return true, + FilterOp::MatchAll | FilterOp::Exists | FilterOp::NotExists => return Ok(true), FilterOp::Or => { - return self - .clauses - .iter() - .any(|clause| clause.iter().all(|f| f.matches_value(doc))); + for clause in &self.clauses { + if Self::all_match_value(clause, doc)? { + return Ok(true); + } + } + return Ok(false); } FilterOp::Expr => { return match &self.expr { - Some(expr) => crate::value_ops::is_truthy(&expr.eval(doc)), - None => false, + Some(expr) => Ok(crate::value_ops::is_truthy(&expr.eval(doc)?)), + None => Ok(false), }; } _ => {} @@ -93,10 +122,10 @@ impl ScanFilter { let field_val = match doc.get(&self.field) { Some(v) => v, - None => return self.op == FilterOp::IsNull, + None => return Ok(self.op == FilterOp::IsNull), }; - match self.op { + Ok(match self.op { FilterOp::Eq => self.value.eq_coerced(field_val), FilterOp::Ne => !self.value.eq_coerced(field_val), FilterOp::Gt => self.value.cmp_coerced(field_val) == std::cmp::Ordering::Less, @@ -193,11 +222,11 @@ impl ScanFilter { | FilterOp::NeColumn => { let other_col = match &self.value { nodedb_types::Value::String(s) => s.as_str(), - _ => return false, + _ => return Ok(false), }; let other_val = match doc.get(other_col) { Some(v) => v, - None => return false, + None => return Ok(false), }; match self.op { FilterOp::GtColumn => { @@ -218,6 +247,6 @@ impl ScanFilter { } } _ => false, - } + }) } } diff --git a/nodedb-query/src/window/aggregate.rs b/nodedb-query/src/window/aggregate.rs index d50fc59f5..74bc6d23c 100644 --- a/nodedb-query/src/window/aggregate.rs +++ b/nodedb-query/src/window/aggregate.rs @@ -21,7 +21,7 @@ pub(super) fn apply_aggregate_window( rows: &mut [(String, serde_json::Value)], indices: &[usize], spec: &WindowFuncSpec, -) { +) -> Result<(), crate::expr::EvalError> { let field = spec .args .first() @@ -39,11 +39,11 @@ pub(super) fn apply_aggregate_window( && matches!(spec.frame.end, FrameBound::CurrentRow); if use_running { - running_aggregate(rows, indices, spec, field); - return; + running_aggregate(rows, indices, spec, field)?; + return Ok(()); } - per_row_aggregate(rows, indices, spec, field); + per_row_aggregate(rows, indices, spec, field) } /// Per-row frame evaluator. @@ -58,22 +58,21 @@ fn per_row_aggregate( indices: &[usize], spec: &WindowFuncSpec, field: &str, -) { +) -> Result<(), crate::expr::EvalError> { let len = indices.len(); if len == 0 { - return; + return Ok(()); } // Extract order-by values for RANGE numeric offsets. let order_expr = spec.order_by.first().map(|(expr, _)| expr); let order_values: Vec = indices .iter() - .map(|&i| { - order_expr - .map(|expr| super::helpers::eval_expr_on_json(expr, &rows[i].1)) - .unwrap_or(serde_json::Value::Null) + .map(|&i| match order_expr { + Some(expr) => super::helpers::eval_expr_on_json(expr, &rows[i].1), + None => Ok(serde_json::Value::Null), }) - .collect(); + .collect::, _>>()?; // Peer groups needed for GROUPS mode (and for RANGE CurrentRow peer // awareness — reused from the frame module which handles both). @@ -104,6 +103,7 @@ fn per_row_aggregate( let row_idx = indices[pos]; set_window_col(&mut rows[row_idx].1, &spec.alias, result); } + Ok(()) } /// Aggregate `field` over the slice `indices[start_idx..=end_idx]`. @@ -227,7 +227,7 @@ mod tests { "n", rows_frame(FrameBound::Preceding(1), FrameBound::Following(1)), ); - apply_aggregate_window(&mut rows, &indices, &spec); + apply_aggregate_window(&mut rows, &indices, &spec).unwrap(); // row 0 (n=1): sum of [1,2] = 3 // row 1 (n=2): sum of [1,2,3] = 6 // row 2 (n=3): sum of [2,3,4] = 9 @@ -249,7 +249,7 @@ mod tests { "n", rows_frame(FrameBound::UnboundedPreceding, FrameBound::CurrentRow), ); - apply_aggregate_window(&mut rows, &indices, &spec); + apply_aggregate_window(&mut rows, &indices, &spec).unwrap(); assert_eq!(rows[0].1["result"], json!(1.0)); assert_eq!(rows[1].1["result"], json!(3.0)); assert_eq!(rows[2].1["result"], json!(6.0)); @@ -266,7 +266,7 @@ mod tests { "n", rows_frame(FrameBound::CurrentRow, FrameBound::UnboundedFollowing), ); - apply_aggregate_window(&mut rows, &indices, &spec); + apply_aggregate_window(&mut rows, &indices, &spec).unwrap(); // row 0: sum 1+2+3+4+5=15 // row 1: sum 2+3+4+5=14 // ... @@ -295,7 +295,7 @@ mod tests { "n", range_frame(FrameBound::UnboundedPreceding, FrameBound::CurrentRow), ); - apply_aggregate_window(&mut rows, &indices, &spec); + apply_aggregate_window(&mut rows, &indices, &spec).unwrap(); // Row a (n=1, pos=0): CURRENT ROW expands to last peer at pos=1, sum=1+1=2 assert_eq!(rows[0].1["result"], json!(2.0)); // Row b (n=1, pos=1): same @@ -324,7 +324,7 @@ mod tests { "n", groups_frame(FrameBound::Preceding(1), FrameBound::Following(1)), ); - apply_aggregate_window(&mut rows, &indices, &spec); + apply_aggregate_window(&mut rows, &indices, &spec).unwrap(); // pos=0 (group 0): frame → groups 0..=1 → rows 0..=2 → sum=1+1+2=4 assert_eq!(rows[0].1["result"], json!(4.0)); // pos=1 (group 0): same frame diff --git a/nodedb-query/src/window/eval.rs b/nodedb-query/src/window/eval.rs index f524c07f9..d580a81bd 100644 --- a/nodedb-query/src/window/eval.rs +++ b/nodedb-query/src/window/eval.rs @@ -20,32 +20,36 @@ use super::spec::WindowFuncSpec; /// Unknown window function names must be rejected by the planner before /// reaching this dispatcher; an unrecognised name here is an internal bug /// and panics rather than silently dropping the projection. +/// +/// A division/modulo-by-zero in any PARTITION BY / ORDER BY / argument +/// expression propagates as `Err(EvalError::DivisionByZero)` rather than +/// folding to NULL. pub fn evaluate_window_functions( rows: &mut [(String, serde_json::Value)], specs: &[WindowFuncSpec], -) { +) -> Result<(), crate::expr::EvalError> { for spec in specs { - let partitions = build_partitions(rows, &spec.partition_by); + let partitions = build_partitions(rows, &spec.partition_by)?; for partition_indices in &partitions { match spec.func_name.as_str() { "row_number" => apply_row_number(rows, partition_indices, &spec.alias), - "rank" => apply_rank(rows, partition_indices, &spec.alias, &spec.order_by), + "rank" => apply_rank(rows, partition_indices, &spec.alias, &spec.order_by)?, "dense_rank" => { - apply_dense_rank(rows, partition_indices, &spec.alias, &spec.order_by) + apply_dense_rank(rows, partition_indices, &spec.alias, &spec.order_by)? } "ntile" => apply_ntile(rows, partition_indices, spec), "percent_rank" => { - apply_percent_rank(rows, partition_indices, &spec.alias, &spec.order_by) + apply_percent_rank(rows, partition_indices, &spec.alias, &spec.order_by)? } "cume_dist" => { - apply_cume_dist(rows, partition_indices, &spec.alias, &spec.order_by) + apply_cume_dist(rows, partition_indices, &spec.alias, &spec.order_by)? } "lag" => apply_lag(rows, partition_indices, spec), "lead" => apply_lead(rows, partition_indices, spec), "nth_value" => apply_nth_value(rows, partition_indices, spec), "sum" | "count" | "avg" | "min" | "max" | "first_value" | "last_value" => { - apply_aggregate_window(rows, partition_indices, spec) + apply_aggregate_window(rows, partition_indices, spec)? } other => { unreachable!( @@ -55,6 +59,7 @@ pub fn evaluate_window_functions( } } } + Ok(()) } #[cfg(test)] @@ -106,7 +111,7 @@ mod tests { order_by: vec![], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); assert_eq!(rows[0].1["rn"], json!(1)); assert_eq!(rows[4].1["rn"], json!(5)); } @@ -122,7 +127,7 @@ mod tests { order_by: vec![], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); assert_eq!(rows[0].1["rn"], json!(1)); assert_eq!(rows[2].1["rn"], json!(3)); assert_eq!(rows[3].1["rn"], json!(1)); @@ -140,7 +145,7 @@ mod tests { order_by: vec![(SqlExpr::Column("salary".into()), true)], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); assert_eq!(rows[0].1["running_total"], json!(100.0)); assert_eq!(rows[1].1["running_total"], json!(220.0)); assert_eq!(rows[2].1["running_total"], json!(310.0)); @@ -159,7 +164,7 @@ mod tests { order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); assert_eq!(rows[0].1["pr"], json!(0.0)); assert_eq!(rows[1].1["pr"], json!(0.25)); assert_eq!(rows[2].1["pr"], json!(0.5)); @@ -185,7 +190,7 @@ mod tests { order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); assert_eq!(rows[0].1["pr"], json!(0.0)); assert_eq!(rows[1].1["pr"], json!(0.0)); assert_eq!(rows[2].1["pr"], json!(2.0 / 3.0)); @@ -203,7 +208,7 @@ mod tests { order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); assert_eq!(rows[0].1["cd"], json!(0.2)); assert_eq!(rows[1].1["cd"], json!(0.4)); assert_eq!(rows[2].1["cd"], json!(0.6)); @@ -227,7 +232,7 @@ mod tests { order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); // Peers share value of last peer's position / N. assert_eq!(rows[0].1["cd"], json!(0.5)); assert_eq!(rows[1].1["cd"], json!(0.5)); @@ -249,7 +254,7 @@ mod tests { order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); assert_eq!(rows[0].1["nv"], json!(null)); assert_eq!(rows[1].1["nv"], json!(2)); assert_eq!(rows[2].1["nv"], json!(2)); @@ -269,6 +274,6 @@ mod tests { order_by: vec![], frame: WindowFrame::default(), }; - evaluate_window_functions(&mut rows, &[spec]); + evaluate_window_functions(&mut rows, &[spec]).unwrap(); } } diff --git a/nodedb-query/src/window/helpers.rs b/nodedb-query/src/window/helpers.rs index d52c304f3..62feeb65f 100644 --- a/nodedb-query/src/window/helpers.rs +++ b/nodedb-query/src/window/helpers.rs @@ -7,12 +7,15 @@ use std::collections::HashMap; use crate::expr::types::SqlExpr; /// Group row indices by partition key, preserving first-seen partition order. +/// +/// A division/modulo-by-zero in a PARTITION BY expression propagates as +/// `Err(EvalError::DivisionByZero)` rather than being folded to NULL. pub(super) fn build_partitions( rows: &[(String, serde_json::Value)], partition_by: &[SqlExpr], -) -> Vec> { +) -> Result>, crate::expr::EvalError> { if partition_by.is_empty() { - return vec![(0..rows.len()).collect()]; + return Ok(vec![(0..rows.len()).collect()]); } let mut groups: HashMap> = HashMap::new(); @@ -21,8 +24,8 @@ pub(super) fn build_partitions( for (i, (_id, doc)) in rows.iter().enumerate() { let key: String = partition_by .iter() - .map(|expr| eval_expr_on_json(expr, doc).to_string()) - .collect::>() + .map(|expr| eval_expr_on_json(expr, doc).map(|v| v.to_string())) + .collect::, _>>()? .join("\x00"); let entry = groups.entry(key.clone()).or_default(); if entry.is_empty() { @@ -31,7 +34,7 @@ pub(super) fn build_partitions( entry.push(i); } - order.iter().filter_map(|k| groups.remove(k)).collect() + Ok(order.iter().filter_map(|k| groups.remove(k)).collect()) } pub(super) fn set_window_col(row: &mut serde_json::Value, alias: &str, val: serde_json::Value) { @@ -45,14 +48,23 @@ pub(super) fn get_field(doc: &serde_json::Value, field: &str) -> serde_json::Val } /// Evaluate a `SqlExpr` against a serde_json document, returning a serde_json value. -pub(super) fn eval_expr_on_json(expr: &SqlExpr, doc: &serde_json::Value) -> serde_json::Value { +/// +/// A division/modulo-by-zero in a PARTITION BY / ORDER BY expression is +/// surfaced as `Err(EvalError::DivisionByZero)` — the same +/// statement-failure treatment WHERE/projection expressions get — rather than +/// being folded to `NULL`. The `Result` is threaded through every window +/// function that reaches this evaluator up to `evaluate_window_functions`. +pub(super) fn eval_expr_on_json( + expr: &SqlExpr, + doc: &serde_json::Value, +) -> Result { match expr { - SqlExpr::Column(name) => get_field(doc, name), - SqlExpr::Literal(v) => serde_json::Value::from(v.clone()), + SqlExpr::Column(name) => Ok(get_field(doc, name)), + SqlExpr::Literal(v) => Ok(serde_json::Value::from(v.clone())), other => { let ndb_doc = nodedb_types::Value::from(doc.clone()); - let result = other.eval(&ndb_doc); - serde_json::Value::from(result) + let result = other.eval(&ndb_doc)?; + Ok(serde_json::Value::from(result)) } } } @@ -72,10 +84,13 @@ pub(super) fn order_keys_equal( a: usize, b: usize, order_by: &[(SqlExpr, bool)], -) -> bool { - order_by.iter().all(|(expr, _)| { - let va = eval_expr_on_json(expr, &rows[a].1); - let vb = eval_expr_on_json(expr, &rows[b].1); - va == vb - }) +) -> Result { + for (expr, _) in order_by { + let va = eval_expr_on_json(expr, &rows[a].1)?; + let vb = eval_expr_on_json(expr, &rows[b].1)?; + if va != vb { + return Ok(false); + } + } + Ok(true) } diff --git a/nodedb-query/src/window/ranking.rs b/nodedb-query/src/window/ranking.rs index bda5da99b..fd0555ef8 100644 --- a/nodedb-query/src/window/ranking.rs +++ b/nodedb-query/src/window/ranking.rs @@ -23,15 +23,15 @@ pub(super) fn apply_rank( indices: &[usize], alias: &str, order_by: &[(SqlExpr, bool)], -) { +) -> Result<(), crate::expr::EvalError> { if indices.is_empty() { - return; + return Ok(()); } let mut current_rank = 1; set_window_col(&mut rows[indices[0]].1, alias, serde_json::json!(1)); for pos in 1..indices.len() { - if !order_keys_equal(rows, indices[pos - 1], indices[pos], order_by) { + if !order_keys_equal(rows, indices[pos - 1], indices[pos], order_by)? { current_rank = pos + 1; } set_window_col( @@ -40,6 +40,7 @@ pub(super) fn apply_rank( serde_json::json!(current_rank), ); } + Ok(()) } pub(super) fn apply_dense_rank( @@ -47,15 +48,15 @@ pub(super) fn apply_dense_rank( indices: &[usize], alias: &str, order_by: &[(SqlExpr, bool)], -) { +) -> Result<(), crate::expr::EvalError> { if indices.is_empty() { - return; + return Ok(()); } let mut current_rank = 1; set_window_col(&mut rows[indices[0]].1, alias, serde_json::json!(1)); for pos in 1..indices.len() { - if !order_keys_equal(rows, indices[pos - 1], indices[pos], order_by) { + if !order_keys_equal(rows, indices[pos - 1], indices[pos], order_by)? { current_rank += 1; } set_window_col( @@ -64,6 +65,7 @@ pub(super) fn apply_dense_rank( serde_json::json!(current_rank), ); } + Ok(()) } pub(super) fn apply_ntile( @@ -101,26 +103,27 @@ pub(super) fn apply_percent_rank( indices: &[usize], alias: &str, order_by: &[(SqlExpr, bool)], -) { +) -> Result<(), crate::expr::EvalError> { let total = indices.len(); if total == 0 { - return; + return Ok(()); } if total == 1 { set_window_col(&mut rows[indices[0]].1, alias, serde_json::json!(0.0)); - return; + return Ok(()); } let denom = (total - 1) as f64; let mut current_rank = 1usize; set_window_col(&mut rows[indices[0]].1, alias, serde_json::json!(0.0)); for pos in 1..total { - if !order_keys_equal(rows, indices[pos - 1], indices[pos], order_by) { + if !order_keys_equal(rows, indices[pos - 1], indices[pos], order_by)? { current_rank = pos + 1; } let pr = (current_rank - 1) as f64 / denom; set_window_col(&mut rows[indices[pos]].1, alias, serde_json::json!(pr)); } + Ok(()) } /// PostgreSQL `cume_dist()` — `rows_at_or_before_current_peer / partition_rows`. @@ -131,10 +134,10 @@ pub(super) fn apply_cume_dist( indices: &[usize], alias: &str, order_by: &[(SqlExpr, bool)], -) { +) -> Result<(), crate::expr::EvalError> { let total = indices.len(); if total == 0 { - return; + return Ok(()); } let denom = total as f64; @@ -142,7 +145,7 @@ pub(super) fn apply_cume_dist( while group_start < total { let mut group_end = group_start + 1; while group_end < total - && order_keys_equal(rows, indices[group_start], indices[group_end], order_by) + && order_keys_equal(rows, indices[group_start], indices[group_end], order_by)? { group_end += 1; } @@ -152,4 +155,5 @@ pub(super) fn apply_cume_dist( } group_start = group_end; } + Ok(()) } diff --git a/nodedb-query/src/window/running.rs b/nodedb-query/src/window/running.rs index 742a14252..21c6082c6 100644 --- a/nodedb-query/src/window/running.rs +++ b/nodedb-query/src/window/running.rs @@ -24,10 +24,10 @@ pub(super) fn running_aggregate( indices: &[usize], spec: &WindowFuncSpec, field: &str, -) { +) -> Result<(), crate::expr::EvalError> { let len = indices.len(); if len == 0 { - return; + return Ok(()); } // Accumulate state incrementally row-by-row, but defer writing results @@ -55,7 +55,7 @@ pub(super) fn running_aggregate( // Check if the *next* row starts a new peer group (or we're at the end). let is_last_in_group = - pos + 1 == len || !order_keys_equal(rows, i, indices[pos + 1], &spec.order_by); + pos + 1 == len || !order_keys_equal(rows, i, indices[pos + 1], &spec.order_by)?; if is_last_in_group { // Compute the result at the end of this peer group. @@ -88,4 +88,5 @@ pub(super) fn running_aggregate( peer_start = pos + 1; } } + Ok(()) } diff --git a/nodedb-query/src/window/value_agg.rs b/nodedb-query/src/window/value_agg.rs index 6078eed2a..616c503bb 100644 --- a/nodedb-query/src/window/value_agg.rs +++ b/nodedb-query/src/window/value_agg.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use nodedb_types::Value; use super::spec::{FrameBound, WindowFrame, WindowFuncSpec}; -use super::value_eval::{cmp_values, eval_arg_for_row, order_keys_equal_v, set_cell}; +use super::value_eval::{WindowError, cmp_values, eval_arg_for_row, order_keys_equal_v, set_cell}; use crate::simd_agg; pub(super) fn apply_v_aggregate( @@ -17,23 +17,27 @@ pub(super) fn apply_v_aggregate( column_index: &HashMap, spec: &WindowFuncSpec, write_col: usize, -) { +) -> Result<(), WindowError> { let use_running = spec.frame.mode == "range" && matches!(spec.frame.start, FrameBound::UnboundedPreceding) && matches!(spec.frame.end, FrameBound::CurrentRow); if use_running { - apply_v_running_aggregate(rows, indices, column_index, spec, write_col); + apply_v_running_aggregate(rows, indices, column_index, spec, write_col) } else { - apply_v_per_row_aggregate(rows, indices, column_index, spec, write_col); + apply_v_per_row_aggregate(rows, indices, column_index, spec, write_col) } } -fn eval_arg(spec: &WindowFuncSpec, row: &[Value], column_index: &HashMap) -> Value { - spec.args - .first() - .map(|expr| eval_arg_for_row(expr, row, column_index)) - .unwrap_or(Value::Null) +fn eval_arg( + spec: &WindowFuncSpec, + row: &[Value], + column_index: &HashMap, +) -> Result { + match spec.args.first() { + Some(expr) => Ok(eval_arg_for_row(expr, row, column_index)?), + None => Ok(Value::Null), + } } fn apply_v_running_aggregate( @@ -42,10 +46,10 @@ fn apply_v_running_aggregate( column_index: &HashMap, spec: &WindowFuncSpec, write_col: usize, -) { +) -> Result<(), WindowError> { let len = indices.len(); if len == 0 { - return; + return Ok(()); } let mut running_sum = 0.0f64; @@ -56,10 +60,10 @@ fn apply_v_running_aggregate( for pos in 0..len { let i = indices[pos]; - let val = rows - .get(i) - .map(|row| eval_arg(spec, row, column_index)) - .unwrap_or(Value::Null); + let val = match rows.get(i) { + Some(row) => eval_arg(spec, row, column_index)?, + None => Value::Null, + }; if let Some(n) = val.as_f64() { running_sum += n; @@ -71,17 +75,17 @@ fn apply_v_running_aggregate( } let is_last_in_group = pos + 1 == len - || !order_keys_equal_v(rows, i, indices[pos + 1], column_index, &spec.order_by); + || !order_keys_equal_v(rows, i, indices[pos + 1], column_index, &spec.order_by)?; if is_last_in_group { - let first_val = rows - .get(indices[0]) - .map(|row| eval_arg(spec, row, column_index)) - .unwrap_or(Value::Null); - let last_val = rows - .get(indices[pos]) - .map(|row| eval_arg(spec, row, column_index)) - .unwrap_or(Value::Null); + let first_val = match rows.get(indices[0]) { + Some(row) => eval_arg(spec, row, column_index)?, + None => Value::Null, + }; + let last_val = match rows.get(indices[pos]) { + Some(row) => eval_arg(spec, row, column_index)?, + None => Value::Null, + }; let result = match spec.func_name.as_str() { "sum" => Value::Float(running_sum), @@ -106,6 +110,7 @@ fn apply_v_running_aggregate( peer_start = pos + 1; } } + Ok(()) } fn apply_v_per_row_aggregate( @@ -114,24 +119,20 @@ fn apply_v_per_row_aggregate( column_index: &HashMap, spec: &WindowFuncSpec, write_col: usize, -) { +) -> Result<(), WindowError> { let len = indices.len(); if len == 0 { - return; + return Ok(()); } let order_expr = spec.order_by.first().map(|(expr, _)| expr); let order_values: Vec = indices .iter() - .map(|&i| { - order_expr - .and_then(|expr| { - rows.get(i) - .map(|row| eval_arg_for_row(expr, row, column_index)) - }) - .unwrap_or(Value::Null) + .map(|&i| match (order_expr, rows.get(i)) { + (Some(expr), Some(row)) => Ok(eval_arg_for_row(expr, row, column_index)?), + _ => Ok(Value::Null), }) - .collect(); + .collect::, WindowError>>()?; let peer_groups: Vec = if spec.frame.mode == "groups" { build_v_peer_groups(&order_values) @@ -141,12 +142,11 @@ fn apply_v_per_row_aggregate( let all_vals: Vec> = indices .iter() - .map(|&i| { - rows.get(i) - .map(|row| eval_arg(spec, row, column_index).as_f64()) - .unwrap_or(None) + .map(|&i| match rows.get(i) { + Some(row) => Ok(eval_arg(spec, row, column_index)?.as_f64()), + None => Ok(None), }) - .collect(); + .collect::, WindowError>>()?; let results: Vec = (0..len) .map(|pos| { @@ -162,11 +162,12 @@ fn apply_v_per_row_aggregate( end_idx, ) }) - .collect(); + .collect::, WindowError>>()?; for (pos, result) in results.into_iter().enumerate() { set_cell(rows, indices[pos], write_col, result); } + Ok(()) } fn aggregate_v_slice( @@ -177,14 +178,14 @@ fn aggregate_v_slice( spec: &WindowFuncSpec, start_idx: usize, end_idx: usize, -) -> Value { +) -> Result { let slice_vals: Vec = all_vals[start_idx..=end_idx] .iter() .filter_map(|v| *v) .collect(); let slice_count = end_idx - start_idx + 1; - match spec.func_name.as_str() { + let result = match spec.func_name.as_str() { "sum" => { let rt = simd_agg::ts_runtime(); Value::Float((rt.sum_f64)(&slice_vals)) @@ -214,34 +215,29 @@ fn aggregate_v_slice( Value::Float((rt.max_f64)(&slice_vals)) } } - "first_value" => indices - .get(start_idx) - .and_then(|&i| rows.get(i)) - .map(|row| { - eval_arg_for_row( - spec.args - .first() - .unwrap_or(&crate::expr::types::SqlExpr::Literal(Value::Null)), - row, - column_index, - ) - }) - .unwrap_or(Value::Null), - "last_value" => indices - .get(end_idx) - .and_then(|&i| rows.get(i)) - .map(|row| { - eval_arg_for_row( - spec.args - .first() - .unwrap_or(&crate::expr::types::SqlExpr::Literal(Value::Null)), - row, - column_index, - ) - }) - .unwrap_or(Value::Null), + "first_value" => match indices.get(start_idx).and_then(|&i| rows.get(i)) { + Some(row) => eval_arg_for_row( + spec.args + .first() + .unwrap_or(&crate::expr::types::SqlExpr::Literal(Value::Null)), + row, + column_index, + )?, + None => Value::Null, + }, + "last_value" => match indices.get(end_idx).and_then(|&i| rows.get(i)) { + Some(row) => eval_arg_for_row( + spec.args + .first() + .unwrap_or(&crate::expr::types::SqlExpr::Literal(Value::Null)), + row, + column_index, + )?, + None => Value::Null, + }, _ => Value::Null, - } + }; + Ok(result) } fn build_v_peer_groups(order_values: &[Value]) -> Vec { @@ -452,7 +448,7 @@ mod tests { row.push(Value::Null); } let indices: Vec = (0..rows.len()).collect(); - apply_v_aggregate(rows, &indices, cols, spec, write_col); + apply_v_aggregate(rows, &indices, cols, spec, write_col).unwrap(); } fn frame(mode: &str, start: FrameBound, end: FrameBound) -> WindowFrame { diff --git a/nodedb-query/src/window/value_eval.rs b/nodedb-query/src/window/value_eval.rs index f89d1788a..c22230d94 100644 --- a/nodedb-query/src/window/value_eval.rs +++ b/nodedb-query/src/window/value_eval.rs @@ -26,6 +26,9 @@ pub enum WindowError { #[error("window frame error: {detail}")] BadFrame { detail: String }, + + #[error("division by zero in window expression")] + Eval(#[from] crate::expr::EvalError), } /// Evaluate window functions over a `Vec>` result set. @@ -51,16 +54,16 @@ pub fn evaluate_window_functions_value( for partition_indices in &partitions { match spec.func_name.as_str() { "row_number" => apply_v_row_number(rows, partition_indices, write_col), - "rank" => apply_v_rank(rows, partition_indices, column_index, spec, write_col), + "rank" => apply_v_rank(rows, partition_indices, column_index, spec, write_col)?, "dense_rank" => { - apply_v_dense_rank(rows, partition_indices, column_index, spec, write_col) + apply_v_dense_rank(rows, partition_indices, column_index, spec, write_col)? } "ntile" => apply_v_ntile(rows, partition_indices, spec, write_col)?, "percent_rank" => { - apply_v_percent_rank(rows, partition_indices, column_index, spec, write_col) + apply_v_percent_rank(rows, partition_indices, column_index, spec, write_col)? } "cume_dist" => { - apply_v_cume_dist(rows, partition_indices, column_index, spec, write_col) + apply_v_cume_dist(rows, partition_indices, column_index, spec, write_col)? } "lag" => apply_v_lag(rows, partition_indices, column_index, spec, write_col)?, "lead" => apply_v_lead(rows, partition_indices, column_index, spec, write_col)?, @@ -68,7 +71,7 @@ pub fn evaluate_window_functions_value( apply_v_nth_value(rows, partition_indices, column_index, spec, write_col)? } "sum" | "count" | "avg" | "min" | "max" | "first_value" | "last_value" => { - apply_v_aggregate(rows, partition_indices, column_index, spec, write_col) + apply_v_aggregate(rows, partition_indices, column_index, spec, write_col)? } other => { return Err(WindowError::ArgEval { @@ -103,7 +106,7 @@ fn build_value_partitions( let mut order: Vec = Vec::new(); for (i, row) in rows.iter().enumerate() { - let key = partition_key(row, column_index, &spec.partition_by); + let key = partition_key(row, column_index, &spec.partition_by)?; let entry = groups.entry(key.clone()).or_default(); if entry.is_empty() { order.push(key); @@ -118,15 +121,15 @@ fn partition_key( row: &[Value], column_index: &HashMap, partition_by: &[SqlExpr], -) -> String { - partition_by +) -> Result { + Ok(partition_by .iter() .map(|expr| { - let v = eval_arg_for_row(expr, row, column_index); - format!("{v:?}") + let v = eval_arg_for_row(expr, row, column_index)?; + Ok(format!("{v:?}")) }) - .collect::>() - .join("\x00") + .collect::, WindowError>>()? + .join("\x00")) } // ── Value comparison helpers (pub(super) for value_agg) ─────────────────────── @@ -146,33 +149,42 @@ pub(super) fn order_keys_equal_v( b: usize, column_index: &HashMap, order_by: &[(SqlExpr, bool)], -) -> bool { - order_by.iter().all(|(expr, _)| { +) -> Result { + for (expr, _) in order_by { let row_a = rows.get(a).map(|r| r.as_slice()).unwrap_or(&[]); let row_b = rows.get(b).map(|r| r.as_slice()).unwrap_or(&[]); - let va = eval_arg_for_row(expr, row_a, column_index); - let vb = eval_arg_for_row(expr, row_b, column_index); - matches!(cmp_values(&va, &vb), std::cmp::Ordering::Equal) - }) + let va = eval_arg_for_row(expr, row_a, column_index)?; + let vb = eval_arg_for_row(expr, row_b, column_index)?; + if !matches!(cmp_values(&va, &vb), std::cmp::Ordering::Equal) { + return Ok(false); + } + } + Ok(true) } // ── Argument evaluation (pub(super) for value_agg) ──────────────────────────── +/// Evaluate a window-function argument expression against one row. +/// +/// This is the Value-native counterpart of `window::helpers::eval_expr_on_json`. +/// A division/modulo-by-zero surfaces as +/// `Err(EvalError::DivisionByZero)` — which the value-path callers convert into +/// `WindowError` via `?` — rather than being folded to `NULL`. pub(super) fn eval_arg_for_row( expr: &SqlExpr, row: &[Value], column_index: &HashMap, -) -> Value { +) -> Result { match expr { - SqlExpr::Column(name) => column_index + SqlExpr::Column(name) => Ok(column_index .get(name.as_str()) .and_then(|&idx| row.get(idx)) .cloned() - .unwrap_or(Value::Null), - SqlExpr::Literal(v) => v.clone(), + .unwrap_or(Value::Null)), + SqlExpr::Literal(v) => Ok(v.clone()), other => { let doc = row_to_obj(row, column_index); - other.eval(&doc) + Ok(other.eval(&doc)?) } } } @@ -231,9 +243,9 @@ fn apply_v_rank( column_index: &HashMap, spec: &WindowFuncSpec, write_col: usize, -) { +) -> Result<(), WindowError> { if indices.is_empty() { - return; + return Ok(()); } let mut current_rank = 1usize; set_cell(rows, indices[0], write_col, Value::Integer(1)); @@ -244,7 +256,7 @@ fn apply_v_rank( indices[pos], column_index, &spec.order_by, - ) { + )? { current_rank = pos + 1; } set_cell( @@ -254,6 +266,7 @@ fn apply_v_rank( Value::Integer(current_rank as i64), ); } + Ok(()) } fn apply_v_dense_rank( @@ -262,9 +275,9 @@ fn apply_v_dense_rank( column_index: &HashMap, spec: &WindowFuncSpec, write_col: usize, -) { +) -> Result<(), WindowError> { if indices.is_empty() { - return; + return Ok(()); } let mut current_rank = 1usize; set_cell(rows, indices[0], write_col, Value::Integer(1)); @@ -275,7 +288,7 @@ fn apply_v_dense_rank( indices[pos], column_index, &spec.order_by, - ) { + )? { current_rank += 1; } set_cell( @@ -285,6 +298,7 @@ fn apply_v_dense_rank( Value::Integer(current_rank as i64), ); } + Ok(()) } fn apply_v_ntile( @@ -311,14 +325,14 @@ fn apply_v_percent_rank( column_index: &HashMap, spec: &WindowFuncSpec, write_col: usize, -) { +) -> Result<(), WindowError> { let total = indices.len(); if total == 0 { - return; + return Ok(()); } if total == 1 { set_cell(rows, indices[0], write_col, Value::Float(0.0)); - return; + return Ok(()); } let denom = (total - 1) as f64; let mut current_rank = 1usize; @@ -330,12 +344,13 @@ fn apply_v_percent_rank( indices[pos], column_index, &spec.order_by, - ) { + )? { current_rank = pos + 1; } let pr = (current_rank - 1) as f64 / denom; set_cell(rows, indices[pos], write_col, Value::Float(pr)); } + Ok(()) } fn apply_v_cume_dist( @@ -344,10 +359,10 @@ fn apply_v_cume_dist( column_index: &HashMap, spec: &WindowFuncSpec, write_col: usize, -) { +) -> Result<(), WindowError> { let total = indices.len(); if total == 0 { - return; + return Ok(()); } let denom = total as f64; let mut group_start = 0; @@ -360,7 +375,7 @@ fn apply_v_cume_dist( indices[group_end], column_index, &spec.order_by, - ) + )? { group_end += 1; } @@ -370,6 +385,7 @@ fn apply_v_cume_dist( } group_start = group_end; } + Ok(()) } // ── Offset functions ────────────────────────────────────────────────────────── @@ -379,18 +395,12 @@ fn collect_arg_values( indices: &[usize], column_index: &HashMap, spec: &WindowFuncSpec, -) -> Vec { +) -> Result, WindowError> { indices .iter() - .map(|&i| { - rows.get(i) - .map(|row| { - spec.args - .first() - .map(|expr| eval_arg_for_row(expr, row, column_index)) - .unwrap_or(Value::Null) - }) - .unwrap_or(Value::Null) + .map(|&i| match (rows.get(i), spec.args.first()) { + (Some(row), Some(expr)) => Ok(eval_arg_for_row(expr, row, column_index)?), + _ => Ok(Value::Null), }) .collect() } @@ -404,7 +414,7 @@ fn apply_v_lag( ) -> Result<(), WindowError> { let offset = usize_arg(spec, 1, 1); let default = default_arg_value(spec, 2); - let values = collect_arg_values(rows, indices, column_index, spec); + let values = collect_arg_values(rows, indices, column_index, spec)?; for (pos, &i) in indices.iter().enumerate() { let val = if pos >= offset { values[pos - offset].clone() @@ -425,7 +435,7 @@ fn apply_v_lead( ) -> Result<(), WindowError> { let offset = usize_arg(spec, 1, 1); let default = default_arg_value(spec, 2); - let values = collect_arg_values(rows, indices, column_index, spec); + let values = collect_arg_values(rows, indices, column_index, spec)?; for (pos, &i) in indices.iter().enumerate() { let val = if pos + offset < indices.len() { values[pos + offset].clone() @@ -445,7 +455,7 @@ fn apply_v_nth_value( write_col: usize, ) -> Result<(), WindowError> { let n = usize_arg(spec, 1, 1).max(1); - let values = collect_arg_values(rows, indices, column_index, spec); + let values = collect_arg_values(rows, indices, column_index, spec)?; for (pos, &i) in indices.iter().enumerate() { let val = if pos + 1 >= n { values[n - 1].clone() diff --git a/nodedb-sql/src/error.rs b/nodedb-sql/src/error.rs index 19bd64eb7..3bf61e378 100644 --- a/nodedb-sql/src/error.rs +++ b/nodedb-sql/src/error.rs @@ -11,6 +11,14 @@ pub enum SqlError { #[error("table not found: {name}")] UnknownTable { name: String }, + /// A function call in an expression names no scalar, aggregate, or + /// window function registered in the [`FunctionRegistry`](crate::functions::registry::FunctionRegistry). + /// Caught at plan time in the resolver so the whole statement is + /// rejected instead of the call silently evaluating to `NULL` for every + /// row at runtime. + #[error("function {name}(...) does not exist")] + UndefinedFunction { name: String }, + #[error("unknown column '{column}' in table '{table}'")] UnknownColumn { table: String, column: String }, diff --git a/nodedb-sql/src/functions/arg_types.rs b/nodedb-sql/src/functions/arg_types.rs index 9afbbb25f..3124745bd 100644 --- a/nodedb-sql/src/functions/arg_types.rs +++ b/nodedb-sql/src/functions/arg_types.rs @@ -35,6 +35,8 @@ const GEOMETRY_ONLY: &[ColumnType] = &[ColumnType::Geometry]; const TIMESTAMP_TYPES: &[ColumnType] = &[ColumnType::Timestamp, ColumnType::Timestamptz]; +const ARRAY_ONLY: &[ColumnType] = &[ColumnType::Array]; + // ── Helper constructors ────────────────────────────────────────────────────── pub(super) const fn any(name: &'static str) -> ArgTypeSpec { @@ -186,6 +188,40 @@ pub static H3_LATLNGTOCELL_ARGS: &[ArgTypeSpec] = &[ pub static H3_CELLTOLATLNG_ARGS: &[ArgTypeSpec] = &[typed("h3_index", TEXT)]; +/// `geo_distance`/`haversine_distance`/`geo_bearing`/`haversine_bearing` +/// (lng1, lat1, lng2, lat2). +pub static HAVERSINE_ARGS: &[ArgTypeSpec] = &[ + typed("lng1", FLOAT64_ONLY), + typed("lat1", FLOAT64_ONLY), + typed("lng2", FLOAT64_ONLY), + typed("lat2", FLOAT64_ONLY), +]; + +/// `geo_circle(lng, lat, radius_m, segments?)`. +pub static GEO_CIRCLE_ARGS: &[ArgTypeSpec] = &[ + typed("lng", FLOAT64_ONLY), + typed("lat", FLOAT64_ONLY), + typed("radius_m", FLOAT64_ONLY), + typed("segments", INT64_ONLY), +]; + +/// `geo_bbox(min_lng, min_lat, max_lng, max_lat)`. +pub static GEO_BBOX_ARGS: &[ArgTypeSpec] = &[ + typed("min_lng", FLOAT64_ONLY), + typed("min_lat", FLOAT64_ONLY), + typed("max_lng", FLOAT64_ONLY), + typed("max_lat", FLOAT64_ONLY), +]; + +/// `geo_line(point1, point2, ...)` — at least 2 Point geometries. +pub static GEO_LINE_ARGS: &[ArgTypeSpec] = &[ + typed("point1", GEOMETRY_ONLY), + typed_variadic("point2", GEOMETRY_ONLY), +]; + +/// `geo_polygon(ring1, ring2, ...)` — each ring an array of `[lng, lat]` pairs. +pub static GEO_POLYGON_ARGS: &[ArgTypeSpec] = &[any_variadic("ring")]; + // ── Timeseries ──────────────────────────────────────────────────────────────── pub static TIME_BUCKET_ARGS: &[ArgTypeSpec] = &[any("interval"), typed("ts", TIMESTAMP_TYPES)]; @@ -248,8 +284,19 @@ pub static NULLIF_ARGS: &[ArgTypeSpec] = &[any("expr1"), any("expr2")]; pub static MATH_1_ARGS: &[ArgTypeSpec] = &[typed("expr", NUMERIC)]; +/// `mod(a, b)` / `power(base, exp)` / `pow(base, exp)` — two required +/// numeric arguments. +pub static MATH_2_ARGS: &[ArgTypeSpec] = &[typed("left", NUMERIC), typed("right", NUMERIC)]; + pub static ROUND_ARGS: &[ArgTypeSpec] = &[typed("expr", NUMERIC), typed("scale", INT64_ONLY)]; +/// `greatest(expr, ...)` / `least(expr, ...)` — one or more arguments of any +/// (comparable) type. +pub static GREATEST_LEAST_ARGS: &[ArgTypeSpec] = &[any_variadic("expr")]; + +/// `typeof(expr)` / `type_of(expr)` — accepts any value, including NULL. +pub static TYPEOF_ARGS: &[ArgTypeSpec] = &[any("expr")]; + pub static STRING_1_ARGS: &[ArgTypeSpec] = &[typed("expr", TEXT)]; pub static LENGTH_ARGS: &[ArgTypeSpec] = &[typed("expr", TEXT)]; @@ -331,3 +378,89 @@ pub static ARRAY_ELEMENTWISE_ARGS: &[ArgTypeSpec] = &[ ]; pub static ARRAY_MAINT_ARGS: &[ArgTypeSpec] = &[typed("name", TEXT)]; + +// ── ID generation / detection (nodedb-query's `functions/id.rs`) ────────────── + +/// `is_uuid(value)` / `is_ulid(value)` / `is_cuid2(value)` / `is_nanoid(value)` +/// / `id_type(value)` / `uuid_version(value)` / `ulid_timestamp(value)` — a +/// single text value to classify. Shared shape across all detector functions. +pub static ID_CHECK_ARGS: &[ArgTypeSpec] = &[typed("value", TEXT)]; + +/// `nanoid(length?)` — optional custom length, defaults to the standard +/// 21-character id when omitted. +pub static NANOID_ARGS: &[ArgTypeSpec] = &[typed("length", INT64_ONLY)]; + +// ── Array element functions (nodedb-query's `functions/array.rs`) ───────────── +// +// Distinct from the "Array engine" table-valued functions above +// (`array_slice`/`array_project`/...): these operate on an in-row `Array` +// value, mirroring the match arms in `nodedb_query::functions::array::try_eval`. + +/// `array_length(arr)` / `cardinality(arr)` / `array_distinct(arr)` / +/// `array_reverse(arr)` — a single array argument. +pub static ARRAY_1_ARGS: &[ArgTypeSpec] = &[typed("arr", ARRAY_ONLY)]; + +/// `array_append(arr, value)` / `array_remove(arr, value)` / +/// `array_contains(arr, value)` / `array_position(arr, value)` — an array +/// plus an element of any type. +pub static ARRAY_ELEM_ARGS: &[ArgTypeSpec] = &[typed("arr", ARRAY_ONLY), any("value")]; + +/// `array_prepend(value, arr)` — value-first argument order, matching +/// `nodedb_query::functions::array::try_eval`'s `"array_prepend"` arm. +pub static ARRAY_PREPEND_ARGS: &[ArgTypeSpec] = &[any("value"), typed("arr", ARRAY_ONLY)]; + +/// `array_concat(arr1, arr2)` / `array_cat(arr1, arr2)`. +pub static ARRAY_CAT_ARGS: &[ArgTypeSpec] = &[typed("arr1", ARRAY_ONLY), typed("arr2", ARRAY_ONLY)]; + +// ── DateTime / duration (nodedb-query's `functions/datetime.rs`) ────────────── + +/// `datetime(value)` / `to_datetime(value)` / `unix_secs(value)` / +/// `epoch_secs(value)` / `unix_millis(value)` / `epoch_millis(value)` / +/// `duration(value)` / `to_duration(value)` / `decimal(value)` / +/// `to_decimal(value)` — a single value whose accepted shape (string, +/// integer micros, or an existing timestamp/duration) varies by function, so +/// the position is left untyped (`any`) rather than over-constrained. +pub static DATETIME_1_ARGS: &[ArgTypeSpec] = &[any("value")]; + +/// `extract(part, ts)` / `date_part(part, ts)` / `date_trunc(part, ts)` / +/// `datetrunc(part, ts)` — a text field-name selector plus the +/// timestamp-like value to read it from. +pub static DATE_PART_ARGS: &[ArgTypeSpec] = &[typed("part", TEXT), any("ts")]; + +/// `date_add(ts, duration)` / `datetime_add(ts, duration)` / +/// `date_sub(ts, duration)` / `datetime_sub(ts, duration)`. +pub static DATE_ARITH_ARGS: &[ArgTypeSpec] = &[any("ts"), typed("duration", TEXT)]; + +/// `date_diff(ts1, ts2)` / `datediff(ts1, ts2)`. +pub static DATE_DIFF_ARGS: &[ArgTypeSpec] = &[any("ts1"), any("ts2")]; + +// ── Legacy JSON manipulation (nodedb-query's `functions/json/legacy.rs`) ────── +// +// Distinct from the PostgreSQL JSON operators and SQL/JSON standard +// functions above: these are the pre-existing `json_*` document-helper +// functions. + +/// `json_extract(json, path)` / `json_get(json, path)`. +pub static JSON_EXTRACT_ARGS: &[ArgTypeSpec] = &[any("json"), typed("path", TEXT)]; + +/// `json_set(json, key, value)`. +pub static JSON_SET_ARGS: &[ArgTypeSpec] = &[any("json"), typed("key", TEXT), any("value")]; + +/// `json_remove(json, key)`. +pub static JSON_REMOVE_ARGS: &[ArgTypeSpec] = &[any("json"), typed("key", TEXT)]; + +/// `json_keys(json)` / `json_values(json)` / `json_length(json)` / +/// `json_len(json)` / `json_type(json)` — a single JSON-shaped value. +pub static JSON_1_ARGS: &[ArgTypeSpec] = &[any("json")]; + +/// `json_array(value, ...)` — zero or more values of any type. +pub static JSON_ARRAY_ARGS: &[ArgTypeSpec] = &[any_variadic("value")]; + +/// `json_object(key, value, ...)` — a flat, variadic key/value list. +pub static JSON_OBJECT_ARGS: &[ArgTypeSpec] = &[any_variadic("kv")]; + +/// `json_contains(container, needle)`. +pub static JSON_CONTAINS_ARGS: &[ArgTypeSpec] = &[any("container"), any("needle")]; + +/// `json_merge(base, overlay)` / `json_patch(base, overlay)`. +pub static JSON_MERGE_ARGS: &[ArgTypeSpec] = &[any("base"), any("overlay")]; diff --git a/nodedb-sql/src/functions/builtins/scalars.rs b/nodedb-sql/src/functions/builtins/scalars.rs index 3279b4486..f73556d45 100644 --- a/nodedb-sql/src/functions/builtins/scalars.rs +++ b/nodedb-sql/src/functions/builtins/scalars.rs @@ -2,9 +2,12 @@ //! Scalar function registrations, split by domain. +mod array_elem; mod array_fn; mod datetime; mod doc; +mod id_fn; +mod json_legacy; mod math; mod misc; mod pg_fts; @@ -24,8 +27,11 @@ pub(super) fn scalar_functions() -> Vec { fns.extend(string::string_functions()); fns.extend(math::math_functions()); fns.extend(pg_json::pg_json_functions()); + fns.extend(json_legacy::json_legacy_functions()); fns.extend(pg_fts::pg_fts_functions()); fns.extend(array_fn::array_fn_functions()); + fns.extend(array_elem::array_elem_functions()); + fns.extend(id_fn::id_fn_functions()); fns.extend(misc::misc_functions()); fns } diff --git a/nodedb-sql/src/functions/builtins/scalars/array_elem.rs b/nodedb-sql/src/functions/builtins/scalars/array_elem.rs new file mode 100644 index 000000000..866775065 --- /dev/null +++ b/nodedb-sql/src/functions/builtins/scalars/array_elem.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Array element scalar function registrations. +//! +//! Distinct from `array_fn.rs`'s "Array engine" table-valued functions +//! (`array_slice`/`array_project`/`array_agg`/...), which operate on a +//! named on-disk array-engine collection: these operate on an in-row +//! `Array` value and are evaluated by +//! `nodedb_query::functions::array::try_eval`. Historically absent from +//! this registry entirely — the plan-time existence gate +//! rejects any name missing here, so every one of these would have been +//! rejected at plan time despite the runtime evaluator fully supporting +//! them. The list is audited to match every match arm in +//! `nodedb_query::functions::array::try_eval` exactly: `array_length` / +//! `cardinality`, `array_append`, `array_prepend`, `array_remove`, +//! `array_concat` / `array_cat`, `array_distinct`, `array_contains`, +//! `array_position`, `array_reverse`. Keep both lists in sync. + +use nodedb_types::columnar::ColumnType; + +use crate::functions::arg_types; +use crate::functions::registry::{FunctionCategory::Scalar, FunctionMeta}; + +use super::super::helpers::{m, no_trigger}; + +pub(super) fn array_elem_functions() -> Vec { + vec![ + m( + "array_length", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::ARRAY_1_ARGS, + ), + // `cardinality` is an alias for `array_length` — same + // `nodedb_query::functions::array::try_eval` match arm + // (`"array_length" | "cardinality"`). + m( + "cardinality", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::ARRAY_1_ARGS, + ), + m( + "array_append", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Array), + arg_types::ARRAY_ELEM_ARGS, + ), + m( + "array_prepend", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Array), + arg_types::ARRAY_PREPEND_ARGS, + ), + m( + "array_remove", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Array), + arg_types::ARRAY_ELEM_ARGS, + ), + m( + "array_concat", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Array), + arg_types::ARRAY_CAT_ARGS, + ), + // `array_cat` is an alias for `array_concat` — same match arm + // (`"array_concat" | "array_cat"`). + m( + "array_cat", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Array), + arg_types::ARRAY_CAT_ARGS, + ), + m( + "array_distinct", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Array), + arg_types::ARRAY_1_ARGS, + ), + m( + "array_contains", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Bool), + arg_types::ARRAY_ELEM_ARGS, + ), + m( + "array_position", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Int64), + arg_types::ARRAY_ELEM_ARGS, + ), + m( + "array_reverse", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Array), + arg_types::ARRAY_1_ARGS, + ), + ] +} diff --git a/nodedb-sql/src/functions/builtins/scalars/datetime.rs b/nodedb-sql/src/functions/builtins/scalars/datetime.rs index 9ad174881..ec0dfcd08 100644 --- a/nodedb-sql/src/functions/builtins/scalars/datetime.rs +++ b/nodedb-sql/src/functions/builtins/scalars/datetime.rs @@ -1,6 +1,22 @@ // SPDX-License-Identifier: Apache-2.0 //! Date/time scalar function registrations. +//! +//! The plan-time existence gate rejects any name missing +//! here, so this list is audited to match every match arm in +//! `nodedb_query::functions::datetime::try_eval` exactly: `now` / +//! `current_timestamp`, `datetime` / `to_datetime`, `unix_secs` / +//! `epoch_secs`, `unix_millis` / `epoch_millis`, `extract` / `date_part`, +//! `date_trunc` / `datetrunc`, `date_add` / `datetime_add`, `date_sub` / +//! `datetime_sub`, `date_diff` / `datediff`, `duration` / `to_duration`, +//! `decimal` / `to_decimal`, `time_bucket`. Before this audit only +//! `time_bucket`, `now`, and `current_timestamp` were registered — the rest +//! resolved at runtime but were unreachable from SQL because the gate +//! rejected them first. Note: the individual date PARTS (`year`, `month`, +//! `day`, `hour`, `minute`, `second`, `dow`/`dayofweek`, `epoch`) are string +//! literals consumed as the first argument of `extract`/`date_part`/ +//! `date_trunc`, not standalone function names, so they are deliberately +//! NOT registered here. Keep both lists in sync. use nodedb_types::columnar::ColumnType; @@ -38,5 +54,206 @@ pub(super) fn datetime_functions() -> Vec { Some(ColumnType::Timestamptz), arg_types::NO_ARGS, ), + m( + "datetime", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::DATETIME_1_ARGS, + ), + // `to_datetime` is an alias for `datetime` — same + // `nodedb_query::functions::datetime::try_eval` match arm + // (`"datetime" | "to_datetime"`). + m( + "to_datetime", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::DATETIME_1_ARGS, + ), + m( + "unix_secs", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::DATETIME_1_ARGS, + ), + // `epoch_secs` is an alias for `unix_secs` — same match arm + // (`"unix_secs" | "epoch_secs"`). + m( + "epoch_secs", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::DATETIME_1_ARGS, + ), + m( + "unix_millis", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::DATETIME_1_ARGS, + ), + // `epoch_millis` is an alias for `unix_millis` — same match arm + // (`"unix_millis" | "epoch_millis"`). + m( + "epoch_millis", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::DATETIME_1_ARGS, + ), + m( + "extract", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Int64), + arg_types::DATE_PART_ARGS, + ), + // `date_part` is an alias for `extract` — same match arm + // (`"extract" | "date_part"`). + m( + "date_part", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Int64), + arg_types::DATE_PART_ARGS, + ), + m( + "date_trunc", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::String), + arg_types::DATE_PART_ARGS, + ), + // `datetrunc` is an alias for `date_trunc` — same match arm + // (`"date_trunc" | "datetrunc"`). + m( + "datetrunc", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::String), + arg_types::DATE_PART_ARGS, + ), + m( + "date_add", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::String), + arg_types::DATE_ARITH_ARGS, + ), + // `datetime_add` is an alias for `date_add` — same match arm + // (`"date_add" | "datetime_add"`). + m( + "datetime_add", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::String), + arg_types::DATE_ARITH_ARGS, + ), + m( + "date_sub", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::String), + arg_types::DATE_ARITH_ARGS, + ), + // `datetime_sub` is an alias for `date_sub` — same match arm + // (`"date_sub" | "datetime_sub"`). + m( + "datetime_sub", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::String), + arg_types::DATE_ARITH_ARGS, + ), + m( + "date_diff", + Scalar, + 2, + 2, + no_trigger(), + None, + arg_types::DATE_DIFF_ARGS, + ), + // `datediff` is an alias for `date_diff` — same match arm + // (`"date_diff" | "datediff"`). + m( + "datediff", + Scalar, + 2, + 2, + no_trigger(), + None, + arg_types::DATE_DIFF_ARGS, + ), + m( + "duration", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::DATETIME_1_ARGS, + ), + // `to_duration` is an alias for `duration` — same match arm + // (`"duration" | "to_duration"`). + m( + "to_duration", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::DATETIME_1_ARGS, + ), + m( + "decimal", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::DATETIME_1_ARGS, + ), + // `to_decimal` is an alias for `decimal` — same match arm + // (`"decimal" | "to_decimal"`). + m( + "to_decimal", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::DATETIME_1_ARGS, + ), ] } diff --git a/nodedb-sql/src/functions/builtins/scalars/id_fn.rs b/nodedb-sql/src/functions/builtins/scalars/id_fn.rs new file mode 100644 index 000000000..6b22aa059 --- /dev/null +++ b/nodedb-sql/src/functions/builtins/scalars/id_fn.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! ID generation / detection scalar function registrations. +//! +//! Evaluated by `nodedb_query::functions::id::try_eval`, but historically +//! absent from this registry entirely — the plan-time +//! existence gate (`resolver::expr::functions::convert_function_depth`) +//! checks every scalar call against this registry, so a `SELECT +//! gen_random_uuid()` (or any of its siblings below) would have been +//! rejected at plan time despite the runtime evaluator fully supporting it. +//! This file closes that gap. The list is audited to match every match arm +//! in `nodedb_query::functions::id::try_eval` exactly: `uuid` / `uuid_v4` / +//! `gen_random_uuid`, `uuid_v7`, `ulid`, `cuid2`, `nanoid`, `is_uuid`, +//! `is_ulid`, `is_cuid2`, `is_nanoid`, `id_type`, `uuid_version`, +//! `ulid_timestamp`. Keep both lists in sync. + +use nodedb_types::columnar::ColumnType; + +use crate::functions::arg_types; +use crate::functions::registry::{FunctionCategory::Scalar, FunctionMeta}; + +use super::super::helpers::{m, no_trigger}; + +pub(super) fn id_fn_functions() -> Vec { + vec![ + m( + "uuid", + Scalar, + 0, + 0, + no_trigger(), + Some(ColumnType::Uuid), + arg_types::NO_ARGS, + ), + // `uuid_v4` and `gen_random_uuid` are aliases for `uuid` — same + // `nodedb_query::functions::id::try_eval` match arm + // (`"uuid" | "uuid_v4" | "gen_random_uuid"`). + m( + "uuid_v4", + Scalar, + 0, + 0, + no_trigger(), + Some(ColumnType::Uuid), + arg_types::NO_ARGS, + ), + m( + "gen_random_uuid", + Scalar, + 0, + 0, + no_trigger(), + Some(ColumnType::Uuid), + arg_types::NO_ARGS, + ), + m( + "uuid_v7", + Scalar, + 0, + 0, + no_trigger(), + Some(ColumnType::Uuid), + arg_types::NO_ARGS, + ), + m( + "ulid", + Scalar, + 0, + 0, + no_trigger(), + Some(ColumnType::Ulid), + arg_types::NO_ARGS, + ), + m( + "cuid2", + Scalar, + 0, + 0, + no_trigger(), + Some(ColumnType::String), + arg_types::NO_ARGS, + ), + // `nanoid(length?)` — the optional length argument means max_args is + // 1 even though the common call form `nanoid()` takes none. + m( + "nanoid", + Scalar, + 0, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::NANOID_ARGS, + ), + m( + "is_uuid", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Bool), + arg_types::ID_CHECK_ARGS, + ), + m( + "is_ulid", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Bool), + arg_types::ID_CHECK_ARGS, + ), + m( + "is_cuid2", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Bool), + arg_types::ID_CHECK_ARGS, + ), + m( + "is_nanoid", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Bool), + arg_types::ID_CHECK_ARGS, + ), + m( + "id_type", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::ID_CHECK_ARGS, + ), + m( + "uuid_version", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::ID_CHECK_ARGS, + ), + m( + "ulid_timestamp", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::ID_CHECK_ARGS, + ), + ] +} diff --git a/nodedb-sql/src/functions/builtins/scalars/json_legacy.rs b/nodedb-sql/src/functions/builtins/scalars/json_legacy.rs new file mode 100644 index 000000000..825e0e966 --- /dev/null +++ b/nodedb-sql/src/functions/builtins/scalars/json_legacy.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Legacy JSON manipulation scalar function registrations. +//! +//! Distinct from `pg_json.rs`'s PostgreSQL JSON operators +//! (`pg_json_get`/...) and SQL/JSON standard functions +//! (`json_value`/`json_query`/`json_exists`): these are the pre-existing +//! `json_*` document-helper functions evaluated by +//! `nodedb_query::functions::json::legacy::try_eval`. Historically absent +//! from this registry entirely — the plan-time existence +//! gate rejects any name missing here. The list is audited to match every +//! match arm in `nodedb_query::functions::json::legacy::try_eval` exactly: +//! `json_extract` / `json_get`, `json_set`, `json_remove`, `json_keys`, +//! `json_values`, `json_length` / `json_len`, `json_type`, `json_array`, +//! `json_object`, `json_contains`, `json_merge` / `json_patch`. Keep both +//! lists in sync. + +use nodedb_types::columnar::ColumnType; + +use crate::functions::arg_types; +use crate::functions::registry::{FunctionCategory::Scalar, FunctionMeta}; + +use super::super::helpers::{m, no_trigger}; + +pub(super) fn json_legacy_functions() -> Vec { + vec![ + m( + "json_extract", + Scalar, + 2, + 2, + no_trigger(), + None, + arg_types::JSON_EXTRACT_ARGS, + ), + // `json_get` is an alias for `json_extract` — same + // `nodedb_query::functions::json::legacy::try_eval` match arm + // (`"json_extract" | "json_get"`). + m( + "json_get", + Scalar, + 2, + 2, + no_trigger(), + None, + arg_types::JSON_EXTRACT_ARGS, + ), + m( + "json_set", + Scalar, + 3, + 3, + no_trigger(), + Some(ColumnType::Json), + arg_types::JSON_SET_ARGS, + ), + m( + "json_remove", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Json), + arg_types::JSON_REMOVE_ARGS, + ), + m( + "json_keys", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Array), + arg_types::JSON_1_ARGS, + ), + m( + "json_values", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Array), + arg_types::JSON_1_ARGS, + ), + m( + "json_length", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::JSON_1_ARGS, + ), + // `json_len` is an alias for `json_length` — same match arm + // (`"json_length" | "json_len"`). + m( + "json_len", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::JSON_1_ARGS, + ), + m( + "json_type", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::JSON_1_ARGS, + ), + m( + "json_array", + Scalar, + 0, + 255, + no_trigger(), + Some(ColumnType::Array), + arg_types::JSON_ARRAY_ARGS, + ), + m( + "json_object", + Scalar, + 0, + 255, + no_trigger(), + Some(ColumnType::Json), + arg_types::JSON_OBJECT_ARGS, + ), + m( + "json_contains", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Bool), + arg_types::JSON_CONTAINS_ARGS, + ), + m( + "json_merge", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Json), + arg_types::JSON_MERGE_ARGS, + ), + // `json_patch` is an alias for `json_merge` — same match arm + // (`"json_merge" | "json_patch"`). + m( + "json_patch", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Json), + arg_types::JSON_MERGE_ARGS, + ), + ] +} diff --git a/nodedb-sql/src/functions/builtins/scalars/math.rs b/nodedb-sql/src/functions/builtins/scalars/math.rs index 18b7d27f6..11833d11f 100644 --- a/nodedb-sql/src/functions/builtins/scalars/math.rs +++ b/nodedb-sql/src/functions/builtins/scalars/math.rs @@ -1,6 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 //! Math scalar function registrations. +//! +//! The plan-time existence gate +//! (`resolver::expr::functions::convert_function_depth`) rejects any name +//! missing here, so this list is audited to match every match arm in +//! `nodedb_query::functions::math::try_eval` exactly: `abs`, `ceil` / +//! `ceiling`, `floor`, `round`, `power` / `pow`, `sqrt`, `mod`, `sign`, +//! `log` / `ln`, `log10`, `log2`, `exp`. Before this audit only `abs`, +//! `ceil`, `floor`, and `round` were registered — the rest resolved at +//! runtime but were unreachable from SQL because the gate rejected them +//! first. Keep both lists in sync. use crate::functions::arg_types; use crate::functions::registry::{FunctionCategory::Scalar, FunctionMeta}; @@ -45,5 +55,115 @@ pub(super) fn math_functions() -> Vec { None, arg_types::ROUND_ARGS, ), + // `ceiling` is an alias for `ceil` — same + // `nodedb_query::functions::math::try_eval` match arm + // (`"ceil" | "ceiling"`). + m( + "ceiling", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), + m( + "sqrt", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), + // `mod(a, b)`: the zero-modulus arm raises `EvalError::DivisionByZero` + // at the runtime evaluator rather than folding to + // NULL — this registration only gates existence/arity, not that + // runtime behavior. + m( + "mod", + Scalar, + 2, + 2, + no_trigger(), + None, + arg_types::MATH_2_ARGS, + ), + m( + "power", + Scalar, + 2, + 2, + no_trigger(), + None, + arg_types::MATH_2_ARGS, + ), + // `pow` is an alias for `power` — same + // `nodedb_query::functions::math::try_eval` match arm (`"power" | "pow"`). + m( + "pow", + Scalar, + 2, + 2, + no_trigger(), + None, + arg_types::MATH_2_ARGS, + ), + m( + "sign", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), + m( + "log", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), + // `ln` is an alias for `log` (natural log) — same + // `nodedb_query::functions::math::try_eval` match arm (`"log" | "ln"`). + m( + "ln", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), + m( + "log10", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), + m( + "log2", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), + m( + "exp", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::MATH_1_ARGS, + ), ] } diff --git a/nodedb-sql/src/functions/builtins/scalars/misc.rs b/nodedb-sql/src/functions/builtins/scalars/misc.rs index 501dd213f..ea001c286 100644 --- a/nodedb-sql/src/functions/builtins/scalars/misc.rs +++ b/nodedb-sql/src/functions/builtins/scalars/misc.rs @@ -1,6 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -//! Miscellaneous scalar function registrations (coalesce, nullif, make_array, utility). +//! Miscellaneous scalar function registrations (coalesce, nullif, greatest, +//! least, make_array, typeof, utility). +//! +//! The plan-time existence gate rejects any name missing +//! here. `coalesce` / `nullif` mirror +//! `nodedb_query::functions::conditional::try_eval`'s full match arm list +//! together with `greatest` / `least` below (before this audit those two +//! were runtime-only); `typeof` / `type_of` mirror +//! `nodedb_query::functions::types::try_eval` in full (previously +//! unregistered entirely). Keep both lists in sync. use nodedb_types::columnar::ColumnType; @@ -29,6 +38,45 @@ pub(super) fn misc_functions() -> Vec { None, arg_types::NULLIF_ARGS, ), + m( + "greatest", + Scalar, + 1, + 255, + no_trigger(), + None, + arg_types::GREATEST_LEAST_ARGS, + ), + m( + "least", + Scalar, + 1, + 255, + no_trigger(), + None, + arg_types::GREATEST_LEAST_ARGS, + ), + // `type_of` is an alias for `typeof` — same + // `nodedb_query::functions::types::try_eval` match arm + // (`"typeof" | "type_of"`). + m( + "typeof", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::TYPEOF_ARGS, + ), + m( + "type_of", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::TYPEOF_ARGS, + ), m( "make_array", Scalar, diff --git a/nodedb-sql/src/functions/builtins/scalars/spatial.rs b/nodedb-sql/src/functions/builtins/scalars/spatial.rs index 81065849c..125945b65 100644 --- a/nodedb-sql/src/functions/builtins/scalars/spatial.rs +++ b/nodedb-sql/src/functions/builtins/scalars/spatial.rs @@ -137,5 +137,261 @@ pub(super) fn spatial_functions() -> Vec { Some(ColumnType::Geometry), arg_types::ST_ENVELOPE_ARGS, ), + m( + "st_disjoint", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Bool), + arg_types::SPATIAL_2_ARGS, + ), + // ── Extended `geo_*` utility functions ── + // + // Evaluated by `nodedb_query::geo_functions::eval_geo_function`, but + // historically absent from this registry (only the `st_*` subset + // above was registered). The plan-time undefined-function + // gate (`resolver::expr::functions::convert_function_depth`) checks + // every scalar call against this registry, so any name missing here + // would make a previously-working spatial query fail with 42883. + // These entries close that gap; each accepts and returns the same + // shapes `eval_geo_function` actually operates on. + m( + "geo_length", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Float64), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_perimeter", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Float64), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_as_geojson", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_as_wkt", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_x", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Float64), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_y", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Float64), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_num_points", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_type", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_is_valid", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Bool), + arg_types::ST_ENVELOPE_ARGS, + ), + m( + "geo_from_geojson", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::STRING_1_ARGS, + ), + m( + "geo_from_wkt", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::STRING_1_ARGS, + ), + m( + "geo_h3_to_boundary", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::H3_CELLTOLATLNG_ARGS, + ), + m( + "geo_h3_resolution", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::H3_CELLTOLATLNG_ARGS, + ), + // `geo_geohash_decode`/`h3_celltolatlng`-shaped: returns an Object + // (min_lng/min_lat/max_lng/max_lat), not a scalar column type — + // `st_geohashdecode` above uses the same `None` convention. + m( + "geo_geohash_decode", + Scalar, + 1, + 1, + no_trigger(), + None, + arg_types::ST_GEOHASHDECODE_ARGS, + ), + m( + "geo_geohash_neighbors", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Array), + arg_types::ST_GEOHASHDECODE_ARGS, + ), + m( + "geo_point", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::ST_POINT_ARGS, + ), + m( + "geo_geohash", + Scalar, + 2, + 3, + no_trigger(), + Some(ColumnType::String), + arg_types::ST_GEOHASH_ARGS, + ), + m( + "geo_h3", + Scalar, + 2, + 3, + no_trigger(), + Some(ColumnType::String), + arg_types::ST_GEOHASH_ARGS, + ), + m( + "geo_distance", + Scalar, + 4, + 4, + no_trigger(), + Some(ColumnType::Float64), + arg_types::HAVERSINE_ARGS, + ), + m( + "haversine_distance", + Scalar, + 4, + 4, + no_trigger(), + Some(ColumnType::Float64), + arg_types::HAVERSINE_ARGS, + ), + m( + "geo_bearing", + Scalar, + 4, + 4, + no_trigger(), + Some(ColumnType::Float64), + arg_types::HAVERSINE_ARGS, + ), + m( + "haversine_bearing", + Scalar, + 4, + 4, + no_trigger(), + Some(ColumnType::Float64), + arg_types::HAVERSINE_ARGS, + ), + m( + "geo_circle", + Scalar, + 3, + 4, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::GEO_CIRCLE_ARGS, + ), + m( + "geo_bbox", + Scalar, + 4, + 4, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::GEO_BBOX_ARGS, + ), + m( + "geo_line", + Scalar, + 2, + 255, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::GEO_LINE_ARGS, + ), + m( + "geo_polygon", + Scalar, + 1, + 255, + no_trigger(), + Some(ColumnType::Geometry), + arg_types::GEO_POLYGON_ARGS, + ), ] } diff --git a/nodedb-sql/src/functions/builtins/scalars/string.rs b/nodedb-sql/src/functions/builtins/scalars/string.rs index 7db36f16b..c60423152 100644 --- a/nodedb-sql/src/functions/builtins/scalars/string.rs +++ b/nodedb-sql/src/functions/builtins/scalars/string.rs @@ -1,6 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 //! String scalar function registrations. +//! +//! The plan-time existence gate rejects any name missing +//! here, so this list is audited to match every match arm in +//! `nodedb_query::functions::string::try_eval` exactly: `upper`, `lower`, +//! `trim`, `ltrim`, `rtrim`, `length` / `char_length` / `character_length`, +//! `substr` / `substring`, `concat`, `replace`, `reverse`. Before this +//! audit `ltrim`, `rtrim`, `char_length`, `character_length`, `substr`, and +//! `reverse` were runtime-only, unreachable from SQL via the gate. Keep both +//! lists in sync. use nodedb_types::columnar::ColumnType; @@ -47,6 +56,45 @@ pub(super) fn string_functions() -> Vec { Some(ColumnType::String), arg_types::STRING_1_ARGS, ), + m( + "ltrim", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::STRING_1_ARGS, + ), + m( + "rtrim", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::STRING_1_ARGS, + ), + // `char_length` / `character_length` are SQL-standard aliases for + // `length` — same `nodedb_query::functions::string::try_eval` match + // arm (`"length" | "char_length" | "character_length"`). + m( + "char_length", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::LENGTH_ARGS, + ), + m( + "character_length", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + arg_types::LENGTH_ARGS, + ), m( "substring", Scalar, @@ -56,6 +104,18 @@ pub(super) fn string_functions() -> Vec { Some(ColumnType::String), arg_types::SUBSTRING_ARGS, ), + // `substr` is an alias for `substring` — same + // `nodedb_query::functions::string::try_eval` match arm + // (`"substr" | "substring"`). + m( + "substr", + Scalar, + 2, + 3, + no_trigger(), + Some(ColumnType::String), + arg_types::SUBSTRING_ARGS, + ), m( "concat", Scalar, @@ -74,5 +134,14 @@ pub(super) fn string_functions() -> Vec { Some(ColumnType::String), arg_types::REPLACE_ARGS, ), + m( + "reverse", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::String), + arg_types::STRING_1_ARGS, + ), ] } diff --git a/nodedb-sql/src/functions/registry.rs b/nodedb-sql/src/functions/registry.rs index 28cbf820e..fb9008d2c 100644 --- a/nodedb-sql/src/functions/registry.rs +++ b/nodedb-sql/src/functions/registry.rs @@ -275,4 +275,252 @@ mod tests { "lower must return String" ); } + + /// INVARIANT: every name the runtime evaluator + /// (`nodedb_query::functions::*::try_eval`) actually dispatches on MUST + /// resolve through `FunctionRegistry::lookup`, or `resolver::expr:: + /// functions::convert_function_depth`'s plan-time existence gate rejects + /// a call the evaluator would otherwise happily run — the exact bug + /// class this whole registry audit exists to close. + /// + /// Each list below is a literal transcription of one + /// `nodedb-query/src/functions/.rs` `try_eval` match arm's + /// name set (including every `|`-joined alias), NOT derived from this + /// registry — a name added to a `try_eval` match arm without a matching + /// registry entry must fail this test, not silently pass because both + /// sides drifted together. When either side changes, update the other. + #[test] + fn runtime_match_arms_are_all_registered() { + let reg = FunctionRegistry::new(); + let assert_all = |module: &str, names: &[&str]| { + for name in names { + assert!( + reg.lookup(name).is_some(), + "nodedb_query::functions::{module}::try_eval handles '{name}' \ + but FunctionRegistry has no entry for it — the \ + plan-time gate would reject a call the runtime evaluator supports" + ); + } + }; + + // `nodedb_query::functions::math::try_eval`. + assert_all( + "math", + &[ + "abs", "round", "ceil", "ceiling", "floor", "power", "pow", "sqrt", "mod", "sign", + "log", "ln", "log10", "log2", "exp", + ], + ); + + // `nodedb_query::functions::string::try_eval`. + assert_all( + "string", + &[ + "upper", + "lower", + "trim", + "ltrim", + "rtrim", + "length", + "char_length", + "character_length", + "substr", + "substring", + "concat", + "replace", + "reverse", + ], + ); + + // `nodedb_query::functions::conditional::try_eval`. + assert_all("conditional", &["coalesce", "nullif", "greatest", "least"]); + + // `nodedb_query::functions::id::try_eval`. + assert_all( + "id", + &[ + "uuid", + "uuid_v4", + "gen_random_uuid", + "uuid_v7", + "ulid", + "cuid2", + "nanoid", + "is_uuid", + "is_ulid", + "is_cuid2", + "is_nanoid", + "id_type", + "uuid_version", + "ulid_timestamp", + ], + ); + + // `nodedb_query::functions::datetime::try_eval`. Individual date + // parts (`year`, `month`, `day`, `hour`, `minute`, `second`, + // `dow`/`dayofweek`, `epoch`) are string literals consumed by + // `extract`/`date_part`/`date_trunc`, not standalone function + // names, so they are deliberately absent from this list. + assert_all( + "datetime", + &[ + "now", + "current_timestamp", + "datetime", + "to_datetime", + "unix_secs", + "epoch_secs", + "unix_millis", + "epoch_millis", + "extract", + "date_part", + "date_trunc", + "datetrunc", + "date_add", + "datetime_add", + "date_sub", + "datetime_sub", + "date_diff", + "datediff", + "duration", + "to_duration", + "decimal", + "to_decimal", + "time_bucket", + ], + ); + + // `nodedb_query::functions::json::{mod,legacy}::try_eval`. + assert_all( + "json", + &[ + "pg_json_get", + "pg_json_get_text", + "pg_json_path_get", + "pg_json_path_get_text", + "pg_json_contains", + "pg_json_contained_by", + "pg_json_has_key", + "pg_json_has_all_keys", + "pg_json_has_any_key", + "json_value", + "json_query", + "json_exists", + "json_extract", + "json_get", + "json_set", + "json_remove", + "json_keys", + "json_values", + "json_length", + "json_len", + "json_type", + "json_array", + "json_object", + "json_contains", + "json_merge", + "json_patch", + ], + ); + + // `nodedb_query::functions::types::try_eval`. + assert_all("types", &["typeof", "type_of"]); + + // `nodedb_query::functions::array::try_eval`. Distinct from the + // "Array engine" table-valued functions (`array_slice`/ + // `array_project`/`array_agg`/`array_elementwise`/`array_flush`/ + // `array_compact`), which are covered by + // `all_builtins_arg_counts_consistent` via their own registrations. + assert_all( + "array", + &[ + "array_length", + "cardinality", + "array_append", + "array_prepend", + "array_remove", + "array_concat", + "array_cat", + "array_distinct", + "array_contains", + "array_position", + "array_reverse", + ], + ); + + // `nodedb_query::functions::fts::pg_fts_exec::try_eval_fts`. + assert_all( + "fts", + &[ + "pg_fts_match", + "pg_to_tsquery", + "pg_plainto_tsquery", + "pg_websearch_to_tsquery", + "pg_phraseto_tsquery", + "pg_to_tsvector", + "pg_ts_rank", + "pg_ts_headline", + ], + ); + + // `nodedb_query::functions::system::try_eval`. + assert_all( + "system", + &["version", "format_type", "pg_get_expr", "col_description"], + ); + + // `nodedb_query::geo_functions::eval_geo_function`. Distinct from + // the other modules above: this one lives in `nodedb-query`'s + // top-level `geo_functions` module (not `functions::`), and + // is dispatched into from `functions::eval_function` for any + // geo-prefixed/ST_-prefixed name — every `|`-joined alias on its + // match arms is flattened into this one list, same as `math`'s + // `ceil`/`ceiling` pairing above. + assert_all( + "geo_functions", + &[ + "geo_distance", + "haversine_distance", + "geo_bearing", + "haversine_bearing", + "geo_point", + "st_point", + "geo_geohash", + "geo_geohash_decode", + "geo_geohash_neighbors", + "st_contains", + "st_intersects", + "st_within", + "st_disjoint", + "st_dwithin", + "st_distance", + "st_buffer", + "st_envelope", + "st_union", + "geo_length", + "geo_perimeter", + "geo_line", + "geo_polygon", + "geo_circle", + "geo_bbox", + "geo_as_geojson", + "geo_from_geojson", + "geo_as_wkt", + "geo_from_wkt", + "geo_x", + "geo_y", + "geo_num_points", + "geo_type", + "geo_is_valid", + "geo_h3", + "geo_h3_to_boundary", + "geo_h3_resolution", + "st_geohash", + "st_geohashdecode", + "h3_latlngtocell", + "h3_celltolatlng", + "st_intersection", + ], + ); + } } diff --git a/nodedb-sql/src/planner/const_fold.rs b/nodedb-sql/src/planner/const_fold.rs index 689c568dd..52d2c0b3b 100644 --- a/nodedb-sql/src/planner/const_fold.rs +++ b/nodedb-sql/src/planner/const_fold.rs @@ -240,7 +240,14 @@ pub fn fold_function_call( .map(|a| fold_constant(a, registry).map(sql_to_ndb_value)) .collect::>()?; - let result = nodedb_query::functions::eval_function(&name.to_lowercase(), &folded_args); + // A call that can fail at plan-time constant-fold (e.g. `mod(5, 0)` + // with literal args) is treated the same as any other + // unfoldable expression — `None` here leaves the `SqlExpr::Function` + // node in place so it flows to the row-scope evaluator, which raises + // the real `22012` error at execution time. This mirrors how + // `fold_binary`'s `checked_div`/`checked_rem` already decline to fold + // (via `?`) rather than baking a bad result into the plan. + let result = nodedb_query::functions::eval_function(&name.to_lowercase(), &folded_args).ok()?; Some(ndb_to_sql_value(result)) } diff --git a/nodedb-sql/src/resolver/expr/functions.rs b/nodedb-sql/src/resolver/expr/functions.rs index 362a10b75..33ff14794 100644 --- a/nodedb-sql/src/resolver/expr/functions.rs +++ b/nodedb-sql/src/resolver/expr/functions.rs @@ -1,13 +1,24 @@ // SPDX-License-Identifier: Apache-2.0 +use std::sync::LazyLock; + use sqlparser::ast; use crate::error::{Result, SqlError}; +use crate::functions::registry::FunctionRegistry; use crate::parser::normalize::{SCHEMA_QUALIFIED_MSG, normalize_ident}; use crate::types::*; use super::convert::convert_expr_depth; +/// Process-wide function registry backing the undefined-function gate in +/// [`convert_function_depth`]. `FunctionRegistry::new()` rebuilds the full +/// builtin table (aggregates + scalars) from scratch, so a fresh instance +/// per function-call site would redo that work on every call in every +/// statement; a lazily-initialized singleton amortizes it, mirroring +/// `planner::const_fold::DEFAULT_REGISTRY`. +static FUNCTION_REGISTRY: LazyLock = LazyLock::new(FunctionRegistry::new); + pub(super) fn convert_function_depth(func: &ast::Function, depth: &mut usize) -> Result { // Intercept PG FTS surface functions and lower them to pg_* internal names // before the generic path runs. @@ -56,6 +67,20 @@ pub(super) fn convert_function_depth(func: &ast::Function, depth: &mut usize) -> return Ok(expr); } + // Plan-time existence gate: every special-form + // interception above (FTS surface functions, catalog functions) has + // already had its shot at `name` and returned early. Anything reaching + // this point is a plain scalar/aggregate/window call that must be a + // known builtin — `FunctionRegistry` is the single source of truth used + // by aggregate/window classification and constant folding elsewhere in + // this crate, so an unknown name here would otherwise flow through to + // `SqlExpr::Function` and silently evaluate to `NULL` for every row at + // runtime (`nodedb_query::functions::eval_function`'s fallback). Reject + // it here instead, so the whole statement fails at plan time. + if FUNCTION_REGISTRY.lookup(&name).is_none() { + return Err(SqlError::UndefinedFunction { name }); + } + let args = match &func.args { ast::FunctionArguments::None => Vec::new(), ast::FunctionArguments::Subquery(_) => { @@ -198,3 +223,78 @@ fn collect_function_args(func: &ast::Function, depth: &mut usize) -> Result>>(), } } + +#[cfg(test)] +mod tests { + use sqlparser::dialect::GenericDialect; + use sqlparser::parser::Parser; + + use super::*; + + /// Parse `SELECT ` and return the sole projection expression. + fn parse_expr(sql: &str) -> ast::Expr { + let stmts = Parser::parse_sql(&GenericDialect {}, sql).unwrap(); + match stmts.into_iter().next().unwrap() { + ast::Statement::Query(q) => match *q.body { + ast::SetExpr::Select(s) => match s.projection.into_iter().next().unwrap() { + ast::SelectItem::UnnamedExpr(e) => e, + ast::SelectItem::ExprWithAlias { expr, .. } => expr, + other => panic!("expected a bare/aliased expr projection, got {other:?}"), + }, + _ => panic!("expected SELECT"), + }, + _ => panic!("expected query"), + } + } + + /// Parse `SELECT ` and return the function-call AST node. + fn function_ast(sql: &str) -> ast::Function { + match parse_expr(sql) { + ast::Expr::Function(f) => f, + other => panic!("expected a function call expr, got {other:?}"), + } + } + + #[test] + fn unknown_function_name_is_rejected() { + let func = function_ast("SELECT totally_bogus_fn(1, 2)"); + let mut depth = 0; + let err = convert_function_depth(&func, &mut depth).unwrap_err(); + match err { + SqlError::UndefinedFunction { name } => assert_eq!(name, "totally_bogus_fn"), + other => panic!("expected SqlError::UndefinedFunction, got {other:?}"), + } + } + + #[test] + fn known_scalar_function_name_resolves() { + let func = function_ast("SELECT upper('x')"); + let mut depth = 0; + let expr = convert_function_depth(&func, &mut depth).unwrap(); + match expr { + SqlExpr::Function { name, .. } => assert_eq!(name, "upper"), + other => panic!("expected SqlExpr::Function, got {other:?}"), + } + } + + /// `func.name` is only lower-cased upstream (`normalize_ident`) for + /// *unquoted* identifiers, so a quoted `"UPPER"` reaches the registry + /// gate with its original case intact. The gate must still resolve it — + /// `FunctionRegistry::lookup` case-folds internally — proving the + /// existence check itself is case-insensitive, not merely riding on + /// upstream normalization. + #[test] + fn function_lookup_is_case_insensitive() { + let mut depth = 0; + let lower = function_ast("SELECT upper('x')"); + let upper_quoted = function_ast(r#"SELECT "UPPER"('x')"#); + assert!( + convert_function_depth(&lower, &mut depth).is_ok(), + "lowercase 'upper' must resolve" + ); + assert!( + convert_function_depth(&upper_quoted, &mut depth).is_ok(), + "quoted 'UPPER' must still resolve via case-insensitive registry lookup" + ); + } +} diff --git a/nodedb-types/src/error/code.rs b/nodedb-types/src/error/code.rs index cda88e83f..57f4d6ac5 100644 --- a/nodedb-types/src/error/code.rs +++ b/nodedb-types/src/error/code.rs @@ -43,6 +43,10 @@ impl ErrorCode { pub const PLAN_ERROR: Self = Self(1200); pub const FAN_OUT_EXCEEDED: Self = Self(1201); pub const SQL_NOT_ENABLED: Self = Self(1202); + /// A function call names no registered scalar/aggregate/window function. + pub const UNDEFINED_FUNCTION: Self = Self(1203); + /// Expression evaluation divided or took a modulus by zero. + pub const DIVISION_BY_ZERO: Self = Self(1204); // Engine ops (1300–1399) pub const ARRAY: Self = Self(1300); diff --git a/nodedb-types/src/error/ctors/read_query_auth.rs b/nodedb-types/src/error/ctors/read_query_auth.rs index dfad8a985..e096f1ddc 100644 --- a/nodedb-types/src/error/ctors/read_query_auth.rs +++ b/nodedb-types/src/error/ctors/read_query_auth.rs @@ -120,6 +120,33 @@ impl NodeDbError { } } + /// A function call in a query names no registered scalar, aggregate, or + /// window function. Distinct from `plan_error` so clients can match on + /// the specific code (SQLSTATE `42883`, `undefined_function`) rather + /// than parsing the message. + pub fn undefined_function(name: impl Into) -> Self { + let name = name.into(); + Self { + code: ErrorCode::UNDEFINED_FUNCTION, + message: format!("function {name}(...) does not exist"), + details: ErrorDetails::UndefinedFunction { name }, + cause: None, + } + } + + /// Expression evaluation divided or took a modulus by zero. Distinct + /// from `plan_error` so clients can match on the specific code + /// (SQLSTATE `22012`, `division_by_zero`) rather than parsing the + /// message. + pub fn division_by_zero() -> Self { + Self { + code: ErrorCode::DIVISION_BY_ZERO, + message: "division by zero".to_string(), + details: ErrorDetails::DivisionByZero, + cause: None, + } + } + pub fn authorization_denied(resource: impl Into) -> Self { let resource = resource.into(); Self { diff --git a/nodedb-types/src/error/details.rs b/nodedb-types/src/error/details.rs index edf1a8892..ecf976aca 100644 --- a/nodedb-types/src/error/details.rs +++ b/nodedb-types/src/error/details.rs @@ -80,6 +80,12 @@ pub enum ErrorDetails { FanOutExceeded { shards_touched: u16, limit: u16 }, #[serde(rename = "sql_not_enabled")] SqlNotEnabled, + /// A function call names no registered scalar/aggregate/window function. + #[serde(rename = "undefined_function")] + UndefinedFunction { name: String }, + /// Expression evaluation divided or took a modulus by zero. + #[serde(rename = "division_by_zero")] + DivisionByZero, // Auth #[serde(rename = "authorization_denied")] diff --git a/nodedb-types/src/error/msgpack/constants.rs b/nodedb-types/src/error/msgpack/constants.rs index 2f50fbfd4..114ac9db1 100644 --- a/nodedb-types/src/error/msgpack/constants.rs +++ b/nodedb-types/src/error/msgpack/constants.rs @@ -69,6 +69,11 @@ // | 61 | MoveTenantSnapshotFailed | // | 62 | MoveTenantCutoverFailed | // | 63 | MoveTenantAlreadyAtTarget | +// | 64 | MirrorReadOnly | +// | 65 | StaleReadNotLeader | +// | 66 | MirrorNotPromoted | +// | 67 | UndefinedFunction | +// | 68 | DivisionByZero | pub(super) const TAG_CONSTRAINT_VIOLATION: u16 = 1; pub(super) const TAG_WRITE_CONFLICT: u16 = 2; @@ -136,3 +141,5 @@ pub(super) const TAG_MOVE_TENANT_ALREADY_AT_TARGET: u16 = 63; pub(super) const TAG_MIRROR_READ_ONLY: u16 = 64; pub(super) const TAG_STALE_READ_NOT_LEADER: u16 = 65; pub(super) const TAG_MIRROR_NOT_PROMOTED: u16 = 66; +pub(super) const TAG_UNDEFINED_FUNCTION: u16 = 67; +pub(super) const TAG_DIVISION_BY_ZERO: u16 = 68; diff --git a/nodedb-types/src/error/msgpack/decode/from_messagepack.rs b/nodedb-types/src/error/msgpack/decode/from_messagepack.rs index 5abd2807a..cfacae913 100644 --- a/nodedb-types/src/error/msgpack/decode/from_messagepack.rs +++ b/nodedb-types/src/error/msgpack/decode/from_messagepack.rs @@ -123,6 +123,14 @@ impl<'a> FromMessagePack<'a> for ErrorDetails { skip_fields(reader, field_count)?; Ok(ErrorDetails::SqlNotEnabled) } + TAG_UNDEFINED_FUNCTION => { + let (name,) = read1_str(reader, field_count)?; + Ok(ErrorDetails::UndefinedFunction { name }) + } + TAG_DIVISION_BY_ZERO => { + skip_fields(reader, field_count)?; + Ok(ErrorDetails::DivisionByZero) + } TAG_AUTHORIZATION_DENIED => { let (resource,) = read1_str(reader, field_count)?; Ok(ErrorDetails::AuthorizationDenied { resource }) @@ -346,6 +354,7 @@ mod tests { for v in [ ErrorDetails::DeadlineExceeded, ErrorDetails::SqlNotEnabled, + ErrorDetails::DivisionByZero, ErrorDetails::AuthExpired, ErrorDetails::SyncConnectionFailed, ErrorDetails::Config, diff --git a/nodedb-types/src/error/msgpack/encode.rs b/nodedb-types/src/error/msgpack/encode.rs index 123e674a0..684e3599e 100644 --- a/nodedb-types/src/error/msgpack/encode.rs +++ b/nodedb-types/src/error/msgpack/encode.rs @@ -153,6 +153,10 @@ impl ToMessagePack for ErrorDetails { limit, } => write2(writer, TAG_FAN_OUT_EXCEEDED, shards_touched, limit), ErrorDetails::SqlNotEnabled => write_unit(writer, TAG_SQL_NOT_ENABLED), + ErrorDetails::UndefinedFunction { name } => { + write1(writer, TAG_UNDEFINED_FUNCTION, name) + } + ErrorDetails::DivisionByZero => write_unit(writer, TAG_DIVISION_BY_ZERO), ErrorDetails::AuthorizationDenied { resource } => { write1(writer, TAG_AUTHORIZATION_DENIED, resource) } diff --git a/nodedb-types/src/error/sqlstate.rs b/nodedb-types/src/error/sqlstate.rs index 2ed472fec..af330a98a 100644 --- a/nodedb-types/src/error/sqlstate.rs +++ b/nodedb-types/src/error/sqlstate.rs @@ -40,6 +40,10 @@ pub const CANNOT_DROP_DEFAULT_DATABASE: &str = "0A000"; /// `22003` — `numeric_value_out_of_range` pub const NUMERIC_VALUE_OUT_OF_RANGE: &str = "22003"; +/// `22012` — `division_by_zero` (`/` or `%` with a zero divisor — +/// raised at runtime instead of evaluating to `NULL`) +pub const DIVISION_BY_ZERO: &str = "22012"; + // ── Class 23 — Integrity Constraint Violation ──────────────────────────────── /// `23000` — `integrity_constraint_violation` (generic) @@ -105,6 +109,10 @@ pub const CANNOT_COERCE: &str = "42846"; /// `42P01` — `undefined_table` (collection not found) pub const UNDEFINED_TABLE: &str = "42P01"; +/// `42883` — `undefined_function` (no scalar/aggregate/window function +/// registered under the called name) +pub const UNDEFINED_FUNCTION: &str = "42883"; + // ── Class 53 — Insufficient Resources ──────────────────────────────────────── /// `53200` — `out_of_memory` @@ -237,6 +245,7 @@ mod tests { NO_DATA, FEATURE_NOT_SUPPORTED, NUMERIC_VALUE_OUT_OF_RANGE, + DIVISION_BY_ZERO, INTEGRITY_CONSTRAINT_VIOLATION, NOT_NULL_VIOLATION, FOREIGN_KEY_VIOLATION, @@ -256,6 +265,7 @@ mod tests { SYNTAX_ERROR, CANNOT_COERCE, UNDEFINED_TABLE, + UNDEFINED_FUNCTION, OUT_OF_MEMORY, TOO_MANY_CONNECTIONS, CONFIGURATION_LIMIT_EXCEEDED, diff --git a/nodedb-types/src/error/types.rs b/nodedb-types/src/error/types.rs index c82e2a6db..ab94bd2af 100644 --- a/nodedb-types/src/error/types.rs +++ b/nodedb-types/src/error/types.rs @@ -98,6 +98,8 @@ impl NodeDbError { | ErrorDetails::AuthExpired | ErrorDetails::Config | ErrorDetails::SqlNotEnabled + | ErrorDetails::UndefinedFunction { .. } + | ErrorDetails::DivisionByZero ) } } diff --git a/nodedb/src/bridge/envelope.rs b/nodedb/src/bridge/envelope.rs index 26f9e71f5..c0d65f0f1 100644 --- a/nodedb/src/bridge/envelope.rs +++ b/nodedb/src/bridge/envelope.rs @@ -381,8 +381,9 @@ pub enum ErrorCode { LegalHoldActive { collection: String }, /// State transition not in allowed list. StateTransitionViolation { collection: String, detail: String }, - /// Transition check predicate returned false. - TransitionCheckViolation { collection: String }, + /// Transition check predicate returned false, or failed to evaluate — + /// `detail` distinguishes the two. + TransitionCheckViolation { collection: String, detail: String }, /// Type guard violation: field type mismatch or REQUIRED absent. TypeGuardViolation { collection: String, detail: String }, /// Value type does not match expected type for operation (e.g. INCR on a string). @@ -426,6 +427,13 @@ pub enum ErrorCode { /// transaction is too large to stage rather than that it hit an internal /// fault. TxnOverlayMemoryExceeded { limit: usize }, + /// Expression evaluation divided or took a modulus by zero. Distinct + /// from `Internal` so it survives the Data Plane + /// → pgwire boundary (including `result_stream::stream_response_channel`, + /// which special-cases this variant the same way it already + /// special-cases `NotFound`) and reaches the client as SQLSTATE `22012` + /// rather than the generic `XX000` every `Internal` maps to. + DivisionByZero, } impl From for ErrorCode { @@ -462,8 +470,8 @@ impl From for ErrorCode { crate::Error::StateTransitionViolation { collection, detail, .. } => Self::StateTransitionViolation { collection, detail }, - crate::Error::TransitionCheckViolation { collection, .. } => { - Self::TransitionCheckViolation { collection } + crate::Error::TransitionCheckViolation { collection, detail } => { + Self::TransitionCheckViolation { collection, detail } } crate::Error::TypeGuardViolation { collection, detail, .. @@ -486,6 +494,7 @@ impl From for ErrorCode { crate::Error::TxnOverlayMemoryExceeded { limit } => { Self::TxnOverlayMemoryExceeded { limit } } + crate::Error::DivisionByZero => Self::DivisionByZero, other => Self::Internal { detail: other.to_string(), }, @@ -493,6 +502,27 @@ impl From for ErrorCode { } } +impl ErrorCode { + /// Map a Data-Plane error [`ErrorCode`] (carried on an error [`Response`]) + /// back to a typed [`crate::Error`]. + /// + /// Control-plane consumers that collapse a shard `Response` into a single + /// `crate::Result` (the single-vShard `owning_core` read path, the + /// scatter-gather merge) must not degrade a typed code to a generic + /// `Dispatch` — that surfaces at pgwire as SQLSTATE `XX000` instead of the + /// code's real SQLSTATE. Codes with a dedicated `crate::Error` variant + /// round-trip (e.g. `DivisionByZero` → `22012`); the rest keep the prior + /// behavior of a `Dispatch` carrying the code's debug name. + pub(crate) fn into_dispatch_error(&self) -> crate::Error { + match self { + ErrorCode::DivisionByZero => crate::Error::DivisionByZero, + other => crate::Error::Dispatch { + detail: format!("{other:?}"), + }, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/nodedb/src/control/insert_select/copy_rows.rs b/nodedb/src/control/insert_select/copy_rows.rs index 56a7f3648..0858d69b5 100644 --- a/nodedb/src/control/insert_select/copy_rows.rs +++ b/nodedb/src/control/insert_select/copy_rows.rs @@ -129,7 +129,9 @@ pub(crate) fn assign_page_rows( Some(schema) => binary_tuple_to_msgpack(&raw, schema).unwrap_or(raw), None => raw, }; - if !spec.filters.is_empty() && !spec.filters.iter().all(|f| f.matches_binary(&value)) { + if !spec.filters.is_empty() + && !crate::bridge::scan_filter::ScanFilter::all_match_binary(&spec.filters, &value)? + { continue; } let surrogate = assign_target_surrogate( diff --git a/nodedb/src/control/planner/context/query/planning.rs b/nodedb/src/control/planner/context/query/planning.rs index 2e0d49ba4..a075adbb2 100644 --- a/nodedb/src/control/planner/context/query/planning.rs +++ b/nodedb/src/control/planner/context/query/planning.rs @@ -90,6 +90,9 @@ impl QueryContext { tenant_id, collection: name, }, + nodedb_sql::SqlError::UndefinedFunction { name } => { + crate::Error::UndefinedFunction { name } + } other => crate::Error::PlanError { detail: format!("{other}"), }, @@ -113,6 +116,9 @@ impl QueryContext { tenant_id, collection: name, }, + nodedb_sql::SqlError::UndefinedFunction { name } => { + crate::Error::UndefinedFunction { name } + } other => crate::Error::PlanError { detail: other.to_string(), }, @@ -281,6 +287,9 @@ impl QueryContext { tenant_id, collection: name, }, + nodedb_sql::SqlError::UndefinedFunction { name } => { + crate::Error::UndefinedFunction { name } + } other => crate::Error::PlanError { detail: other.to_string(), }, @@ -302,6 +311,9 @@ impl QueryContext { tenant_id, collection: name, }, + nodedb_sql::SqlError::UndefinedFunction { name } => { + crate::Error::UndefinedFunction { name } + } other => crate::Error::PlanError { detail: other.to_string(), }, diff --git a/nodedb/src/control/security/rls/eval.rs b/nodedb/src/control/security/rls/eval.rs index b74044510..2477d3c3d 100644 --- a/nodedb/src/control/security/rls/eval.rs +++ b/nodedb/src/control/security/rls/eval.rs @@ -133,7 +133,7 @@ fn check_compiled_write( }; let doc_mp = nodedb_types::json_to_msgpack_or_empty(document); - if !filters.iter().all(|f| f.matches_binary(&doc_mp)) { + if !crate::bridge::scan_filter::ScanFilter::all_match_binary(&filters, &doc_mp)? { info!( policy = %policy.name, username = %auth.username, diff --git a/nodedb/src/control/server/exchange/gather.rs b/nodedb/src/control/server/exchange/gather.rs index 06a9a72bd..10b96632b 100644 --- a/nodedb/src/control/server/exchange/gather.rs +++ b/nodedb/src/control/server/exchange/gather.rs @@ -208,15 +208,18 @@ pub(crate) async fn gather_all_cores( // the read plan targets one collection, so one non-zero value survives. let mut max_read_version = Lsn::ZERO; let mut shard_watermarks: Vec<(VShardId, Lsn)> = Vec::new(); - let mut had_error = false; - let mut error_msg = String::new(); + // First error seen across cores, kept as a TYPED `crate::Error` so a code + // like `DivisionByZero` surfaces as SQLSTATE 22012 rather than collapsing + // to a generic `Dispatch` (XX000). Only surfaced if no core produced data. + let mut first_error: Option = None; for (core_id, result) in results { let resp = match result { Ok(r) => r, Err(e) => { - had_error = true; - error_msg = e.to_string(); + if first_error.is_none() { + first_error = Some(e); + } continue; } }; @@ -226,8 +229,9 @@ pub(crate) async fn gather_all_cores( match ec { crate::bridge::envelope::ErrorCode::NotFound => continue, _ => { - had_error = true; - error_msg = format!("{ec:?}"); + if first_error.is_none() { + first_error = Some(ec.into_dispatch_error()); + } } } } @@ -255,8 +259,11 @@ pub(crate) async fn gather_all_cores( all_elements.extend(extract_msgpack_elements(payload_bytes)); } - if had_error && all_elements.is_empty() && raw.is_empty() { - return Err(crate::Error::Dispatch { detail: error_msg }); + if all_elements.is_empty() + && raw.is_empty() + && let Some(err) = first_error + { + return Err(err); } let merged_array = encode_msgpack_array(&all_elements); diff --git a/nodedb/src/control/server/exchange/owning_core.rs b/nodedb/src/control/server/exchange/owning_core.rs index 02df697d6..527551d0b 100644 --- a/nodedb/src/control/server/exchange/owning_core.rs +++ b/nodedb/src/control/server/exchange/owning_core.rs @@ -114,9 +114,9 @@ pub async fn gather_single_owning_core( && let Some(ec) = resp.error_code.as_deref() && !matches!(ec, ErrorCode::NotFound) { - return Err(crate::Error::Dispatch { - detail: format!("{ec:?}"), - }); + // Preserve typed codes (e.g. `DivisionByZero` → SQLSTATE 22012) instead + // of collapsing every Data-Plane error to a generic `Dispatch` (XX000). + return Err(ec.into_dispatch_error()); } let payload_bytes: &[u8] = resp.payload.as_ref(); diff --git a/nodedb/src/control/server/pgwire/types/error_map.rs b/nodedb/src/control/server/pgwire/types/error_map.rs index b03a44f42..82d0273ba 100644 --- a/nodedb/src/control/server/pgwire/types/error_map.rs +++ b/nodedb/src/control/server/pgwire/types/error_map.rs @@ -38,6 +38,12 @@ pub fn error_to_sqlstate(err: &crate::Error) -> (&'static str, &'static str, Str it is hard-deleted" ), ), + crate::Error::UndefinedFunction { name } => ( + "ERROR", + sqlstate::UNDEFINED_FUNCTION, + format!("function {name}(...) does not exist"), + ), + crate::Error::DivisionByZero => ("ERROR", sqlstate::DIVISION_BY_ZERO, err.to_string()), crate::Error::DocumentNotFound { collection, document_id, @@ -134,6 +140,10 @@ pub(crate) fn numeric_code_to_sqlstate(code: nodedb_types::error::ErrorCode) -> Ec::DOCUMENT_NOT_FOUND => sqlstate::NO_DATA, // Mirrors the `BadRequest` / `PlanError` arms. Ec::BAD_REQUEST | Ec::PLAN_ERROR => sqlstate::SYNTAX_ERROR, + // Mirrors the `UndefinedFunction` arm. + Ec::UNDEFINED_FUNCTION => sqlstate::UNDEFINED_FUNCTION, + // Mirrors the `DivisionByZero` arm. + Ec::DIVISION_BY_ZERO => sqlstate::DIVISION_BY_ZERO, // Mirrors the `FanOutExceeded` arm. Ec::FAN_OUT_EXCEEDED => sqlstate::STATEMENT_TOO_COMPLEX, // Mirrors the `RejectedAuthz` arm. diff --git a/nodedb/src/control/server/response_translate/vector.rs b/nodedb/src/control/server/response_translate/vector.rs index 68e27a3a1..6d9619de9 100644 --- a/nodedb/src/control/server/response_translate/vector.rs +++ b/nodedb/src/control/server/response_translate/vector.rs @@ -56,8 +56,16 @@ fn apply_rls_filter(hits: &mut Vec, rls_filters: &[u8], top_k: usize) { return; } }; + // RLS is a security boundary: fail closed. A division/modulo-by-zero + // evaluating a filter against one hit drops that hit rather than + // showing it, the same way a filter that evaluates to + // `false` already excludes it — matching the "deny on any doubt" + // posture the corrupt-filter branch above already uses. This + // response-translation layer has no natural way to fail the whole + // request without larger dispatch-chain changes, and dropping hits is + // the strictly safer outcome for an RLS filter regardless. hits.retain(|h| match h.body.as_deref() { - Some(body) => filters.iter().all(|f| f.matches_binary(body)), + Some(body) => ScanFilter::all_match_binary(&filters, body).unwrap_or(false), None => false, }); if hits.len() > top_k { diff --git a/nodedb/src/control/server/result_stream.rs b/nodedb/src/control/server/result_stream.rs index f9b88c521..bcf7260d6 100644 --- a/nodedb/src/control/server/result_stream.rs +++ b/nodedb/src/control/server/result_stream.rs @@ -66,6 +66,20 @@ pub(crate) fn stream_response_channel( // No rows on this source — end the stream cleanly. return; } + // A streamed Data Plane `Response` error otherwise degrades + // to the generic `crate::Error::Dispatch` + // below (SQLSTATE XX000) — special-cased here the same way + // `NotFound` already is, so a division/modulo-by-zero + // survives this stream-consumption boundary and reaches + // pgwire as SQLSTATE `22012` instead of a generic internal + // error. + if matches!( + resp.error_code.as_deref(), + Some(crate::bridge::envelope::ErrorCode::DivisionByZero) + ) { + Err(crate::Error::DivisionByZero)?; + return; + } let detail = match resp.error_code { Some(ref ec) => format!("data plane error: {ec:?}"), None => "unknown data plane error".to_string(), diff --git a/nodedb/src/control/server/shared/check_constraint/simple.rs b/nodedb/src/control/server/shared/check_constraint/simple.rs index 705aa3e8d..8c169bbe7 100644 --- a/nodedb/src/control/server/shared/check_constraint/simple.rs +++ b/nodedb/src/control/server/shared/check_constraint/simple.rs @@ -35,8 +35,19 @@ pub(super) fn enforce_simple_check( // Build a Value::Object from the fields for evaluation. let doc = nodedb_types::Value::Object(fields.clone()); - // Evaluate the expression against the document. - let result = expr.eval(&doc); + // Evaluate the expression against the document. A division/modulo-by-zero + // is neither a PASS nor an ordinary FAIL — it's an evaluation error, + // reported under SQLSTATE 22012 rather than folded + // into the 23514 (check_violation) the ordinary-failure arm below uses. + let result = expr.eval(&doc).map_err(|e| { + ddl_err( + nodedb_types::error::sqlstate::DIVISION_BY_ZERO, + &format!( + "CHECK constraint '{}' failed to evaluate: {}: {e}", + constraint.name, constraint.check_sql + ), + ) + })?; // NULL passes CHECK (SQL semantics: NULL is not FALSE). match result { diff --git a/nodedb/src/control/server/shared/ddl/neutral/query_functions/balance_as_of.rs b/nodedb/src/control/server/shared/ddl/neutral/query_functions/balance_as_of.rs index b7c0aada2..0c15c5471 100644 --- a/nodedb/src/control/server/shared/ddl/neutral/query_functions/balance_as_of.rs +++ b/nodedb/src/control/server/shared/ddl/neutral/query_functions/balance_as_of.rs @@ -152,7 +152,13 @@ pub async fn balance_as_of( } let src_val = nodedb_types::Value::from(src_doc.clone()); - let delta_val = serde_json::Value::from(mat_def.value_expr.eval(&src_val)); + let delta_val = + serde_json::Value::from(mat_def.value_expr.eval(&src_val).map_err(|e| { + err( + nodedb_types::error::sqlstate::DIVISION_BY_ZERO, + &format!("BALANCE_AS_OF: value_expr failed to evaluate: {e}"), + ) + })?); if let Some(d) = json_to_decimal(&delta_val) { recent_sum += d; } diff --git a/nodedb/src/control/server/shared/ddl/neutral/query_functions/verify_balance.rs b/nodedb/src/control/server/shared/ddl/neutral/query_functions/verify_balance.rs index f2adecdf5..9771e4ebe 100644 --- a/nodedb/src/control/server/shared/ddl/neutral/query_functions/verify_balance.rs +++ b/nodedb/src/control/server/shared/ddl/neutral/query_functions/verify_balance.rs @@ -143,7 +143,13 @@ pub async fn verify_balance( continue; } let src_val = nodedb_types::Value::from(src_doc.clone()); - let delta = serde_json::Value::from(mat_def.value_expr.eval(&src_val)); + let delta = + serde_json::Value::from(mat_def.value_expr.eval(&src_val).map_err(|e| { + err( + nodedb_types::error::sqlstate::DIVISION_BY_ZERO, + &format!("VERIFY_BALANCE: value_expr failed to evaluate: {e}"), + ) + })?); if let Some(d) = json_to_decimal(&delta) { computed += d; } diff --git a/nodedb/src/control/server/shared/ddl/sql_parse.rs b/nodedb/src/control/server/shared/ddl/sql_parse.rs index 3788ee2fe..bf94e7adf 100644 --- a/nodedb/src/control/server/shared/ddl/sql_parse.rs +++ b/nodedb/src/control/server/shared/ddl/sql_parse.rs @@ -101,7 +101,13 @@ fn try_eval_scalar_function(s: &str) -> Option { // registry so we don't accidentally evaluate user identifiers. let registry = nodedb_sql::planner::const_fold::default_registry(); if registry.lookup(&name).is_some() { - let val = nodedb_query::functions::eval_function(&name, &[]); + // Zero-arg call: `math::try_eval`'s zero-modulus arm (the only + // fallible arm in the scalar-function table) cannot be reached + // with an empty argument list, so folding the + // `Result` to `Value::Null` on error is unreachable in practice, + // not a silent swallow. + let val = nodedb_query::functions::eval_function(&name, &[]) + .unwrap_or(nodedb_types::Value::Null); if !matches!(val, nodedb_types::Value::Null) { return Some(val); } diff --git a/nodedb/src/control/server/shared/ddl/sqlstate.rs b/nodedb/src/control/server/shared/ddl/sqlstate.rs index 053d40cab..864890065 100644 --- a/nodedb/src/control/server/shared/ddl/sqlstate.rs +++ b/nodedb/src/control/server/shared/ddl/sqlstate.rs @@ -92,10 +92,10 @@ pub fn error_code_to_sqlstate(code: &ErrorCode) -> (&'static str, &'static str, sqlstate::STATE_TRANSITION_VIOLATION, format!("state transition violation on {collection}: {detail}"), ), - ErrorCode::TransitionCheckViolation { collection } => ( + ErrorCode::TransitionCheckViolation { collection, detail } => ( "ERROR", sqlstate::TRANSITION_CHECK_VIOLATION, - format!("transition check violation on {collection}"), + format!("transition check violation on {collection}: {detail}"), ), ErrorCode::TypeGuardViolation { collection, detail } => ( "ERROR", @@ -144,6 +144,12 @@ pub fn error_code_to_sqlstate(code: &ErrorCode) -> (&'static str, &'static str, ), ), ErrorCode::Internal { detail } => ("ERROR", sqlstate::INTERNAL_ERROR, detail.clone()), + // Division/modulo by zero. + ErrorCode::DivisionByZero => ( + "ERROR", + sqlstate::DIVISION_BY_ZERO, + "division by zero".into(), + ), ErrorCode::Unsupported { detail } => { ("ERROR", sqlstate::FEATURE_NOT_SUPPORTED, detail.clone()) } diff --git a/nodedb/src/control/trigger/batch/before.rs b/nodedb/src/control/trigger/batch/before.rs index 1a75abff8..40509ab97 100644 --- a/nodedb/src/control/trigger/batch/before.rs +++ b/nodedb/src/control/trigger/batch/before.rs @@ -95,13 +95,15 @@ pub async fn execute_before_batch( for trigger in &before_triggers { let op_str = event.as_str(); - // Pre-filter by WHEN clause. + // Pre-filter by WHEN clause. A BEFORE trigger runs pre-commit, so a + // division/modulo-by-zero in its WHEN predicate fails the statement + // (SQLSTATE 22012) rather than folding to "does not fire". let mask = when_filter::filter_batch_by_when( &rows, collection, op_str, trigger.when_condition.as_deref(), - ); + )?; let effective_identity = resolve_trigger_identity(trigger, identity, tenant_id); diff --git a/nodedb/src/control/trigger/batch/when_filter.rs b/nodedb/src/control/trigger/batch/when_filter.rs index a111c76de..b68e27840 100644 --- a/nodedb/src/control/trigger/batch/when_filter.rs +++ b/nodedb/src/control/trigger/batch/when_filter.rs @@ -30,15 +30,22 @@ use crate::control::trigger::when_parse::WhenTarget; /// /// If raw bytes are unavailable (rows created via `from_decoded`, i.e. in /// tests) the function falls through to the existing decode + substitute path. +/// +/// A division/modulo-by-zero in the WHEN predicate returns +/// `Err(EvalError::DivisionByZero)` rather than folding a row to "does not +/// fire". Callers decide what that means for their timing: a pre-commit BEFORE +/// trigger propagates it and fails the statement (SQLSTATE 22012); a +/// post-commit AFTER dispatcher (which has no statement left to fail) logs it +/// and skips the trigger. pub fn filter_batch_by_when( rows: &[TriggerBatchRow], collection: &str, operation: &str, when_condition: Option<&str>, -) -> Vec { +) -> Result, nodedb_query::EvalError> { let when_cond = match when_condition { Some(cond) => cond, - None => return vec![true; rows.len()], + None => return Ok(vec![true; rows.len()]), }; // Attempt to parse as binary-evaluable filter(s) — supports AND-joined predicates. @@ -53,13 +60,17 @@ pub fn filter_batch_by_when( WhenTarget::Old => row.old_raw(), }; match raw { - Some(bytes) => filters.iter().all(|f| f.matches_binary(bytes)), + // A division/modulo-by-zero propagates as `Err` — never a + // silent "does not fire". + Some(bytes) => { + crate::bridge::scan_filter::ScanFilter::all_match_binary(&filters, bytes) + } // No raw bytes (test rows from from_decoded) — fall back to // the decode + substitute path for this row. None => { let bindings = build_row_bindings(row, collection, operation); let bound_cond = bindings.substitute(when_cond); - evaluate_simple_condition(&bound_cond) + Ok(evaluate_simple_condition(&bound_cond)) } } }) @@ -67,13 +78,14 @@ pub fn filter_batch_by_when( } // General path: substitute bindings and evaluate. - rows.iter() + Ok(rows + .iter() .map(|row| { let bindings = build_row_bindings(row, collection, operation); let bound_cond = bindings.substitute(when_cond); evaluate_simple_condition(&bound_cond) }) - .collect() + .collect()) } /// Build [`RowBindings`] for a single batch row. @@ -117,14 +129,14 @@ mod tests { row_with_field("x", nodedb_types::Value::Integer(1)), row_with_field("x", nodedb_types::Value::Integer(2)), ]; - let mask = filter_batch_by_when(&rows, "c", "INSERT", None); + let mask = filter_batch_by_when(&rows, "c", "INSERT", None).unwrap(); assert_eq!(mask, vec![true, true]); } #[test] fn when_true_all_pass() { let rows = vec![row_with_field("x", nodedb_types::Value::Integer(1))]; - let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("TRUE")); + let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("TRUE")).unwrap(); assert_eq!(mask, vec![true]); } @@ -134,7 +146,7 @@ mod tests { row_with_field("x", nodedb_types::Value::Integer(1)), row_with_field("x", nodedb_types::Value::Integer(2)), ]; - let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("FALSE")); + let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("FALSE")).unwrap(); assert_eq!(mask, vec![false, false]); } diff --git a/nodedb/src/data/executor/core_loop/filter_match.rs b/nodedb/src/data/executor/core_loop/filter_match.rs index 23c8dc882..5b9e75572 100644 --- a/nodedb/src/data/executor/core_loop/filter_match.rs +++ b/nodedb/src/data/executor/core_loop/filter_match.rs @@ -13,6 +13,7 @@ //! step so the staging handlers (and, ideally, the two base-scan call sites) //! evaluate strict predicates identically. +use nodedb_query::EvalError; use nodedb_types::columnar::StrictSchema; use crate::bridge::scan_filter::ScanFilter; @@ -29,20 +30,24 @@ use super::CoreLoop; /// [`CoreLoop::strict_aware_matcher`] — this function does no lookup itself, /// so it never re-pays the `doc_configs` hash lookup per row. /// -/// Returns `false` for a strict body that fails to decode against its own -/// schema (a malformed or stale Binary Tuple never matches, mirroring the -/// base scan's behavior). +/// Returns `Ok(false)` for a strict body that fails to decode against its +/// own schema (a malformed or stale Binary Tuple never matches, mirroring +/// the base scan's behavior). Returns `Err(EvalError::DivisionByZero)` when +/// `filters` contains a `FilterOp::Expr` predicate that divides or takes a +/// modulus by zero — this is the base document scan's WHERE predicate, so +/// the behavior-flip rule applies: the query fails instead of the row being +/// silently excluded. pub(in crate::data::executor) fn matches_with_resolved_schema( strict_schema: Option<&StrictSchema>, filters: &[ScanFilter], body: &[u8], -) -> bool { +) -> Result { match strict_schema { Some(schema) => match strict_format::binary_tuple_to_msgpack(body, schema) { - Some(msgpack) => filters.iter().all(|f| f.matches_binary(&msgpack)), - None => false, + Some(msgpack) => ScanFilter::all_match_binary(filters, &msgpack), + None => Ok(false), }, - None => filters.iter().all(|f| f.matches_binary(body)), + None => ScanFilter::all_match_binary(filters, body), } } @@ -73,19 +78,26 @@ impl CoreLoop { }) } - /// Build a reusable `Fn(&[u8]) -> bool` closure evaluating `filters` - /// against a stored row body, resolving `collection`'s strict schema - /// ONCE up front (not per row) and capturing it in the closure. Suitable - /// for passing as the `&dyn Fn(&[u8]) -> bool` predicate - /// [`CoreLoop::merge_overlay_into_scan`] expects, or for a hot per-row - /// scan loop. + /// Build a reusable `Fn(&[u8]) -> Result` closure + /// evaluating `filters` against a stored row body, resolving + /// `collection`'s strict schema ONCE up front (not per row) and + /// capturing it in the closure. Suitable for a hot per-row scan loop + /// directly, or as the fallible half of a Cell-wrapping call pattern — + /// [`CoreLoop::merge_overlay_into_scan`] actually expects an + /// *infallible* `&dyn Fn(&[u8]) -> bool`, not this function's own + /// `Result`-returning output, so callers that feed it into that merge + /// wrap the closure this function returns in a second, infallible one + /// that stashes any `Err` into a local `Cell>` and + /// checks it once the merge call returns — see + /// `stage_bulk_delete.rs`/`stage_bulk_update.rs`'s `raw_matches` / + /// `matches` pair for the exact pattern. pub(in crate::data::executor) fn strict_aware_matcher<'a>( &self, database_id: u64, tid: u64, collection: &str, filters: &'a [ScanFilter], - ) -> impl Fn(&[u8]) -> bool + 'a { + ) -> impl Fn(&[u8]) -> Result + 'a { let strict_schema = self.resolve_strict_schema(database_id, tid, collection); move |body: &[u8]| matches_with_resolved_schema(strict_schema.as_ref(), filters, body) } diff --git a/nodedb/src/data/executor/enforcement/materialized_sum.rs b/nodedb/src/data/executor/enforcement/materialized_sum.rs index 12e287876..8cad08218 100644 --- a/nodedb/src/data/executor/enforcement/materialized_sum.rs +++ b/nodedb/src/data/executor/enforcement/materialized_sum.rs @@ -64,8 +64,14 @@ fn apply_single_binding( source_doc: &serde_json::Value, ) -> Result, ErrorCode> { // 1. Evaluate value_expr against the source document to get the delta. + // A materialized-sum binding fires on the write path: a + // division/modulo-by-zero fails the write instead of silently skipping + // the balance update. let source_val = nodedb_types::Value::from(source_doc.clone()); - let delta_val = binding.value_expr.eval(&source_val); + let delta_val = binding + .value_expr + .eval(&source_val) + .map_err(|_e| ErrorCode::DivisionByZero)?; let delta_json = serde_json::Value::from(delta_val); let delta = json_to_decimal(&delta_json); let Some(delta) = delta else { diff --git a/nodedb/src/data/executor/enforcement/transition_check.rs b/nodedb/src/data/executor/enforcement/transition_check.rs index 58a08527a..1925bd058 100644 --- a/nodedb/src/data/executor/enforcement/transition_check.rs +++ b/nodedb/src/data/executor/enforcement/transition_check.rs @@ -25,7 +25,16 @@ pub fn check_transition_predicates( let old_val = nodedb_types::Value::from(old_doc.clone()); let new_val = nodedb_types::Value::from(new_doc.clone()); for check in checks { - let result = check.predicate.eval_with_old(&new_val, &old_val); + // A division/modulo-by-zero inside the predicate is neither a PASS + // nor an ordinary FAIL — it's an evaluation error. `eval_with_old` + // can only fail with `EvalError::DivisionByZero`, so it surfaces as + // SQLSTATE 22012 (`ErrorCode::DivisionByZero`), matching generated + // columns / materialized-sum enforcement and Postgres, rather than + // being reported under this check's own 23xxx violation code. + let result = check + .predicate + .eval_with_old(&new_val, &old_val) + .map_err(|_e| ErrorCode::DivisionByZero)?; let passed = match result { nodedb_types::Value::Bool(b) => b, nodedb_types::Value::Null => false, // NULL treated as FALSE for constraint purposes. @@ -34,6 +43,7 @@ pub fn check_transition_predicates( if !passed { return Err(ErrorCode::TransitionCheckViolation { collection: collection.to_string(), + detail: format!("transition check '{}' predicate failed", check.name), }); } } @@ -151,4 +161,29 @@ mod tests { let new = serde_json::json!({"x": 2}); assert!(check_transition_predicates("coll", &[check], &old, &new).is_err()); } + + #[test] + fn division_by_zero_predicate_errors_with_division_by_zero_code() { + // Predicate: NEW.amount / OLD.divisor >= 1, with OLD.divisor = 0. + // A division-by-zero is an evaluation error surfaced as SQLSTATE 22012 + // (`ErrorCode::DivisionByZero`), not this check's own violation code. + let check = make_check( + "ratio", + SqlExpr::BinaryOp { + left: Box::new(SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Column("amount".into())), + op: BinaryOp::Div, + right: Box::new(SqlExpr::OldColumn("divisor".into())), + }), + op: BinaryOp::GtEq, + right: Box::new(SqlExpr::Literal(nodedb_types::Value::Integer(1))), + }, + ); + let old = serde_json::json!({"divisor": 0}); + let new = serde_json::json!({"amount": 10}); + assert!(matches!( + check_transition_predicates("coll", &[check], &old, &new), + Err(ErrorCode::DivisionByZero) + )); + } } diff --git a/nodedb/src/data/executor/enforcement/typeguard.rs b/nodedb/src/data/executor/enforcement/typeguard.rs index 4e4d4b583..2f519a153 100644 --- a/nodedb/src/data/executor/enforcement/typeguard.rs +++ b/nodedb/src/data/executor/enforcement/typeguard.rs @@ -39,7 +39,11 @@ pub fn inject_defaults( } })?; let doc = nodedb_types::Value::Object(fields.clone()); - fields.insert(guard.field.clone(), expr.eval(&doc)); + // A division/modulo-by-zero — the only error `eval` can produce — + // surfaces as SQLSTATE 22012, not this guard's own violation + // code, matching generated-column enforcement. + let val = expr.eval(&doc).map_err(|_e| ErrorCode::DivisionByZero)?; + fields.insert(guard.field.clone(), val); } // DEFAULT: inject only if absent or null. else if let Some(ref expr_str) = guard.default_expr { @@ -56,7 +60,10 @@ pub fn inject_defaults( ), })?; let doc = nodedb_types::Value::Object(fields.clone()); - fields.insert(guard.field.clone(), expr.eval(&doc)); + // Div-by-zero → SQLSTATE 22012, not this guard's violation + // code (see the VALUE arm above). + let val = expr.eval(&doc).map_err(|_e| ErrorCode::DivisionByZero)?; + fields.insert(guard.field.clone(), val); } } } @@ -174,7 +181,11 @@ pub fn check_type_guards( guard.field, check_str ), })?; - let result = check_expr.eval(doc); + // Div-by-zero → SQLSTATE 22012, not this guard's violation code, + // matching CHECK-constraint enforcement elsewhere. + let result = check_expr + .eval(doc) + .map_err(|_e| ErrorCode::DivisionByZero)?; match result { nodedb_types::Value::Bool(true) => {} // CHECK passed nodedb_types::Value::Null => {} // NULL passes CHECK (SQL semantics) diff --git a/nodedb/src/data/executor/handlers/accum/feed.rs b/nodedb/src/data/executor/handlers/accum/feed.rs index b4f4c5341..8250d2745 100644 --- a/nodedb/src/data/executor/handlers/accum/feed.rs +++ b/nodedb/src/data/executor/handlers/accum/feed.rs @@ -9,18 +9,28 @@ use nodedb_physical::physical_plan::AggregateSpec; impl AggAccum { /// Feed one document into this accumulator. - pub(crate) fn feed(&mut self, agg: &AggregateSpec, doc: &[u8]) { + /// + /// Returns `Err(EvalError::DivisionByZero)` when the aggregate argument + /// expression divides or takes a modulus by zero: a row whose `SUM(a/b)` + /// argument sees `b = 0` fails the whole statement with SQLSTATE + /// `22012`, exactly like a WHERE/projection expression, rather than + /// being silently excluded from the accumulation. + pub(crate) fn feed( + &mut self, + agg: &AggregateSpec, + doc: &[u8], + ) -> Result<(), nodedb_query::EvalError> { use nodedb_query::msgpack_scan::aggregate_helpers as ah; match self { AggAccum::Count { n } => { if (agg.field == "*" && agg.expr.is_none()) - || ah::extract_non_null(doc, &agg.field, agg.expr.as_ref()).is_some() + || ah::extract_non_null(doc, &agg.field, agg.expr.as_ref())?.is_some() { *n += 1; } } AggAccum::SumAvg { sum, comp, n } => { - if let Some(v) = ah::extract_f64(doc, &agg.field, agg.expr.as_ref()) { + if let Some(v) = ah::extract_f64(doc, &agg.field, agg.expr.as_ref())? { let y = v - *comp; let t = *sum + y; *comp = (t - *sum) - y; @@ -37,18 +47,18 @@ impl AggAccum { // the key so finalize can derive an order-independent // sum (which also makes the state mergeable across // spilled runs). - if let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref()) + if let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref())? && bytes != [0xc0u8] && let Entry::Vacant(slot) = seen.entry(bytes) - && let Some(v) = ah::extract_f64(doc, &agg.field, agg.expr.as_ref()) + && let Some(v) = ah::extract_f64(doc, &agg.field, agg.expr.as_ref())? { slot.insert(v); } } AggAccum::Min { best } => { - if let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref()) { + if let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref())? { if v.is_null() { - return; + return Ok(()); } let replace = match best { None => true, @@ -63,9 +73,9 @@ impl AggAccum { } } AggAccum::Max { best } => { - if let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref()) { + if let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref())? { if v.is_null() { - return; + return Ok(()); } let replace = match best { None => true, @@ -80,14 +90,14 @@ impl AggAccum { } } AggAccum::CountDistinct { seen } => { - if let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref()) + if let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref())? && bytes != [0xc0u8] { seen.insert(bytes); } } AggAccum::Welford { n, mean, m2 } => { - if let Some(v) = ah::extract_f64(doc, &agg.field, agg.expr.as_ref()) { + if let Some(v) = ah::extract_f64(doc, &agg.field, agg.expr.as_ref())? { *n += 1; let delta = v - *mean; *mean += delta / *n as f64; @@ -96,7 +106,7 @@ impl AggAccum { } } AggAccum::Hll { hll } => { - if let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref()) + if let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref())? && bytes != [0xc0u8] { hll.add(fnv1a(&bytes)); @@ -104,13 +114,13 @@ impl AggAccum { } AggAccum::TDigest { digest } => { let actual = field_after_colon(&agg.field); - if let Some(v) = ah::extract_f64(doc, actual, agg.expr.as_ref()) { + if let Some(v) = ah::extract_f64(doc, actual, agg.expr.as_ref())? { digest.add(v); } } AggAccum::TopK { ss, .. } => { let actual = field_after_colon(&agg.field); - if let Some(bytes) = ah::extract_bytes(doc, actual, agg.expr.as_ref()) + if let Some(bytes) = ah::extract_bytes(doc, actual, agg.expr.as_ref())? && bytes != [0xc0u8] { ss.add(fnv1a(&bytes)); @@ -118,7 +128,7 @@ impl AggAccum { } AggAccum::ArrayAgg { values } => { if values.len() < ARRAY_AGG_CAP - && let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref()) + && let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref())? && !v.is_null() { values.push(v); @@ -126,10 +136,10 @@ impl AggAccum { } AggAccum::ArrayAggDistinct { seen, values } => { if values.len() < ARRAY_AGG_CAP - && let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref()) + && let Some(bytes) = ah::extract_bytes(doc, &agg.field, agg.expr.as_ref())? && bytes != [0xc0u8] && seen.insert(bytes) - && let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref()) + && let Some(v) = ah::extract_value(doc, &agg.field, agg.expr.as_ref())? { values.push(v); } @@ -137,19 +147,20 @@ impl AggAccum { AggAccum::PercentileCont { values, .. } => { let actual = field_after_colon(&agg.field); if values.len() < ARRAY_AGG_CAP - && let Some(v) = ah::extract_f64(doc, actual, agg.expr.as_ref()) + && let Some(v) = ah::extract_f64(doc, actual, agg.expr.as_ref())? { values.push(v); } } AggAccum::StringAgg { parts } => { if parts.len() < ARRAY_AGG_CAP - && let Some(s) = ah::extract_str(doc, &agg.field, agg.expr.as_ref()) + && let Some(s) = ah::extract_str(doc, &agg.field, agg.expr.as_ref())? { parts.push(s); } } } + Ok(()) } } diff --git a/nodedb/src/data/executor/handlers/accum/state.rs b/nodedb/src/data/executor/handlers/accum/state.rs index e22fd4b17..969d5a9ec 100644 --- a/nodedb/src/data/executor/handlers/accum/state.rs +++ b/nodedb/src/data/executor/handlers/accum/state.rs @@ -81,10 +81,15 @@ impl GroupState { } } - pub(crate) fn feed(&mut self, aggregates: &[AggregateSpec], doc: &[u8]) { + pub(crate) fn feed( + &mut self, + aggregates: &[AggregateSpec], + doc: &[u8], + ) -> Result<(), nodedb_query::EvalError> { for (accum, agg) in self.accums.iter_mut().zip(aggregates) { - accum.feed(agg, doc); + accum.feed(agg, doc)?; } + Ok(()) } /// Merge a partial `GroupState` from a spilled run into `self`. diff --git a/nodedb/src/data/executor/handlers/accum/tests.rs b/nodedb/src/data/executor/handlers/accum/tests.rs index 714288abe..a5a060c02 100644 --- a/nodedb/src/data/executor/handlers/accum/tests.rs +++ b/nodedb/src/data/executor/handlers/accum/tests.rs @@ -46,19 +46,19 @@ fn merge_from_count() { let mut combined = AggAccum::new(&spec); for d in &docs_a { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } for d in &docs_b { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -84,22 +84,22 @@ fn merge_from_sum_avg() { let mut combined_sum = AggAccum::new(&sum_spec); let mut combined_avg = AggAccum::new(&avg_spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined_sum.feed(&sum_spec, d); - combined_avg.feed(&avg_spec, d); + combined_sum.feed(&sum_spec, d).unwrap(); + combined_avg.feed(&avg_spec, d).unwrap(); } // Merge path. let mut a_sum = AggAccum::new(&sum_spec); let mut a_avg = AggAccum::new(&avg_spec); for d in &docs_a { - a_sum.feed(&sum_spec, d); - a_avg.feed(&avg_spec, d); + a_sum.feed(&sum_spec, d).unwrap(); + a_avg.feed(&avg_spec, d).unwrap(); } let mut b_sum = AggAccum::new(&sum_spec); let mut b_avg = AggAccum::new(&avg_spec); for d in &docs_b { - b_sum.feed(&sum_spec, d); - b_avg.feed(&avg_spec, d); + b_sum.feed(&sum_spec, d).unwrap(); + b_avg.feed(&avg_spec, d).unwrap(); } a_sum.merge_from(b_sum); a_avg.merge_from(b_avg); @@ -140,22 +140,22 @@ fn merge_from_sum_avg_distinct() { let mut combined_sum = AggAccum::new(&sum_spec); let mut combined_avg = AggAccum::new(&avg_spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined_sum.feed(&sum_spec, d); - combined_avg.feed(&avg_spec, d); + combined_sum.feed(&sum_spec, d).unwrap(); + combined_avg.feed(&avg_spec, d).unwrap(); } // Spilled-run merge path. let mut a_sum = AggAccum::new(&sum_spec); let mut a_avg = AggAccum::new(&avg_spec); for d in &docs_a { - a_sum.feed(&sum_spec, d); - a_avg.feed(&avg_spec, d); + a_sum.feed(&sum_spec, d).unwrap(); + a_avg.feed(&avg_spec, d).unwrap(); } let mut b_sum = AggAccum::new(&sum_spec); let mut b_avg = AggAccum::new(&avg_spec); for d in &docs_b { - b_sum.feed(&sum_spec, d); - b_avg.feed(&avg_spec, d); + b_sum.feed(&sum_spec, d).unwrap(); + b_avg.feed(&avg_spec, d).unwrap(); } a_sum.merge_from(b_sum); a_avg.merge_from(b_avg); @@ -185,21 +185,21 @@ fn merge_from_min_max() { let mut combined_min = AggAccum::new(&min_spec); let mut combined_max = AggAccum::new(&max_spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined_min.feed(&min_spec, d); - combined_max.feed(&max_spec, d); + combined_min.feed(&min_spec, d).unwrap(); + combined_max.feed(&max_spec, d).unwrap(); } let mut a_min = AggAccum::new(&min_spec); let mut a_max = AggAccum::new(&max_spec); for d in &docs_a { - a_min.feed(&min_spec, d); - a_max.feed(&max_spec, d); + a_min.feed(&min_spec, d).unwrap(); + a_max.feed(&max_spec, d).unwrap(); } let mut b_min = AggAccum::new(&min_spec); let mut b_max = AggAccum::new(&max_spec); for d in &docs_b { - b_min.feed(&min_spec, d); - b_max.feed(&max_spec, d); + b_min.feed(&min_spec, d).unwrap(); + b_max.feed(&max_spec, d).unwrap(); } a_min.merge_from(b_min); a_max.merge_from(b_max); @@ -227,16 +227,16 @@ fn merge_from_welford() { let mut combined = AggAccum::new(&spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -268,15 +268,15 @@ fn merge_from_count_distinct() { let mut combined = AggAccum::new(&spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -298,16 +298,16 @@ fn merge_from_hll() { let mut combined = AggAccum::new(&spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -331,11 +331,11 @@ fn merge_from_tdigest() { let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -355,11 +355,11 @@ fn merge_from_topk() { let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -378,16 +378,16 @@ fn merge_from_array_agg() { let mut combined = AggAccum::new(&spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -409,16 +409,16 @@ fn merge_from_string_agg() { let mut combined = AggAccum::new(&spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); @@ -440,16 +440,16 @@ fn merge_from_percentile_cont() { let mut combined = AggAccum::new(&spec); for d in docs_a.iter().chain(docs_b.iter()) { - combined.feed(&spec, d); + combined.feed(&spec, d).unwrap(); } let mut a = AggAccum::new(&spec); for d in &docs_a { - a.feed(&spec, d); + a.feed(&spec, d).unwrap(); } let mut b = AggAccum::new(&spec); for d in &docs_b { - b.feed(&spec, d); + b.feed(&spec, d).unwrap(); } a.merge_from(b); diff --git a/nodedb/src/data/executor/handlers/aggregate/exec.rs b/nodedb/src/data/executor/handlers/aggregate/exec.rs index d72b4e53a..ce8285069 100644 --- a/nodedb/src/data/executor/handlers/aggregate/exec.rs +++ b/nodedb/src/data/executor/handlers/aggregate/exec.rs @@ -253,10 +253,29 @@ impl CoreLoop { } }; if !having_predicates.is_empty() { + // `Vec::retain`'s closure must return `bool`, so a + // division/modulo-by-zero in a HAVING predicate is + // captured via this `Cell` side-channel and checked + // once the retain finishes — HAVING is WHERE-shaped, + // so it gets the full error treatment. + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); agg_result.rows.retain(|row| { + if predicate_err.get().is_some() { + return true; + } let mp = nodedb_types::json_to_msgpack_or_empty(row); - having_predicates.iter().all(|f| f.matches_binary(&mp)) + match ScanFilter::all_match_binary(&having_predicates, &mp) { + Ok(keep) => keep, + Err(e) => { + predicate_err.set(Some(e)); + true + } + } }); + if predicate_err.take().is_some() { + return self.response_error(task, ErrorCode::DivisionByZero); + } } } diff --git a/nodedb/src/data/executor/handlers/aggregate/shuffle_merge.rs b/nodedb/src/data/executor/handlers/aggregate/shuffle_merge.rs index 9546b7aee..cf5a38449 100644 --- a/nodedb/src/data/executor/handlers/aggregate/shuffle_merge.rs +++ b/nodedb/src/data/executor/handlers/aggregate/shuffle_merge.rs @@ -68,7 +68,10 @@ pub(in crate::data::executor) fn merge_state_frames( while let Some(row) = reader.next_row()? { // Byte-identical group key reconstruction from the flat row fields. - let key = msgpack_scan::build_group_key(&row, &row_keys); + // `row_keys` are plain column specs (the producer already materialized + // any computed key into a field), so this cannot divide by zero; the + // `?` upholds the crate-wide contract without a reachable error path. + let key = msgpack_scan::build_group_key(&row, &row_keys)?; // Extract the `__agg_state` binary field. let (val_start, _val_end) = msgpack_scan::extract_field(&row, 0, AGG_STATE_FIELD) @@ -208,11 +211,13 @@ mod tests { ) { let mut groups: HashMap = HashMap::new(); for doc in docs { - let key = nodedb_query::msgpack_scan::build_group_key(doc, group_by); + let key = + nodedb_query::msgpack_scan::build_group_key(doc, group_by).expect("group key"); groups .entry(key) .or_insert_with(|| GroupState::new(specs)) - .feed(specs, doc); + .feed(specs, doc) + .expect("feed"); } let mut f = std::fs::File::create(path).expect("create frame file"); @@ -257,10 +262,12 @@ mod tests { ) -> HashMap> { let mut map: HashMap = HashMap::new(); for doc in docs { - let key = nodedb_query::msgpack_scan::build_group_key(doc, group_by); + let key = + nodedb_query::msgpack_scan::build_group_key(doc, group_by).expect("group key"); map.entry(key) .or_insert_with(|| GroupState::new(specs)) - .feed(specs, doc); + .feed(specs, doc) + .expect("feed"); } map.into_iter() .map(|(k, s)| (k, s.finalize(specs).into_iter().map(|(_, v)| v).collect())) diff --git a/nodedb/src/data/executor/handlers/aggregate/streaming/accumulate.rs b/nodedb/src/data/executor/handlers/aggregate/streaming/accumulate.rs index b821110cc..a3ec0f324 100644 --- a/nodedb/src/data/executor/handlers/aggregate/streaming/accumulate.rs +++ b/nodedb/src/data/executor/handlers/aggregate/streaming/accumulate.rs @@ -113,21 +113,43 @@ impl CoreLoop { break; } for (_, value) in chunk { + // WHERE-filter evaluation is fatal to the whole accumulation + // on a division/modulo-by-zero, exactly like a spill error + // below — both break out via `spill_err`. let outer_key = if use_field_index { let idx = msgpack_scan::FieldIndex::build(value, 0) .unwrap_or_else(msgpack_scan::FieldIndex::empty); - if !filter_predicates - .iter() - .all(|f| f.matches_binary_indexed(value, &idx)) - { - continue; + match ScanFilter::all_match_binary_indexed(&filter_predicates, value, &idx) { + Ok(true) => {} + Ok(false) => continue, + Err(e) => { + spill_err = Some(crate::Error::from(e)); + break; + } + } + match msgpack_scan::group_key::build_group_key_indexed(value, group_by, &idx) { + Ok(k) => k, + Err(e) => { + spill_err = Some(crate::Error::from(e)); + break; + } } - msgpack_scan::group_key::build_group_key_indexed(value, group_by, &idx) } else { - if !filter_predicates.iter().all(|f| f.matches_binary(value)) { - continue; + match ScanFilter::all_match_binary(&filter_predicates, value) { + Ok(true) => {} + Ok(false) => continue, + Err(e) => { + spill_err = Some(crate::Error::from(e)); + break; + } + } + match msgpack_scan::build_group_key(value, group_by) { + Ok(k) => k, + Err(e) => { + spill_err = Some(crate::Error::from(e)); + break; + } } - msgpack_scan::build_group_key(value, group_by) }; if let Err(e) = spiller.feed(outer_key.clone(), aggregates, value) { @@ -136,7 +158,13 @@ impl CoreLoop { } if need_sub { - let sub_key = msgpack_scan::build_group_key(value, &sub_specs); + let sub_key = match msgpack_scan::build_group_key(value, &sub_specs) { + Ok(k) => k, + Err(e) => { + spill_err = Some(crate::Error::from(e)); + break; + } + }; // Composite key: outer + U+001F + sub. let composite = format!("{outer_key}\x1F{sub_key}"); if let Err(e) = spiller.feed(composite, sub_aggregates, value) { diff --git a/nodedb/src/data/executor/handlers/aggregate/streaming/finalize.rs b/nodedb/src/data/executor/handlers/aggregate/streaming/finalize.rs index d19ef3046..0a65b748d 100644 --- a/nodedb/src/data/executor/handlers/aggregate/streaming/finalize.rs +++ b/nodedb/src/data/executor/handlers/aggregate/streaming/finalize.rs @@ -153,10 +153,27 @@ impl CoreLoop { } }; if !having_predicates.is_empty() { + // `Vec::retain`'s closure must return `bool`, so a division/ + // modulo-by-zero in a HAVING predicate is captured via this + // `Cell` side-channel and checked once the retain finishes. + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); results.retain(|row| { + if predicate_err.get().is_some() { + return true; + } let mp = nodedb_types::json_to_msgpack_or_empty(row); - having_predicates.iter().all(|f| f.matches_binary(&mp)) + match ScanFilter::all_match_binary(&having_predicates, &mp) { + Ok(keep) => keep, + Err(e) => { + predicate_err.set(Some(e)); + true + } + } }); + if let Some(e) = predicate_err.take() { + return Err(crate::Error::from(e)); + } } } diff --git a/nodedb/src/data/executor/handlers/aggregate/streaming/over_docs.rs b/nodedb/src/data/executor/handlers/aggregate/streaming/over_docs.rs index fed6151cb..c7fef659f 100644 --- a/nodedb/src/data/executor/handlers/aggregate/streaming/over_docs.rs +++ b/nodedb/src/data/executor/handlers/aggregate/streaming/over_docs.rs @@ -78,14 +78,12 @@ impl CoreLoop { sub_aggregates, }) { Ok(g) => g, - Err(e) => { - return self.response_error( - task, - ErrorCode::Internal { - detail: e.to_string(), - }, - ); - } + // Map through `From for ErrorCode` rather than a + // blanket `Internal` wrap, so a division/modulo-by-zero in a + // WHERE filter, computed GROUP BY key, or aggregate argument + // keeps its `DivisionByZero` code (SQLSTATE 22012) instead of + // degrading to a generic internal error (XX000). + Err(e) => return self.response_error(task, ErrorCode::from(e)), }; match self.finalize_groups(super::finalize::FinalizeGroupsParams { diff --git a/nodedb/src/data/executor/handlers/bulk_dml/scan.rs b/nodedb/src/data/executor/handlers/bulk_dml/scan.rs index c3094208a..04b00fdef 100644 --- a/nodedb/src/data/executor/handlers/bulk_dml/scan.rs +++ b/nodedb/src/data/executor/handlers/bulk_dml/scan.rs @@ -42,7 +42,7 @@ impl CoreLoop { for entry in range.flatten() { let key = entry.0.value(); let value_bytes = entry.1.value(); - if matches(value_bytes) + if matches(value_bytes)? && let Some(doc_id) = key.strip_prefix(&prefix) { ids.push(doc_id.to_string()); diff --git a/nodedb/src/data/executor/handlers/bulk_dml/update.rs b/nodedb/src/data/executor/handlers/bulk_dml/update.rs index 1a08221ca..5735f7624 100644 --- a/nodedb/src/data/executor/handlers/bulk_dml/update.rs +++ b/nodedb/src/data/executor/handlers/bulk_dml/update.rs @@ -245,7 +245,18 @@ impl CoreLoop { } } nodedb_physical::physical_plan::UpdateValue::Expr(expr) => { - let result: nodedb_types::Value = expr.eval(&eval_doc); + let result: nodedb_types::Value = match expr.eval(&eval_doc) { + Ok(v) => v, + // A division/modulo-by-zero in an + // UPDATE assignment fails the whole + // statement, unlike the literal- + // decode-failure case above which + // skips just that field. + Err(e) => { + return self + .response_error(task, crate::Error::from(e)); + } + }; result.into() } }; diff --git a/nodedb/src/data/executor/handlers/columnar_mutation.rs b/nodedb/src/data/executor/handlers/columnar_mutation.rs index 28a0e0c6a..7b9b0d9b7 100644 --- a/nodedb/src/data/executor/handlers/columnar_mutation.rs +++ b/nodedb/src/data/executor/handlers/columnar_mutation.rs @@ -96,10 +96,14 @@ impl CoreLoop { let mut affected = 0u64; for row in &rows { // Skip rows that don't match WHERE filters. - if !filter_predicates.is_empty() - && !row_matches_filters(row, &schema, &filter_predicates) - { - continue; + if !filter_predicates.is_empty() { + match row_matches_filters(row, &schema, &filter_predicates) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } + } } // Apply field updates to the row. let mut new_row = row.clone(); @@ -283,14 +287,19 @@ impl CoreLoop { // Collect only the PK values of rows that match the WHERE filter // (can't mutate while iterating). let rows: Vec> = engine.scan_memtable_rows().collect(); - let pk_values: Vec = rows - .iter() - .filter(|row| { - filter_predicates.is_empty() - || row_matches_filters(row, &schema, &filter_predicates) - }) - .map(|row| row[pk_cols[0]].clone()) - .collect(); + let mut pk_values: Vec = Vec::new(); + for row in &rows { + if !filter_predicates.is_empty() { + match row_matches_filters(row, &schema, &filter_predicates) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } + } + } + pk_values.push(row[pk_cols[0]].clone()); + } // Undo capture (only on the durable COMMIT-replay path): the location // and PK bytes of each tombstoned row, so the undo can clear its diff --git a/nodedb/src/data/executor/handlers/columnar_read/convert.rs b/nodedb/src/data/executor/handlers/columnar_read/convert.rs index ad2fb9ad4..dd49e7298 100644 --- a/nodedb/src/data/executor/handlers/columnar_read/convert.rs +++ b/nodedb/src/data/executor/handlers/columnar_read/convert.rs @@ -47,7 +47,7 @@ pub(in crate::data::executor) fn row_to_projected_json( projection: &[String], computed_cols: &[crate::bridge::expr_eval::ComputedColumn], all_versions: bool, -) -> serde_json::Value { +) -> crate::Result { let mut obj = serde_json::Map::new(); for (i, col_def) in schema.columns.iter().enumerate() { let force_system_col = @@ -70,10 +70,11 @@ pub(in crate::data::executor) fn row_to_projected_json( if matches!(existing, Some(v) if !v.is_null()) { continue; } - obj.insert( - cc.alias.clone(), - serde_json::Value::from(cc.expr.eval(&doc_val)), - ); + // A computed column is projection-shaped: a division/modulo-by- + // zero fails the whole scan rather than silently materializing + // NULL into the response row. + let v = cc.expr.eval(&doc_val)?; + obj.insert(cc.alias.clone(), serde_json::Value::from(v)); } if !projection.is_empty() { obj.retain(|k, _| { @@ -83,7 +84,7 @@ pub(in crate::data::executor) fn row_to_projected_json( }); } } - serde_json::Value::Object(obj) + Ok(serde_json::Value::Object(obj)) } /// Write a timeseries columnar memtable cell value directly as msgpack bytes. diff --git a/nodedb/src/data/executor/handlers/columnar_read/filter.rs b/nodedb/src/data/executor/handlers/columnar_read/filter.rs index 58cc20f27..389e47f05 100644 --- a/nodedb/src/data/executor/handlers/columnar_read/filter.rs +++ b/nodedb/src/data/executor/handlers/columnar_read/filter.rs @@ -2,19 +2,25 @@ //! Row-level WHERE predicate evaluation for memtable scans. +use nodedb_query::EvalError; use nodedb_query::scan_filter::{FilterOp, ScanFilter}; /// Check whether a memtable row satisfies all filter predicates. /// -/// Returns `true` if every filter passes (AND semantics). Uses the full +/// Returns `Ok(true)` if every filter passes (AND semantics). Uses the full /// `ScanFilter::matches_value` path which handles `FilterOp::Expr` predicates /// (scalar functions, JSON operators, column arithmetic) in addition to simple /// comparison operators. +/// +/// Returns `Err(EvalError::DivisionByZero)` when a `FilterOp::Expr` +/// predicate divides or takes a modulus by zero — this is the columnar +/// engine's WHERE predicate, so the behavior-flip rule applies: the query +/// fails instead of the row being silently excluded. pub(in crate::data::executor) fn row_matches_filters( row: &[nodedb_types::value::Value], schema: &nodedb_types::columnar::ColumnarSchema, filters: &[ScanFilter], -) -> bool { +) -> Result { // Build a Value::Object so that ScanFilter::matches_value can navigate // field paths and expression predicates (e.g. pg_json_get_text). let mut map = std::collections::HashMap::with_capacity(schema.columns.len()); @@ -29,9 +35,9 @@ pub(in crate::data::executor) fn row_matches_filters( if filter.op == FilterOp::MatchAll { continue; } - if !filter.matches_value(&doc) { - return false; + if !filter.matches_value(&doc)? { + return Ok(false); } } - true + Ok(true) } diff --git a/nodedb/src/data/executor/handlers/columnar_read/scan.rs b/nodedb/src/data/executor/handlers/columnar_read/scan.rs index 996f05d23..3ba9305cc 100644 --- a/nodedb/src/data/executor/handlers/columnar_read/scan.rs +++ b/nodedb/src/data/executor/handlers/columnar_read/scan.rs @@ -243,7 +243,7 @@ impl CoreLoop { // surrogate prefilter is active we therefore scan flushed segments and // apply a per-row membership test below, mirroring the live-memtable // phase. See the method-level doc comment for the full rationale. - self.scan_flushed_columnar_segments( + if let Err(e) = self.scan_flushed_columnar_segments( super::scan_flushed::FlushedScanCtx { collection, engine_key: &engine_key, @@ -262,7 +262,15 @@ impl CoreLoop { ts_valid_until_idx, }, &mut matched, - ); + ) { + // `scan_flushed_columnar_segments` returns `crate::Result<()>`, + // so `e` already carries the real typed error (e.g. a + // division/modulo-by-zero in `filter_predicates`) — propagate + // it as-is via `From for ErrorCode` instead of + // collapsing it to `Internal`/`XX000`, mirroring + // `document/read/scan.rs`'s `Err(e) => self.response_error(task, e)`. + return self.response_error(task, e); + } // ── Phase 2: live memtable ────────────────────────────────────────── // Rows still in the active memtable (not yet flushed). @@ -296,18 +304,39 @@ impl CoreLoop { ) { continue; } - if !filter_predicates.is_empty() - && !row_matches_filters(&row, schema, &filter_predicates) - { - continue; + if !filter_predicates.is_empty() { + match row_matches_filters(&row, schema, &filter_predicates) { + Ok(true) => {} + Ok(false) => continue, + // `row_matches_filters` returns `EvalError`, which + // has exactly one variant and no direct + // `Into` — mirrors + // `stage_columnar_dml.rs`'s identical call site, + // which hardcodes the same typed code rather than + // collapsing to `Internal`/`XX000`. + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } + } } - let obj = super::convert::row_to_projected_json( + let obj = match super::convert::row_to_projected_json( &row, schema, projection, &computed_cols, all_versions, - ); + ) { + Ok(v) => v, + // `row_to_projected_json` returns `crate::Result<_>` + // (unlike `row_matches_filters` above) — its only + // fallible step is a computed-column expression eval, + // and `computed_cols` here is real, not `&[]` like the + // DML staging path, so propagate the actual typed error + // rather than assuming which one it is. + Err(e) => { + return self.response_error(task, e); + } + }; matched.push((row_surrogate, row, obj)); if sort_keys.is_empty() && matched.len() >= limit { break; @@ -329,7 +358,7 @@ impl CoreLoop { task.request.tenant_id, collection.to_string(), ); - self.merge_overlay_into_columnar_scan( + if let Err(e) = self.merge_overlay_into_columnar_scan( crate::data::executor::handlers::transaction::overlay::ColumnarOverlayMergeParams { txn_id, coll_key: &coll_key, @@ -340,7 +369,16 @@ impl CoreLoop { all_versions, }, &mut matched, - ); + ) { + // `merge_overlay_into_columnar_scan` returns + // `crate::Result<()>` (the overlay's own residual-predicate + // re-check can divide/modulo by zero) — propagate the typed + // error directly, matching both `document/read/scan.rs`'s + // generic `self.response_error(task, e)` pattern and + // `stage_columnar_dml.rs`'s identical call to this same + // function (`Err(e) => return Err(self.response_error(task, e))`). + return self.response_error(task, e); + } } if !sort_keys.is_empty() { diff --git a/nodedb/src/data/executor/handlers/columnar_read/scan_flushed.rs b/nodedb/src/data/executor/handlers/columnar_read/scan_flushed.rs index d6c2f19fc..af05d245f 100644 --- a/nodedb/src/data/executor/handlers/columnar_read/scan_flushed.rs +++ b/nodedb/src/data/executor/handlers/columnar_read/scan_flushed.rs @@ -52,7 +52,7 @@ impl CoreLoop { Vec, serde_json::Value, )>, - ) { + ) -> crate::Result<()> { let FlushedScanCtx { collection, engine_key, @@ -196,7 +196,7 @@ impl CoreLoop { continue; } if !filter_predicates.is_empty() - && !row_matches_filters(&row, schema, filter_predicates) + && !row_matches_filters(&row, schema, filter_predicates)? { continue; } @@ -223,10 +223,11 @@ impl CoreLoop { if matches!(existing, Some(v) if !v.is_null()) { continue; } - obj.insert( - cc.alias.clone(), - serde_json::Value::from(cc.expr.eval(&doc_val)), - ); + // Computed-column projection is + // projection-shaped: a division/modulo-by-zero + // fails the whole scan. + let v = cc.expr.eval(&doc_val)?; + obj.insert(cc.alias.clone(), serde_json::Value::from(v)); } if !projection.is_empty() { obj.retain(|k, _| { @@ -243,5 +244,6 @@ impl CoreLoop { } } } + Ok(()) } } diff --git a/nodedb/src/data/executor/handlers/columnar_write/row_ingest.rs b/nodedb/src/data/executor/handlers/columnar_write/row_ingest.rs index 5b21561a8..a7983c6be 100644 --- a/nodedb/src/data/executor/handlers/columnar_write/row_ingest.rs +++ b/nodedb/src/data/executor/handlers/columnar_write/row_ingest.rs @@ -140,11 +140,16 @@ impl CoreLoop { Some(prior) => { let existing_val = row_values_to_object(schema, &prior); let excluded_val = row_values_to_object(schema, &values); - let merged = apply_on_conflict_updates( + let merged = match apply_on_conflict_updates( existing_val, &excluded_val, on_conflict_updates, - ); + ) { + Ok(v) => v, + Err(e) => { + return Err(self.response_error(task, e)); + } + }; let merged_obj = match merged { nodedb_types::Value::Object(m) => m, _ => { diff --git a/nodedb/src/data/executor/handlers/document/index_fetch.rs b/nodedb/src/data/executor/handlers/document/index_fetch.rs index d8d64ab6f..ed871ab21 100644 --- a/nodedb/src/data/executor/handlers/document/index_fetch.rs +++ b/nodedb/src/data/executor/handlers/document/index_fetch.rs @@ -85,7 +85,7 @@ impl CoreLoop { crate::types::TenantId::new(tid), collection.to_string(), ); - self.merge_overlay_into_index_lookup( + if let Err(e) = self.merge_overlay_into_index_lookup( super::super::transaction::overlay::IndexOverlayMergeParams { txn_id, coll_key: &coll_key, @@ -96,13 +96,18 @@ impl CoreLoop { // `DocumentOp::IndexLookup` carries no residual // filters at all (it returns bare doc IDs, used by // bitmap-producer callers) — the empty slice makes - // the merge's residual re-check a no-op. + // the merge's residual re-check a no-op, so this + // call site can never actually observe `Err` — + // handled uniformly with the other call site + // anyway rather than assuming that invariant. residual: &[], strict_schema: None, }, &mut doc_ids, &|body| self.decode_indexed_body(&config_key, body), - ); + ) { + return self.response_error(task, e); + } } let payload = serde_json::json!(doc_ids); match sonic_rs::to_vec(&payload) { @@ -238,7 +243,7 @@ impl CoreLoop { }; if let Some(txn_id) = task.request.txn_id { let (is_array, case_insensitive) = self.index_path_flags(&config_key, path); - self.merge_overlay_into_index_lookup( + if let Err(e) = self.merge_overlay_into_index_lookup( super::super::transaction::overlay::IndexOverlayMergeParams { txn_id, coll_key: &coll_key, @@ -251,7 +256,9 @@ impl CoreLoop { }, &mut doc_ids, &|body| self.decode_indexed_body(&config_key, body), - ); + ) { + return self.response_error(task, e); + } } let mut rows: Vec<(String, Vec)> = Vec::new(); @@ -277,10 +284,16 @@ impl CoreLoop { // excluded — checked on the raw stored body, exactly as a // base scan applies its filters, for committed and staged // bodies alike. - if !residual.is_empty() - && !matches_with_resolved_schema(strict_schema.as_ref(), &residual, &bytes) - { - continue; + match if residual.is_empty() { + Ok(true) + } else { + matches_with_resolved_schema(strict_schema.as_ref(), &residual, &bytes) + } { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } let payload = if let Some(ref schema) = strict_schema { match super::super::super::strict_format::binary_tuple_to_msgpack( diff --git a/nodedb/src/data/executor/handlers/document/read/fetch.rs b/nodedb/src/data/executor/handlers/document/read/fetch.rs index fbbcad39e..cba41bbd0 100644 --- a/nodedb/src/data/executor/handlers/document/read/fetch.rs +++ b/nodedb/src/data/executor/handlers/document/read/fetch.rs @@ -20,6 +20,8 @@ //! `effective_schema` is `None` and the shared sort/window/computed/projection //! pipeline operates on a uniform shape. +use std::cell::Cell; + use tracing::warn; use nodedb_types::columnar::schema::{ @@ -100,8 +102,22 @@ impl CoreLoop { // shared sort/window/computed/projection pipeline (which scans // msgpack) operates uniformly, then hand it downstream with no // schema (bodies are already normalized). - let predicate = |body: &[u8]| { - matches_with_resolved_schema(strict_schema, filter_predicates, body) + // `versioned_scan_as_of` takes an infallible `Fn(&[u8]) -> bool` + // predicate (a storage-engine primitive out of scope for this + // fix), so a division/modulo-by-zero is captured via this + // `Cell` side-channel and checked once the scan returns, + // rather than silently folded away. + let predicate_err: Cell> = Cell::new(None); + let predicate = |body: &[u8]| match matches_with_resolved_schema( + strict_schema, + filter_predicates, + body, + ) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } }; let scan_limit = offset.saturating_add(limit); let raw = self.sparse.versioned_scan_as_of( @@ -115,6 +131,9 @@ impl CoreLoop { }, &predicate, )?; + if let Some(e) = predicate_err.take() { + return Err(crate::Error::from(e)); + } let rows = raw .into_iter() .map(|(doc_id, body)| (doc_id, normalize_body(&body, strict_schema))) @@ -129,8 +148,18 @@ impl CoreLoop { // normalized to MessagePack and gets the synthetic `_ts_*` // temporal columns injected BEFORE the shared downstream runs, // so a user can `SELECT` / `ORDER BY` / project on them. - let predicate = |body: &[u8]| { - matches_with_resolved_schema(strict_schema, filter_predicates, body) + // See the `AsOf` arm above for the `Cell` side-channel rationale. + let predicate_err: Cell> = Cell::new(None); + let predicate = |body: &[u8]| match matches_with_resolved_schema( + strict_schema, + filter_predicates, + body, + ) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } }; let scan_limit = offset.saturating_add(limit); let raw = self.sparse.versioned_scan_all( @@ -141,6 +170,9 @@ impl CoreLoop { scan_limit, &predicate, )?; + if let Some(e) = predicate_err.take() { + return Err(crate::Error::from(e)); + } let mut rows: Vec<(String, Vec)> = Vec::with_capacity(raw.len()); for row in raw { let msgpack_body = match strict_schema { @@ -187,11 +219,23 @@ impl CoreLoop { let database_id = task.request.database_id.as_u64(); let bitemporal = self.is_bitemporal(database_id, tid, collection); + // `scan_documents_filtered`/`versioned_scan_as_of`/`scan_collection` + // take an infallible `Fn(&[u8]) -> bool` predicate (a storage-engine + // primitive out of scope for this fix), so a division/modulo-by-zero + // is captured via this `Cell` side-channel and checked once every + // branch below returns, rather than silently folded away. + let predicate_err: Cell> = Cell::new(None); let matches = |value: &[u8]| -> bool { if filter_predicates.is_empty() { return true; } - matches_with_resolved_schema(strict_schema, filter_predicates, value) + match matches_with_resolved_schema(strict_schema, filter_predicates, value) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } + } }; let rows = if filter_predicates.is_empty() { @@ -280,6 +324,10 @@ impl CoreLoop { } }; + if let Some(e) = predicate_err.take() { + return Err(crate::Error::from(e)); + } + Ok(FetchedRows { rows, effective_schema: strict_schema.cloned(), diff --git a/nodedb/src/data/executor/handlers/document/read/projection.rs b/nodedb/src/data/executor/handlers/document/read/projection.rs index 63859a380..947c27987 100644 --- a/nodedb/src/data/executor/handlers/document/read/projection.rs +++ b/nodedb/src/data/executor/handlers/document/read/projection.rs @@ -20,11 +20,11 @@ pub(in crate::data::executor) fn apply_projection( data: serde_json::Value, computed_cols: &[ComputedColumn], projection: &[String], -) -> serde_json::Value { - match data { +) -> crate::Result { + Ok(match data { serde_json::Value::Object(obj) => { if computed_cols.is_empty() && projection.is_empty() { - return serde_json::Value::Object(obj); + return Ok(serde_json::Value::Object(obj)); } let doc_val = nodedb_types::Value::from(serde_json::Value::Object(obj.clone())); @@ -45,16 +45,17 @@ pub(in crate::data::executor) fn apply_projection( if matches!(existing, Some(v) if !v.is_null()) { continue; } - out.insert( - cc.alias.clone(), - serde_json::Value::from(cc.expr.eval(&doc_val)), - ); + // A division/modulo-by-zero in a computed column fails the + // whole query instead of silently materializing NULL into + // the response. + let v = cc.expr.eval(&doc_val)?; + out.insert(cc.alias.clone(), serde_json::Value::from(v)); } serde_json::Value::Object(out) } other => other, - } + }) } /// Apply projection and computed columns on raw msgpack bytes. @@ -65,9 +66,9 @@ pub(in crate::data::executor) fn apply_projection_msgpack( data: &[u8], computed_cols: &[ComputedColumn], projection: &[String], -) -> Vec { +) -> crate::Result> { if computed_cols.is_empty() && projection.is_empty() { - return data.to_vec(); + return Ok(data.to_vec()); } let field_count = if projection.is_empty() { @@ -98,7 +99,10 @@ pub(in crate::data::executor) fn apply_projection_msgpack( continue; } nodedb_query::msgpack_scan::write_str(&mut buf, &cc.alias); - let result = cc.expr.eval(&doc_val); + // A division/modulo-by-zero in a computed column fails the whole + // query instead of silently materializing NULL into the + // response. + let result = cc.expr.eval(&doc_val)?; if let Ok(mp) = nodedb_types::value_to_msgpack(&result) { buf.extend_from_slice(&mp); } else { @@ -107,7 +111,7 @@ pub(in crate::data::executor) fn apply_projection_msgpack( } } - buf + Ok(buf) } #[cfg(test)] @@ -128,7 +132,7 @@ mod tests { }]; let projection = vec!["name".to_string(), "age".to_string()]; - let projected = apply_projection(data, &computed, &projection); + let projected = apply_projection(data, &computed, &projection).unwrap(); assert_eq!( projected, @@ -156,7 +160,7 @@ mod tests { }]; let projection = vec!["name".to_string(), "age".to_string(), "rn".to_string()]; - let projected = apply_projection(data, &computed, &projection); + let projected = apply_projection(data, &computed, &projection).unwrap(); assert_eq!( projected, @@ -180,7 +184,7 @@ mod tests { "pr_score".to_string(), ]; - let projected = apply_projection(data, &[], &projection); + let projected = apply_projection(data, &[], &projection).unwrap(); assert_eq!( projected, @@ -191,4 +195,22 @@ mod tests { }) ); } + + /// A computed column that divides by zero fails the projection instead + /// of silently materializing `NULL`. + #[test] + fn apply_projection_computed_column_division_by_zero_errors() { + use crate::bridge::expr_eval::BinaryOp; + let data = serde_json::json!({"denom": 0}); + let computed = vec![ComputedColumn { + alias: "bad".into(), + expr: SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(nodedb_types::Value::Integer(10))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Column("denom".into())), + }, + }]; + let err = apply_projection(data, &computed, &[]).unwrap_err(); + assert!(matches!(err, crate::Error::DivisionByZero), "got {err:?}"); + } } diff --git a/nodedb/src/data/executor/handlers/document/read/scan.rs b/nodedb/src/data/executor/handlers/document/read/scan.rs index 7c2b0362d..642ebd363 100644 --- a/nodedb/src/data/executor/handlers/document/read/scan.rs +++ b/nodedb/src/data/executor/handlers/document/read/scan.rs @@ -158,17 +158,32 @@ impl CoreLoop { crate::types::TenantId::new(tid), collection.to_string(), ); + // `merge_overlay_into_scan` takes an infallible + // `Fn(&[u8]) -> bool` predicate, so a division/modulo-by- + // zero is captured via this `Cell` side-channel and + // checked once the merge returns. + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); let matches = |value: &[u8]| -> bool { if filter_predicates.is_empty() { return true; } - crate::data::executor::core_loop::filter_match::matches_with_resolved_schema( + match crate::data::executor::core_loop::filter_match::matches_with_resolved_schema( effective_schema.as_ref(), &filter_predicates, value, - ) + ) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } + } }; self.merge_overlay_into_scan(txn_id, &coll_key, &mut filtered, &matches); + if predicate_err.take().is_some() { + return self.response_error(task, ErrorCode::DivisionByZero); + } } // Bound an unbounded (no-LIMIT) scan by the memory budget. If @@ -253,15 +268,19 @@ impl CoreLoop { // with the same `category` but different ids/payload are // distinct as documents but the same under // `SELECT DISTINCT category`. Project first, then dedupe. - let projected_rows: Vec<_> = sorted + let projected_rows: Vec<_> = match sorted .into_iter() .map(|(doc_id, val)| { let mp = decode_scanned_document_msgpack(&val, Some(schema)); let projected = - apply_projection_msgpack(&mp, &computed_cols, projection); - (doc_id, projected) + apply_projection_msgpack(&mp, &computed_cols, projection)?; + Ok((doc_id, projected)) }) - .collect(); + .collect::>>() + { + Ok(rows) => rows, + Err(e) => return self.response_error(task, e), + }; let deduped = if distinct { let mut seen = std::collections::HashSet::new(); projected_rows @@ -283,23 +302,29 @@ impl CoreLoop { (id, doc) }) .collect(); - crate::bridge::window_func::evaluate_window_functions( + if let Err(e) = crate::bridge::window_func::evaluate_window_functions( &mut decoded_rows, &window_specs, - ); + ) { + return self.response_error(task, crate::Error::from(e)); + } // Project first, then dedupe on the projected JSON value // so `SELECT DISTINCT col` honours SQL semantics. - let projected_rows: Vec<_> = decoded_rows + let projected_rows: Vec<_> = match decoded_rows .into_iter() .map(|(doc_id, data)| { - let projected = apply_projection(data, &computed_cols, projection); - DocumentRow { + let projected = apply_projection(data, &computed_cols, projection)?; + Ok(DocumentRow { id: doc_id, data: projected, - } + }) }) - .collect(); + .collect::>>() + { + Ok(rows) => rows, + Err(e) => return self.response_error(task, e), + }; let deduped: Vec<_> = if distinct { let mut seen = std::collections::HashSet::new(); @@ -319,15 +344,19 @@ impl CoreLoop { if needs_transform { // Project first so DISTINCT acts on the projected // row, not the raw document. - let projected_rows: Vec<_> = sorted + let projected_rows: Vec<_> = match sorted .into_iter() .map(|(doc_id, value)| { let mp = doc_format::json_to_msgpack(&value); let projected = - apply_projection_msgpack(&mp, &computed_cols, projection); - (doc_id, projected) + apply_projection_msgpack(&mp, &computed_cols, projection)?; + Ok((doc_id, projected)) }) - .collect(); + .collect::>>() + { + Ok(rows) => rows, + Err(e) => return self.response_error(task, e), + }; let deduped = if distinct { let mut seen = std::collections::HashSet::new(); projected_rows @@ -357,12 +386,7 @@ impl CoreLoop { } } } - Err(e) => self.response_error( - task, - ErrorCode::Internal { - detail: e.to_string(), - }, - ), + Err(e) => self.response_error(task, e), } } } diff --git a/nodedb/src/data/executor/handlers/generated.rs b/nodedb/src/data/executor/handlers/generated.rs index 3de03264e..aefa9be6a 100644 --- a/nodedb/src/data/executor/handlers/generated.rs +++ b/nodedb/src/data/executor/handlers/generated.rs @@ -27,7 +27,13 @@ pub fn evaluate_generated_columns( for idx in order { let spec = &specs[idx]; let doc_val = nodedb_types::Value::from(doc.clone()); - let result = spec.expr.eval(&doc_val); + // A generated column's expression is write-path-shaped: a + // division/modulo-by-zero fails the write instead of silently + // materializing NULL into the stored column. + let result = spec + .expr + .eval(&doc_val) + .map_err(|_e| ErrorCode::DivisionByZero)?; let computed = serde_json::Value::from(result); if let Some(obj) = doc.as_object_mut() { obj.insert(spec.name.clone(), computed); diff --git a/nodedb/src/data/executor/handlers/grouping_sets_exec.rs b/nodedb/src/data/executor/handlers/grouping_sets_exec.rs index bd76599c2..2e0520b26 100644 --- a/nodedb/src/data/executor/handlers/grouping_sets_exec.rs +++ b/nodedb/src/data/executor/handlers/grouping_sets_exec.rs @@ -149,22 +149,36 @@ pub(super) fn execute_grouping_sets( if use_field_index { let idx = msgpack_scan::FieldIndex::build(raw, 0) .unwrap_or_else(msgpack_scan::FieldIndex::empty); - if !filter_predicates - .iter() - .all(|f| f.matches_binary_indexed(raw, &idx)) - { - continue; + match ScanFilter::all_match_binary_indexed(&filter_predicates, raw, &idx) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return core.response_error(task, ErrorCode::DivisionByZero); + } + } + } else { + match ScanFilter::all_match_binary(&filter_predicates, raw) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return core.response_error(task, ErrorCode::DivisionByZero); + } } - } else if !filter_predicates.iter().all(|f| f.matches_binary(raw)) { - continue; } } - let group_key = msgpack_scan::build_group_key(raw, &active_specs); - groups + let group_key = match msgpack_scan::build_group_key(raw, &active_specs) { + Ok(k) => k, + Err(_e) => return core.response_error(task, ErrorCode::DivisionByZero), + }; + if groups .entry(group_key) .or_insert_with(|| GroupState::new(&real_agg_slice)) - .feed(&real_agg_slice, raw); + .feed(&real_agg_slice, raw) + .is_err() + { + return core.response_error(task, ErrorCode::DivisionByZero); + } } // For an empty grouping set (grand-total), produce one aggregate row @@ -172,8 +186,16 @@ pub(super) fn execute_grouping_sets( if active_keys.is_empty() && groups.is_empty() { let mut grand = GroupState::new(&real_agg_slice); for raw in &owned_docs { - if filter_predicates.iter().all(|f| f.matches_binary(raw)) { - grand.feed(&real_agg_slice, raw); + match ScanFilter::all_match_binary(&filter_predicates, raw) { + Ok(true) => { + if grand.feed(&real_agg_slice, raw).is_err() { + return core.response_error(task, ErrorCode::DivisionByZero); + } + } + Ok(false) => {} + Err(_e) => { + return core.response_error(task, ErrorCode::DivisionByZero); + } } } groups.insert(String::new(), grand); @@ -240,10 +262,27 @@ pub(super) fn execute_grouping_sets( // Apply HAVING. if !having_predicates.is_empty() { + // `Vec::retain`'s closure must return `bool`, so a division/modulo- + // by-zero in a HAVING predicate is captured via this `Cell` + // side-channel and checked once the retain finishes. + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); all_rows.retain(|row| { + if predicate_err.get().is_some() { + return true; + } let mp = nodedb_types::json_to_msgpack_or_empty(row); - having_predicates.iter().all(|f| f.matches_binary(&mp)) + match ScanFilter::all_match_binary(&having_predicates, &mp) { + Ok(keep) => keep, + Err(e) => { + predicate_err.set(Some(e)); + true + } + } }); + if predicate_err.take().is_some() { + return core.response_error(task, ErrorCode::DivisionByZero); + } } // Apply user aliases for real aggregates (grouping aliases were applied above). diff --git a/nodedb/src/data/executor/handlers/join/grace_partitioner.rs b/nodedb/src/data/executor/handlers/join/grace_partitioner.rs index a939a38d9..afc7c4710 100644 --- a/nodedb/src/data/executor/handlers/join/grace_partitioner.rs +++ b/nodedb/src/data/executor/handlers/join/grace_partitioner.rs @@ -125,7 +125,7 @@ pub(super) fn grace_join_in_memory( probe_docs: Vec<(String, Vec)>, partitions: usize, spec: &GraceSpec, -) -> Vec> { +) -> Result>, nodedb_query::EvalError> { let build_keys = spec.build_keys; let probe_keys = spec.probe_keys; let join_type = spec.join_type; @@ -183,12 +183,12 @@ pub(super) fn grace_join_in_memory( index_collection, join_filters: &[], emit_unmatched_right, - }); + })?; results.append(&mut part_results); } results.truncate(limit); - results + Ok(results) } #[cfg(test)] @@ -236,6 +236,7 @@ mod tests { join_filters: &[], emit_unmatched_right: spec.emit_unmatched_right, }) + .unwrap() } /// Single-key fixtures: matches, non-matches, duplicate keys (count must be @@ -317,7 +318,8 @@ mod tests { let want = as_multiset(reference(&build, &probe, &spec)); for p in [1usize, 2, 4, 8] { - let candidate = grace_join_in_memory(build.clone(), probe.clone(), p, &spec); + let candidate = + grace_join_in_memory(build.clone(), probe.clone(), p, &spec).unwrap(); assert_eq!( want, as_multiset(candidate), @@ -404,7 +406,8 @@ mod tests { }; let want = as_multiset(reference(&build, &probe, &spec)); for p in [1usize, 2, 4, 8] { - let candidate = grace_join_in_memory(build.clone(), probe.clone(), p, &spec); + let candidate = + grace_join_in_memory(build.clone(), probe.clone(), p, &spec).unwrap(); assert_eq!( want, as_multiset(candidate), @@ -433,7 +436,8 @@ mod tests { }; let want = as_multiset(reference(&build, &probe, &spec)); for p in [1usize, 2, 4, 8] { - let candidate = grace_join_in_memory(build.clone(), probe.clone(), p, &spec); + let candidate = + grace_join_in_memory(build.clone(), probe.clone(), p, &spec).unwrap(); assert_eq!( want, as_multiset(candidate), @@ -462,7 +466,8 @@ mod tests { }; let want = as_multiset(reference(&build, &probe, &spec)); for p in [1usize, 2, 4, 8] { - let candidate = grace_join_in_memory(build.clone(), probe.clone(), p, &spec); + let candidate = + grace_join_in_memory(build.clone(), probe.clone(), p, &spec).unwrap(); assert_eq!( want, as_multiset(candidate), @@ -497,7 +502,8 @@ mod tests { ..unbounded_spec }; for p in [1usize, 2, 4, 8] { - let candidate = grace_join_in_memory(build.clone(), probe.clone(), p, &limited_spec); + let candidate = + grace_join_in_memory(build.clone(), probe.clone(), p, &limited_spec).unwrap(); assert_eq!(candidate.len(), limit, "limit truncation p={p}"); } } diff --git a/nodedb/src/data/executor/handlers/join/grace_probe.rs b/nodedb/src/data/executor/handlers/join/grace_probe.rs index 850c18726..89c701d00 100644 --- a/nodedb/src/data/executor/handlers/join/grace_probe.rs +++ b/nodedb/src/data/executor/handlers/join/grace_probe.rs @@ -61,10 +61,14 @@ impl CoreLoop { let flush = |batch: &mut Vec<(String, Vec)>, batch_bytes: &mut usize, results: &mut Vec>, - index_matched: &mut [bool]| { + index_matched: &mut [bool]| + -> Result<(), nodedb_query::EvalError> { if batch.is_empty() { - return; + return Ok(()); } + // A residual-ON-predicate div/modulo-by-zero propagates out of the + // flush closure; the caller's `?` converts it to + // `crate::Error::DivisionByZero` (SQLSTATE 22012). probe_rows_into( &ProbeParams { probe_docs: batch, @@ -80,9 +84,10 @@ impl CoreLoop { }, results, index_matched, - ); + )?; batch.clear(); *batch_bytes = 0; + Ok(()) }; probe_source.for_each(self, |id, bytes| { @@ -100,7 +105,7 @@ impl CoreLoop { &mut batch_bytes, &mut results, &mut index_matched, - ); + )?; } Ok(()) })?; @@ -111,7 +116,7 @@ impl CoreLoop { &mut batch_bytes, &mut results, &mut index_matched, - ); + )?; // RIGHT/FULL: emit unmatched index-side rows ONCE, after all probe // batches. The in-memory path runs this same sweep via probe_hash_index. diff --git a/nodedb/src/data/executor/handlers/join/grace_spill.rs b/nodedb/src/data/executor/handlers/join/grace_spill.rs index f7412eebd..94c034824 100644 --- a/nodedb/src/data/executor/handlers/join/grace_spill.rs +++ b/nodedb/src/data/executor/handlers/join/grace_spill.rs @@ -327,6 +327,9 @@ impl PartitionedSpiller { let probe_docs = item.probe.materialize()?; let index = HashIndex::build(&build_docs, &build_key_refs); + // `join_filters: &[]` here → `probe_hash_index` never evaluates a + // residual predicate on this path, so the `?` is a no-op today; + // it is threaded for signature consistency. let mut part = probe_hash_index(&ProbeParams { probe_docs: &probe_docs, index: &index, @@ -338,7 +341,7 @@ impl PartitionedSpiller { index_collection: &index_collection, join_filters: &[], emit_unmatched_right, - }); + })?; results.append(&mut part); continue; } @@ -637,8 +640,9 @@ mod tests { emit_unmatched_right: true, }; // partitions=4 matches the reference's own clamp rules - let want = - as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 4, &spec)); + let want = as_multiset( + grace_join_in_memory(build.clone(), probe.clone(), 4, &spec).unwrap(), + ); for p in [1usize, 4] { let dir = tempfile::tempdir().unwrap(); @@ -724,8 +728,9 @@ mod tests { index_collection: "r", emit_unmatched_right: true, }; - let want = - as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 4, &spec)); + let want = as_multiset( + grace_join_in_memory(build.clone(), probe.clone(), 4, &spec).unwrap(), + ); for p in [1usize, 4] { let dir = tempfile::tempdir().unwrap(); // budget=1 forces spill; materialize_cap large → no re-partition. @@ -766,8 +771,9 @@ mod tests { index_collection: "r", emit_unmatched_right: true, }; - let want = - as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 4, &spec)); + let want = as_multiset( + grace_join_in_memory(build.clone(), probe.clone(), 4, &spec).unwrap(), + ); for p in [1usize, 4] { let dir = tempfile::tempdir().unwrap(); let got = run_spiller( @@ -838,8 +844,9 @@ mod tests { index_collection: "r", emit_unmatched_right: true, }; - let want = - as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 1, &spec)); + let want = as_multiset( + grace_join_in_memory(build.clone(), probe.clone(), 1, &spec).unwrap(), + ); let dir = tempfile::tempdir().unwrap(); // materialize_cap sizing (the property under test is RE-PARTITION // that COMPLETES). Each build row is a small msgpack map diff --git a/nodedb/src/data/executor/handlers/join/hash.rs b/nodedb/src/data/executor/handlers/join/hash.rs index d35780afb..85c69776b 100644 --- a/nodedb/src/data/executor/handlers/join/hash.rs +++ b/nodedb/src/data/executor/handlers/join/hash.rs @@ -124,7 +124,14 @@ pub(super) struct ProbeParams<'a> { /// the EXACT same emission logic batch-by-batch without duplicating it. The /// composed behavior here is byte-identical to passing the whole probe side in a /// single batch. -pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec> { +/// +/// A division/modulo-by-zero in a join's residual ON predicate PROPAGATES +/// as an `EvalError` (surfaced to the client as SQLSTATE 22012) rather than +/// folding to "no match": this fn is fallible and threads the error up to +/// the bridge Response boundary. +pub(super) fn probe_hash_index( + p: &ProbeParams<'_>, +) -> Result>, nodedb_query::EvalError> { let is_right = p.join_type == "right" || p.join_type == "full"; let is_cross = p.join_type == "cross"; @@ -134,7 +141,7 @@ pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec> { for (_, left_val) in p.probe_docs { for (_, right_val) in p.index_docs { if results.len() >= p.limit { - return results; + return Ok(results); } let merged = merge_join_docs_binary( left_val, @@ -142,13 +149,16 @@ pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec> { p.probe_collection, p.index_collection, ); - if p.join_filters.is_empty() || binary_row_matches_filters(&merged, p.join_filters) + // A division/modulo-by-zero in a join's residual ON + // predicate PROPAGATES here (SQLSTATE 22012) rather than + // folding to "no match". + if p.join_filters.is_empty() || binary_row_matches_filters(&merged, p.join_filters)? { results.push(merged); } } } - return results; + return Ok(results); } // For RIGHT/FULL joins, pre-allocate a complete tracking vector so we @@ -162,14 +172,14 @@ pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec> { }; let mut results = Vec::new(); - probe_rows_into(p, &mut results, &mut index_matched); + probe_rows_into(p, &mut results, &mut index_matched)?; // RIGHT/FULL: emit unmatched index-side rows. if is_right && p.emit_unmatched_right { emit_unmatched_right_into(p, &mut results, &index_matched); } - results + Ok(results) } /// Append join output for the probe rows in `p.probe_docs` to `results`, @@ -189,11 +199,14 @@ pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec> { /// /// `index_matched` must be sized `p.index_docs.len()` for RIGHT/FULL joins (and /// may be empty otherwise — it is only indexed when the join is RIGHT/FULL). +/// +/// A division/modulo-by-zero in a residual ON predicate PROPAGATES as an +/// `EvalError` (SQLSTATE 22012) rather than folding to "no match". pub(super) fn probe_rows_into( p: &ProbeParams<'_>, results: &mut Vec>, index_matched: &mut [bool], -) { +) -> Result<(), nodedb_query::EvalError> { let is_left = p.join_type == "left" || p.join_type == "full"; let is_right = p.join_type == "right" || p.join_type == "full"; let is_semi = p.join_type == "semi"; @@ -205,22 +218,26 @@ pub(super) fn probe_rows_into( if !is_right && results.len() >= p.limit { break; } - let (_, _, matched_indices) = p.index.probe(value, p.probe_keys, p.index_docs); - let matched_indices = matched_indices - .into_iter() - .filter(|&index| { - if p.join_filters.is_empty() { - return true; - } - let merged = merge_join_docs_binary( - value, - Some(&p.index_docs[index].1), - p.probe_collection, - p.index_collection, - ); - binary_row_matches_filters(&merged, p.join_filters) - }) - .collect::>(); + let (_, _, candidate_indices) = p.index.probe(value, p.probe_keys, p.index_docs); + // A closure can't use `?`, so filter with an explicit loop: a div-by-zero + // in the residual ON predicate returns `Err` from this fn (SQLSTATE + // 22012) instead of folding to "no match". + let mut matched_indices: Vec = Vec::new(); + for index in candidate_indices { + if p.join_filters.is_empty() { + matched_indices.push(index); + continue; + } + let merged = merge_join_docs_binary( + value, + Some(&p.index_docs[index].1), + p.probe_collection, + p.index_collection, + ); + if binary_row_matches_filters(&merged, p.join_filters)? { + matched_indices.push(index); + } + } if !matched_indices.is_empty() { if is_semi { @@ -255,6 +272,7 @@ pub(super) fn probe_rows_into( )); } } + Ok(()) } /// Emit unmatched index-side (build/right) rows for RIGHT/FULL joins into diff --git a/nodedb/src/data/executor/handlers/join/hash_handlers.rs b/nodedb/src/data/executor/handlers/join/hash_handlers.rs index fabb5683e..dec78a984 100644 --- a/nodedb/src/data/executor/handlers/join/hash_handlers.rs +++ b/nodedb/src/data/executor/handlers/join/hash_handlers.rs @@ -358,7 +358,7 @@ impl CoreLoop { ) }; - let mut results = probe_hash_index(&ProbeParams { + let mut results = match probe_hash_index(&ProbeParams { probe_docs: &left_docs, index: &right_index, index_docs: &right_docs, @@ -369,7 +369,12 @@ impl CoreLoop { index_collection: right_prefix, join_filters: &join_filters, emit_unmatched_right: true, - }); + }) { + Ok(r) => r, + // Div/modulo-by-zero in a residual ON predicate surfaces to the + // client as SQLSTATE 22012. + Err(_e) => return self.response_error(join.task, ErrorCode::DivisionByZero), + }; if enforce_output_budget && results.len() >= probe_limit { return self.response_error(join.task, ErrorCode::ResourcesExhausted); diff --git a/nodedb/src/data/executor/handlers/join/nested_loop.rs b/nodedb/src/data/executor/handlers/join/nested_loop.rs index 8b85860c3..c1bafc613 100644 --- a/nodedb/src/data/executor/handlers/join/nested_loop.rs +++ b/nodedb/src/data/executor/handlers/join/nested_loop.rs @@ -162,7 +162,15 @@ impl CoreLoop { left_collection, right_collection, ); - predicates.iter().all(|p| p.matches_binary(&merged)) + match crate::bridge::scan_filter::ScanFilter::all_match_binary( + &predicates, + &merged, + ) { + Ok(b) => b, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } + } }; if passes { diff --git a/nodedb/src/data/executor/handlers/join/params.rs b/nodedb/src/data/executor/handlers/join/params.rs index 5b62a6c75..3c0f9bf33 100644 --- a/nodedb/src/data/executor/handlers/join/params.rs +++ b/nodedb/src/data/executor/handlers/join/params.rs @@ -147,7 +147,28 @@ impl JoinParams<'_> { } })?; if !filters.is_empty() { - results.retain(|row| super::binary_row_matches_filters(row, &filters)); + // `Vec::retain`'s closure must return `bool`, so a division/ + // modulo-by-zero hit while matching a post-filter is + // captured in `first_err` and checked once the retain pass + // finishes — this post-filter path is WHERE-shaped, so + // (unlike the hash-join probe hot path this same helper + // also serves) it gets the full error treatment here. + let mut first_err: Option = None; + results.retain(|row| { + if first_err.is_some() { + return true; + } + match super::binary_row_matches_filters(row, &filters) { + Ok(keep) => keep, + Err(e) => { + first_err = Some(crate::Error::from(e)); + true + } + } + }); + if let Some(e) = first_err { + return Err(e); + } } } @@ -164,7 +185,7 @@ impl JoinParams<'_> { row, &computed, &[], - ); + )?; } } else if !self.projection.is_empty() { for row in results.iter_mut() { diff --git a/nodedb/src/data/executor/handlers/join/shuffle_join.rs b/nodedb/src/data/executor/handlers/join/shuffle_join.rs index ea2b095bd..e528df754 100644 --- a/nodedb/src/data/executor/handlers/join/shuffle_join.rs +++ b/nodedb/src/data/executor/handlers/join/shuffle_join.rs @@ -336,12 +336,15 @@ mod tests { let s = spec(&build_keys, &probe_keys, jt); // Reference: the owned-Vec in-memory grace oracle. - let want = as_multiset(grace_join_in_memory( - build.iter().map(|r| (String::new(), r.clone())).collect(), - probe.iter().map(|r| (String::new(), r.clone())).collect(), - 64, - &s, - )); + let want = as_multiset( + grace_join_in_memory( + build.iter().map(|r| (String::new(), r.clone())).collect(), + probe.iter().map(|r| (String::new(), r.clone())).collect(), + 64, + &s, + ) + .unwrap(), + ); // budget 0 = unlimited (in-memory build); spill_budget = build // spills to disk + re-partitions, then completes. BOTH must equal @@ -422,7 +425,8 @@ mod tests { probe.iter().map(|r| (String::new(), r.clone())).collect(), 64, &ref_spec, - ); + ) + .unwrap(); let task = make_task(); let join = JoinParams { diff --git a/nodedb/src/data/executor/handlers/join/support.rs b/nodedb/src/data/executor/handlers/join/support.rs index e27e05d4a..d42f9502f 100644 --- a/nodedb/src/data/executor/handlers/join/support.rs +++ b/nodedb/src/data/executor/handlers/join/support.rs @@ -3,6 +3,7 @@ //! Shared binary-msgpack helpers used by the join execution handlers: //! row merging, map-header writing, field filtering, and projection. +use nodedb_query::EvalError; use nodedb_query::msgpack_scan; use crate::data::executor::msgpack_utils::write_str; @@ -134,25 +135,33 @@ pub(super) fn compare_preextracted( /// ScanFilter field names may be unqualified ("amount") while the merged /// join row has qualified keys ("orders.amount"). We try the field name /// as-is first, then fall back to suffix matching. +/// +/// Returns `Err(EvalError::DivisionByZero)` when a `FilterOp::Expr` +/// predicate divides or takes a modulus by zero. Every caller propagates +/// this as a genuine statement error (SQLSTATE 22012): the +/// WHERE-shaped post-filter path (`join::params::filter_and_project`) and the +/// hash-join probe path (`join::hash`'s `probe_hash_index` / `probe_rows_into`, +/// including the grace-hash spill/streaming family) both surface it rather than +/// folding a residual ON-predicate error to "no match". pub(super) fn binary_row_matches_filters( row: &[u8], filters: &[crate::bridge::scan_filter::ScanFilter], -) -> bool { +) -> Result { use crate::bridge::scan_filter::FilterOp; - filters.iter().all(|f| { + for f in filters { if f.op == FilterOp::MatchAll { - return true; + continue; } // Try exact field name first. - if f.matches_binary(row) { - return true; + if f.matches_binary(row)? { + continue; } // Qualified-name fallback: field "amount" may be stored as "orders.amount". // Build a mini map with unqualified names for the fields this filter needs, // so matches_binary can find them. let Some((count, mut pos)) = msgpack_scan::map_header(row, 0) else { - return false; + return Ok(false); }; // Collect all fields the filter needs (left field + right column for ColumnCompare). @@ -178,12 +187,12 @@ pub(super) fn binary_row_matches_filters( let key = msgpack_scan::read_str(row, pos); let key_end = match msgpack_scan::skip_value(row, pos) { Some(p) => p, - None => return false, + None => return Ok(false), }; let val_start = key_end; let val_end = match msgpack_scan::skip_value(row, val_start) { Some(p) => p, - None => return false, + None => return Ok(false), }; if let Some(k) = key { for &need in &needed { @@ -197,7 +206,7 @@ pub(super) fn binary_row_matches_filters( } if found.is_empty() { - return false; + return Ok(false); } // Build a mini map with unqualified names. @@ -207,8 +216,11 @@ pub(super) fn binary_row_matches_filters( write_str(&mut mini, name); mini.extend_from_slice(&row[*vs..*ve]); } - f.matches_binary(&mini) - }) + if !f.matches_binary(&mini)? { + return Ok(false); + } + } + Ok(true) } /// Apply projection to a binary msgpack row, keeping only requested columns. @@ -303,7 +315,7 @@ mod tests { expr: None, }]; - assert!(binary_row_matches_filters(&merged, &filters)); + assert!(binary_row_matches_filters(&merged, &filters).unwrap()); } #[test] diff --git a/nodedb/src/data/executor/handlers/kv/crud/write_upsert.rs b/nodedb/src/data/executor/handlers/kv/crud/write_upsert.rs index 1404a1d60..b0d49b7b8 100644 --- a/nodedb/src/data/executor/handlers/kv/crud/write_upsert.rs +++ b/nodedb/src/data/executor/handlers/kv/crud/write_upsert.rs @@ -74,11 +74,15 @@ impl CoreLoop { ); } }; - let merged = crate::data::executor::handlers::upsert::apply_on_conflict_updates( - existing_val, - &excluded_val, - updates, - ); + let merged = + match crate::data::executor::handlers::upsert::apply_on_conflict_updates( + existing_val, + &excluded_val, + updates, + ) { + Ok(v) => v, + Err(e) => return self.response_error(task, e), + }; match nodedb_types::value_to_msgpack(&merged) { Ok(b) => b, Err(_) => { diff --git a/nodedb/src/data/executor/handlers/kv/scan.rs b/nodedb/src/data/executor/handlers/kv/scan.rs index 37d9fa5d5..8df9ecf7f 100644 --- a/nodedb/src/data/executor/handlers/kv/scan.rs +++ b/nodedb/src/data/executor/handlers/kv/scan.rs @@ -142,12 +142,17 @@ impl CoreLoop { }; // Apply filter predicates post-scan (already works on raw msgpack). - if !filter_predicates.is_empty() - && !filter_predicates - .iter() - .all(|f| f.matches_binary(&entry_mp)) - { - continue; + if !filter_predicates.is_empty() { + match crate::bridge::scan_filter::ScanFilter::all_match_binary( + &filter_predicates, + &entry_mp, + ) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } + } } result_entries.push(entry_mp); diff --git a/nodedb/src/data/executor/handlers/merge.rs b/nodedb/src/data/executor/handlers/merge.rs index 95bfc8aba..95ca08ac7 100644 --- a/nodedb/src/data/executor/handlers/merge.rs +++ b/nodedb/src/data/executor/handlers/merge.rs @@ -187,7 +187,11 @@ impl CoreLoop { // Build merged document for predicate / expression evaluation. let merged = build_merged(&target_doc, source_doc, source_alias); // Find first MATCHED arm whose predicate is satisfied. - if let Some(arm) = find_arm(clauses, MergeClauseKindOp::Matched, &merged) { + let arm = match find_arm(clauses, MergeClauseKindOp::Matched, &merged) { + Ok(arm) => arm, + Err(e) => return self.response_error(task, e), + }; + if let Some(arm) = arm { let db_id = task.request.database_id.as_u64(); match apply_action( self, @@ -212,8 +216,11 @@ impl CoreLoop { } else { // No matching source row — check NOT MATCHED BY SOURCE arms. let merged = target_doc.clone(); - if let Some(arm) = find_arm(clauses, MergeClauseKindOp::NotMatchedBySource, &merged) - { + let arm = match find_arm(clauses, MergeClauseKindOp::NotMatchedBySource, &merged) { + Ok(arm) => arm, + Err(e) => return self.response_error(task, e), + }; + if let Some(arm) = arm { let db_id = task.request.database_id.as_u64(); match apply_action( self, @@ -243,7 +250,11 @@ impl CoreLoop { if matched_source_keys.contains(src_key.as_str()) { continue; } - if let Some(arm) = find_arm(clauses, MergeClauseKindOp::NotMatched, src_doc) { + let arm = match find_arm(clauses, MergeClauseKindOp::NotMatched, src_doc) { + Ok(arm) => arm, + Err(e) => return self.response_error(task, e), + }; + if let Some(arm) = arm { match apply_insert_action( self, ApplyInsertActionParams { diff --git a/nodedb/src/data/executor/handlers/merge_helpers.rs b/nodedb/src/data/executor/handlers/merge_helpers.rs index d47dfc7f8..e60ed5162 100644 --- a/nodedb/src/data/executor/handlers/merge_helpers.rs +++ b/nodedb/src/data/executor/handlers/merge_helpers.rs @@ -12,23 +12,38 @@ use nodedb_physical::physical_plan::document::merge_types::{ /// Find the first clause of the given kind whose extra_predicate is satisfied /// against `context_doc`. +/// +/// A MERGE clause's `AND ` extra predicate is WHERE-shaped, so a +/// division/modulo-by-zero inside it fails the whole statement — the same +/// behavior-flip rule 4 applies to WHERE/projection — rather than silently +/// skipping to the next clause. pub(super) fn find_arm<'a>( clauses: &'a [MergeClauseOp], kind: MergeClauseKindOp, context_doc: &serde_json::Value, -) -> Option<&'a MergeClauseOp> { +) -> crate::Result> { let context_bytes = doc_format::encode_to_msgpack(context_doc); - clauses.iter().find(|c| { + for c in clauses { if c.kind != kind { - return false; + continue; } if c.extra_predicate.is_empty() { - return true; + return Ok(Some(c)); } let filters: Vec = zerompk::from_msgpack(&c.extra_predicate).unwrap_or_default(); - filters.iter().all(|f| f.matches_binary(&context_bytes)) - }) + let mut all_match = true; + for f in &filters { + if !f.matches_binary(&context_bytes)? { + all_match = false; + break; + } + } + if all_match { + return Ok(Some(c)); + } + } + Ok(None) } /// Parameters for [`apply_action`]. @@ -85,7 +100,7 @@ pub(super) fn apply_action( Ok(true) } MergeActionOp::Update { updates } => { - let updated = build_update_doc(target_doc, source_doc, source_alias, updates); + let updated = build_update_doc(target_doc, source_doc, source_alias, updates)?; let updated_bytes = if let Some(schema) = strict_schema { let ndb_val: nodedb_types::Value = updated.clone().into(); @@ -168,7 +183,7 @@ pub(super) fn apply_insert_action( Ok(false) } MergeActionOp::Insert { columns, values } => { - let json_doc = build_insert_doc(columns, values, source_doc, source_alias); + let json_doc = build_insert_doc(columns, values, source_doc, source_alias)?; let doc_id = json_doc .get("id") .map(json_to_str) @@ -210,7 +225,7 @@ pub(in crate::data::executor) fn build_insert_doc( values: &[UpdateValue], source_doc: &serde_json::Value, source_alias: &str, -) -> serde_json::Value { +) -> crate::Result { let mut new_doc = serde_json::Map::new(); if columns.is_empty() { if let Some(obj) = source_doc.as_object() { @@ -228,10 +243,10 @@ pub(in crate::data::executor) fn build_insert_doc( ); let merged_ndb: nodedb_types::Value = merged.into(); for (col, val) in columns.iter().zip(values.iter()) { - new_doc.insert(col.clone(), resolve_update_value(val, &merged_ndb)); + new_doc.insert(col.clone(), resolve_update_value(val, &merged_ndb)?); } } - serde_json::Value::Object(new_doc) + Ok(serde_json::Value::Object(new_doc)) } /// Build the post-update JSON document a MATCHED / NOT-MATCHED-BY-SOURCE @@ -244,28 +259,36 @@ pub(in crate::data::executor) fn build_update_doc( source_doc: &serde_json::Value, source_alias: &str, updates: &[(String, UpdateValue)], -) -> serde_json::Value { +) -> crate::Result { let merged = build_merged(target_doc, source_doc, source_alias); let merged_ndb: nodedb_types::Value = merged.into(); let mut updated = target_doc.clone(); if let Some(obj) = updated.as_object_mut() { for (field, update_val) in updates { - obj.insert(field.clone(), resolve_update_value(update_val, &merged_ndb)); + obj.insert( + field.clone(), + resolve_update_value(update_val, &merged_ndb)?, + ); } } - updated + Ok(updated) } /// Resolve one `UpdateValue` to JSON: a literal decodes directly from its /// msgpack encoding, an expression evaluates against the merged document. -/// Shared by [`build_insert_doc`] and [`build_update_doc`]. -fn resolve_update_value(val: &UpdateValue, merged_ndb: &nodedb_types::Value) -> serde_json::Value { - match val { +/// Shared by [`build_insert_doc`] and [`build_update_doc`]. An assignment +/// expression is write-path-shaped, so a division/modulo-by-zero fails the +/// whole MERGE statement. +fn resolve_update_value( + val: &UpdateValue, + merged_ndb: &nodedb_types::Value, +) -> crate::Result { + Ok(match val { UpdateValue::Literal(bytes) => { nodedb_types::json_from_msgpack(bytes).unwrap_or(serde_json::Value::Null) } - UpdateValue::Expr(expr) => expr.eval(merged_ndb).into(), - } + UpdateValue::Expr(expr) => expr.eval(merged_ndb)?.into(), + }) } /// Build merged document: target fields at top level, source fields as diff --git a/nodedb/src/data/executor/handlers/merge_orchestrated/plan.rs b/nodedb/src/data/executor/handlers/merge_orchestrated/plan.rs index afa5342aa..22827d358 100644 --- a/nodedb/src/data/executor/handlers/merge_orchestrated/plan.rs +++ b/nodedb/src/data/executor/handlers/merge_orchestrated/plan.rs @@ -147,11 +147,11 @@ impl CoreLoop { target_doc.clone() }; - if let Some(arm) = find_arm(params.clauses, arm_kind, &context) { + if let Some(arm) = find_arm(params.clauses, arm_kind, &context)? { match &arm.action { MergeActionOp::Update { updates: upd } => { let updated = - build_update_doc(&target_doc, source_doc, params.source_alias, upd); + build_update_doc(&target_doc, source_doc, params.source_alias, upd)?; updates.push(MergeUpdate { doc_id: doc_id.clone(), surrogate, @@ -175,7 +175,7 @@ impl CoreLoop { if matched_source_keys.contains(src_key.as_str()) { continue; } - if let Some(arm) = find_arm(params.clauses, MergeClauseKindOp::NotMatched, src_doc) + if let Some(arm) = find_arm(params.clauses, MergeClauseKindOp::NotMatched, src_doc)? && let MergeActionOp::Insert { columns, values } = &arm.action { let body = encode_doc_body(&build_insert_doc( @@ -183,7 +183,7 @@ impl CoreLoop { values, src_doc, params.source_alias, - )); + )?); inserts.push(MergeInsert { join_key: src_key.clone(), body, diff --git a/nodedb/src/data/executor/handlers/point/apply_put/types.rs b/nodedb/src/data/executor/handlers/point/apply_put/types.rs index 5ce1b3367..d2cb7c44d 100644 --- a/nodedb/src/data/executor/handlers/point/apply_put/types.rs +++ b/nodedb/src/data/executor/handlers/point/apply_put/types.rs @@ -105,11 +105,8 @@ pub(in crate::data::executor) fn map_enforcement_error(e: ErrorCode) -> crate::E ErrorCode::StateTransitionViolation { collection, detail } => { crate::Error::StateTransitionViolation { collection, detail } } - ErrorCode::TransitionCheckViolation { collection } => { - crate::Error::TransitionCheckViolation { - collection, - detail: "transition check predicate failed".to_string(), - } + ErrorCode::TransitionCheckViolation { collection, detail } => { + crate::Error::TransitionCheckViolation { collection, detail } } ErrorCode::RetentionViolation { collection } => crate::Error::RetentionViolation { collection, diff --git a/nodedb/src/data/executor/handlers/point/update.rs b/nodedb/src/data/executor/handlers/point/update.rs index 6383f80a9..c3780b8d7 100644 --- a/nodedb/src/data/executor/handlers/point/update.rs +++ b/nodedb/src/data/executor/handlers/point/update.rs @@ -184,7 +184,16 @@ impl CoreLoop { } } UpdateValue::Expr(expr) => { - let result: nodedb_types::Value = expr.eval(&eval_doc); + let result: nodedb_types::Value = match expr.eval(&eval_doc) { + Ok(v) => v, + // Division/modulo by zero fails the + // statement, same as the + // literal-decode-failure arm above. + Err(_e) => { + return self + .response_error(task, ErrorCode::DivisionByZero); + } + }; // Convert nodedb_types::Value → serde_json::Value so the // downstream re-encode path (strict or msgpack) can proceed // through its existing json-based branches unchanged. diff --git a/nodedb/src/data/executor/handlers/provider_scan.rs b/nodedb/src/data/executor/handlers/provider_scan.rs index fa8e17b78..5e783827d 100644 --- a/nodedb/src/data/executor/handlers/provider_scan.rs +++ b/nodedb/src/data/executor/handlers/provider_scan.rs @@ -69,7 +69,26 @@ impl CoreLoop { } }; if !predicates.is_empty() { - rows.retain(|row| predicates.iter().all(|f| f.matches_binary(row))); + // `Vec::retain`'s closure must return `bool`, so a division/ + // modulo-by-zero is captured via this `Cell` side-channel + // and checked once the retain finishes. + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); + rows.retain(|row| { + if predicate_err.get().is_some() { + return true; + } + match ScanFilter::all_match_binary(&predicates, row) { + Ok(keep) => keep, + Err(e) => { + predicate_err.set(Some(e)); + true + } + } + }); + if predicate_err.take().is_some() { + return self.response_error(task, ErrorCode::DivisionByZero); + } } } diff --git a/nodedb/src/data/executor/handlers/recursive.rs b/nodedb/src/data/executor/handlers/recursive.rs index 2f3d0433d..c31d96b8d 100644 --- a/nodedb/src/data/executor/handlers/recursive.rs +++ b/nodedb/src/data/executor/handlers/recursive.rs @@ -156,8 +156,12 @@ impl CoreLoop { continue; } }; - if !base_preds.iter().all(|f| f.matches_binary(&mp)) { - continue; + match ScanFilter::all_match_binary(&base_preds, &mp) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } let key = if distinct { nodedb_types::msgpack_to_json_string(&mp).unwrap_or_default() @@ -209,8 +213,12 @@ impl CoreLoop { }; // Apply recursive filters (WHERE clause from recursive branch). - if !recursive_preds.iter().all(|f| f.matches_binary(&mp)) { - continue; + match ScanFilter::all_match_binary(&recursive_preds, &mp) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } // Check join link: collection_field value must be in frontier. @@ -264,8 +272,12 @@ impl CoreLoop { Some(m) => m, None => continue, }; - if !recursive_preds.iter().all(|f| f.matches_binary(&mp)) { - continue; + match ScanFilter::all_match_binary(&recursive_preds, &mp) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } let key = if distinct { nodedb_types::msgpack_to_json_string(&mp).unwrap_or_default() diff --git a/nodedb/src/data/executor/handlers/rls_eval.rs b/nodedb/src/data/executor/handlers/rls_eval.rs index 3e9e85264..87dc2e136 100644 --- a/nodedb/src/data/executor/handlers/rls_eval.rs +++ b/nodedb/src/data/executor/handlers/rls_eval.rs @@ -33,7 +33,18 @@ pub fn rls_check_document(rls_filters: &[u8], doc: &serde_json::Value) -> bool { }; let msgpack = nodedb_types::json_to_msgpack_or_empty(doc); - filters.iter().all(|f| f.matches_binary(&msgpack)) + // RLS is a security boundary: fail closed on a division/modulo-by-zero + // in a filter, exactly like the deserialization failure above — deny + // rather than propagate a query error, so a + // malformed/adversarial RLS predicate can never be used to distinguish + // "row exists but errors" from "row doesn't exist". + match ScanFilter::all_match_binary(&filters, &msgpack) { + Ok(pass) => pass, + Err(e) => { + tracing::warn!(error = %e, "RLS filter evaluation failed — denying access"); + false + } + } } /// Evaluate RLS filters against raw MessagePack document bytes. @@ -55,7 +66,14 @@ pub fn rls_check_msgpack_bytes(rls_filters: &[u8], doc_bytes: &[u8]) -> bool { // Ensure bytes are standard msgpack for matches_binary. let mp = super::super::doc_format::json_to_msgpack(doc_bytes); - filters.iter().all(|f| f.matches_binary(&mp)) + // RLS is a security boundary: fail closed — see `rls_check_document`. + match ScanFilter::all_match_binary(&filters, &mp) { + Ok(pass) => pass, + Err(e) => { + tracing::warn!(error = %e, "RLS filter evaluation failed — denying access"); + false + } + } } #[cfg(test)] diff --git a/nodedb/src/data/executor/handlers/spatial.rs b/nodedb/src/data/executor/handlers/spatial.rs index 83e582564..de3974c79 100644 --- a/nodedb/src/data/executor/handlers/spatial.rs +++ b/nodedb/src/data/executor/handlers/spatial.rs @@ -283,18 +283,26 @@ impl CoreLoop { continue; } - if !attr_filters.iter().all(|f| f.matches_value(&doc)) { - continue; + match ScanFilter::all_match_value(&attr_filters, &doc) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } - if !row_level_filters.iter().all(|f| f.matches_value(&doc)) { - continue; + match ScanFilter::all_match_value(&row_level_filters, &doc) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } results.push(project_doc(&doc, &doc_id, projection)); } - if let Some(txn_id) = task.request.txn_id { - self.merge_overlay_into_spatial_scan( + if let Some(txn_id) = task.request.txn_id + && let Err(e) = self.merge_overlay_into_spatial_scan( super::transaction::overlay::SpatialOverlayMergeParams { txn_id, coll_key: &coll_key, @@ -307,7 +315,9 @@ impl CoreLoop { row_level_filters: &row_level_filters, }, &mut results, - ); + ) + { + return self.response_error(task, e); } match response_codec::encode_value_vec(&results) { @@ -389,11 +399,19 @@ impl CoreLoop { continue; } - if !attr_filters.iter().all(|f| f.matches_value(&doc)) { - continue; + match ScanFilter::all_match_value(attr_filters, &doc) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } - if !rls_filters.iter().all(|f| f.matches_value(&doc)) { - continue; + match ScanFilter::all_match_value(rls_filters, &doc) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } results.push(project_doc(&doc, doc_id, projection)); @@ -405,7 +423,7 @@ impl CoreLoop { crate::types::TenantId::new(tid), collection.to_string(), ); - self.merge_overlay_into_spatial_scan( + if let Err(e) = self.merge_overlay_into_spatial_scan( super::transaction::overlay::SpatialOverlayMergeParams { txn_id, coll_key: &coll_key, @@ -418,7 +436,9 @@ impl CoreLoop { row_level_filters: rls_filters, }, &mut results, - ); + ) { + return self.response_error(task, e); + } } match response_codec::encode_value_vec(&results) { diff --git a/nodedb/src/data/executor/handlers/spill/groupby.rs b/nodedb/src/data/executor/handlers/spill/groupby.rs index 9186880df..35be1d117 100644 --- a/nodedb/src/data/executor/handlers/spill/groupby.rs +++ b/nodedb/src/data/executor/handlers/spill/groupby.rs @@ -86,14 +86,14 @@ impl GroupBySpiller { } if let Some(state) = self.in_mem.get_mut(&key) { - state.feed(aggregates, doc); + state.feed(aggregates, doc)?; } else { if self.in_mem.len() >= self.cap { self.spill_current_run()?; } let key_len = key.len(); let mut state = GroupState::new(aggregates); - state.feed(aggregates, doc); + state.feed(aggregates, doc)?; self.in_mem.insert(key, state); self.bytes_estimate = self .bytes_estimate @@ -167,7 +167,8 @@ mod tests { for (key, doc) in groups { map.entry(key.clone()) .or_insert_with(|| GroupState::new(specs)) - .feed(specs, doc); + .feed(specs, doc) + .unwrap(); } map.into_iter() .map(|(k, s)| (k, s.finalize(specs).into_iter().map(|(_, v)| v).collect())) diff --git a/nodedb/src/data/executor/handlers/timeseries/raw_scan/row_emit.rs b/nodedb/src/data/executor/handlers/timeseries/raw_scan/row_emit.rs index 0b5780d86..a19a37e6d 100644 --- a/nodedb/src/data/executor/handlers/timeseries/raw_scan/row_emit.rs +++ b/nodedb/src/data/executor/handlers/timeseries/raw_scan/row_emit.rs @@ -110,17 +110,20 @@ pub(super) fn extract_timestamp(row: &rmpv::Value) -> i64 { pub(super) fn apply_computed_columns_rmpv( row: rmpv::Value, computed_cols: &[crate::bridge::expr_eval::ComputedColumn], -) -> rmpv::Value { +) -> crate::Result { let doc = rmpv_to_nodedb_value(&row); let mut fields: Vec<(rmpv::Value, rmpv::Value)> = Vec::with_capacity(computed_cols.len()); for cc in computed_cols { - let result = cc.expr.eval(&doc); + // A computed column is projection-shaped: a division/modulo-by-zero + // fails the whole scan instead of silently materializing NULL into + // the response. + let result = cc.expr.eval(&doc)?; fields.push(( rmpv::Value::String(cc.alias.as_str().into()), nodedb_value_to_rmpv(&result), )); } - rmpv::Value::Map(fields) + Ok(rmpv::Value::Map(fields)) } /// Convert rmpv row to nodedb_types::Value for expression evaluation. diff --git a/nodedb/src/data/executor/handlers/timeseries/raw_scan/scan.rs b/nodedb/src/data/executor/handlers/timeseries/raw_scan/scan.rs index add1b0aef..27b5c98e2 100644 --- a/nodedb/src/data/executor/handlers/timeseries/raw_scan/scan.rs +++ b/nodedb/src/data/executor/handlers/timeseries/raw_scan/scan.rs @@ -131,8 +131,15 @@ impl CoreLoop { // Encode rmpv row to msgpack bytes for binary filter eval. let mut buf = Vec::new(); rmpv::encode::write_value(&mut buf, &row).ok(); - if !filter_predicates.iter().all(|f| f.matches_binary(&buf)) { - continue; + match crate::bridge::scan_filter::ScanFilter::all_match_binary( + filter_predicates, + &buf, + ) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return self.response_error(task, ErrorCode::DivisionByZero); + } } } results.push(row); @@ -182,7 +189,7 @@ impl CoreLoop { // aggregates and bucketed queries remain committed-only. if let Some(txn_id) = txn_id { let coll_key = (task.request.database_id, tid, collection.to_string()); - self.merge_overlay_into_timeseries_scan( + if let Err(e) = self.merge_overlay_into_timeseries_scan( crate::data::executor::handlers::transaction::overlay::TimeseriesOverlayMergeParams { txn_id, coll_key: &coll_key, @@ -192,7 +199,9 @@ impl CoreLoop { limit, }, &mut results, - ); + ) { + return self.response_error(task, e); + } } // Apply computed columns (e.g. time_bucket) if present. @@ -213,10 +222,14 @@ impl CoreLoop { if computed_cols.is_empty() { results } else { - results + match results .into_iter() .map(|row| apply_computed_columns_rmpv(row, &computed_cols)) - .collect() + .collect::>>() + { + Ok(rows) => rows, + Err(e) => return self.response_error(task, e), + } } } else { results diff --git a/nodedb/src/data/executor/handlers/transaction/overlay/columnar_merge.rs b/nodedb/src/data/executor/handlers/transaction/overlay/columnar_merge.rs index f6ef3a20a..2604f60fd 100644 --- a/nodedb/src/data/executor/handlers/transaction/overlay/columnar_merge.rs +++ b/nodedb/src/data/executor/handlers/transaction/overlay/columnar_merge.rs @@ -75,7 +75,7 @@ impl CoreLoop { &self, params: ColumnarOverlayMergeParams<'_>, matched: &mut Vec, - ) { + ) -> crate::Result<()> { let ColumnarOverlayMergeParams { txn_id, coll_key, @@ -90,11 +90,14 @@ impl CoreLoop { // never ages out of the overlay reaper. self.touch_overlay(txn_id); let Some(overlay) = self.txn_overlays.get(&txn_id) else { - return; + return Ok(()); }; - let predicate = |row: &[Value]| -> bool { - filter_predicates.is_empty() || row_matches_filters(row, schema, filter_predicates) + let predicate = |row: &[Value]| -> Result { + if filter_predicates.is_empty() { + return Ok(true); + } + row_matches_filters(row, schema, filter_predicates) }; // Surrogates already represented in the base result. Additions @@ -111,7 +114,17 @@ impl CoreLoop { // result). A row with no recorded surrogate has no overlay identity // to resolve and is left untouched, matching the base scan's own // "no prefilter possible" treatment of unrecorded surrogates. + // + // `Vec::retain_mut`'s closure must return `bool`, so a + // division/modulo-by-zero hit while projecting a computed column + // can't `?` out of it directly; it's captured in `first_err` and + // checked once the retain pass finishes, aborting the merge before + // the overlay-addition pass below runs. + let mut first_err: Option = None; matched.retain_mut(|(surrogate, row, json)| { + if first_err.is_some() { + return true; + } let Some(s) = surrogate else { return true; }; @@ -119,16 +132,27 @@ impl CoreLoop { Some(Staged::Tombstone) => false, Some(Staged::Put(body)) => match decode_staged_row(body) { Some(new_row) => { - if !predicate(&new_row) { - return false; + match predicate(&new_row) { + Ok(true) => {} + Ok(false) => return false, + Err(e) => { + first_err = Some(crate::Error::from(e)); + return true; + } } - *json = row_to_projected_json( + match row_to_projected_json( &new_row, schema, projection, computed_cols, all_versions, - ); + ) { + Ok(v) => *json = v, + Err(e) => { + first_err = Some(e); + return true; + } + } *row = new_row; true } @@ -139,6 +163,9 @@ impl CoreLoop { None => true, } }); + if let Some(e) = first_err { + return Err(e); + } // Overlay additions: staged puts for surrogates the base scan did // not return, appended when the decoded row satisfies the scan's @@ -153,13 +180,14 @@ impl CoreLoop { let Some(new_row) = decode_staged_row(body) else { continue; }; - if !predicate(&new_row) { + if !predicate(&new_row)? { continue; } let json = - row_to_projected_json(&new_row, schema, projection, computed_cols, all_versions); + row_to_projected_json(&new_row, schema, projection, computed_cols, all_versions)?; matched.push((Some(Surrogate::new(surrogate)), new_row, json)); seen.insert(surrogate); } + Ok(()) } } diff --git a/nodedb/src/data/executor/handlers/transaction/overlay/merge.rs b/nodedb/src/data/executor/handlers/transaction/overlay/merge.rs index 53b22a0b7..d3169e4ec 100644 --- a/nodedb/src/data/executor/handlers/transaction/overlay/merge.rs +++ b/nodedb/src/data/executor/handlers/transaction/overlay/merge.rs @@ -251,7 +251,7 @@ impl CoreLoop { params: IndexOverlayMergeParams<'_>, doc_ids: &mut Vec, decode: &dyn Fn(&[u8]) -> Option, - ) { + ) -> crate::Result<()> { let IndexOverlayMergeParams { txn_id, coll_key, @@ -265,7 +265,7 @@ impl CoreLoop { // Read-your-own-writes refreshes the lease (see the reaper). self.touch_overlay(txn_id); let Some(overlay) = self.txn_overlays.get(&txn_id) else { - return; + return Ok(()); }; let normalize = |s: String| -> String { @@ -290,8 +290,23 @@ impl CoreLoop { // overlay merge uses, so a staged Put that satisfies the indexed term // but not the residual is excluded exactly like it would be from a // base scan result. + // `residual_matches` feeds `Vec::retain`/a plain `for` loop below, + // both of which need a `bool`, so a division/modulo-by-zero is + // captured via this `Cell` side-channel and checked once both + // passes finish. + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); let residual_matches = |body: &[u8]| -> bool { - residual.is_empty() || matches_with_resolved_schema(strict_schema, residual, body) + if residual.is_empty() { + return true; + } + match matches_with_resolved_schema(strict_schema, residual, body) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } + } }; // Base doc IDs are hex surrogates; track their surrogates so additions @@ -335,6 +350,10 @@ impl CoreLoop { Staged::Tombstone => {} } } + if let Some(e) = predicate_err.take() { + return Err(crate::Error::from(e)); + } + Ok(()) } /// Resolve the current body for a hex-surrogate `doc_id` in `coll_key`, diff --git a/nodedb/src/data/executor/handlers/transaction/overlay/spatial_merge.rs b/nodedb/src/data/executor/handlers/transaction/overlay/spatial_merge.rs index 4b29fb50d..eecb35d96 100644 --- a/nodedb/src/data/executor/handlers/transaction/overlay/spatial_merge.rs +++ b/nodedb/src/data/executor/handlers/transaction/overlay/spatial_merge.rs @@ -60,19 +60,29 @@ pub(in crate::data::executor) struct SpatialOverlayMergeParams<'a> { /// Decode a staged spatial-collection overlay body into a full (unprojected) /// `Value::Object`, handling both possible staged shapes (see module doc). -/// Returns `None` for a body that fails to decode, or a `Value::Array` +/// Returns `Ok(None)` for a body that fails to decode, or a `Value::Array` /// staged row whose collection has no known columnar schema (defensively -/// treated as "does not match" rather than surfacing a panic). -fn decode_staged_spatial_row(body: &[u8], schema: Option<&ColumnarSchema>) -> Option { - match nodedb_types::value_from_msgpack(body).ok()? { - Value::Array(row) => { - let schema = schema?; - let json = row_to_projected_json(&row, schema, &[], &[], false); - Some(Value::from(json)) - } - obj @ Value::Object(_) => Some(obj), +/// treated as "does not match" rather than surfacing a panic). Returns +/// `Err` only when the row *does* decode but its computed-column projection +/// hits a division/modulo-by-zero — `row_to_projected_json` is called with +/// no computed columns here (`&[]`), so this is currently unreachable, but +/// the `Result` return keeps the signature honest about what +/// `row_to_projected_json` can do. +fn decode_staged_spatial_row( + body: &[u8], + schema: Option<&ColumnarSchema>, +) -> crate::Result> { + Ok(match nodedb_types::value_from_msgpack(body).ok() { + Some(Value::Array(row)) => match schema { + Some(schema) => { + let json = row_to_projected_json(&row, schema, &[], &[], false)?; + Some(Value::from(json)) + } + None => None, + }, + Some(obj @ Value::Object(_)) => Some(obj), _ => None, - } + }) } /// Extract the hex-surrogate identity from a projected spatial result row @@ -95,7 +105,7 @@ impl CoreLoop { &self, params: SpatialOverlayMergeParams<'_>, results: &mut Vec, - ) { + ) -> crate::Result<()> { let SpatialOverlayMergeParams { txn_id, coll_key, @@ -111,7 +121,7 @@ impl CoreLoop { // Read-your-own-writes refreshes the lease (see the reaper). self.touch_overlay(txn_id); let Some(overlay) = self.txn_overlays.get(&txn_id) else { - return; + return Ok(()); }; // A staged columnar-row body needs the collection's columnar schema @@ -120,15 +130,18 @@ impl CoreLoop { // via `ensure_columnar_engine_schema` at insert time. let schema = self.columnar_engines.get(coll_key).map(|e| e.schema()); - let row_matches = |doc: &Value| -> bool { + // A division/modulo-by-zero in an attribute or row-level filter is + // WHERE-shaped: it fails the whole scan, same as + // `handlers::spatial`'s direct `matches_value` calls. + let row_matches = |doc: &Value| -> crate::Result { let Some(doc_geom) = extract_geometry(doc, field) else { - return false; + return Ok(false); }; if !apply_predicate(predicate, query_geom, &doc_geom, distance_meters) { - return false; + return Ok(false); } - attr_filters.iter().all(|f| f.matches_value(doc)) - && row_level_filters.iter().all(|f| f.matches_value(doc)) + Ok(ScanFilter::all_match_value(attr_filters, doc)? + && ScanFilter::all_match_value(row_level_filters, doc)?) }; // Surrogates already represented in the base result. @@ -140,28 +153,50 @@ impl CoreLoop { // the geometry out of the query region). A row with no resolvable // surrogate identity has no overlay identity to resolve and is left // untouched. + // + // `Vec::retain_mut`'s closure must return `bool`, so an evaluation + // error is captured in `first_err` and checked once the retain pass + // finishes, aborting the merge before the overlay-addition pass runs. + let mut first_err: Option = None; results.retain_mut(|row| { + if first_err.is_some() { + return true; + } let Some(raw) = row_surrogate(row) else { return true; }; match overlay.get(coll_key, raw) { Some(Staged::Tombstone) => false, - Some(Staged::Put(body)) => match decode_staged_spatial_row(body, schema) { - Some(doc) => { - if !row_matches(&doc) { - return false; + Some(Staged::Put(body)) => { + let doc = match decode_staged_spatial_row(body, schema) { + Ok(Some(doc)) => doc, + // A staged body that fails to decode carries no + // usable row: drop it rather than surface stale + // base data. + Ok(None) => return false, + Err(e) => { + first_err = Some(e); + return true; + } + }; + match row_matches(&doc) { + Ok(true) => {} + Ok(false) => return false, + Err(e) => { + first_err = Some(e); + return true; } - let doc_id = surrogate_to_doc_id(Surrogate(raw)); - *row = project_doc(&doc, &doc_id, projection); - true } - // A staged body that fails to decode carries no usable - // row: drop it rather than surface stale base data. - None => false, - }, + let doc_id = surrogate_to_doc_id(Surrogate(raw)); + *row = project_doc(&doc, &doc_id, projection); + true + } None => true, } }); + if let Some(e) = first_err { + return Err(e); + } // Overlay additions: staged puts for surrogates the base scan did // not return, appended when the decoded geometry satisfies the @@ -174,15 +209,16 @@ impl CoreLoop { let Staged::Put(body) = staged else { continue; }; - let Some(doc) = decode_staged_spatial_row(body, schema) else { + let Some(doc) = decode_staged_spatial_row(body, schema)? else { continue; }; - if !row_matches(&doc) { + if !row_matches(&doc)? { continue; } let doc_id = surrogate_to_doc_id(Surrogate(surrogate)); results.push(project_doc(&doc, &doc_id, projection)); seen.insert(surrogate); } + Ok(()) } } diff --git a/nodedb/src/data/executor/handlers/transaction/overlay/timeseries_merge.rs b/nodedb/src/data/executor/handlers/transaction/overlay/timeseries_merge.rs index 1b36e8afd..4a900ada9 100644 --- a/nodedb/src/data/executor/handlers/transaction/overlay/timeseries_merge.rs +++ b/nodedb/src/data/executor/handlers/transaction/overlay/timeseries_merge.rs @@ -108,7 +108,7 @@ impl CoreLoop { &self, params: TimeseriesOverlayMergeParams<'_>, results: &mut Vec, - ) { + ) -> crate::Result<()> { let TimeseriesOverlayMergeParams { txn_id, coll_key, @@ -121,7 +121,7 @@ impl CoreLoop { // Read-your-own-writes refreshes the lease (see the reaper). self.touch_overlay(txn_id); let Some(overlay) = self.txn_overlays.get(&txn_id) else { - return; + return Ok(()); }; for (_surrogate, staged) in overlay.iter_for_collection(coll_key) { @@ -148,11 +148,12 @@ impl CoreLoop { // Re-apply the scan's WHERE predicate on the staged body (already // msgpack), exactly like the raw scan's `need_json_filter` path // does per base row. - if has_filters && !filter_predicates.iter().all(|f| f.matches_binary(body)) { + if has_filters && !ScanFilter::all_match_binary(filter_predicates, body)? { continue; } results.push(staged_row_to_rmpv(&row)); } + Ok(()) } } diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs index 45b87dd9e..9acbb36db 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/body.rs @@ -164,7 +164,11 @@ impl CoreLoop { detail: format!("staged update field '{field}': {e}"), })?, UpdateValue::Expr(expr) => { - let result: nodedb_types::Value = expr.eval(&eval_doc); + // Division/modulo by zero fails the staged + // write, same as the literal decode-failure arm + // above. + let result: nodedb_types::Value = + expr.eval(&eval_doc).map_err(crate::Error::from)?; result.into() } }; diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_delete.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_delete.rs index 64912da88..a26c273a2 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_delete.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_delete.rs @@ -80,9 +80,25 @@ impl CoreLoop { }; { - let matches = + // `merge_overlay_into_scan` takes an infallible + // `Fn(&[u8]) -> bool` predicate, so a division/modulo-by-zero is + // captured via this `Cell` side-channel and checked once the + // merge returns. + let raw_matches = self.strict_aware_matcher(database_id.as_u64(), tid, collection, &filters); + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); + let matches = |body: &[u8]| match raw_matches(body) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } + }; self.merge_overlay_into_scan(txn_id, &coll_key, &mut rows, &matches); + if predicate_err.take().is_some() { + return self.response_error(task, ErrorCode::DivisionByZero); + } } let mut affected = 0u64; diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_update.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_update.rs index 8b2f4ba12..43f6cde5c 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_update.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_bulk_update.rs @@ -101,9 +101,25 @@ impl CoreLoop { // (an earlier staged update may have moved a row in or out), and // appends overlay-only rows that now match. { - let matches = + // `merge_overlay_into_scan` takes an infallible + // `Fn(&[u8]) -> bool` predicate, so a division/modulo-by-zero is + // captured via this `Cell` side-channel and checked once the + // merge returns. + let raw_matches = self.strict_aware_matcher(database_id.as_u64(), tid, collection, &filters); + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); + let matches = |body: &[u8]| match raw_matches(body) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } + }; self.merge_overlay_into_scan(txn_id, &coll_key, &mut rows, &matches); + if let Some(e) = predicate_err.take() { + return self.response_error(task, crate::Error::from(e)); + } } let mut affected = 0u64; @@ -155,14 +171,7 @@ impl CoreLoop { ) -> Result)>, Response> { let matching_ids = self .scan_matching_documents(database_id, tid, collection, filters) - .map_err(|e| { - self.response_error( - task, - ErrorCode::Internal { - detail: e.to_string(), - }, - ) - })?; + .map_err(|e| self.response_error(task, e))?; let mut rows: Vec<(String, Vec)> = Vec::with_capacity(matching_ids.len()); for doc_id in matching_ids { if let Ok(Some(bytes)) = self.sparse.get(database_id, tid, collection, &doc_id) { diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_columnar_dml.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_columnar_dml.rs index f5d5b4d2f..30021a5f6 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_columnar_dml.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_columnar_dml.rs @@ -225,28 +225,38 @@ impl CoreLoop { // shared `ColumnarMatchedRow` tuple the overlay merge consumes. A // missing engine means the only affected rows are overlay-only staged // inserts, which the merge appends below. - let mut matched: Vec<(Option, Vec, serde_json::Value)> = - match self.columnar_engines.get(&coll_key) { - Some(engine) => engine - .scan_memtable_rows_with_surrogates() - .filter(|(_, row)| { - filter_predicates.is_empty() - || row_matches_filters(row, &schema, &filter_predicates) - }) - .map(|(surrogate, row)| { - let json = row_to_projected_json(&row, &schema, &[], &[], false); - (surrogate, row, json) - }) - .collect(), - None => Vec::new(), - }; + let mut matched: Vec<(Option, Vec, serde_json::Value)> = Vec::new(); + if let Some(engine) = self.columnar_engines.get(&coll_key) { + for (surrogate, row) in engine.scan_memtable_rows_with_surrogates() { + if !filter_predicates.is_empty() { + match row_matches_filters(&row, &schema, &filter_predicates) { + Ok(true) => {} + Ok(false) => continue, + Err(_e) => { + return Err(self.response_error(task, ErrorCode::DivisionByZero)); + } + } + } + // No computed columns on this path (`&[]` below), so this + // can never actually raise `DivisionByZero` today — handled + // uniformly with every other `row_to_projected_json` caller + // instead of assuming that invariant with an `unwrap`. + let json = match row_to_projected_json(&row, &schema, &[], &[], false) { + Ok(v) => v, + Err(_e) => { + return Err(self.response_error(task, ErrorCode::DivisionByZero)); + } + }; + matched.push((surrogate, row, json)); + } + } // Fold the transaction's own staged writes into the base set: drops // tombstoned surrogates, re-checks staged puts against the predicate, // and appends overlay-only staged inserts that now match — so a row // this txn just inserted (or an earlier staged update moved into the // predicate) is affected too. - self.merge_overlay_into_columnar_scan( + if let Err(e) = self.merge_overlay_into_columnar_scan( ColumnarOverlayMergeParams { txn_id, coll_key: &coll_key, @@ -257,7 +267,9 @@ impl CoreLoop { all_versions: false, }, &mut matched, - ); + ) { + return Err(self.response_error(task, e)); + } Ok(matched .into_iter() diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_kv.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_kv.rs index 5b340f23d..f6b0416f4 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_kv.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_kv.rs @@ -291,11 +291,15 @@ impl CoreLoop { ); } }; - let merged = crate::data::executor::handlers::upsert::apply_on_conflict_updates( - existing_val, - &excluded_val, - updates, - ); + let merged = + match crate::data::executor::handlers::upsert::apply_on_conflict_updates( + existing_val, + &excluded_val, + updates, + ) { + Ok(v) => v, + Err(e) => return self.response_error(ctx.task, e), + }; match nodedb_types::value_to_msgpack(&merged) { Ok(b) => (b, "update"), Err(_) => { diff --git a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs index 9ace6e230..81151176a 100644 --- a/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs +++ b/nodedb/src/data/executor/handlers/transaction/stage_write/stage_upsert.rs @@ -137,7 +137,7 @@ impl CoreLoop { let merged = if on_conflict_updates.is_empty() { merge_values(existing_val, new_val) } else { - apply_on_conflict_updates(existing_val, &new_val, on_conflict_updates) + apply_on_conflict_updates(existing_val, &new_val, on_conflict_updates)? }; let bitemporal = self.is_bitemporal(ctx.database_id, ctx.tid, ctx.collection); diff --git a/nodedb/src/data/executor/handlers/update_from_join_collect.rs b/nodedb/src/data/executor/handlers/update_from_join_collect.rs index d7838fc86..e59502eca 100644 --- a/nodedb/src/data/executor/handlers/update_from_join_collect.rs +++ b/nodedb/src/data/executor/handlers/update_from_join_collect.rs @@ -144,7 +144,14 @@ impl CoreLoop { Ok(v) => v, Err(_) => continue, }, - UpdateValue::Expr(expr) => expr.eval(&merged_ndb).into(), + // Division/modulo by zero fails the statement, same + // as the literal decode-failure arm above would if + // it propagated instead of skipping (kept as-is; + // only the newly-fallible expr path is threaded + // here). + UpdateValue::Expr(expr) => { + expr.eval(&merged_ndb).map_err(crate::Error::from)?.into() + } }; target_obj.insert(field.clone(), val); } @@ -252,12 +259,12 @@ impl CoreLoop { match super::super::strict_format::binary_tuple_to_json(value_bytes, schema) { Some(doc) => { let msgpack = doc_format::encode_to_msgpack(&doc); - target_filters.iter().all(|f| f.matches_binary(&msgpack)) + ScanFilter::all_match_binary(target_filters, &msgpack)? } None => false, } } else { - target_filters.iter().all(|f| f.matches_binary(value_bytes)) + ScanFilter::all_match_binary(target_filters, value_bytes)? }; if matches && let Some(doc_id) = key.strip_prefix(&prefix) { rows.push((doc_id.to_string(), value_bytes.to_vec())); @@ -272,9 +279,25 @@ impl CoreLoop { // that satisfies the predicate is surfaced, one that no longer does is // dropped, exactly as for a base row. if let Some(txn_id) = txn_id { - let matches = + // `merge_overlay_into_scan` takes an infallible + // `Fn(&[u8]) -> bool` predicate, so a division/modulo-by-zero + // is captured via this `Cell` side-channel and checked once the + // merge returns. + let raw_matches = self.strict_aware_matcher(database_id, tid, target_collection, target_filters); + let predicate_err: std::cell::Cell> = + std::cell::Cell::new(None); + let matches = |body: &[u8]| match raw_matches(body) { + Ok(b) => b, + Err(e) => { + predicate_err.set(Some(e)); + false + } + }; self.merge_overlay_into_scan(txn_id, target_coll_key, &mut rows, &matches); + if let Some(e) = predicate_err.take() { + return Err(crate::Error::from(e)); + } } Ok(rows) } diff --git a/nodedb/src/data/executor/handlers/upsert.rs b/nodedb/src/data/executor/handlers/upsert.rs index 33acaafbd..847988400 100644 --- a/nodedb/src/data/executor/handlers/upsert.rs +++ b/nodedb/src/data/executor/handlers/upsert.rs @@ -149,7 +149,10 @@ impl CoreLoop { let merged = if on_conflict_updates.is_empty() { merge_values(existing_val, new_val) } else { - apply_on_conflict_updates(existing_val, &new_val, on_conflict_updates) + match apply_on_conflict_updates(existing_val, &new_val, on_conflict_updates) { + Ok(v) => v, + Err(e) => return self.response_error(task, e), + } }; let sys_from_ms = if bitemporal { @@ -376,7 +379,7 @@ pub(in crate::data::executor) fn apply_on_conflict_updates( existing: nodedb_types::Value, excluded: &nodedb_types::Value, updates: &[(String, nodedb_physical::physical_plan::UpdateValue)], -) -> nodedb_types::Value { +) -> crate::Result { let mut obj = match existing { nodedb_types::Value::Object(map) => map, // If the existing row isn't an object (shouldn't happen for @@ -396,13 +399,16 @@ pub(in crate::data::executor) fn apply_on_conflict_updates( Err(_) => continue, } } + // `ON CONFLICT DO UPDATE SET` is write-path-shaped: a + // division/modulo-by-zero fails the statement instead of + // silently writing NULL. nodedb_physical::physical_plan::UpdateValue::Expr(expr) => { - expr.eval_with_excluded(&snapshot, excluded) + expr.eval_with_excluded(&snapshot, excluded)? } }; obj.insert(field.clone(), new_val); } - nodedb_types::Value::Object(obj) + Ok(nodedb_types::Value::Object(obj)) } /// Merge two `nodedb_types::Value` objects: overlay `new` fields onto `existing`. diff --git a/nodedb/src/data/executor/wal_replay_kv_insert_conflict.rs b/nodedb/src/data/executor/wal_replay_kv_insert_conflict.rs index cbda177d8..ef391b5b3 100644 --- a/nodedb/src/data/executor/wal_replay_kv_insert_conflict.rs +++ b/nodedb/src/data/executor/wal_replay_kv_insert_conflict.rs @@ -235,7 +235,27 @@ impl CoreLoop { return 0; } }; - let merged = apply_on_conflict_updates(existing_val, &excluded_val, updates); + let merged = match apply_on_conflict_updates(existing_val, &excluded_val, updates) { + Ok(v) => v, + Err(e) => { + // A division/modulo-by-zero can only be reached by + // replaying a WAL record logged before this fix + // shipped (a fresh write would now fail at statement + // time, before ever reaching the WAL) — warn and + // skip, matching every other decode-failure branch + // in this replay path, rather than crashing startup + // on a historical record. + warn!( + core = self.core_id, + collection = %collection, + key = %String::from_utf8_lossy(key), + ?e, + "WAL kv_insert_on_conflict_update replay: ON CONFLICT expression \ + failed to evaluate, skipping record" + ); + return 0; + } + }; match nodedb_types::value_to_msgpack(&merged) { Ok(b) => b, Err(e) => { @@ -419,7 +439,8 @@ mod tests { // decoded logical value, not raw bytes. let existing_val = nodedb_types::value_from_msgpack(&seed).expect("decode seed"); let excluded_val = nodedb_types::value_from_msgpack(&excluded).expect("decode excluded"); - let expected = super::apply_on_conflict_updates(existing_val, &excluded_val, &updates); + let expected = + super::apply_on_conflict_updates(existing_val, &excluded_val, &updates).unwrap(); let stored = get_value(&h.core, "players", b"p1").expect("value present after replay"); let stored_val = nodedb_types::value_from_msgpack(&stored).expect("decode stored value"); @@ -521,7 +542,8 @@ mod tests { // raw bytes (per-instance key ordering differs between live and replay). let existing_val = nodedb_types::value_from_msgpack(&seed).expect("decode seed"); let excluded_val = nodedb_types::value_from_msgpack(&excluded).expect("decode excluded"); - let expected = super::apply_on_conflict_updates(existing_val, &excluded_val, &updates); + let expected = + super::apply_on_conflict_updates(existing_val, &excluded_val, &updates).unwrap(); // Read at now_ms=0: this record installs an absolute expiry of 6_000, // which is already in the past on the wall clock `get_value` uses, so // read before expiry to assert the merged value landed. diff --git a/nodedb/src/engine/crdt/tenant_state/apply_validated.rs b/nodedb/src/engine/crdt/tenant_state/apply_validated.rs index 898a11ba8..3767fb546 100644 --- a/nodedb/src/engine/crdt/tenant_state/apply_validated.rs +++ b/nodedb/src/engine/crdt/tenant_state/apply_validated.rs @@ -156,13 +156,26 @@ impl TenantCrdtEngine { } else { Surrogate::ZERO }; - let ValidationOutcome::Rejected(violations) = - self.validate_committed_row(coll, row, sg) - else { - continue; - }; - let Some(violation) = violations.into_iter().next() else { - continue; + let violation = match self.validate_committed_row(coll, row, sg) { + ValidationOutcome::Accepted => continue, + ValidationOutcome::Rejected(violations) => match violations.into_iter().next() { + Some(v) => v, + None => continue, + }, + // A CHECK predicate that could not be evaluated (division/modulo + // by zero) fails closed: roll back and route to the DLQ + // exactly like a genuine violation — never silently + // treated as accepted (which the old `let-else` would have done). + ValidationOutcome::EvalError { + constraint_name, + error, + } => Violation { + constraint_name, + reason: format!("CHECK predicate failed to evaluate: {error}"), + hint: nodedb_crdt::dead_letter::CompensationHint::ManualIntervention { + reason: format!("CHECK predicate raised an evaluation error: {error}"), + }, + }, }; let violation = self.dlq_and_translate(coll, delta, peer_id, violation); match previous { diff --git a/nodedb/src/engine/document/predicate.rs b/nodedb/src/engine/document/predicate.rs index db99a53f3..8f5c053f4 100644 --- a/nodedb/src/engine/document/predicate.rs +++ b/nodedb/src/engine/document/predicate.rs @@ -43,10 +43,15 @@ impl IndexPredicate { /// Evaluate the predicate against a document value. Returns `true` /// only when the expression evaluates to `Bool(true)` — `NULL`, - /// `Bool(false)`, and any non-boolean result exclude the row from - /// the index (matching Postgres partial-index semantics). + /// `Bool(false)`, any non-boolean result, and a division/modulo-by-zero + /// evaluation error all exclude the row from + /// the index. This mirrors the existing "anything but exactly `true` + /// excludes" partial-index philosophy already documented above (which + /// already fails closed rather than defaulting to "index everything"); + /// an eval error gets the same fail-closed treatment as an ordinary + /// `false`/`NULL` result, not a distinct outcome. pub fn evaluate(&self, doc: &Value) -> bool { - matches!(self.expr.eval(doc), Value::Bool(true)) + matches!(self.expr.eval(doc), Ok(Value::Bool(true))) } /// Convenience overload for the document write path, which holds diff --git a/nodedb/src/error.rs b/nodedb/src/error.rs index f786e2737..e2b947927 100644 --- a/nodedb/src/error.rs +++ b/nodedb/src/error.rs @@ -194,6 +194,18 @@ pub enum Error { #[error("query plan error: {detail}")] PlanError { detail: String }, + /// A function call in a query names no registered scalar, aggregate, or + /// window function. Propagated from `SqlError::UndefinedFunction`; the + /// pgwire layer renders this as SQLSTATE `42883` (undefined_function). + #[error("function {name}(...) does not exist")] + UndefinedFunction { name: String }, + + /// Expression evaluation divided or took a modulus by zero. Propagated + /// from `nodedb_query::EvalError::DivisionByZero`; the pgwire layer + /// renders this as SQLSTATE `22012` (division_by_zero). + #[error("division by zero")] + DivisionByZero, + /// Descriptor lease conflict; pgwire retries within `PLAN_RETRY_BUDGET`. #[error("retryable schema change on {descriptor}")] RetryableSchemaChanged { descriptor: String }, diff --git a/nodedb/src/error_from.rs b/nodedb/src/error_from.rs index d94a111c9..6ca744ca5 100644 --- a/nodedb/src/error_from.rs +++ b/nodedb/src/error_from.rs @@ -16,6 +16,18 @@ impl From for Error { } } +/// `EvalError` has exactly one variant today (`DivisionByZero`); the +/// `match` is exhaustive rather than a `_ =>` fallback so +/// a future evaluator error is forced to pick its own `crate::Error` +/// mapping instead of silently inheriting this one. +impl From for Error { + fn from(e: nodedb_query::EvalError) -> Self { + match e { + nodedb_query::EvalError::DivisionByZero => Self::DivisionByZero, + } + } +} + impl From for Error { fn from(e: crate::control::pubsub::TopicError) -> Self { Self::BadRequest { @@ -224,6 +236,8 @@ impl From for NodeDbError { NodeDbError::quota_overcommit(field, detail) } Error::PlanError { detail } => NodeDbError::plan_error(detail), + Error::UndefinedFunction { name } => NodeDbError::undefined_function(name), + Error::DivisionByZero => NodeDbError::division_by_zero(), Error::RetryableSchemaChanged { descriptor } => { NodeDbError::plan_error(format!("retryable schema change on {descriptor}")) } diff --git a/nodedb/src/event/trigger/dispatcher/batch.rs b/nodedb/src/event/trigger/dispatcher/batch.rs index f71741cf5..e1f9ffadc 100644 --- a/nodedb/src/event/trigger/dispatcher/batch.rs +++ b/nodedb/src/event/trigger/dispatcher/batch.rs @@ -61,12 +61,27 @@ pub async fn dispatch_trigger_batch( } for trigger in &after_row_triggers { - let mask = when_filter::filter_batch_by_when( + // An AFTER trigger fires post-commit — there is no statement left to + // fail. A division/modulo-by-zero in its WHEN predicate is surfaced + // observably (warn) and skips this trigger, rather than being + // silently folded to "does not fire". + let mask = match when_filter::filter_batch_by_when( &batch.rows, &batch.collection, &batch.operation, trigger.when_condition.as_deref(), - ); + ) { + Ok(mask) => mask, + Err(e) => { + tracing::warn!( + trigger = %trigger.name, + collection = %batch.collection, + error = %e, + "AFTER trigger WHEN predicate raised an evaluation error; skipping trigger for this batch" + ); + continue; + } + }; let passing = when_filter::count_passing(&mask); if passing == 0 { diff --git a/nodedb/src/storage/cold_filter.rs b/nodedb/src/storage/cold_filter.rs index a6942530b..64bcdda69 100644 --- a/nodedb/src/storage/cold_filter.rs +++ b/nodedb/src/storage/cold_filter.rs @@ -17,6 +17,8 @@ //! Called from `read_parquet_filtered()` which replaces `read_parquet_with_predicate` //! for queries that have filter predicates. +use std::sync::{Arc, Mutex}; + use arrow::array::{Array, Float64Array, Int64Array, RecordBatch, StringArray}; use arrow::datatypes::DataType; use bytes::Bytes; @@ -83,6 +85,21 @@ pub fn read_parquet_filtered( }; // Step 3: Apply row-level filter if predicates exist. + // + // Side-channel: a division/modulo-by-zero in a pushed-down WHERE + // predicate is captured here rather than propagated + // through `ArrowPredicateFn`'s `Result` return + // type, which cannot carry an `EvalError` — the previous code converted + // it to `arrow::error::ArrowError::DivideByZero`, and `reader.collect()` + // below then turned THAT into a generic `crate::Error::ColdStorage` + // (`XX000`), losing the typed error entirely. `Arc>` rather + // than the plain `Cell>` side-channel used elsewhere + // in this diff (e.g. `document/read/scan.rs`'s `merge_overlay_into_scan` + // predicate) because `ArrowPredicateFn::new` requires its closure be + // `Send + 'static` (`parquet::arrow::arrow_reader::filter:: + // ArrowPredicateFn`), and `Cell` is neither `Send` nor `Sync`. + let predicate_err: Arc>> = Arc::new(Mutex::new(None)); + let reader = if filters.is_empty() { reader_builder .build() @@ -92,6 +109,7 @@ pub fn read_parquet_filtered( } else { let filter_schema = schema.clone(); let filters_owned: Vec = filters.to_vec(); + let predicate_err = predicate_err.clone(); let predicate = ArrowPredicateFn::new(ProjectionMask::all(), move |batch: RecordBatch| { let num_rows = batch.num_rows(); @@ -99,7 +117,24 @@ pub fn read_parquet_filtered( .map(|row_idx| { let doc = record_batch_row_to_json(&batch, row_idx, &filter_schema); let msgpack = nodedb_types::json_msgpack::json_to_msgpack_or_empty(&doc); - filters_owned.iter().all(|f| f.matches_binary(&msgpack)) + let mut row_matches = true; + for f in &filters_owned { + match f.matches_binary(&msgpack) { + Ok(true) => {} + Ok(false) => { + row_matches = false; + break; + } + Err(e) => { + if let Ok(mut slot) = predicate_err.lock() { + *slot = Some(e); + } + row_matches = false; + break; + } + } + } + row_matches }) .collect(); @@ -121,6 +156,20 @@ pub fn read_parquet_filtered( .map_err(|e| crate::Error::ColdStorage { detail: format!("read batches: {e}"), })?; + + // Check the side-channel AFTER the reader finishes: a division/modulo- + // by-zero row was already excluded from every batch's mask above (never + // surfaced as a mis-filtered result), so `reader.collect()` succeeding + // does not mean the read was clean — it means the predicate closure + // never got a chance to return an `Err` at all. This is the typed + // `crate::Error::DivisionByZero` the pre-fix `ArrowError` conversion + // above lost. + if let Ok(mut slot) = predicate_err.lock() + && slot.take().is_some() + { + return Err(crate::Error::DivisionByZero); + } + Ok(batches) } @@ -379,7 +428,6 @@ mod tests { use super::*; use arrow::array::ArrayRef; use arrow::datatypes::{Field, Schema}; - use std::sync::Arc; fn make_test_parquet(rows: &[(&str, i64, &str)]) -> Vec { let schema = Arc::new(Schema::new(vec![ @@ -447,6 +495,69 @@ mod tests { assert_eq!(rows[0].0, "d2"); } + /// A `FilterOp::Expr` predicate that divides by a zero-valued column + /// must fail the whole cold-storage read with the + /// typed `crate::Error::DivisionByZero`, not the generic `ColdStorage` + /// the pre-fix `ArrowError` conversion collapsed it to (verified at the + /// SQL/pgwire layer as `XX000` vs `22012` — this is the storage-layer + /// unit underneath that behavior). + #[test] + fn expr_predicate_division_by_zero_returns_typed_error() { + use nodedb_query::expr::{BinaryOp, SqlExpr}; + + let parquet = make_test_parquet(&[("d1", 0, "alice"), ("d2", 30, "bob")]); + + let filters = vec![ScanFilter { + field: String::new(), + op: "expr".into(), + value: nodedb_types::Value::Null, + clauses: vec![], + expr: Some(SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(nodedb_types::Value::Integer(10))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Column("age".into())), + }), + }]; + + let err = read_parquet_filtered(&parquet, &filters, &[]).unwrap_err(); + assert!( + matches!(err, crate::Error::DivisionByZero), + "expected crate::Error::DivisionByZero, got: {err:?}" + ); + } + + /// Control: the same expression predicate over rows that never divide + /// by zero still filters correctly — the fix must not turn every + /// `FilterOp::Expr` predicate into an error. + #[test] + fn expr_predicate_valid_division_still_filters() { + use nodedb_query::expr::{BinaryOp, SqlExpr}; + + let parquet = make_test_parquet(&[("d1", 2, "alice"), ("d2", 30, "bob")]); + + // `10 / age > 1` is true only for d1 (10/2 = 5 > 1); d2 (10/30 = 0) fails. + let filters = vec![ScanFilter { + field: String::new(), + op: "expr".into(), + value: nodedb_types::Value::Null, + clauses: vec![], + expr: Some(SqlExpr::BinaryOp { + left: Box::new(SqlExpr::BinaryOp { + left: Box::new(SqlExpr::Literal(nodedb_types::Value::Integer(10))), + op: BinaryOp::Div, + right: Box::new(SqlExpr::Column("age".into())), + }), + op: BinaryOp::Gt, + right: Box::new(SqlExpr::Literal(nodedb_types::Value::Integer(1))), + }), + }]; + + let batches = read_parquet_filtered(&parquet, &filters, &[]).unwrap(); + let rows = batches_to_document_rows(&batches); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0, "d1"); + } + #[test] fn projection_limits_columns() { let parquet = make_test_parquet(&[("d1", 25, "alice")]); diff --git a/nodedb/tests/executor_tests/test_array_ops.rs b/nodedb/tests/executor_tests/test_array_ops.rs index 18e043696..68ba5702f 100644 --- a/nodedb/tests/executor_tests/test_array_ops.rs +++ b/nodedb/tests/executor_tests/test_array_ops.rs @@ -247,10 +247,10 @@ fn array_length_function() { .unwrap() .0; let doc = nodedb_types::Value::from(serde_json::json!({"tags": ["a", "b", "c"]})); - assert_eq!(expr.eval(&doc), nodedb_types::Value::Integer(3)); + assert_eq!(expr.eval(&doc).unwrap(), nodedb_types::Value::Integer(3)); let doc2 = nodedb_types::Value::from(serde_json::json!({"tags": []})); - assert_eq!(expr.eval(&doc2), nodedb_types::Value::Integer(0)); + assert_eq!(expr.eval(&doc2).unwrap(), nodedb_types::Value::Integer(0)); } #[test] @@ -260,7 +260,7 @@ fn array_append_function() { .0; let doc = nodedb_types::Value::from(serde_json::json!({"tags": ["a", "b"]})); assert_eq!( - expr.eval(&doc), + expr.eval(&doc).unwrap(), nodedb_types::Value::Array(vec![ nodedb_types::Value::String("a".into()), nodedb_types::Value::String("b".into()), @@ -276,7 +276,7 @@ fn array_remove_function() { .0; let doc = nodedb_types::Value::from(serde_json::json!({"tags": ["a", "b", "c"]})); assert_eq!( - expr.eval(&doc), + expr.eval(&doc).unwrap(), nodedb_types::Value::Array(vec![ nodedb_types::Value::String("a".into()), nodedb_types::Value::String("c".into()), diff --git a/nodedb/tests/pgwire_extended_query_engines2.rs b/nodedb/tests/pgwire_extended_query_engines2.rs index faf682434..2543fd356 100644 --- a/nodedb/tests/pgwire_extended_query_engines2.rs +++ b/nodedb/tests/pgwire_extended_query_engines2.rs @@ -306,3 +306,104 @@ async fn extended_query_oid_mismatch_text_for_int_errors_or_coerces() { } } } + +// ── Cross-engine: SQLSTATEs through the extended protocol ─────────────────── + +/// Undefined-function rejection (SQLSTATE `42883`) through the +/// extended-query path. `sql_undefined_function.rs` proves this fires at +/// PLAN time over simple-query (where Parse+Bind+Execute collapse into one +/// round trip). Naively, `pgwire_database_authorization.rs`'s +/// `prepared_parse_denies_*` tests suggest Parse/Describe is where this +/// server plans a statement (authorization and undefined-table checks both +/// reject there) — but confirmed empirically here, `.prepare()` for this +/// statement actually *succeeds*: the function-registry existence gate is +/// not consulted during Parse/Describe (no columns/params to resolve for +/// this shape rules it out early), so the error is only raised once +/// `.query()` drives Bind+Execute and the plan is actually built. Asserting +/// on the real surfacing point, not the naive assumption. +#[tokio::test] +async fn extended_query_unknown_function_errors_42883() { + let srv = TestServer::start().await; + + let stmt = srv + .client + .prepare("SELECT some_function_that_does_not_exist_216(1, 2)") + .await + .expect( + "Parse/Describe succeeds for this statement shape — the \ + undefined-function gate is not consulted until Bind/Execute", + ); + + let err = srv + .client + .query(&stmt, &[]) + .await + .expect_err("Execute must reject a call to an unregistered scalar function"); + let db_err = err + .as_db_error() + .expect("server must return a typed SQLSTATE for the unknown function"); + assert_eq!( + db_err.code().code(), + "42883", + "unknown-function rejection must carry SQLSTATE 42883, got {}", + db_err.code().code() + ); +} + +/// Division-by-zero rejection (SQLSTATE `22012`) through the +/// extended-query path. Unlike the undefined-function gate above, `10 / d` +/// is a well-typed expression regardless of `d`'s runtime value, so +/// Parse/Describe must succeed — the zero divisor can only be discovered +/// once a bound row is actually evaluated, i.e. at Bind/Execute +/// (`.query()`) time. Mirrors `sql_division_by_zero.rs`'s simple-query +/// coverage of the same fix, through Parse/Bind/Execute instead. +#[tokio::test] +async fn extended_query_division_by_zero_errors_22012_at_execute() { + let srv = TestServer::start().await; + srv.exec( + "CREATE COLLECTION ext_divzero_216 (id STRING PRIMARY KEY, d INT) \ + WITH (engine='document_strict')", + ) + .await + .expect("CREATE ext_divzero_216"); + srv.exec("INSERT INTO ext_divzero_216 (id, d) VALUES ('nonzero', 2)") + .await + .expect("INSERT nonzero row"); + srv.exec("INSERT INTO ext_divzero_216 (id, d) VALUES ('zero', 0)") + .await + .expect("INSERT zero-divisor row"); + + let stmt = srv + .client + .prepare_typed( + "SELECT 10 / d AS ratio FROM ext_divzero_216 WHERE id = $1", + &[Type::TEXT], + ) + .await + .expect("Parse must succeed — `10 / d` is well-typed independent of d's runtime value"); + + let err = srv + .client + .query(&stmt, &[&"zero"]) + .await + .expect_err("Execute over the zero-divisor row must fail the statement"); + let db_err = err + .as_db_error() + .expect("server must return a typed SQLSTATE for division by zero"); + assert_eq!( + db_err.code().code(), + "22012", + "zero-divisor Execute-time rejection must carry SQLSTATE 22012, got {}", + db_err.code().code() + ); + + // Control: the identical prepared statement, bound against the + // non-zero row, must still succeed — the fix must not turn division + // itself into a blanket error on this path. + let rows = srv + .client + .query(&stmt, &[&"nonzero"]) + .await + .expect("Execute over the non-zero-divisor row must still succeed"); + assert_eq!(rows.len(), 1, "expected exactly 1 row for the nonzero id"); +} diff --git a/nodedb/tests/rls_fuzz.rs b/nodedb/tests/rls_fuzz.rs index fbc51edc8..24b345621 100644 --- a/nodedb/tests/rls_fuzz.rs +++ b/nodedb/tests/rls_fuzz.rs @@ -115,7 +115,7 @@ proptest! { let doc = serde_json::json!({ doc_field: doc_val }); let msgpack = nodedb_types::json_msgpack::json_to_msgpack(&doc).unwrap(); // Deny filter should NOT match any document. - let passes = deny_filters.iter().all(|f| f.matches_binary(&msgpack)); + let passes = deny_filters.iter().all(|f| f.matches_binary(&msgpack).unwrap()); prop_assert!(!passes, "deny filter should reject all documents"); } diff --git a/nodedb/tests/sql_division_by_zero.rs b/nodedb/tests/sql_division_by_zero.rs new file mode 100644 index 000000000..95dd0fee4 --- /dev/null +++ b/nodedb/tests/sql_division_by_zero.rs @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Pgwire coverage for division-by-zero handling: division and modulo by a +//! zero divisor must fail the statement at RUNTIME with SQLSTATE `22012` +//! (`division_by_zero`), not silently evaluate to a NULL row — the pre-fix +//! behavior. `nodedb-query/src/expr/binary.rs`'s `eval_binary_op` folded a +//! zero-divisor `/` or `%` to `Value::Null` for both the f64 and Decimal +//! arithmetic paths; `nodedb-query/src/functions/math.rs`'s `mod` scalar +//! function did the same. This is a runtime concern, distinct from unit 1's +//! plan-time `UndefinedFunction` gate: `1/0` is a perfectly well-typed, +//! well-formed expression — the error can only be known once the divisor +//! value is evaluated. +//! +//! ## Why every test here uses `FROM ` with a column divisor +//! +//! A bare `SELECT ` with NO `FROM` clause never reaches the runtime +//! evaluator this unit fixes: `nodedb-sql`'s planner special-cases a +//! from-less SELECT into a plan-time `SqlPlan::ConstantResult` +//! (`nodedb-sql/src/planner/select/select_stmt.rs`), computed via +//! `eval_constant_expr` (`nodedb-sql/src/planner/select/helpers.rs`), which +//! is `const_fold::fold_constant(expr, ..).unwrap_or(SqlValue::Null)` — ANY +//! expression the plan-time constant folder can't fully fold (not just +//! division-by-zero; integer overflow and every `CASE` expression hit the +//! exact same `unwrap_or(Null)`, see `sql_arithmetic_overflow.rs`'s hedged +//! assertions for the pre-existing overflow case) silently becomes NULL +//! there, before any row-scope `SqlExpr::eval` call exists. That is a +//! separate, pre-existing, general fold-coverage gap in `nodedb-sql`, +//! architecturally isolated from the `nodedb-query` row-scope evaluator this +//! unit modifies — out of scope here (fixing it would mean also changing +//! integer-overflow and `CASE` behavior for from-less SELECTs, well beyond +//! "division/modulo-by-zero only"). +//! +//! A column reference is never constant-foldable (`fold_constant` has no +//! arm for `SqlExpr::Column`), so `FROM ` with the divisor read +//! from a column guarantees the expression reaches the real row-scope +//! evaluator these tests are meant to exercise — the same path a real +//! `SELECT ... FROM t WHERE ` query takes. +//! +//! ## Why the float-path test divides an integer literal by a float column +//! +//! A decimal-point numeric literal (e.g. `1.5`) combined arithmetically with +//! a stored `FLOAT` column is, independently of this fix, always NULL in +//! this codebase — confirmed by direct diagnosis: `SELECT 4.0 + fdenom` +//! (fdenom = 2.0, a perfectly valid addition) already returns NULL with no +//! error on an unmodified evaluator, while `SELECT 4 + fdenom` (integer +//! literal) correctly returns `6`. That is a pre-existing, general +//! decimal-literal/float-column type-coercion gap, unrelated to division or +//! to zero divisors (it reproduces with `+`), and out of scope for a +//! division/modulo-by-zero-only fix. `2/fdenom` (integer literal ÷ float +//! column) sidesteps it while still exercising the f64 arithmetic branch of +//! `eval_binary_op` (neither operand is `Value::Decimal`, so the Decimal +//! branch is provably not what's under test here). + +mod common; + +use common::pgwire_harness::TestServer; + +async fn seed(srv: &TestServer, collection: &str) { + srv.exec(&format!("CREATE COLLECTION {collection}")) + .await + .unwrap(); + srv.exec(&format!( + "INSERT INTO {collection} (id, denom, fdenom) VALUES ('a', 1, 1.0)" + )) + .await + .unwrap(); + srv.exec(&format!( + "INSERT INTO {collection} (id, denom, fdenom) VALUES ('b', 0, 0.0)" + )) + .await + .unwrap(); + srv.exec(&format!( + "INSERT INTO {collection} (id, denom, fdenom) VALUES ('c', 2, 2.0)" + )) + .await + .unwrap(); +} + +/// Integer division by a zero-valued column. +#[tokio::test] +async fn integer_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_int_216").await; + + srv.expect_error( + "SELECT 1/denom FROM divzero_int_216 WHERE id = 'b'", + "22012", + ) + .await; +} + +/// Float division by a zero-valued column — the f64 arithmetic path in +/// `eval_binary_op`, distinct from the Decimal/Integer path (see the module +/// doc comment for why the dividend is an integer literal, not `1.5`). +#[tokio::test] +async fn float_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_float_216").await; + + srv.expect_error( + "SELECT 2/fdenom FROM divzero_float_216 WHERE id = 'b'", + "22012", + ) + .await; + + // Control: the same expression over a non-zero float column still + // succeeds with the correct value. + let rows = srv + .query_text("SELECT 2/fdenom FROM divzero_float_216 WHERE id = 'c'") + .await + .expect("2 / 2.0 must still succeed"); + assert_eq!(rows, vec!["1".to_string()]); +} + +/// Modulo by a zero-valued column. +#[tokio::test] +async fn modulo_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_mod_216").await; + + srv.expect_error( + "SELECT 5 % denom FROM divzero_mod_216 WHERE id = 'b'", + "22012", + ) + .await; +} + +/// The WHERE-clause behavior flip (intended and Postgres-correct): a WHERE +/// predicate that divides by a column whose +/// value is zero for some row used to fold that row's predicate to +/// `Value::Null`/false and silently exclude it from the result — a +/// division-by-zero was indistinguishable from a legitimate non-match. +/// It must now fail the whole statement with `22012` instead, exactly like +/// evaluating the same expression in the SELECT list would. +#[tokio::test] +async fn where_clause_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_where_216").await; + + srv.expect_error( + "SELECT * FROM divzero_where_216 WHERE 10 / denom > 1", + "22012", + ) + .await; +} + +/// CASE laziness end-to-end: an untaken CASE branch containing a +/// division-by-zero expression must never be evaluated, so the statement +/// succeeds and returns the ELSE value. Uses a column reference (`denom`) +/// so the CASE expression is not plan-time constant-folded away — see the +/// module doc comment; `SqlExpr::Case` is never constant-foldable anyway, +/// so this exercises the row-scope evaluator's own CASE laziness +/// (`nodedb-query/src/expr/eval.rs`), which is exactly what's under test. +#[tokio::test] +async fn case_laziness_untaken_branch_succeeds() { + let srv = TestServer::start().await; + seed(&srv, "divzero_case_216").await; + + let rows = srv + .query_text( + "SELECT CASE WHEN false THEN 1/denom ELSE 42 END \ + FROM divzero_case_216 WHERE id = 'b'", + ) + .await + .expect("untaken CASE branch with 1/denom must not raise an error"); + assert_eq!(rows, vec!["42".to_string()]); +} + +/// Control: a valid (non-zero-divisor) division still succeeds and returns +/// the correct value — the fix must not turn division itself into an error. +#[tokio::test] +async fn valid_division_still_succeeds() { + let srv = TestServer::start().await; + seed(&srv, "divzero_valid_216").await; + + let rows = srv + .query_text("SELECT 10/denom FROM divzero_valid_216 WHERE id = 'c'") + .await + .expect("10 / 2 must still succeed"); + assert_eq!(rows, vec!["5".to_string()]); +} + +// ── Columnar engine coverage (adversarial-review follow-up) ─────────────────── +// +// The Document-engine tests above exercise `nodedb-query`'s row-scope +// evaluator directly. The columnar engine has its own scan handler +// (`nodedb/src/data/executor/handlers/columnar_read/scan.rs`) with its own +// error-plumbing from that same evaluator back out to a pgwire response — +// before this fix, four blocks there wrapped a division/modulo-by-zero +// `EvalError`/`crate::Error` as `ErrorCode::Internal { detail: e.to_string() }` +// instead of propagating the typed error, so a columnar query hit generic +// `XX000` instead of the correct `22012`. These tests prove the columnar +// path end-to-end, independent of the Document-engine coverage above. + +async fn seed_columnar(srv: &TestServer, collection: &str) { + srv.exec(&format!( + "CREATE COLLECTION {collection} (id TEXT PRIMARY KEY, denom INT) WITH (engine='columnar')" + )) + .await + .unwrap(); + srv.exec(&format!( + "INSERT INTO {collection} (id, denom) VALUES ('a', 1)" + )) + .await + .unwrap(); + srv.exec(&format!( + "INSERT INTO {collection} (id, denom) VALUES ('b', 0)" + )) + .await + .unwrap(); + srv.exec(&format!( + "INSERT INTO {collection} (id, denom) VALUES ('c', 2)" + )) + .await + .unwrap(); +} + +/// Columnar engine, WHERE-clause division by zero: `row_matches_filters`'s +/// `Err` arm in `execute_columnar_scan`'s live-memtable phase. +#[tokio::test] +async fn columnar_where_clause_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed_columnar(&srv, "cdivzero_where_216").await; + + srv.expect_error( + "SELECT * FROM cdivzero_where_216 WHERE 10 / denom > 1", + "22012", + ) + .await; +} + +/// Columnar engine, computed SELECT column division by zero: +/// `row_to_projected_json`'s `Err` arm in `execute_columnar_scan`'s +/// live-memtable phase (a real, non-empty `computed_cols`). +/// +/// `denom` must be listed explicitly alongside the computed `ratio` column +/// (not just referenced inside the expression): `row_to_projected_json` +/// only includes a stored column in the row object it hands to the computed +/// expression evaluator when that column is itself in the projection list +/// (or force-included) — an explicit, non-computed `SELECT` entry for every +/// column a computed expression reads from is how a real client query would +/// request the same shape. +#[tokio::test] +async fn columnar_computed_column_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed_columnar(&srv, "cdivzero_select_216").await; + + srv.expect_error( + "SELECT id, denom, 10/denom AS ratio FROM cdivzero_select_216 WHERE id = 'b'", + "22012", + ) + .await; +} + +/// Control: a valid (non-zero-divisor) columnar computed column still +/// succeeds — the fix must not turn columnar division itself into an error. +#[tokio::test] +async fn columnar_computed_column_valid_division_still_succeeds() { + let srv = TestServer::start().await; + seed_columnar(&srv, "cdivzero_valid_216").await; + + let rows = srv + .query_named_rows( + "SELECT id, denom, 10/denom AS ratio FROM cdivzero_valid_216 WHERE id = 'c'", + ) + .await + .expect("10 / 2 must still succeed on the columnar engine"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].get("ratio").map(String::as_str), Some("5")); +} diff --git a/nodedb/tests/sql_division_by_zero_composite.rs b/nodedb/tests/sql_division_by_zero_composite.rs new file mode 100644 index 000000000..9b3583fc9 --- /dev/null +++ b/nodedb/tests/sql_division_by_zero_composite.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Pgwire coverage for division-by-zero in *composite* evaluation paths that +//! historically folded a zero-divisor to NULL and silently dropped the row +//! from the result instead of failing the statement. +//! +//! `sql_division_by_zero.rs` locks the row-scope paths (SELECT list, WHERE, +//! columnar scan). This file locks the paths that evaluate an expression +//! outside a single row's projection/filter and previously swallowed the +//! error: +//! +//! - **Aggregate argument** — `SUM(1/denom)` evaluates the argument per row in +//! the streaming accumulator. A zero divisor used to exclude that row from +//! the accumulation; it must now fail the statement with `22012`. +//! - **GROUP BY key** — `GROUP BY 10/denom` evaluates the key expression per +//! row to build the group key. A zero divisor used to bucket the row under a +//! `null` key; it must now fail with `22012`. +//! - **Window ORDER BY** — `... OVER (ORDER BY 1/denom)` evaluates the order +//! key per row. A zero divisor used to fold to NULL; it must now fail. +//! - **Join residual ON predicate** — `JOIN ... ON a.grp = b.grp AND +//! 1/a.denom > 0` evaluates the non-equijoin residual per candidate pair in +//! the hash-join probe. A zero divisor used to fold to "no match"; it must +//! now fail. +//! +//! Every divisor is a stored column so the expression is never plan-time +//! constant-folded (see `sql_division_by_zero.rs`'s module doc for why), and +//! every collection is seeded with one `denom = 0` row so the error is +//! actually reachable. + +mod common; + +use common::pgwire_harness::TestServer; + +/// Seed a schemaless collection with a zero-divisor row plus non-zero rows, +/// all sharing a `grp` value so GROUP BY / self-join produce multi-row groups. +async fn seed(srv: &TestServer, collection: &str) { + srv.exec(&format!("CREATE COLLECTION {collection}")) + .await + .unwrap(); + for (id, denom) in [("a", 2), ("b", 0), ("c", 4)] { + srv.exec(&format!( + "INSERT INTO {collection} (id, grp, denom) VALUES ('{id}', 1, {denom})" + )) + .await + .unwrap(); + } +} + +/// `SUM` over a per-row expression argument that divides by a zero column. +#[tokio::test] +async fn aggregate_argument_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_agg").await; + + srv.expect_error("SELECT SUM(1/denom) FROM divzero_agg", "22012") + .await; +} + +/// A computed GROUP BY key that divides by a zero column. +#[tokio::test] +async fn group_by_key_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_group").await; + + srv.expect_error( + "SELECT COUNT(*) FROM divzero_group GROUP BY 10/denom", + "22012", + ) + .await; +} + +/// A window ORDER BY key that divides by a zero column. `RANK()` (unlike a +/// pure `ROW_NUMBER()`, which numbers in partition order without evaluating the +/// ORDER BY expression) compares the ORDER BY value across rows to detect peer +/// groups, so it evaluates `1/denom` per row and must fail with `22012`. +#[tokio::test] +async fn window_order_by_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_window").await; + + srv.expect_error( + "SELECT id, RANK() OVER (ORDER BY 1/denom) AS rnk FROM divzero_window", + "22012", + ) + .await; +} + +/// A hash-join residual ON predicate that divides by a zero column. +#[tokio::test] +async fn join_residual_predicate_division_by_zero_errors_22012() { + let srv = TestServer::start().await; + seed(&srv, "divzero_join").await; + + srv.expect_error( + "SELECT a.id FROM divzero_join a JOIN divzero_join b \ + ON a.grp = b.grp AND 1/a.denom > 0", + "22012", + ) + .await; +} + +/// Control: the same aggregate/group-by shapes over only non-zero divisors +/// still succeed — the fix must not turn valid division into an error. +#[tokio::test] +async fn valid_composite_division_still_succeeds() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION divzero_ok").await.unwrap(); + for (id, denom) in [("a", 2), ("c", 4)] { + srv.exec(&format!( + "INSERT INTO divzero_ok (id, grp, denom) VALUES ('{id}', 1, {denom})" + )) + .await + .unwrap(); + } + + // SUM(10/denom) = 10/2 + 10/4 = 5 + 2 (integer division) = 7. + let rows = srv + .query_text("SELECT SUM(10/denom) FROM divzero_ok") + .await + .expect("aggregate over non-zero divisors must succeed"); + assert_eq!(rows.len(), 1); +} diff --git a/nodedb/tests/sql_undefined_function.rs b/nodedb/tests/sql_undefined_function.rs new file mode 100644 index 000000000..7b1359837 --- /dev/null +++ b/nodedb/tests/sql_undefined_function.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Pgwire coverage for undefined scalar function calls: a call to a scalar +//! function with no registered definition must fail the whole statement at +//! PLAN time with +//! SQLSTATE `42883` (`undefined_function`), not silently evaluate to a NULL +//! row (the pre-fix behavior — the runtime evaluator's `try_eval` chain in +//! `nodedb-query/src/functions/mod.rs` ends with +//! `eval_geo_function(name, args).unwrap_or(Value::Null)`, so an unknown +//! name never surfaced as an error before this fix landed). + +mod common; + +use common::pgwire_harness::TestServer; + +/// The baseline case: an unknown scalar function name in a `SELECT` with no +/// `FROM` must be rejected with SQLSTATE `42883`. +#[tokio::test] +async fn unknown_scalar_function_errors_42883() { + let srv = TestServer::start().await; + + srv.expect_error("SELECT some_function_that_does_not_exist(1, 2)", "42883") + .await; +} + +/// Plan-time proof: the error must fire even when the statement would scan +/// zero rows. If the rejection happened during row-by-row evaluation +/// instead of at plan time, a `LIMIT 0` (or an empty collection) would +/// short-circuit before evaluating any row and the call would go +/// unnoticed. Pairing an existing, +/// genuinely empty collection with `LIMIT 0` gives two independent reasons +/// no row would ever reach the evaluator, so an error here can only come +/// from planning. +#[tokio::test] +async fn unknown_scalar_function_errors_with_zero_rows_scanned() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION undefined_fn_empty") + .await + .unwrap(); + + srv.expect_error( + "SELECT bogus_fn_zero_rows(1) FROM undefined_fn_empty LIMIT 0", + "42883", + ) + .await; +} + +/// Positive control: a plain, valid builtin scalar function must keep +/// working — the undefined-function gate must not become a false positive +/// on real functions. +#[tokio::test] +async fn known_scalar_function_still_works() { + let srv = TestServer::start().await; + + let rows = srv + .query_text("SELECT UPPER('hello')") + .await + .expect("UPPER('hello') must still succeed"); + assert_eq!(rows, vec!["HELLO".to_string()]); +} + +/// Positive control for the geo/spatial risk: many +/// `geo_*` utility functions (evaluated by +/// `nodedb_query::geo_functions::eval_geo_function`) were historically +/// absent from the SQL-side `FunctionRegistry` that plan-time validation +/// consults — a naive existence gate would have wrongly rejected them. +/// `geo_as_wkt` is one such function; `ST_Point` supplies its geometry +/// argument. Both must resolve and produce the expected WKT text. +#[tokio::test] +async fn known_geo_function_still_works() { + let srv = TestServer::start().await; + + let rows = srv + .query_text("SELECT geo_as_wkt(ST_Point(1.0, 2.0))") + .await + .expect("geo_as_wkt(ST_Point(...)) must still succeed"); + assert_eq!(rows, vec!["POINT(1 2)".to_string()]); +} + +/// Control re-assert: an undefined *table* must still report `42P01` +/// (`undefined_table`), distinct from the new `42883` undefined-function +/// path this file otherwise covers — proving the new gate didn't fold the +/// two error classes together or regress the existing one. +#[tokio::test] +async fn undefined_table_still_errors_42p01() { + let srv = TestServer::start().await; + + srv.expect_error("SELECT * FROM this_collection_does_not_exist_216", "42P01") + .await; +} + +/// Registry-completeness smoke tests (adversarial-review follow-up): +/// `FunctionRegistry` was originally seeded from only the *old* undocumented +/// function surface, while `nodedb_query::functions::*::try_eval`'s match +/// arms cover a much larger set the runtime evaluator has supported all +/// along. Before those registrations were added, every call below failed +/// plan time with `42883` even though the runtime fully implements them — +/// exactly the false-positive risk this file's `known_geo_function_still_works` +/// test already calls out for the `geo_*` family. One representative per +/// function family, each routed through pgwire like a real client query. +#[tokio::test] +async fn registry_completeness_smoke_math_sqrt() { + let srv = TestServer::start().await; + let rows = srv + .query_text("SELECT sqrt(16.0)") + .await + .expect("sqrt(16.0) must succeed now that math.rs registers it"); + assert_eq!(rows, vec!["4".to_string()]); +} + +#[tokio::test] +async fn registry_completeness_smoke_math_mod() { + let srv = TestServer::start().await; + let rows = srv + .query_text("SELECT mod(10, 3)") + .await + .expect("mod(10, 3) must succeed now that math.rs registers it"); + assert_eq!(rows, vec!["1".to_string()]); +} + +#[tokio::test] +async fn registry_completeness_smoke_datetime_hour_via_date_part() { + let srv = TestServer::start().await; + let rows = srv + .query_text("SELECT date_part('hour', '2024-01-15T13:30:00Z')") + .await + .expect("date_part('hour', ...) must succeed now that datetime.rs registers it"); + assert_eq!(rows, vec!["13".to_string()]); +} + +#[tokio::test] +async fn registry_completeness_smoke_conditional_greatest() { + let srv = TestServer::start().await; + let rows = srv + .query_text("SELECT greatest(1, 5, 3)") + .await + .expect("greatest(1, 5, 3) must succeed now that misc.rs registers it"); + assert_eq!(rows, vec!["5".to_string()]); +} + +#[tokio::test] +async fn registry_completeness_smoke_array_append() { + let srv = TestServer::start().await; + let rows = srv + .query_text("SELECT array_append(ARRAY[1, 2], 3)") + .await + .expect("array_append(...) must succeed now that array_elem.rs registers it"); + assert_eq!(rows.len(), 1); + assert!( + rows[0].contains('3'), + "expected the appended element 3 in the result array, got: {}", + rows[0] + ); +} + +#[tokio::test] +async fn registry_completeness_smoke_id_gen_random_uuid() { + let srv = TestServer::start().await; + let rows = srv + .query_text("SELECT gen_random_uuid()") + .await + .expect("gen_random_uuid() must succeed now that id_fn.rs registers it"); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].len(), + 36, + "expected a canonical 36-char UUID string, got: {}", + rows[0] + ); +} + +#[tokio::test] +async fn registry_completeness_smoke_types_typeof() { + let srv = TestServer::start().await; + let rows = srv + .query_text("SELECT type_of(42)") + .await + .expect("type_of(42) must succeed now that misc.rs registers it"); + assert_eq!(rows, vec!["int".to_string()]); +} diff --git a/nodedb/tests/trigger_batching.rs b/nodedb/tests/trigger_batching.rs index b2d2abd55..d76d49c1a 100644 --- a/nodedb/tests/trigger_batching.rs +++ b/nodedb/tests/trigger_batching.rs @@ -140,28 +140,28 @@ fn classify_invalid_body_row_at_a_time() { #[test] fn when_none_all_pass() { let rows = vec![row("a"), row("b"), row("c")]; - let mask = filter_batch_by_when(&rows, "c", "INSERT", None); + let mask = filter_batch_by_when(&rows, "c", "INSERT", None).unwrap(); assert_eq!(mask, vec![true, true, true]); } #[test] fn when_true_all_pass() { let rows = vec![row("a"), row("b")]; - let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("TRUE")); + let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("TRUE")).unwrap(); assert_eq!(mask, vec![true, true]); } #[test] fn when_false_none_pass() { let rows = vec![row("a"), row("b")]; - let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("FALSE")); + let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("FALSE")).unwrap(); assert_eq!(mask, vec![false, false]); } #[test] fn when_null_none_pass() { let rows = vec![row("a")]; - let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("NULL")); + let mask = filter_batch_by_when(&rows, "c", "INSERT", Some("NULL")).unwrap(); assert_eq!(mask, vec![false]); }