Skip to main content

mongreldb_core/
constraint.rs

1//! Engine-side declarative constraints (unique, foreign key, check) enforced
2//! authoritatively inside the core transaction path. Opt-in per-table via
3//! [`crate::schema::Schema::constraints`]; tables with an empty constraint set
4//! (the default — including every legacy table and every Kit-managed table)
5//! behave exactly as before. This subsystem is independent of the Kit's own
6//! guard-table mechanism: the Kit continues to enforce its constraints exactly
7//! as before, and these engine constraints only fire for tables whose schema
8//! carries a non-empty [`TableConstraints`].
9//!
10//! Enforcement is performed in [`crate::database::Database::commit_transaction`]
11//! as a pre-sequencer validation pass (under the transaction's read snapshot,
12//! outside the WAL mutex) plus `WriteKey::Unique` registration so that two
13//! concurrent transactions inserting the same key cannot both commit.
14
15use crate::error::{MongrelError, Result};
16use crate::memtable::Value;
17use serde::{Deserialize, Serialize};
18use std::cmp::Ordering;
19use std::collections::HashMap;
20
21/// A declarative constraint set attached to a table's schema. Empty by default.
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
23pub struct TableConstraints {
24    #[serde(default)]
25    pub uniques: Vec<UniqueConstraint>,
26    #[serde(default)]
27    pub foreign_keys: Vec<ForeignKey>,
28    #[serde(default)]
29    pub checks: Vec<CheckConstraint>,
30}
31
32impl TableConstraints {
33    pub fn is_empty(&self) -> bool {
34        self.uniques.is_empty() && self.foreign_keys.is_empty() && self.checks.is_empty()
35    }
36}
37
38/// A multi-column uniqueness constraint (beyond the single-column `PRIMARY_KEY`
39/// flag). Enforced via an existence scan against the read snapshot plus
40/// `WriteKey::Unique` registration at commit (first-committer-wins).
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct UniqueConstraint {
43    pub id: u16,
44    pub name: String,
45    pub columns: Vec<u16>,
46}
47
48/// Referential action for a [`ForeignKey`] parent delete or referenced-key
49/// update.
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
51pub enum FkAction {
52    /// Reject the parent delete if any child row references it (default).
53    #[default]
54    Restrict,
55    /// Cascade the delete to child rows.
56    Cascade,
57    /// Set the referencing columns to `NULL` in child rows.
58    SetNull,
59}
60
61/// A foreign key: the listed `columns` must reference an existing row in
62/// `ref_table` whose `ref_columns` match. The parent table is named by string so
63/// the link survives the parent's numeric table-id assignment across reopens.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct ForeignKey {
66    pub id: u16,
67    pub name: String,
68    pub columns: Vec<u16>,
69    pub ref_table: String,
70    pub ref_columns: Vec<u16>,
71    #[serde(default)]
72    pub on_delete: FkAction,
73    #[serde(default)]
74    pub on_update: FkAction,
75}
76
77/// A CHECK constraint: the row is rejected when `expr` evaluates to `False`
78/// (SQL three-valued logic: `Unknown`/`True` both pass).
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
80pub struct CheckConstraint {
81    pub id: u16,
82    pub name: String,
83    pub expr: CheckExpr,
84}
85
86/// A minimal boolean expression IR for CHECK constraints, evaluated against a
87/// row's cells. Terms ([`CheckExpr::Col`] / [`CheckExpr::Lit`]) resolve to
88/// [`Value`]; comparisons and logical ops are boolean. Any comparison involving
89/// `Value::Null` yields three-valued `Unknown`.
90///
91/// The [`CheckExpr::Regex`] variant stores the pattern string for serde/equality
92/// and caches the compiled [`regex::Regex`] in a [`std::sync::OnceLock`] (populated
93/// on first evaluation). The cache is `#[serde(skip)]` and excluded from
94/// [`PartialEq`].
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub enum CheckExpr {
97    /// Constant true (the trivially-satisfied CHECK).
98    True,
99    /// A bare column reference used as a boolean (truthiness coercion).
100    Col(u16),
101    /// A literal value.
102    Lit(Value),
103    Add(Box<CheckExpr>, Box<CheckExpr>),
104    Sub(Box<CheckExpr>, Box<CheckExpr>),
105    Mul(Box<CheckExpr>, Box<CheckExpr>),
106    Div(Box<CheckExpr>, Box<CheckExpr>),
107    Mod(Box<CheckExpr>, Box<CheckExpr>),
108    IsNull(u16),
109    IsNotNull(u16),
110    Eq(Box<CheckExpr>, Box<CheckExpr>),
111    Ne(Box<CheckExpr>, Box<CheckExpr>),
112    Lt(Box<CheckExpr>, Box<CheckExpr>),
113    Le(Box<CheckExpr>, Box<CheckExpr>),
114    Gt(Box<CheckExpr>, Box<CheckExpr>),
115    Ge(Box<CheckExpr>, Box<CheckExpr>),
116    And(Box<CheckExpr>, Box<CheckExpr>),
117    Or(Box<CheckExpr>, Box<CheckExpr>),
118    Not(Box<CheckExpr>),
119    /// Regex pattern match against a column's value (PostgreSQL `~`/`~*`/`!~`/`!~*`).
120    /// The column value must be `Value::Bytes` (UTF-8 string); non-bytes and null
121    /// yield `Unknown` (CHECK passes). `negated` inverts the match result;
122    /// `case_insensitive` enables case-insensitive matching. The Rust `regex`
123    /// crate is linear-time (no catastrophic backtracking), so ReDoS is not a
124    /// concern.
125    Regex {
126        col: u16,
127        pattern: String,
128        negated: bool,
129        case_insensitive: bool,
130        #[serde(skip)]
131        cached: std::sync::OnceLock<regex::Regex>,
132    },
133}
134
135impl PartialEq for CheckExpr {
136    fn eq(&self, other: &Self) -> bool {
137        match (self, other) {
138            (Self::True, Self::True) => true,
139            (Self::Col(a), Self::Col(b)) => a == b,
140            (Self::Lit(a), Self::Lit(b)) => a == b,
141            (Self::Add(a1, a2), Self::Add(b1, b2))
142            | (Self::Sub(a1, a2), Self::Sub(b1, b2))
143            | (Self::Mul(a1, a2), Self::Mul(b1, b2))
144            | (Self::Div(a1, a2), Self::Div(b1, b2))
145            | (Self::Mod(a1, a2), Self::Mod(b1, b2)) => a1 == b1 && a2 == b2,
146            (Self::IsNull(a), Self::IsNull(b)) => a == b,
147            (Self::IsNotNull(a), Self::IsNotNull(b)) => a == b,
148            (Self::Eq(a1, a2), Self::Eq(b1, b2)) => a1 == b1 && a2 == b2,
149            (Self::Ne(a1, a2), Self::Ne(b1, b2)) => a1 == b1 && a2 == b2,
150            (Self::Lt(a1, a2), Self::Lt(b1, b2)) => a1 == b1 && a2 == b2,
151            (Self::Le(a1, a2), Self::Le(b1, b2)) => a1 == b1 && a2 == b2,
152            (Self::Gt(a1, a2), Self::Gt(b1, b2)) => a1 == b1 && a2 == b2,
153            (Self::Ge(a1, a2), Self::Ge(b1, b2)) => a1 == b1 && a2 == b2,
154            (Self::And(a1, a2), Self::And(b1, b2)) => a1 == b1 && a2 == b2,
155            (Self::Or(a1, a2), Self::Or(b1, b2)) => a1 == b1 && a2 == b2,
156            (Self::Not(a), Self::Not(b)) => a == b,
157            (
158                Self::Regex {
159                    col: ac,
160                    pattern: ap,
161                    negated: an,
162                    case_insensitive: ai,
163                    ..
164                },
165                Self::Regex {
166                    col: bc,
167                    pattern: bp,
168                    negated: bn,
169                    case_insensitive: bi,
170                    ..
171                },
172            ) => ac == bc && ap == bp && an == bn && ai == bi,
173            _ => false,
174        }
175    }
176}
177
178/// Three-valued logic result for CHECK evaluation.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180enum Tri {
181    True,
182    False,
183    Unknown,
184}
185
186impl CheckExpr {
187    /// Evaluate the expression against a row's cells. A CHECK constraint is
188    /// satisfied unless this returns [`Tri::False`].
189    pub fn satisfied(&self, cells: &HashMap<u16, Value>) -> bool {
190        !matches!(self.eval(cells), Tri::False)
191    }
192
193    /// Validate that all regex patterns in this expression compile successfully.
194    /// Call at DDL time (table creation / constraint addition) so invalid
195    /// patterns are rejected eagerly rather than silently never-matching at
196    /// first commit.
197    pub fn validate(&self) -> Result<()> {
198        match self {
199            CheckExpr::Eq(a, b)
200            | CheckExpr::Ne(a, b)
201            | CheckExpr::Lt(a, b)
202            | CheckExpr::Le(a, b)
203            | CheckExpr::Gt(a, b)
204            | CheckExpr::Ge(a, b)
205            | CheckExpr::And(a, b)
206            | CheckExpr::Or(a, b)
207            | CheckExpr::Add(a, b)
208            | CheckExpr::Sub(a, b)
209            | CheckExpr::Mul(a, b)
210            | CheckExpr::Div(a, b)
211            | CheckExpr::Mod(a, b) => {
212                a.validate()?;
213                b.validate()?;
214            }
215            CheckExpr::Not(a) => a.validate()?,
216            CheckExpr::Regex {
217                pattern,
218                case_insensitive,
219                ..
220            } => {
221                let mut builder = regex::RegexBuilder::new(pattern);
222                builder.case_insensitive(*case_insensitive);
223                builder.build().map_err(|e| {
224                    MongrelError::InvalidArgument(format!("invalid regex pattern: {e}"))
225                })?;
226            }
227            _ => {}
228        }
229        Ok(())
230    }
231
232    fn eval(&self, cells: &HashMap<u16, Value>) -> Tri {
233        match self {
234            CheckExpr::True => Tri::True,
235            CheckExpr::Col(id) => match cells.get(id) {
236                None | Some(Value::Null) => Tri::Unknown,
237                Some(v) => Tri::from_truthy(v),
238            },
239            CheckExpr::Lit(v) => Tri::from_truthy(v),
240            CheckExpr::Add(_, _)
241            | CheckExpr::Sub(_, _)
242            | CheckExpr::Mul(_, _)
243            | CheckExpr::Div(_, _)
244            | CheckExpr::Mod(_, _) => Tri::from_truthy(&self.eval_term(cells)),
245            CheckExpr::IsNull(id) => match cells.get(id) {
246                None | Some(Value::Null) => Tri::True,
247                Some(_) => Tri::False,
248            },
249            CheckExpr::IsNotNull(id) => match cells.get(id) {
250                None | Some(Value::Null) => Tri::False,
251                Some(_) => Tri::True,
252            },
253            CheckExpr::Eq(a, b) => compare(a.eval_term(cells), b.eval_term(cells), |o| {
254                o == Ordering::Equal
255            }),
256            CheckExpr::Ne(a, b) => compare(a.eval_term(cells), b.eval_term(cells), |o| {
257                o != Ordering::Equal
258            }),
259            CheckExpr::Lt(a, b) => compare(a.eval_term(cells), b.eval_term(cells), |o| {
260                o == Ordering::Less
261            }),
262            CheckExpr::Le(a, b) => compare(a.eval_term(cells), b.eval_term(cells), |o| {
263                o != Ordering::Greater
264            }),
265            CheckExpr::Gt(a, b) => compare(a.eval_term(cells), b.eval_term(cells), |o| {
266                o == Ordering::Greater
267            }),
268            CheckExpr::Ge(a, b) => compare(a.eval_term(cells), b.eval_term(cells), |o| {
269                o != Ordering::Less
270            }),
271            CheckExpr::And(a, b) => and3(a.eval(cells), b.eval(cells)),
272            CheckExpr::Or(a, b) => or3(a.eval(cells), b.eval(cells)),
273            CheckExpr::Not(a) => not3(a.eval(cells)),
274            CheckExpr::Regex {
275                col,
276                pattern,
277                negated,
278                case_insensitive,
279                cached,
280            } => match cells.get(col) {
281                None | Some(Value::Null) => Tri::Unknown,
282                Some(Value::Bytes(b)) => {
283                    let re = cached.get_or_init(|| {
284                        let mut builder = regex::RegexBuilder::new(pattern);
285                        builder.case_insensitive(*case_insensitive);
286                        // Invalid patterns are rejected at DDL validation; if we
287                        // reach here with an invalid pattern, create a regex that
288                        // never matches (safe default).
289                        builder
290                            .build()
291                            .unwrap_or_else(|_| regex::Regex::new("$^").unwrap())
292                    });
293                    let matched = re.is_match(std::str::from_utf8(b).unwrap_or(""));
294                    match (*negated, matched) {
295                        (false, true) | (true, false) => Tri::True,
296                        (false, false) | (true, true) => Tri::False,
297                    }
298                }
299                // Non-bytes values (numbers, bools, etc.) are not regex-matchable.
300                Some(_) => Tri::Unknown,
301            },
302        }
303    }
304
305    /// Evaluate a term (Col/Lit) to a [`Value`]; boolean expressions coerce via
306    /// truthiness (True→1, False/Unknown→Null) so they can still be compared.
307    fn eval_term(&self, cells: &HashMap<u16, Value>) -> Value {
308        match self {
309            CheckExpr::Col(id) => cells.get(id).cloned().unwrap_or(Value::Null),
310            CheckExpr::Lit(v) => v.clone(),
311            CheckExpr::Add(a, b) => arithmetic(a.eval_term(cells), b.eval_term(cells), Arith::Add),
312            CheckExpr::Sub(a, b) => arithmetic(a.eval_term(cells), b.eval_term(cells), Arith::Sub),
313            CheckExpr::Mul(a, b) => arithmetic(a.eval_term(cells), b.eval_term(cells), Arith::Mul),
314            CheckExpr::Div(a, b) => arithmetic(a.eval_term(cells), b.eval_term(cells), Arith::Div),
315            CheckExpr::Mod(a, b) => arithmetic(a.eval_term(cells), b.eval_term(cells), Arith::Mod),
316            other => match other.eval(cells) {
317                Tri::True => Value::Int64(1),
318                Tri::False | Tri::Unknown => Value::Null,
319            },
320        }
321    }
322}
323
324#[derive(Clone, Copy)]
325enum Arith {
326    Add,
327    Sub,
328    Mul,
329    Div,
330    Mod,
331}
332
333fn arithmetic(left: Value, right: Value, op: Arith) -> Value {
334    match (left, right) {
335        (Value::Int64(left), Value::Int64(right)) => {
336            let value = match op {
337                Arith::Add => left.checked_add(right),
338                Arith::Sub => left.checked_sub(right),
339                Arith::Mul => left.checked_mul(right),
340                Arith::Div => left.checked_div(right),
341                Arith::Mod => left.checked_rem(right),
342            };
343            value.map(Value::Int64).unwrap_or(Value::Null)
344        }
345        (Value::Int64(left), Value::Float64(right)) => arithmetic_float(left as f64, right, op),
346        (Value::Float64(left), Value::Int64(right)) => arithmetic_float(left, right as f64, op),
347        (Value::Float64(left), Value::Float64(right)) => arithmetic_float(left, right, op),
348        _ => Value::Null,
349    }
350}
351
352fn arithmetic_float(left: f64, right: f64, op: Arith) -> Value {
353    if matches!(op, Arith::Div | Arith::Mod) && right == 0.0 {
354        return Value::Null;
355    }
356    let value = match op {
357        Arith::Add => left + right,
358        Arith::Sub => left - right,
359        Arith::Mul => left * right,
360        Arith::Div => left / right,
361        Arith::Mod => left % right,
362    };
363    if value.is_finite() {
364        Value::Float64(value)
365    } else {
366        Value::Null
367    }
368}
369
370impl Tri {
371    fn from_truthy(v: &Value) -> Tri {
372        match v {
373            Value::Null => Tri::Unknown,
374            Value::Bool(b) => {
375                if *b {
376                    Tri::True
377                } else {
378                    Tri::False
379                }
380            }
381            Value::Int64(n) => {
382                if *n != 0 {
383                    Tri::True
384                } else {
385                    Tri::False
386                }
387            }
388            Value::Float64(f) => {
389                if *f != 0.0 {
390                    Tri::True
391                } else {
392                    Tri::False
393                }
394            }
395            Value::Bytes(b) => {
396                if b.is_empty() {
397                    Tri::False
398                } else {
399                    Tri::True
400                }
401            }
402            Value::Embedding(v) => {
403                if v.is_empty() {
404                    Tri::False
405                } else {
406                    Tri::True
407                }
408            }
409            Value::GeneratedEmbedding(v) => {
410                if v.vector.is_empty() {
411                    Tri::False
412                } else {
413                    Tri::True
414                }
415            }
416            Value::Interval { .. } => Tri::Unknown,
417            Value::Uuid(_) | Value::Json(_) => Tri::Unknown,
418            Value::Decimal(d) => {
419                if *d != 0 {
420                    Tri::True
421                } else {
422                    Tri::False
423                }
424            }
425        }
426    }
427}
428
429fn and3(a: Tri, b: Tri) -> Tri {
430    match (a, b) {
431        (Tri::False, _) | (_, Tri::False) => Tri::False,
432        (Tri::Unknown, _) | (_, Tri::Unknown) => Tri::Unknown,
433        _ => Tri::True,
434    }
435}
436
437fn or3(a: Tri, b: Tri) -> Tri {
438    match (a, b) {
439        (Tri::True, _) | (_, Tri::True) => Tri::True,
440        (Tri::Unknown, _) | (_, Tri::Unknown) => Tri::Unknown,
441        _ => Tri::False,
442    }
443}
444
445fn not3(a: Tri) -> Tri {
446    match a {
447        Tri::True => Tri::False,
448        Tri::False => Tri::True,
449        Tri::Unknown => Tri::Unknown,
450    }
451}
452
453/// Run a comparison from two values. If either term is `Null` the comparison is
454/// `Unknown`; otherwise apply `pred` to the concrete ordering (incomparable
455/// types also yield `Unknown`).
456fn compare(a: Value, b: Value, pred: impl Fn(Ordering) -> bool) -> Tri {
457    if matches!(a, Value::Null) || matches!(b, Value::Null) {
458        return Tri::Unknown;
459    }
460    match value_cmp(&a, &b) {
461        Some(o) => {
462            if pred(o) {
463                Tri::True
464            } else {
465                Tri::False
466            }
467        }
468        None => Tri::Unknown,
469    }
470}
471
472/// Cross-type comparison for values. Same-shape values compare naturally;
473/// Int64/Float64 cross-compare numerically; Bytes lexicographically; Bool as
474/// 0/1; other distinct-type pairs are `None` (incomparable).
475pub(crate) fn value_cmp(a: &Value, b: &Value) -> Option<Ordering> {
476    match (a, b) {
477        (Value::Null, Value::Null) => Some(Ordering::Equal),
478        (Value::Bool(x), Value::Bool(y)) => Some((*x as u8).cmp(&(*y as u8))),
479        (Value::Int64(x), Value::Int64(y)) => Some(x.cmp(y)),
480        (Value::Float64(x), Value::Float64(y)) => x.partial_cmp(y),
481        (Value::Int64(x), Value::Float64(y)) => (*x as f64).partial_cmp(y),
482        (Value::Float64(x), Value::Int64(y)) => x.partial_cmp(&(*y as f64)),
483        (Value::Bytes(x), Value::Bytes(y)) => Some(x.cmp(y)),
484        (x, y) if x.as_embedding().is_some() && y.as_embedding().is_some() => {
485            let x = x.as_embedding().unwrap();
486            let y = y.as_embedding().unwrap();
487            for (a, b) in x.iter().zip(y.iter()) {
488                match a.partial_cmp(b)? {
489                    Ordering::Equal => continue,
490                    non_eq => return Some(non_eq),
491                }
492            }
493            Some(x.len().cmp(&y.len()))
494        }
495        _ => None,
496    }
497}
498
499/// Encode a set of column values into a stable, unambiguous composite key for
500/// uniqueness / FK matching. Returns `None` if any referenced column is missing
501/// or `Null` — SQL semantics: a UNIQUE constraint ignores rows where any
502/// constrained column is NULL, and an FK with a NULL component is not checked.
503pub(crate) fn encode_composite_key(
504    columns: &[u16],
505    cells: &HashMap<u16, Value>,
506) -> Option<Vec<u8>> {
507    let mut out = Vec::new();
508    for cid in columns {
509        let v = cells.get(cid)?;
510        if matches!(v, Value::Null) {
511            return None;
512        }
513        let k = v.encode_key();
514        // Length-prefix so concatenated multi-column keys stay unambiguous.
515        out.extend_from_slice(&(k.len() as u32).to_be_bytes());
516        out.extend_from_slice(&k);
517    }
518    Some(out)
519}
520
521/// Validate CHECK constraints for a single staged row.
522pub(crate) fn validate_checks(
523    checks: &[CheckConstraint],
524    cells: &HashMap<u16, Value>,
525) -> Result<()> {
526    for c in checks {
527        if !c.expr.satisfied(cells) {
528            return Err(MongrelError::InvalidArgument(format!(
529                "CHECK constraint '{}' failed",
530                c.name
531            )));
532        }
533    }
534    Ok(())
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    fn m(cols: &[(u16, Value)]) -> HashMap<u16, Value> {
542        cols.iter().cloned().collect()
543    }
544
545    #[test]
546    fn check_eq_literal() {
547        // col 1 == 5
548        let e = CheckExpr::Eq(
549            Box::new(CheckExpr::Col(1)),
550            Box::new(CheckExpr::Lit(Value::Int64(5))),
551        );
552        assert!(e.satisfied(&m(&[(1, Value::Int64(5))])));
553        assert!(!e.satisfied(&m(&[(1, Value::Int64(6))])));
554        // null comparison → unknown → satisfied (CHECK passes on unknown)
555        assert!(e.satisfied(&m(&[(1, Value::Null)])));
556    }
557
558    #[test]
559    fn check_range_and() {
560        // 0 <= col1 <= 100
561        let e = CheckExpr::And(
562            Box::new(CheckExpr::Ge(
563                Box::new(CheckExpr::Col(1)),
564                Box::new(CheckExpr::Lit(Value::Int64(0))),
565            )),
566            Box::new(CheckExpr::Le(
567                Box::new(CheckExpr::Col(1)),
568                Box::new(CheckExpr::Lit(Value::Int64(100))),
569            )),
570        );
571        assert!(e.satisfied(&m(&[(1, Value::Int64(50))])));
572        assert!(!e.satisfied(&m(&[(1, Value::Int64(101))])));
573        assert!(!e.satisfied(&m(&[(1, Value::Int64(-1))])));
574    }
575
576    #[test]
577    fn check_numeric_cross_type() {
578        let e = CheckExpr::Lt(
579            Box::new(CheckExpr::Col(1)),
580            Box::new(CheckExpr::Lit(Value::Float64(10.0))),
581        );
582        assert!(e.satisfied(&m(&[(1, Value::Int64(5))])));
583        assert!(!e.satisfied(&m(&[(1, Value::Int64(20))])));
584    }
585
586    #[test]
587    fn check_arithmetic_terms() {
588        let e = CheckExpr::Le(
589            Box::new(CheckExpr::Mul(
590                Box::new(CheckExpr::Add(
591                    Box::new(CheckExpr::Col(1)),
592                    Box::new(CheckExpr::Lit(Value::Int64(2))),
593                )),
594                Box::new(CheckExpr::Col(2)),
595            )),
596            Box::new(CheckExpr::Lit(Value::Int64(20))),
597        );
598        assert!(e.satisfied(&m(&[(1, Value::Int64(3)), (2, Value::Int64(4))])));
599        assert!(!e.satisfied(&m(&[(1, Value::Int64(4)), (2, Value::Int64(4))])));
600    }
601
602    #[test]
603    fn check_arithmetic_error_is_unknown() {
604        let divide_by_zero = CheckExpr::Eq(
605            Box::new(CheckExpr::Div(
606                Box::new(CheckExpr::Col(1)),
607                Box::new(CheckExpr::Lit(Value::Int64(0))),
608            )),
609            Box::new(CheckExpr::Lit(Value::Int64(1))),
610        );
611        assert!(divide_by_zero.satisfied(&m(&[(1, Value::Int64(10))])));
612
613        let overflow = CheckExpr::Gt(
614            Box::new(CheckExpr::Add(
615                Box::new(CheckExpr::Col(1)),
616                Box::new(CheckExpr::Lit(Value::Int64(1))),
617            )),
618            Box::new(CheckExpr::Lit(Value::Int64(0))),
619        );
620        assert!(overflow.satisfied(&m(&[(1, Value::Int64(i64::MAX))])));
621    }
622
623    #[test]
624    fn check_not_incomparable_is_unknown_passes() {
625        // comparing int to bytes is incomparable → unknown → satisfied
626        let e = CheckExpr::Eq(
627            Box::new(CheckExpr::Col(1)),
628            Box::new(CheckExpr::Lit(Value::Bytes(b"x".to_vec()))),
629        );
630        assert!(e.satisfied(&m(&[(1, Value::Int64(5))])));
631    }
632
633    #[test]
634    fn encode_composite_key_skips_null() {
635        let k = encode_composite_key(&[1, 2], &m(&[(1, Value::Int64(5)), (2, Value::Null)]));
636        assert!(k.is_none());
637        let k = encode_composite_key(&[1, 2], &m(&[(1, Value::Int64(5)), (2, Value::Int64(7))]));
638        assert!(k.is_some());
639        // distinct values → distinct keys
640        let k2 = encode_composite_key(&[1, 2], &m(&[(1, Value::Int64(6)), (2, Value::Int64(7))]));
641        assert_ne!(k.unwrap(), k2.unwrap());
642    }
643
644    fn regex_expr(col: u16, pattern: &str, negated: bool, ci: bool) -> CheckExpr {
645        CheckExpr::Regex {
646            col,
647            pattern: pattern.to_string(),
648            negated,
649            case_insensitive: ci,
650            cached: std::sync::OnceLock::new(),
651        }
652    }
653
654    #[test]
655    fn check_regex_match() {
656        let e = regex_expr(1, r"^a\w+z$", false, false);
657        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"abz".to_vec()))])));
658        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"alfredz".to_vec()))])));
659        assert!(!e.satisfied(&m(&[(1, Value::Bytes(b"abc".to_vec()))])));
660    }
661
662    #[test]
663    fn check_regex_null_is_unknown() {
664        let e = regex_expr(1, r"^\d+$", false, false);
665        assert!(e.satisfied(&m(&[(1, Value::Null)])));
666        assert!(e.satisfied(&m(&[]))); // column absent
667    }
668
669    #[test]
670    fn check_regex_negated() {
671        let e = regex_expr(1, r"^\d+$", true, false);
672        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"abc".to_vec()))])));
673        assert!(!e.satisfied(&m(&[(1, Value::Bytes(b"123".to_vec()))])));
674    }
675
676    #[test]
677    fn check_regex_case_insensitive() {
678        let e = regex_expr(1, r"^hello$", false, true);
679        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"HELLO".to_vec()))])));
680        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"Hello".to_vec()))])));
681    }
682
683    #[test]
684    fn check_regex_non_bytes_is_unknown() {
685        let e = regex_expr(1, r"\d+", false, false);
686        // Int64 is not regex-matchable → Unknown → satisfied
687        assert!(e.satisfied(&m(&[(1, Value::Int64(42))])));
688    }
689
690    #[test]
691    fn check_regex_validate_rejects_invalid_pattern() {
692        assert!(regex_expr(1, "[", false, false).validate().is_err());
693        assert!(regex_expr(1, r"^\d+$", false, false).validate().is_ok());
694    }
695
696    #[test]
697    fn check_regex_partial_eq_ignores_cache() {
698        let a = regex_expr(1, r"^\d+$", false, false);
699        let b = regex_expr(1, r"^\d+$", false, false);
700        assert_eq!(a, b);
701        // Populate cache on one — equality still holds because cached is ignored.
702        a.satisfied(&m(&[(1, Value::Bytes(b"1".to_vec()))]));
703        assert_eq!(a, b);
704    }
705
706    #[test]
707    fn check_regex_serde_roundtrip() {
708        let e = regex_expr(1, r"^\w+@[\w.]+$", true, true);
709        let json = serde_json::to_string(&e).unwrap();
710        // Ensure the `cached` OnceLock field is NOT serialized.
711        assert!(!json.contains("cached"));
712        let de: CheckExpr = serde_json::from_str(&json).unwrap();
713        assert_eq!(e, de);
714        // Deserialized regex still evaluates correctly.
715        assert!(de.satisfied(&m(&[(1, Value::Bytes(b"not-an-email".to_vec()))])));
716        assert!(!de.satisfied(&m(&[(1, Value::Bytes(b"a@b.com".to_vec()))])));
717    }
718
719    #[test]
720    fn check_regex_inside_logical_ops() {
721        // col 1 matches digits AND col 2 is not null
722        let e = CheckExpr::And(
723            Box::new(regex_expr(1, r"^\d+$", false, false)),
724            Box::new(CheckExpr::IsNotNull(2)),
725        );
726        assert!(e.satisfied(&m(&[
727            (1, Value::Bytes(b"42".to_vec())),
728            (2, Value::Int64(1))
729        ])));
730        assert!(!e.satisfied(&m(&[(1, Value::Bytes(b"42".to_vec())), (2, Value::Null)])));
731        assert!(!e.satisfied(&m(&[
732            (1, Value::Bytes(b"abc".to_vec())),
733            (2, Value::Int64(1))
734        ])));
735    }
736}