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::Interval { .. } => Tri::Unknown,
410            Value::Uuid(_) | Value::Json(_) => Tri::Unknown,
411            Value::Decimal(d) => {
412                if *d != 0 {
413                    Tri::True
414                } else {
415                    Tri::False
416                }
417            }
418        }
419    }
420}
421
422fn and3(a: Tri, b: Tri) -> Tri {
423    match (a, b) {
424        (Tri::False, _) | (_, Tri::False) => Tri::False,
425        (Tri::Unknown, _) | (_, Tri::Unknown) => Tri::Unknown,
426        _ => Tri::True,
427    }
428}
429
430fn or3(a: Tri, b: Tri) -> Tri {
431    match (a, b) {
432        (Tri::True, _) | (_, Tri::True) => Tri::True,
433        (Tri::Unknown, _) | (_, Tri::Unknown) => Tri::Unknown,
434        _ => Tri::False,
435    }
436}
437
438fn not3(a: Tri) -> Tri {
439    match a {
440        Tri::True => Tri::False,
441        Tri::False => Tri::True,
442        Tri::Unknown => Tri::Unknown,
443    }
444}
445
446/// Run a comparison from two values. If either term is `Null` the comparison is
447/// `Unknown`; otherwise apply `pred` to the concrete ordering (incomparable
448/// types also yield `Unknown`).
449fn compare(a: Value, b: Value, pred: impl Fn(Ordering) -> bool) -> Tri {
450    if matches!(a, Value::Null) || matches!(b, Value::Null) {
451        return Tri::Unknown;
452    }
453    match value_cmp(&a, &b) {
454        Some(o) => {
455            if pred(o) {
456                Tri::True
457            } else {
458                Tri::False
459            }
460        }
461        None => Tri::Unknown,
462    }
463}
464
465/// Cross-type comparison for values. Same-shape values compare naturally;
466/// Int64/Float64 cross-compare numerically; Bytes lexicographically; Bool as
467/// 0/1; other distinct-type pairs are `None` (incomparable).
468pub(crate) fn value_cmp(a: &Value, b: &Value) -> Option<Ordering> {
469    match (a, b) {
470        (Value::Null, Value::Null) => Some(Ordering::Equal),
471        (Value::Bool(x), Value::Bool(y)) => Some((*x as u8).cmp(&(*y as u8))),
472        (Value::Int64(x), Value::Int64(y)) => Some(x.cmp(y)),
473        (Value::Float64(x), Value::Float64(y)) => x.partial_cmp(y),
474        (Value::Int64(x), Value::Float64(y)) => (*x as f64).partial_cmp(y),
475        (Value::Float64(x), Value::Int64(y)) => x.partial_cmp(&(*y as f64)),
476        (Value::Bytes(x), Value::Bytes(y)) => Some(x.cmp(y)),
477        (Value::Embedding(x), Value::Embedding(y)) => {
478            for (a, b) in x.iter().zip(y.iter()) {
479                match a.partial_cmp(b)? {
480                    Ordering::Equal => continue,
481                    non_eq => return Some(non_eq),
482                }
483            }
484            Some(x.len().cmp(&y.len()))
485        }
486        _ => None,
487    }
488}
489
490/// Encode a set of column values into a stable, unambiguous composite key for
491/// uniqueness / FK matching. Returns `None` if any referenced column is missing
492/// or `Null` — SQL semantics: a UNIQUE constraint ignores rows where any
493/// constrained column is NULL, and an FK with a NULL component is not checked.
494pub(crate) fn encode_composite_key(
495    columns: &[u16],
496    cells: &HashMap<u16, Value>,
497) -> Option<Vec<u8>> {
498    let mut out = Vec::new();
499    for cid in columns {
500        let v = cells.get(cid)?;
501        if matches!(v, Value::Null) {
502            return None;
503        }
504        let k = v.encode_key();
505        // Length-prefix so concatenated multi-column keys stay unambiguous.
506        out.extend_from_slice(&(k.len() as u32).to_be_bytes());
507        out.extend_from_slice(&k);
508    }
509    Some(out)
510}
511
512/// Validate CHECK constraints for a single staged row.
513pub(crate) fn validate_checks(
514    checks: &[CheckConstraint],
515    cells: &HashMap<u16, Value>,
516) -> Result<()> {
517    for c in checks {
518        if !c.expr.satisfied(cells) {
519            return Err(MongrelError::InvalidArgument(format!(
520                "CHECK constraint '{}' failed",
521                c.name
522            )));
523        }
524    }
525    Ok(())
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    fn m(cols: &[(u16, Value)]) -> HashMap<u16, Value> {
533        cols.iter().cloned().collect()
534    }
535
536    #[test]
537    fn check_eq_literal() {
538        // col 1 == 5
539        let e = CheckExpr::Eq(
540            Box::new(CheckExpr::Col(1)),
541            Box::new(CheckExpr::Lit(Value::Int64(5))),
542        );
543        assert!(e.satisfied(&m(&[(1, Value::Int64(5))])));
544        assert!(!e.satisfied(&m(&[(1, Value::Int64(6))])));
545        // null comparison → unknown → satisfied (CHECK passes on unknown)
546        assert!(e.satisfied(&m(&[(1, Value::Null)])));
547    }
548
549    #[test]
550    fn check_range_and() {
551        // 0 <= col1 <= 100
552        let e = CheckExpr::And(
553            Box::new(CheckExpr::Ge(
554                Box::new(CheckExpr::Col(1)),
555                Box::new(CheckExpr::Lit(Value::Int64(0))),
556            )),
557            Box::new(CheckExpr::Le(
558                Box::new(CheckExpr::Col(1)),
559                Box::new(CheckExpr::Lit(Value::Int64(100))),
560            )),
561        );
562        assert!(e.satisfied(&m(&[(1, Value::Int64(50))])));
563        assert!(!e.satisfied(&m(&[(1, Value::Int64(101))])));
564        assert!(!e.satisfied(&m(&[(1, Value::Int64(-1))])));
565    }
566
567    #[test]
568    fn check_numeric_cross_type() {
569        let e = CheckExpr::Lt(
570            Box::new(CheckExpr::Col(1)),
571            Box::new(CheckExpr::Lit(Value::Float64(10.0))),
572        );
573        assert!(e.satisfied(&m(&[(1, Value::Int64(5))])));
574        assert!(!e.satisfied(&m(&[(1, Value::Int64(20))])));
575    }
576
577    #[test]
578    fn check_arithmetic_terms() {
579        let e = CheckExpr::Le(
580            Box::new(CheckExpr::Mul(
581                Box::new(CheckExpr::Add(
582                    Box::new(CheckExpr::Col(1)),
583                    Box::new(CheckExpr::Lit(Value::Int64(2))),
584                )),
585                Box::new(CheckExpr::Col(2)),
586            )),
587            Box::new(CheckExpr::Lit(Value::Int64(20))),
588        );
589        assert!(e.satisfied(&m(&[(1, Value::Int64(3)), (2, Value::Int64(4))])));
590        assert!(!e.satisfied(&m(&[(1, Value::Int64(4)), (2, Value::Int64(4))])));
591    }
592
593    #[test]
594    fn check_arithmetic_error_is_unknown() {
595        let divide_by_zero = CheckExpr::Eq(
596            Box::new(CheckExpr::Div(
597                Box::new(CheckExpr::Col(1)),
598                Box::new(CheckExpr::Lit(Value::Int64(0))),
599            )),
600            Box::new(CheckExpr::Lit(Value::Int64(1))),
601        );
602        assert!(divide_by_zero.satisfied(&m(&[(1, Value::Int64(10))])));
603
604        let overflow = CheckExpr::Gt(
605            Box::new(CheckExpr::Add(
606                Box::new(CheckExpr::Col(1)),
607                Box::new(CheckExpr::Lit(Value::Int64(1))),
608            )),
609            Box::new(CheckExpr::Lit(Value::Int64(0))),
610        );
611        assert!(overflow.satisfied(&m(&[(1, Value::Int64(i64::MAX))])));
612    }
613
614    #[test]
615    fn check_not_incomparable_is_unknown_passes() {
616        // comparing int to bytes is incomparable → unknown → satisfied
617        let e = CheckExpr::Eq(
618            Box::new(CheckExpr::Col(1)),
619            Box::new(CheckExpr::Lit(Value::Bytes(b"x".to_vec()))),
620        );
621        assert!(e.satisfied(&m(&[(1, Value::Int64(5))])));
622    }
623
624    #[test]
625    fn encode_composite_key_skips_null() {
626        let k = encode_composite_key(&[1, 2], &m(&[(1, Value::Int64(5)), (2, Value::Null)]));
627        assert!(k.is_none());
628        let k = encode_composite_key(&[1, 2], &m(&[(1, Value::Int64(5)), (2, Value::Int64(7))]));
629        assert!(k.is_some());
630        // distinct values → distinct keys
631        let k2 = encode_composite_key(&[1, 2], &m(&[(1, Value::Int64(6)), (2, Value::Int64(7))]));
632        assert_ne!(k.unwrap(), k2.unwrap());
633    }
634
635    fn regex_expr(col: u16, pattern: &str, negated: bool, ci: bool) -> CheckExpr {
636        CheckExpr::Regex {
637            col,
638            pattern: pattern.to_string(),
639            negated,
640            case_insensitive: ci,
641            cached: std::sync::OnceLock::new(),
642        }
643    }
644
645    #[test]
646    fn check_regex_match() {
647        let e = regex_expr(1, r"^a\w+z$", false, false);
648        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"abz".to_vec()))])));
649        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"alfredz".to_vec()))])));
650        assert!(!e.satisfied(&m(&[(1, Value::Bytes(b"abc".to_vec()))])));
651    }
652
653    #[test]
654    fn check_regex_null_is_unknown() {
655        let e = regex_expr(1, r"^\d+$", false, false);
656        assert!(e.satisfied(&m(&[(1, Value::Null)])));
657        assert!(e.satisfied(&m(&[]))); // column absent
658    }
659
660    #[test]
661    fn check_regex_negated() {
662        let e = regex_expr(1, r"^\d+$", true, false);
663        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"abc".to_vec()))])));
664        assert!(!e.satisfied(&m(&[(1, Value::Bytes(b"123".to_vec()))])));
665    }
666
667    #[test]
668    fn check_regex_case_insensitive() {
669        let e = regex_expr(1, r"^hello$", false, true);
670        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"HELLO".to_vec()))])));
671        assert!(e.satisfied(&m(&[(1, Value::Bytes(b"Hello".to_vec()))])));
672    }
673
674    #[test]
675    fn check_regex_non_bytes_is_unknown() {
676        let e = regex_expr(1, r"\d+", false, false);
677        // Int64 is not regex-matchable → Unknown → satisfied
678        assert!(e.satisfied(&m(&[(1, Value::Int64(42))])));
679    }
680
681    #[test]
682    fn check_regex_validate_rejects_invalid_pattern() {
683        assert!(regex_expr(1, "[", false, false).validate().is_err());
684        assert!(regex_expr(1, r"^\d+$", false, false).validate().is_ok());
685    }
686
687    #[test]
688    fn check_regex_partial_eq_ignores_cache() {
689        let a = regex_expr(1, r"^\d+$", false, false);
690        let b = regex_expr(1, r"^\d+$", false, false);
691        assert_eq!(a, b);
692        // Populate cache on one — equality still holds because cached is ignored.
693        a.satisfied(&m(&[(1, Value::Bytes(b"1".to_vec()))]));
694        assert_eq!(a, b);
695    }
696
697    #[test]
698    fn check_regex_serde_roundtrip() {
699        let e = regex_expr(1, r"^\w+@[\w.]+$", true, true);
700        let json = serde_json::to_string(&e).unwrap();
701        // Ensure the `cached` OnceLock field is NOT serialized.
702        assert!(!json.contains("cached"));
703        let de: CheckExpr = serde_json::from_str(&json).unwrap();
704        assert_eq!(e, de);
705        // Deserialized regex still evaluates correctly.
706        assert!(de.satisfied(&m(&[(1, Value::Bytes(b"not-an-email".to_vec()))])));
707        assert!(!de.satisfied(&m(&[(1, Value::Bytes(b"a@b.com".to_vec()))])));
708    }
709
710    #[test]
711    fn check_regex_inside_logical_ops() {
712        // col 1 matches digits AND col 2 is not null
713        let e = CheckExpr::And(
714            Box::new(regex_expr(1, r"^\d+$", false, false)),
715            Box::new(CheckExpr::IsNotNull(2)),
716        );
717        assert!(e.satisfied(&m(&[
718            (1, Value::Bytes(b"42".to_vec())),
719            (2, Value::Int64(1))
720        ])));
721        assert!(!e.satisfied(&m(&[(1, Value::Bytes(b"42".to_vec())), (2, Value::Null)])));
722        assert!(!e.satisfied(&m(&[
723            (1, Value::Bytes(b"abc".to_vec())),
724            (2, Value::Int64(1))
725        ])));
726    }
727}