Skip to content

Commit da607a0

Browse files
authored
Merge pull request #226 from emanzx/fix/216-suppressed-expression-errors
fix(sql): raise 42883/22012 for undefined functions and division by zero instead of folding to NULL
2 parents 969c9d2 + 4f34945 commit da607a0

138 files changed

Lines changed: 5227 additions & 1015 deletions

File tree

Some content is hidden

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

nodedb-crdt/src/constraint_checks.rs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,29 @@ fn render_value(value: &LoroValue) -> String {
2828
}
2929

3030
impl Validator {
31+
///
32+
/// Returns `Err(EvalError)` when a CHECK predicate cannot be *evaluated*
33+
/// (division/modulo by zero). This is distinct from an
34+
/// ordinary constraint failure (`Ok(Some(Violation))`): the caller lifts it
35+
/// to [`ValidationOutcome::EvalError`], a hard statement failure that
36+
/// declarative conflict policies must never "resolve".
3137
pub(crate) fn check_constraint(
3238
&self,
3339
state: &impl RowLookup,
3440
change: &ProposedChange,
3541
constraint: &Constraint,
36-
) -> Option<Violation> {
42+
) -> Result<Option<Violation>, nodedb_query::EvalError> {
3743
match &constraint.kind {
38-
ConstraintKind::Unique => self.check_unique(state, change, constraint),
44+
ConstraintKind::Unique => Ok(self.check_unique(state, change, constraint)),
3945
ConstraintKind::ForeignKey {
4046
ref_collection,
4147
ref_key,
4248
}
4349
| ConstraintKind::BiTemporalFK {
4450
ref_collection,
4551
ref_key,
46-
} => self.check_foreign_key(state, change, constraint, ref_collection, ref_key),
47-
ConstraintKind::NotNull => self.check_not_null(change, constraint),
52+
} => Ok(self.check_foreign_key(state, change, constraint, ref_collection, ref_key)),
53+
ConstraintKind::NotNull => Ok(self.check_not_null(change, constraint)),
4854
ConstraintKind::Check { expr, .. } => self.check_expr(change, constraint, expr),
4955
}
5056
}
@@ -60,7 +66,7 @@ impl Validator {
6066
change: &ProposedChange,
6167
constraint: &Constraint,
6268
expr: &str,
63-
) -> Option<Violation> {
69+
) -> Result<Option<Violation>, nodedb_query::EvalError> {
6470
// Build a row Value::Object from the proposed change's fields so the
6571
// expression evaluator can resolve column references by name.
6672
let mut row = std::collections::HashMap::with_capacity(change.fields.len());
@@ -72,7 +78,10 @@ impl Validator {
7278
let parsed = match nodedb_query::expr_parse::parse_generated_expr(expr) {
7379
Ok((expr, _deps)) => expr,
7480
Err(e) => {
75-
return Some(Violation {
81+
// A malformed *stored* predicate is a catalog-integrity failure,
82+
// not an evaluation error — it stays an ordinary Violation
83+
// (fails closed) so a corrupt entry can't bypass its invariant.
84+
return Ok(Some(Violation {
7685
constraint_name: constraint.name.clone(),
7786
reason: format!("invalid CHECK expression `{expr}`: {e}"),
7887
hint: CompensationHint::ManualIntervention {
@@ -81,20 +90,27 @@ impl Validator {
8190
constraint.name
8291
),
8392
},
84-
});
93+
}));
8594
}
8695
};
8796

97+
// A division/modulo-by-zero inside the predicate
98+
// is neither a PASS nor an ordinary FAIL — it's an evaluation error,
99+
// propagated as `Err(EvalError)` and lifted by `validate` to
100+
// `ValidationOutcome::EvalError`. That keeps it out of the conflict-
101+
// policy machinery: an unevaluable predicate is a hard SQLSTATE-22012
102+
// failure, never something LastWriterWins et al. may "resolve".
88103
match parsed.eval(&row_value) {
89-
nodedb_types::Value::Bool(true) => None,
90-
nodedb_types::Value::Null => None,
91-
_ => Some(Violation {
104+
Ok(nodedb_types::Value::Bool(true)) => Ok(None),
105+
Ok(nodedb_types::Value::Null) => Ok(None),
106+
Ok(_) => Ok(Some(Violation {
92107
constraint_name: constraint.name.clone(),
93108
reason: format!("CHECK `{}` failed: {expr}", constraint.name),
94109
hint: CompensationHint::ManualIntervention {
95110
reason: format!("row violates CHECK predicate `{expr}`"),
96111
},
97-
}),
112+
})),
113+
Err(e) => Err(e),
98114
}
99115
}
100116

nodedb-crdt/src/pre_validate.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ pub fn pre_validate(
4545
reason: v.reason.clone(),
4646
}
4747
}
48+
// An unevaluable predicate (division/modulo by zero)
49+
// is rejected before it ever reaches Raft — same terminal outcome as a
50+
// violation, so the delta never commits.
51+
ValidationOutcome::EvalError {
52+
constraint_name,
53+
error,
54+
} => PreValidationResult::FastReject {
55+
constraint: constraint_name,
56+
reason: format!("CHECK predicate failed to evaluate: {error}"),
57+
},
4858
}
4959
}
5060

nodedb-crdt/src/validator/policy_dispatch.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
55
use crate::CrdtAuthContext;
66
use crate::constraint::{Constraint, ConstraintKind};
7+
use crate::dead_letter::CompensationHint;
78
use crate::error::Result;
89
use crate::policy::{ConflictPolicy, PolicyResolution, ResolvedAction};
910
use crate::row_lookup::RowLookup;
1011

1112
use super::core::Validator;
12-
use super::types::{ProposedChange, ValidationOutcome};
13+
use super::types::{ProposedChange, ValidationOutcome, Violation};
1314

1415
impl Validator {
1516
/// Validate with declarative policy resolution.
@@ -195,6 +196,56 @@ impl Validator {
195196
}
196197
}
197198
}
199+
ValidationOutcome::EvalError {
200+
constraint_name,
201+
error,
202+
} => {
203+
// An unevaluable predicate (division/modulo by zero)
204+
// is NOT a resolvable conflict — it must never be
205+
// handed to a declarative policy, which would "resolve" a delta
206+
// the server cannot actually evaluate. Escalate straight to the
207+
// DLQ (fails closed) regardless of the collection's configured
208+
// policy, mirroring `EscalateToDlq` but for an eval error.
209+
let constraint = self
210+
.constraints
211+
.all()
212+
.iter()
213+
.find(|c| c.name == constraint_name)
214+
.cloned()
215+
.unwrap_or_else(|| Constraint {
216+
name: constraint_name.clone(),
217+
collection: change.collection.clone(),
218+
field: String::new(),
219+
kind: ConstraintKind::NotNull,
220+
});
221+
let reason = format!("CHECK `{constraint_name}` failed to evaluate: {error}");
222+
let hint = CompensationHint::ManualIntervention {
223+
reason: reason.clone(),
224+
};
225+
self.dlq
226+
.enqueue(crate::dead_letter::EnqueueDeadLetterArgs {
227+
peer_id,
228+
user_id: auth.user_id,
229+
tenant_id: auth.tenant_id,
230+
delta: delta_bytes,
231+
constraint: &constraint,
232+
reason: reason.clone(),
233+
hint: hint.clone(),
234+
})?;
235+
tracing::warn!(
236+
constraint = %constraint_name,
237+
collection = %change.collection,
238+
%error,
239+
"CRDT CHECK predicate raised an evaluation error; escalated to DLQ"
240+
);
241+
Ok(PolicyResolution::Escalate {
242+
violations: vec![Violation {
243+
constraint_name,
244+
reason,
245+
hint,
246+
}],
247+
})
248+
}
198249
}
199250
}
200251
}

nodedb-crdt/src/validator/tests.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,41 @@ fn check_constraint_rejects_malformed_expr() {
415415
_ => panic!("expected rejection for malformed CHECK expression"),
416416
}
417417
}
418+
419+
/// A CHECK predicate that divides by zero is an *evaluation* error, not an
420+
/// ordinary violation: `validate` returns `ValidationOutcome::EvalError` so the
421+
/// downstream conflict-policy machinery never "resolves" an unevaluable
422+
/// predicate. Distinct from both `Rejected` (a genuine violation) and
423+
/// `Accepted`.
424+
#[test]
425+
fn check_constraint_division_by_zero_surfaces_eval_error() {
426+
let state = CrdtState::new(1).unwrap();
427+
let mut cs = ConstraintSet::new();
428+
// `100 / age` is unevaluable when age = 0.
429+
cs.add_check(
430+
"people_ratio_check",
431+
"people",
432+
"age",
433+
"100 / age > 0",
434+
"100 / age > 0",
435+
);
436+
let validator = Validator::new(cs, 100);
437+
438+
let change = ProposedChange {
439+
collection: "people".into(),
440+
row_id: "p1".into(),
441+
surrogate: nodedb_types::Surrogate::ZERO,
442+
fields: vec![("age".into(), LoroValue::I64(0))],
443+
};
444+
445+
match validator.validate(&state, &change) {
446+
ValidationOutcome::EvalError {
447+
constraint_name,
448+
error,
449+
} => {
450+
assert_eq!(constraint_name, "people_ratio_check");
451+
assert_eq!(error, nodedb_query::EvalError::DivisionByZero);
452+
}
453+
other => panic!("expected ValidationOutcome::EvalError, got {other:?}"),
454+
}
455+
}

nodedb-crdt/src/validator/types.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ pub enum ValidationOutcome {
1313
Accepted,
1414
/// One or more constraints violated — delta rejected.
1515
Rejected(Vec<Violation>),
16+
/// A CHECK predicate could not be *evaluated* — division/modulo by zero.
17+
/// Kept distinct from [`ValidationOutcome::Rejected`]
18+
/// because an eval error is a hard statement failure (SQLSTATE `22012`),
19+
/// NOT a resolvable conflict: declarative conflict policies
20+
/// (`LastWriterWins`, `RenameSuffix`, …) must never "resolve" an
21+
/// unevaluable predicate the way they resolve a genuine violation. It fails
22+
/// closed — escalated straight to the dead-letter queue.
23+
EvalError {
24+
/// The CHECK constraint whose predicate failed to evaluate.
25+
constraint_name: String,
26+
/// The underlying evaluation error (currently only `DivisionByZero`).
27+
error: nodedb_query::EvalError,
28+
},
1629
}
1730

1831
/// A single constraint violation.

nodedb-crdt/src/validator/validate.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,19 @@ impl Validator {
8383
let mut violations = Vec::new();
8484

8585
for constraint in constraints {
86-
if let Some(violation) = self.check_constraint(state, change, constraint) {
87-
violations.push(violation);
86+
match self.check_constraint(state, change, constraint) {
87+
Ok(Some(violation)) => violations.push(violation),
88+
Ok(None) => {}
89+
// A predicate that could not be evaluated (division/modulo by
90+
// zero) is a hard failure, not a violation to
91+
// be batched with the others — surface it immediately so it
92+
// bypasses conflict-policy resolution downstream.
93+
Err(error) => {
94+
return ValidationOutcome::EvalError {
95+
constraint_name: constraint.name.clone(),
96+
error,
97+
};
98+
}
8899
}
89100
}
90101

0 commit comments

Comments
 (0)