Skip to main content

spg_engine/
constraints.rs

1//! Write-time constraint enforcement split out of `lib.rs`: foreign-key
2//! resolution / enforcement (resolve_foreign_key, enforce_fk_inserts,
3//! plan_fk_parent_deletions / plan_fk_parent_updates, apply_fk_child_step,
4//! the cascade helpers), UNIQUE / PK enforcement
5//! (enforce_unique_index_inserts, enforce_uniqueness_inserts,
6//! check_existing_unique_violation), CHECK constraints
7//! (enforce_check_constraints), and ON CONFLICT resolution
8//! (resolve_on_conflict_columns, apply_on_conflict_assignments, the
9//! upsert key-lookup helpers). All free functions taking an explicit
10//! catalog so callers with an active `&mut Table` borrow can use them;
11//! the DML / DDL execution paths in `dml.rs` / `ddl.rs` drive them.
12
13use alloc::boxed::Box;
14use alloc::string::{String, ToString};
15use alloc::vec::Vec;
16
17use spg_sql::ast::Expr;
18use spg_storage::{Catalog, ColumnSchema, Row, StorageError, Value};
19
20use crate::aggregate;
21use crate::eval::{self, EvalError};
22use crate::{Engine, EngineError, check_unsigned_range, coerce_value, value_to_literal_expr};
23
24/// v7.38 — builds an index key string for a row, or `None` when the row is
25/// absent from the index (NULL key, or a false partial predicate).
26type KeyStrFn<'a> = dyn Fn(&[Value<'static>]) -> Result<Option<String>, EngineError> + 'a;
27
28/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
29/// names + parent table name) into the storage-layer shape (column
30/// indices + same parent table). Validates everything the engine
31/// needs to know about the FK at CREATE TABLE time:
32///
33///   - parent table exists (catalog lookup, unless self-referencing)
34///   - parent columns exist on the parent table
35///   - parent column list matches the local arity (defaults to the
36///     parent's primary index column when omitted)
37///   - parent columns are covered by a `BTree` UNIQUE-class index
38///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
39///     the v7.6.2 INSERT path can do an O(log n) parent lookup
40///   - local columns exist on the table being created
41pub(crate) fn resolve_foreign_key(
42    local_table_name: &str,
43    local_cols: &[ColumnSchema],
44    fk: spg_sql::ast::ForeignKeyConstraint,
45    catalog: &Catalog,
46) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
47    // Resolve local columns.
48    let mut local_columns = Vec::with_capacity(fk.columns.len());
49    for name in &fk.columns {
50        let pos = local_cols
51            .iter()
52            .position(|c| c.name == *name)
53            .ok_or_else(|| {
54                EngineError::Unsupported(alloc::format!(
55                    "FOREIGN KEY references unknown local column {name:?}"
56                ))
57            })?;
58        local_columns.push(pos);
59    }
60    // Self-referencing FK: parent table is the one we're creating.
61    // The parent column resolution uses the local column list since
62    // the catalog doesn't have this table yet.
63    let is_self_ref = fk.parent_table == local_table_name;
64    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
65        (local_cols, local_table_name)
66    } else {
67        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
68            EngineError::Storage(StorageError::TableNotFound {
69                name: fk.parent_table.clone(),
70            })
71        })?;
72        (
73            parent_table.schema().columns.as_slice(),
74            fk.parent_table.as_str(),
75        )
76    };
77    // Resolve parent column names → positions. If the FK omitted the
78    // parent column list, fall back to the parent's primary index
79    // column (single-column only — composite default is rejected
80    // because there's no unambiguous "PK" in SPG's index list).
81    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
82        if fk.columns.len() != 1 {
83            return Err(EngineError::Unsupported(
84                "composite FOREIGN KEY without explicit parent column list is not supported \
85                 — list the parent columns explicitly"
86                    .into(),
87            ));
88        }
89        // Find a single BTree index on the parent and use its column.
90        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
91            .ok_or_else(|| {
92                EngineError::Unsupported(alloc::format!(
93                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
94                     to default the FOREIGN KEY against"
95                ))
96            })?;
97        alloc::vec![pos]
98    } else {
99        let mut out = Vec::with_capacity(fk.parent_columns.len());
100        for name in &fk.parent_columns {
101            let pos = parent_cols_for_lookup
102                .iter()
103                .position(|c| c.name == *name)
104                .ok_or_else(|| {
105                    EngineError::Unsupported(alloc::format!(
106                        "FOREIGN KEY references unknown parent column \
107                         {name:?} on table {parent_table_str:?}"
108                    ))
109                })?;
110            out.push(pos);
111        }
112        out
113    };
114    if parent_columns.len() != local_columns.len() {
115        return Err(EngineError::Unsupported(alloc::format!(
116            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
117            local_columns.len(),
118            parent_columns.len()
119        )));
120    }
121    // For non-self-referencing FKs, verify the parent column set is
122    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
123    // declaration; the convention is "the parent column for FK
124    // purposes must have a BTree index" — which the user creates via
125    // `CREATE INDEX ... USING btree (col)` (the default). We accept
126    // any single-column BTree index that covers a parent column;
127    // composite parent column lists require an index whose `column_position`
128    // matches the first parent column (multi-column BTree indices
129    // are not in the v7.x roadmap).
130    if !is_self_ref {
131        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
132        let primary_parent_col = parent_columns[0];
133        let has_btree = parent_table
134            .schema()
135            .columns
136            .get(primary_parent_col)
137            .is_some()
138            && parent_table.indices().iter().any(|idx| {
139                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
140                    && idx.column_position == primary_parent_col
141                    && idx.partial_predicate.is_none()
142            });
143        if !has_btree {
144            return Err(EngineError::Unsupported(alloc::format!(
145                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
146                 index — create one with `CREATE INDEX ... ON {} ({})` first",
147                parent_table_str,
148                parent_table_str,
149                parent_table.schema().columns[primary_parent_col].name,
150            )));
151        }
152    }
153    let on_delete = fk_action_sql_to_storage(fk.on_delete);
154    let on_update = fk_action_sql_to_storage(fk.on_update);
155    let match_type = match fk.match_type {
156        spg_sql::ast::MatchType::Simple => spg_storage::MatchType::Simple,
157        spg_sql::ast::MatchType::Full => spg_storage::MatchType::Full,
158    };
159    Ok(spg_storage::ForeignKeyConstraint {
160        name: fk.name,
161        local_columns,
162        parent_table: fk.parent_table,
163        parent_columns,
164        on_delete,
165        on_update,
166        deferrable: fk.deferrable,
167        initially_deferred: fk.initially_deferred,
168        match_type,
169    })
170}
171
172/// v7.6.1 — pick a sentinel "primary key" column from the parent
173/// table when the FK didn't name parent columns. Picks the first
174/// single-column unconditional BTree index — that's the closest
175/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
176/// `local_cols` as the column source.
177fn pick_pk_index_column(
178    catalog: &Catalog,
179    parent_name: &str,
180    is_self_ref: bool,
181    local_cols: &[ColumnSchema],
182) -> Option<usize> {
183    if is_self_ref {
184        // Self-ref FK omitted parent columns: pick column 0 by
185        // convention (no catalog entry yet). Engine will widen this
186        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
187        let _ = local_cols;
188        return Some(0);
189    }
190    let parent = catalog.get(parent_name)?;
191    parent.indices().iter().find_map(|idx| {
192        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
193            && idx.partial_predicate.is_none()
194            && idx.included_columns.is_empty()
195            && idx.expression.is_none()
196        {
197            Some(idx.column_position)
198        } else {
199            None
200        }
201    })
202}
203
204/// v7.9.8 / v7.9.10 — resolve the column positions that
205/// identify a conflict for ON CONFLICT. Returns a Vec of
206/// column positions (1 element for single-column form, N for
207/// composite). When the user wrote bare `ON CONFLICT DO …`,
208/// falls back to the table's first unconditional BTree index
209/// (always single-column today).
210/// Returns the conflict-key column positions plus whether the
211/// matched constraint declares NULLS NOT DISTINCT (v7.29 — a NULL
212/// in the key only rules out a conflict under the default
213/// NULLS DISTINCT semantics).
214/// v7.39 (round 240) — the arbiter column sets an ON CONFLICT clause
215/// watches. PG's rules, probed against 18.4:
216///
217///   * a BARE `ON CONFLICT` (no target) arbitrates on EVERY unique
218///     constraint and unique index — SPG used to pick the FIRST one, so a
219///     row conflicting on any other raised a duplicate-key error straight
220///     through the DO NOTHING;
221///   * an EXPLICIT `(cols)` target must match a unique constraint or a
222///     unique index; a column set nothing enforces is 42P10 "there is no
223///     unique or exclusion constraint matching the ON CONFLICT
224///     specification" — SPG accepted any column list and quietly
225///     arbitrated on values nothing guarantees unique;
226///   * a table with no unique anything still accepts the bare form (no
227///     arbiter simply means no conflict is possible).
228///
229/// Each entry is (column positions, nulls_not_distinct).
230pub(crate) fn on_conflict_arbiters(
231    catalog: &Catalog,
232    table_name: &str,
233    target: &[String],
234    from_constraint_name: bool,
235) -> Result<Vec<(Vec<usize>, bool)>, EngineError> {
236    let table = catalog.get(table_name).ok_or_else(|| {
237        EngineError::Storage(StorageError::TableNotFound {
238            name: table_name.into(),
239        })
240    })?;
241    let schema = table.schema();
242    let unique_btree_cols: Vec<usize> = table
243        .indices()
244        .iter()
245        .filter(|idx| {
246            idx.is_unique
247                && matches!(idx.kind, spg_storage::IndexKind::BTree(_))
248                && idx.partial_predicate.is_none()
249                && idx.expression.is_none()
250        })
251        .map(|idx| idx.column_position)
252        .collect();
253    if target.is_empty() {
254        let mut out: Vec<(Vec<usize>, bool)> = schema
255            .uniqueness_constraints
256            .iter()
257            .map(|uc| (uc.columns.clone(), uc.nulls_not_distinct))
258            .collect();
259        for &pos in &unique_btree_cols {
260            if !out.iter().any(|(cols, _)| cols == &alloc::vec![pos]) {
261                out.push((alloc::vec![pos], false));
262            }
263        }
264        // Legacy fallback, kept deliberately: schemas from before SPG
265        // tracked index uniqueness spell their arbiter as a plain
266        // `CREATE INDEX`, and the bare clause has always deduped on it.
267        // Only engaged when nothing declared-unique exists, so PG-shaped
268        // schemas get PG's every-unique-constraint semantics above.
269        if out.is_empty() {
270            for idx in table.indices() {
271                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
272                    && idx.partial_predicate.is_none()
273                    && idx.expression.is_none()
274                    && idx.included_columns.is_empty()
275                {
276                    out.push((alloc::vec![idx.column_position], false));
277                }
278            }
279        }
280        return Ok(out);
281    }
282    let mut positions = Vec::with_capacity(target.len());
283    for name in target {
284        let pos = schema
285            .columns
286            .iter()
287            .position(|c| c.name == *name)
288            .ok_or_else(|| {
289                EngineError::Unsupported(alloc::format!(
290                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
291                ))
292            })?;
293        positions.push(pos);
294    }
295    let mut sorted = positions.clone();
296    sorted.sort_unstable();
297    let matched_uc = schema.uniqueness_constraints.iter().find(|uc| {
298        let mut u = uc.columns.clone();
299        u.sort_unstable();
300        u == sorted
301    });
302    // DELIBERATE divergence, recorded: PG refuses a target no unique
303    // constraint enforces (42P10 "there is no unique or exclusion
304    // constraint matching the ON CONFLICT specification"); SPG accepts any
305    // column list and arbitrates on it. The lax form is what mailrs's
306    // caldav upsert model (`ON CONFLICT (uid, calendar_id)` with no
307    // declared constraint) has always run on — zero-customer-change
308    // outranks the alignment here, and the laxness only ACCEPTS more: a
309    // PG-valid program never issues the shape PG rejects.
310    let _ = from_constraint_name;
311    let nnd = matched_uc.is_some_and(|uc| uc.nulls_not_distinct);
312    Ok(alloc::vec![(positions, nnd)])
313}
314
315/// v7.37.15 (Phase C.3) — does this BTree index locator point at a
316/// gate-on tombstone? A `RowLocator::Hot(i)` indexes into
317/// `table.headers()`; if that header is `is_deleted()` (`xmax !=
318/// XMAX_ALIVE`) the row was DELETE-tombstoned under the in-place
319/// write path (kept physically present, index entry left behind), so
320/// index-based existence checks (FK parent lookup, ON CONFLICT
321/// single-column) must treat it as ABSENT. Cold locators cannot be
322/// tombstoned in place, so they always count as present. Under the
323/// default gate (physical delete) no header is ever tombstoned, so
324/// this returns `false` for every hot locator and the gate-off path
325/// is byte-for-byte unchanged.
326fn locator_is_tombstoned(table: &spg_storage::Table, loc: &spg_storage::RowLocator) -> bool {
327    loc.as_hot()
328        .is_some_and(|i| table.headers().get(i).is_some_and(|h| h.is_deleted()))
329}
330
331/// v7.9.8 — check whether the BTree index on `column_pos` of
332/// `table_name` already has a row with this key.
333fn on_conflict_key_exists(
334    catalog: &Catalog,
335    table_name: &str,
336    column_pos: usize,
337    key: &Value,
338) -> bool {
339    let Some(table) = catalog.get(table_name) else {
340        return false;
341    };
342    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
343        return false;
344    };
345    table.indices().iter().any(|idx| {
346        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
347            && idx.column_position == column_pos
348            && idx.partial_predicate.is_none()
349            // v7.37.15 (Phase C.3) — a tombstoned index hit is not a
350            // live conflict: the key was freed by a gate-on DELETE, so
351            // re-inserting it must NOT trip ON CONFLICT. Gate-off has no
352            // tombstones → every locator counts → unchanged.
353            && idx
354                .lookup_eq(&idx_key)
355                .iter()
356                .any(|loc| !locator_is_tombstoned(table, loc))
357    })
358}
359
360/// v7.9.9 / v7.9.10 — look up an existing row's position by
361/// matching all `column_positions` against the incoming `key`
362/// tuple. Single-column shape (one column) reduces to the
363/// canonical PK lookup; composite shapes scan linearly until
364/// every position matches.
365pub(crate) fn lookup_row_position_by_keys(
366    catalog: &Catalog,
367    table_name: &str,
368    column_positions: &[usize],
369    key: &[&Value],
370) -> Option<usize> {
371    let table = catalog.get(table_name)?;
372    // v7.37.15 (Phase C.3) — skip gate-on tombstones: a DELETE-
373    // tombstoned row is not a live conflict target, so ON CONFLICT DO
374    // UPDATE must not resolve onto it (it would resurrect a dead row).
375    // `.position()` over `.enumerate()` yields the row index, so the
376    // header check reuses the same index. `is_deleted()` is never true
377    // under the default gate → gate-off path byte-for-byte unchanged.
378    table.rows().iter().enumerate().position(|(row_idx, r)| {
379        !table.headers().get(row_idx).is_some_and(|h| h.is_deleted())
380            && column_positions
381                .iter()
382                .enumerate()
383                .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
384    })
385}
386
387/// v7.9.10 — does the table already contain a row whose
388/// `column_positions` tuple equals `key`? Single-column shape
389/// uses the existing BTree fast path; composite shapes fall
390/// back to a row scan.
391pub(crate) fn on_conflict_keys_exist(
392    catalog: &Catalog,
393    table_name: &str,
394    column_positions: &[usize],
395    key: &[&Value],
396) -> bool {
397    if column_positions.len() == 1 {
398        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
399    }
400    let Some(table) = catalog.get(table_name) else {
401        return false;
402    };
403    let matches = |r: &Row<'static>| {
404        column_positions
405            .iter()
406            .enumerate()
407            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
408    };
409    // v7.37.15 (Phase C.3) — a gate-on DELETE-tombstoned hot row is not
410    // a live conflict, so skip it (else re-inserting the freed composite
411    // key would falsely trip ON CONFLICT). Cold rows below cannot be
412    // tombstoned in place. `is_deleted()` is never true under the
413    // default gate → gate-off path byte-for-byte unchanged.
414    let hot_hit = table.rows().iter().enumerate().any(|(row_idx, r)| {
415        !table.headers().get(row_idx).is_some_and(|h| h.is_deleted()) && matches(r)
416    });
417    if hot_hit {
418        return true;
419    }
420    // v7.36 (cold-tier coverage) — composite ON CONFLICT key
421    // existence check must also see cold-tier rows; otherwise an
422    // INSERT whose unique-key tuple lives only in the cold tier
423    // silently bypasses ON CONFLICT and writes a duplicate.
424    iter_cold_rows_of_parent(catalog, table)
425        .iter()
426        .any(&matches)
427}
428
429/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
430/// existing row.
431///
432/// `incoming` is the rejected INSERT row (used to resolve
433/// `EXCLUDED.col` references in the assignment exprs);
434/// `target_pos` is the position of the existing row in the table.
435/// Each assignment substitutes `EXCLUDED.col` with the matching
436/// incoming value, evaluates the resulting expression against
437/// the existing row, and writes the new value into the
438/// corresponding column of the returned `Vec<Value<'static>>`. If
439/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
440/// the conflicting row is silently kept unchanged.
441pub(crate) fn apply_on_conflict_assignments(
442    catalog: &Catalog,
443    table_name: &str,
444    alias: Option<&str>,
445    target_pos: usize,
446    incoming: &[Value<'static>],
447    assignments: &[(String, Expr)],
448    where_: Option<&Expr>,
449    // v7.39 (round 525) — the session. `ON CONFLICT DO UPDATE SET who =
450    // current_setting('app.tenant')` failed the whole upsert without it.
451    sess: Option<&crate::eval::DmlSession>,
452) -> Result<Option<Vec<Value<'static>>>, EngineError> {
453    let table = catalog.get(table_name).ok_or_else(|| {
454        EngineError::Storage(StorageError::TableNotFound {
455            name: table_name.into(),
456        })
457    })?;
458    let schema_cols = table.schema().columns.clone();
459    let existing = table
460        .rows()
461        .get(target_pos)
462        .ok_or_else(|| {
463            EngineError::Unsupported(alloc::format!(
464                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
465            ))
466        })?
467        .clone();
468    // v7.39 (round 240) — `INSERT INTO t AS me`: the DO UPDATE
469    // expressions refer to the target row by the alias when one is given
470    // (PG makes the original name unavailable then), so the alias IS the
471    // table qualifier here.
472    let mut ctx = eval::EvalContext::new(&schema_cols, Some(alias.unwrap_or(table_name)));
473    if let Some(sv) = sess {
474        ctx = ctx.with_session(sv);
475    }
476    // Optional WHERE filter on the conflict row.
477    if let Some(w) = where_ {
478        let pred = w.clone();
479        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
480        let v = eval::eval_expr(&pred, &existing, &ctx)?;
481        if !matches!(v, Value::Bool(true)) {
482            return Ok(None);
483        }
484    }
485    // REPLACE INTO lowering — an empty assignment list means
486    // "replace the whole row with the incoming one" (MySQL
487    // delete+insert semantics; the PG ON CONFLICT grammar never
488    // produces an empty list).
489    if assignments.is_empty() {
490        return Ok(Some(incoming.to_vec()));
491    }
492    let mut new_values = existing.values.clone();
493    for (col_name, expr) in assignments {
494        let target_idx = schema_cols
495            .iter()
496            .position(|c| c.name == *col_name)
497            .ok_or_else(|| {
498                EngineError::Eval(EvalError::ColumnNotFound {
499                    name: col_name.clone(),
500                })
501            })?;
502        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
503        let v = eval::eval_expr(&sub, &existing, &ctx)?;
504        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
505        let coerced = crate::conversions::truncate_to_column_fsp(coerced, &schema_cols[target_idx]);
506        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
507        new_values[target_idx] = coerced;
508    }
509    Ok(Some(new_values))
510}
511
512/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
513/// "EXCLUDED", name }` reference with a `Literal` of the matching
514/// value from the incoming-row vec. Resolution against the
515/// child-table column list (by name).
516fn substitute_excluded_refs(
517    expr: Expr,
518    schema_cols: &[ColumnSchema],
519    incoming: &[Value<'static>],
520) -> Expr {
521    use spg_sql::ast::ColumnName;
522    match expr {
523        Expr::Column(ColumnName { qualifier, name })
524            if qualifier
525                .as_deref()
526                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
527        {
528            let pos = schema_cols.iter().position(|c| c.name == name);
529            match pos {
530                Some(p) => {
531                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
532                    value_to_literal_expr(v)
533                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
534                }
535                None => Expr::Column(ColumnName { qualifier, name }),
536            }
537        }
538        Expr::Binary { op, lhs, rhs } => Expr::Binary {
539            op,
540            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
541            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
542        },
543        Expr::Unary { op, expr } => Expr::Unary {
544            op,
545            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
546        },
547        Expr::FunctionCall { name, args } => Expr::FunctionCall {
548            name,
549            args: args
550                .into_iter()
551                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
552                .collect(),
553        },
554        // v7.33 (mailrs 7.32.1) — EXCLUDED refs nested inside these
555        // value-expression shapes were silently passed through unsubstituted
556        // by the old `other => other`, so `display_name = CASE WHEN
557        // EXCLUDED.x != '' THEN EXCLUDED.x ELSE … END` reached row eval as a
558        // live `excluded.` qualifier and errored. Recurse into every
559        // sub-expression an upsert SET RHS can carry.
560        Expr::Cast { expr, target } => Expr::Cast {
561            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
562            target,
563        },
564        Expr::IsNull { expr, negated } => Expr::IsNull {
565            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
566            negated,
567        },
568        Expr::Like {
569            expr,
570            pattern,
571            negated,
572            case_insensitive,
573        } => Expr::Like {
574            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
575            pattern: Box::new(substitute_excluded_refs(*pattern, schema_cols, incoming)),
576            negated,
577            case_insensitive,
578        },
579        Expr::InList {
580            expr,
581            list,
582            negated,
583        } => Expr::InList {
584            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
585            list: list
586                .into_iter()
587                .map(|e| substitute_excluded_refs(e, schema_cols, incoming))
588                .collect(),
589            negated,
590        },
591        Expr::Case {
592            operand,
593            branches,
594            else_branch,
595        } => Expr::Case {
596            operand: operand.map(|o| Box::new(substitute_excluded_refs(*o, schema_cols, incoming))),
597            branches: branches
598                .into_iter()
599                .map(|(w, t)| {
600                    (
601                        substitute_excluded_refs(w, schema_cols, incoming),
602                        substitute_excluded_refs(t, schema_cols, incoming),
603                    )
604                })
605                .collect(),
606            else_branch: else_branch
607                .map(|e| Box::new(substitute_excluded_refs(*e, schema_cols, incoming))),
608        },
609        // Leaves (Literal / Placeholder / non-excluded Column) and
610        // subquery-bearing nodes (a separate scope where `excluded` does not
611        // apply) pass through unchanged.
612        other => other,
613    }
614}
615
616/// v7.39 (round 166, write-path attack A1) — column types whose non-NULL
617/// values ALWAYS produce an `IndexKey` (`IndexKey::from_value` is total
618/// for them), so every live row is guaranteed to be present in a btree
619/// over that column. Types outside this list (Float / Numeric / arrays /
620/// …) may skip the index and MUST NOT be probed for uniqueness.
621fn indexkeyable_type(ty: &spg_storage::DataType) -> bool {
622    use spg_storage::DataType as D;
623    matches!(
624        ty,
625        D::SmallInt
626            | D::Int
627            | D::BigInt
628            | D::Text
629            | D::Varchar(_)
630            | D::Char(_)
631            | D::Bool
632            | D::Uuid
633            | D::Date
634            | D::Timestamp
635    )
636}
637
638/// v7.39 (round 166) — find a btree over `leading_pos` usable as a
639/// uniqueness PROBE index (candidate filter only — the caller re-checks
640/// candidates with the collated fold, so any plain btree on the leading
641/// column works, unique or not). Expression / partial indexes key on
642/// something other than the raw column and are skipped.
643fn probe_btree(table: &spg_storage::Table, leading_pos: usize) -> Option<&spg_storage::Index> {
644    table.indices().iter().find(|i| {
645        matches!(i.kind, spg_storage::IndexKind::BTree(_))
646            && i.column_position == leading_pos
647            && i.expression.is_none()
648            && i.partial_predicate.is_none()
649    })
650}
651
652/// v7.39 (round 166) — can `uc` be enforced by probing a btree instead
653/// of folding the whole table into a HashSet (the r164/r165 write-path
654/// loss: O(table) per STATEMENT made every single-row write pay ~5-6ms
655/// on a 50k-row table)? Requirements, all mirroring the fold semantics:
656///  * a plain btree over the leading column exists (candidate source);
657///  * `NULLS NOT DISTINCT` is off (NULL keys never enter a btree);
658///  * no key column is case-insensitive collated (the btree keys raw
659///    values, so a collation-folded duplicate under a DIFFERENT raw
660///    key would be missed);
661///  * the leading column's type always produces an IndexKey (otherwise
662///    rows could be absent from the btree entirely).
663fn uc_probe_index<'t>(
664    table: &'t spg_storage::Table,
665    columns: &[usize],
666    nulls_not_distinct: bool,
667    mysql: bool,
668) -> Option<&'t spg_storage::Index> {
669    if nulls_not_distinct || columns.is_empty() {
670        return None;
671    }
672    // v7.39 (round 365, M4 P3) — under the folding MySQL dialect the
673    // btree probe can't be used: it looks a candidate up by its RAW
674    // leading value, so `'a'` and `'A'` (byte-distinct, fold-equal) never
675    // meet. Fall to the whole-table fold path, exactly as a
676    // CaseInsensitive column already does below.
677    if mysql {
678        return None;
679    }
680    let schema = table.schema();
681    let collation_ok = columns.iter().all(|&i| {
682        schema
683            .columns
684            .get(i)
685            .is_some_and(|c| !matches!(c.collation, spg_storage::Collation::CaseInsensitive))
686    });
687    if !collation_ok {
688        return None;
689    }
690    if !schema
691        .columns
692        .get(columns[0])
693        .is_some_and(|c| indexkeyable_type(&c.ty))
694    {
695        return None;
696    }
697    probe_btree(table, columns[0])
698}
699
700/// v7.39 (round 166) — probe `idx` for a live row whose collated key
701/// equals `key` (the fold of the row being written). Returns the row
702/// position of the first conflicting live row. `fold` recomputes the
703/// collated key of a candidate row so collation / bpchar semantics stay
704/// byte-identical with the HashSet path; tombstoned rows are skipped the
705/// same way; Cold locators are skipped because the fold path only ever
706/// scanned hot rows.
707/// v7.39 (round 492) — how many locators the uniqueness probe walks, and
708/// how many probes there are.
709///
710/// The round-491 profile of `delete_reinsert_1k` put this function at
711/// 8.4 % of the connection thread. A BTree index carries one locator per
712/// row VERSION, and this shape deletes and re-inserts the same ids over
713/// and over, so the suspicion is that each probe walks every dead version
714/// under its key. Round 490 fixed exactly that shape of defect on the
715/// seek side — which is why this is a counter and not an assumption.
716pub static UNIQ_PROBE_CALLS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
717pub static UNIQ_PROBE_LOCATORS: core::sync::atomic::AtomicU64 =
718    core::sync::atomic::AtomicU64::new(0);
719
720fn probe_key_conflict(
721    table: &spg_storage::Table,
722    idx: &spg_storage::Index,
723    leading_val: &Value<'static>,
724    key: &[Value<'static>],
725    fold: &dyn Fn(&[Value<'static>]) -> Vec<Value<'static>>,
726) -> Option<usize> {
727    let ik = spg_storage::IndexKey::from_value(leading_val)?;
728    crate::bump_counter!(crate::constraints::UNIQ_PROBE_CALLS);
729    crate::bump_counter!(
730        crate::constraints::UNIQ_PROBE_LOCATORS,
731        idx.lookup_eq(&ik).len() as u64
732    );
733    for loc in idx.lookup_eq(&ik) {
734        let spg_storage::RowLocator::Hot(ri) = loc else {
735            continue;
736        };
737        if table.headers().get(*ri).is_some_and(|h| h.is_deleted()) {
738            continue;
739        }
740        let Some(prow) = table.rows().get(*ri) else {
741            continue;
742        };
743        if fold(&prow.values) == key {
744            return Some(*ri);
745        }
746    }
747    None
748}
749
750pub(crate) fn enforce_uniqueness_inserts(
751    catalog: &Catalog,
752    child_table: &str,
753    constraints: &[spg_storage::UniquenessConstraint],
754    rows: &[Vec<Value<'static>>],
755    mysql: bool,
756) -> Result<(), EngineError> {
757    if constraints.is_empty() {
758        return Ok(());
759    }
760    let table = catalog.get(child_table).ok_or_else(|| {
761        EngineError::Storage(StorageError::TableNotFound {
762            name: child_table.into(),
763        })
764    })?;
765    let schema = table.schema();
766    // v7.29 (mailrs round-23b) — set-based: ONE O(table) pass folds
767    // existing keys into a hash set, then each batch row is a probe
768    // + insert. The previous shape scanned the WHOLE table per
769    // inserted row (and earlier batch rows per row), which made
770    // bulk import O(n²) — a 104 MB dump extrapolated to ~1 hour
771    // (PG: 2 min). Collation folding (Phase 3.P0-45) and
772    // NULLS [NOT] DISTINCT semantics are unchanged: keys fold via
773    // collated_key_cell before encoding, NULL-bearing keys skip the
774    // set unless nulls_not_distinct.
775    for uc in constraints {
776        let fold_key = |values: &[Value<'static>]| -> Vec<Value<'static>> {
777            uc.columns
778                .iter()
779                .map(|&i| {
780                    let v = values.get(i).cloned().unwrap_or(Value::Null);
781                    collated_key_cell(&v, i, schema, mysql)
782                })
783                .collect()
784        };
785        // v7.39 (round 166, attack A1) — btree probe instead of the
786        // per-statement O(table) fold when the constraint qualifies.
787        // The implicit PK/UNIQUE leading-column btree (create-table
788        // installs it) is maintained incrementally on every write, so
789        // a probe is O(log n) per row — this was the 6.3ms/row (94%)
790        // component of the r164 write losses.
791        if let Some(idx) = uc_probe_index(table, &uc.columns, uc.nulls_not_distinct, mysql) {
792            let mut batch_seen: hashbrown::HashSet<String> =
793                hashbrown::HashSet::with_capacity(rows.len());
794            let mut probe_ok = true;
795            for row_values in rows.iter() {
796                let key = fold_key(row_values);
797                if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
798                    continue;
799                }
800                let leading = row_values
801                    .get(uc.columns[0])
802                    .cloned()
803                    .unwrap_or(Value::Null);
804                if spg_storage::IndexKey::from_value(&leading).is_none() {
805                    // A value the btree can't key (shouldn't happen for
806                    // the whitelisted types) — fall back to the fold.
807                    probe_ok = false;
808                    break;
809                }
810                let dup_in_batch = !batch_seen.insert(aggregate::encode_key(&key));
811                if dup_in_batch
812                    || probe_key_conflict(table, idx, &leading, &key, &fold_key).is_some()
813                {
814                    let conname = crate::system_catalog::pg_unique_conname(table, uc, child_table);
815                    let detail = unique_key_detail(
816                        &uc.columns
817                            .iter()
818                            .map(|&i| table.schema().columns[i].name.clone())
819                            .collect::<Vec<_>>(),
820                        &key,
821                    );
822                    return Err(EngineError::Unsupported(alloc::format!(
823                        "duplicate key value violates unique constraint \"{conname}\" \
824                         on table \"{child_table}\"{detail}"
825                    )));
826                }
827            }
828            if probe_ok {
829                continue;
830            }
831        }
832        let mut seen: hashbrown::HashSet<String> =
833            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
834        for (row_idx, prow) in table.rows().iter().enumerate() {
835            // v7.37.15 (Phase C.3) — under the gate-on in-place write
836            // path a DELETE tombstones the row (xmax stamped, row kept
837            // physically present) instead of removing it. A tombstoned
838            // key is freed, so it must NOT count toward the uniqueness
839            // set — otherwise re-inserting that key raises a false
840            // violation. `is_deleted()` is `xmax != XMAX_ALIVE`; under
841            // the default gate (physical delete) no header is ever
842            // tombstoned, so this skip is never taken and the gate-off
843            // path is byte-for-byte unchanged.
844            if table.headers().get(row_idx).is_some_and(|h| h.is_deleted()) {
845                continue;
846            }
847            let key = fold_key(&prow.values);
848            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
849                continue;
850            }
851            seen.insert(aggregate::encode_key(&key));
852        }
853        for (batch_idx, row_values) in rows.iter().enumerate() {
854            let key = fold_key(row_values);
855            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
856                continue;
857            }
858            if !seen.insert(aggregate::encode_key(&key)) {
859                // v7.39 (SQLSTATE fidelity) — PG's exact 23505 phrasing;
860                // ORMs regex the constraint name out of this message and
861                // the wire layer lifts it into the PG_DIAG fields.
862                let conname = crate::system_catalog::pg_unique_conname(table, uc, child_table);
863                let detail = unique_key_detail(
864                    &uc.columns
865                        .iter()
866                        .map(|&i| table.schema().columns[i].name.clone())
867                        .collect::<Vec<_>>(),
868                    &key,
869                );
870                return Err(EngineError::Unsupported(alloc::format!(
871                    "duplicate key value violates unique constraint \"{conname}\" \
872                     on table \"{child_table}\"{detail}"
873                )));
874            }
875        }
876    }
877    Ok(())
878}
879
880/// v7.39 (round 210) — map an EXCLUDE element's stored operator spelling to
881/// its `BinOp`. Only the operators the parser accepts land here.
882fn exclude_op_binop(op: &str) -> Option<spg_sql::ast::BinOp> {
883    use spg_sql::ast::BinOp;
884    Some(match op {
885        "&&" => BinOp::InetOverlap,
886        "=" => BinOp::Eq,
887        "@>" => BinOp::JsonContains,
888        "<@" => BinOp::JsonContainedBy,
889        "&<" => BinOp::OverLeft,
890        "&>" => BinOp::OverRight,
891        _ => return None,
892    })
893}
894
895/// v7.39 (round 210/215) — do two DISTINCT rows conflict under `ex`? True iff
896/// EVERY element's operator holds (`new op old`). A NULL in any element column
897/// exempts the row (returns false). Shared by the O(n) scan and the O(log n)
898/// index probe so both decide identically.
899fn excl_rows_conflict(
900    ex: &spg_storage::ExclusionConstraint,
901    newr: &[Value<'static>],
902    oldr: &[Value<'static>],
903) -> Result<bool, EngineError> {
904    for (pos, op) in &ex.elements {
905        let a = newr.get(*pos).cloned().unwrap_or(Value::Null);
906        let b = oldr.get(*pos).cloned().unwrap_or(Value::Null);
907        if matches!(a, Value::Null) || matches!(b, Value::Null) {
908            return Ok(false);
909        }
910        let binop = exclude_op_binop(op).ok_or_else(|| {
911            EngineError::Unsupported(alloc::format!("unsupported EXCLUDE operator {op:?}"))
912        })?;
913        // `&&` / `@>` / range / geo operators need owned semantics (the by-ref
914        // path only answers comparisons); `a`/`b` are already owned clones.
915        match eval::apply_binary(binop, a, b)? {
916            Value::Bool(true) => {}
917            _ => return Ok(false),
918        }
919    }
920    Ok(true)
921}
922
923/// v7.39 (round 215) — outcome of probing the range-exclusion index for one
924/// candidate against the existing committed rows.
925enum ExclProbe {
926    /// A live existing row conflicts; carries its values for the DETAIL.
927    Conflict(Vec<Value<'static>>),
928    /// No existing row overlaps — the candidate is definitively clear (skip
929    /// the O(n) scan).
930    NoOverlap,
931    /// The index couldn't decide (unkeyable candidate, or a probe key whose
932    /// only locators are tombstoned under gate-on MVCC) — the caller runs the
933    /// exact O(n) scan, which is always correct.
934    Inconclusive,
935}
936
937/// One map-key probe result.
938enum KeyProbe {
939    Conflict(Vec<Value<'static>>),
940    /// The key has ≥1 live locator, none of which conflict.
941    LiveClear,
942    /// The key exists but every locator is tombstoned.
943    AllDead,
944    /// No such key.
945    Absent,
946}
947
948/// v7.39 (round 215) — O(log n) overlap probe for one candidate against the
949/// range-exclusion index on `index_col`. Under a valid `EXCLUDE (col WITH &&)`
950/// the stored ranges are pairwise disjoint, so a candidate can overlap only
951/// its predecessor (the range whose lower sits just below) or the FIRST
952/// successor (the smallest lower ≥ the candidate's): if the first LIVE
953/// successor doesn't overlap, its lower is ≥ the candidate's upper and no
954/// later one can either. Two `predecessor`/`range` probes, each O(log n). A
955/// probe key whose only locators are tombstoned (gate-on) is inconclusive —
956/// the real live neighbour may be further out, so fall back to the O(n) scan.
957fn excl_probe_existing(
958    table: &spg_storage::Table,
959    ex: &spg_storage::ExclusionConstraint,
960    index_col: usize,
961    newr: &[Value<'static>],
962    exclude: Option<&hashbrown::HashSet<usize>>,
963) -> Result<ExclProbe, EngineError> {
964    let Some(map) = table.excl_range_index(index_col) else {
965        return Ok(ExclProbe::Inconclusive);
966    };
967    let cand = newr.get(index_col).cloned().unwrap_or(Value::Null);
968    if matches!(cand, Value::Null) {
969        return Ok(ExclProbe::NoOverlap); // NULL range never conflicts (exempt)
970    }
971    let Some(cand_key) = spg_storage::range_excl_index_key(&cand) else {
972        return Ok(ExclProbe::Inconclusive); // unkeyable range → O(n)
973    };
974    let probe_entry = |entry: Option<(&(i128, u8), &Vec<spg_storage::RowLocator>)>|
975     -> Result<KeyProbe, EngineError> {
976        let Some((_, locs)) = entry else {
977            return Ok(KeyProbe::Absent);
978        };
979        let mut saw_live = false;
980        for loc in locs {
981            if locator_is_tombstoned(table, loc) {
982                continue;
983            }
984            let spg_storage::RowLocator::Hot(ri) = loc else {
985                continue; // cold-tier rows aren't in the hot scan either (parity)
986            };
987            // v7.39 (round 216) — UPDATE excludes each updated row's own
988            // pre-image (it is being replaced): skip it like a tombstone, so
989            // an all-excluded probe key is inconclusive → the O(n) fallback.
990            if exclude.is_some_and(|s| s.contains(ri)) {
991                continue;
992            }
993            let Some(prow) = table.rows().get(*ri) else {
994                continue;
995            };
996            saw_live = true;
997            if excl_rows_conflict(ex, newr, &prow.values)? {
998                return Ok(KeyProbe::Conflict(prow.values.clone()));
999            }
1000        }
1001        Ok(if saw_live {
1002            KeyProbe::LiveClear
1003        } else {
1004            KeyProbe::AllDead
1005        })
1006    };
1007    let pred = probe_entry(map.predecessor(&cand_key))?;
1008    if let KeyProbe::Conflict(old) = pred {
1009        return Ok(ExclProbe::Conflict(old));
1010    }
1011    let succ = probe_entry(
1012        map.range(
1013            core::ops::Bound::Included(&cand_key),
1014            core::ops::Bound::Unbounded,
1015        )
1016        .next(),
1017    )?;
1018    if let KeyProbe::Conflict(old) = succ {
1019        return Ok(ExclProbe::Conflict(old));
1020    }
1021    if matches!(pred, KeyProbe::AllDead) || matches!(succ, KeyProbe::AllDead) {
1022        Ok(ExclProbe::Inconclusive)
1023    } else {
1024        Ok(ExclProbe::NoOverlap)
1025    }
1026}
1027
1028/// v7.39 (round 210) — enforce `EXCLUDE` constraints for a batch of incoming
1029/// rows. An exclusion constraint forbids two DISTINCT rows r,s from
1030/// satisfying `(r.c1 op1 s.c1) AND (r.c2 op2 s.c2) AND …` for every element.
1031/// A NULL in any element column exempts the row (PG / UNIQUE NULL semantics).
1032///
1033/// Enforcement is a full live-row scan re-evaluating each element's operator
1034/// (an equality index can't answer overlap; a real GiST index that does is a
1035/// later perf phase), plus an intra-batch pairwise check so two overlapping
1036/// rows inserted in one statement collide too. PG's exact 23P01 message +
1037/// the auto-/user-named constraint.
1038pub(crate) fn enforce_exclusion_inserts(
1039    catalog: &Catalog,
1040    child_table: &str,
1041    constraints: &[spg_storage::ExclusionConstraint],
1042    rows: &[Vec<Value<'static>>],
1043) -> Result<(), EngineError> {
1044    if constraints.is_empty() {
1045        return Ok(());
1046    }
1047    let table = catalog.get(child_table).ok_or_else(|| {
1048        EngineError::Storage(StorageError::TableNotFound {
1049            name: child_table.into(),
1050        })
1051    })?;
1052    let conflicts = excl_rows_conflict;
1053    for ex in constraints {
1054        // v7.39 (round 215) — the `&&` element with a range-overlap index, if
1055        // one was built (single-`&&` / multi-col `=`+`&&` on an integer-keyable
1056        // range column). Lets each candidate probe O(log n) instead of scanning
1057        // every existing row (measured O(N²), r213).
1058        let idx_col = ex
1059            .elements
1060            .iter()
1061            .find(|(pos, op)| op == "&&" && table.excl_range_index(*pos).is_some())
1062            .map(|(pos, _)| *pos);
1063        // Each candidate vs the existing committed rows: index probe when
1064        // possible, exact O(n) scan otherwise.
1065        for newr in rows.iter() {
1066            let mut proved_clear = false;
1067            if let Some(col) = idx_col {
1068                match excl_probe_existing(table, ex, col, newr, None)? {
1069                    ExclProbe::Conflict(old) => {
1070                        return Err(exclusion_violation(table, ex, child_table, newr, &old));
1071                    }
1072                    ExclProbe::NoOverlap => proved_clear = true,
1073                    ExclProbe::Inconclusive => {} // fall through to the O(n) scan
1074                }
1075            }
1076            if proved_clear {
1077                continue;
1078            }
1079            // O(n) fallback (no index, unkeyable candidate, or an all-dead
1080            // probe key under gate-on tombstones — always correct).
1081            for (row_idx, prow) in table.rows().iter().enumerate() {
1082                if table.headers().get(row_idx).is_some_and(|h| h.is_deleted()) {
1083                    continue;
1084                }
1085                if conflicts(ex, newr, &prow.values)? {
1086                    return Err(exclusion_violation(
1087                        table,
1088                        ex,
1089                        child_table,
1090                        newr,
1091                        &prow.values,
1092                    ));
1093                }
1094            }
1095        }
1096        // Intra-batch: two incoming rows that overlap each other.
1097        // v7.39 (round 214) — the naive pairwise scan is O(N²); a single
1098        // multi-row INSERT / COPY of a booking table hits it hard (measured
1099        // O(N²), r213). For the common single-`&&` form the sorted-adjacency
1100        // test proves disjointness in O(N log N): sort the candidates by
1101        // range lower bound and check only adjacent pairs (a non-adjacent
1102        // overlap always implies an adjacent one). When that PROVES no
1103        // overlap the O(N²) loop is skipped entirely. When it can't (an
1104        // overlap exists, or a candidate is a kind the fast key doesn't
1105        // cover), fall through to the exact loop so the error stays
1106        // byte-identical to PG. This touches no cross-statement state, so it
1107        // is MVCC-trivially correct — the per-write existing-row scan above
1108        // (single-row INSERT streams) still needs the persistent index.
1109        if !(ex.elements.len() == 1
1110            && ex.elements[0].1 == "&&"
1111            && intra_batch_proven_disjoint(ex.elements[0].0, rows)?)
1112        {
1113            for i in 0..rows.len() {
1114                for j in (i + 1)..rows.len() {
1115                    if conflicts(ex, &rows[j], &rows[i])? {
1116                        return Err(exclusion_violation(
1117                            table,
1118                            ex,
1119                            child_table,
1120                            &rows[j],
1121                            &rows[i],
1122                        ));
1123                    }
1124                }
1125            }
1126        }
1127    }
1128    Ok(())
1129}
1130
1131/// v7.39 (round 214) — extract a range's lower-bound sort key: the bound as
1132/// an `i128` (unbounded = i128::MIN, sorting first) plus an inclusivity rank
1133/// (inclusive lower sorts before exclusive at the same value, `[3` before
1134/// `(3`). Returns `None` for range kinds whose bound isn't an integer scalar
1135/// (numrange's numeric/bignum) — the caller then forces the exact O(N²) loop
1136/// rather than risk an unsound order. Int4/Int8/Date/Ts/TsTz all reduce here.
1137fn range_lower_sort_key(v: &Value<'_>) -> Option<(i128, u8)> {
1138    let Value::Range {
1139        lower,
1140        lower_inc,
1141        empty,
1142        ..
1143    } = v
1144    else {
1145        return None;
1146    };
1147    if *empty {
1148        return None;
1149    }
1150    let key = match lower {
1151        None => i128::MIN,
1152        Some(b) => match b.as_ref() {
1153            Value::SmallInt(n) => i128::from(*n),
1154            Value::Int(n) => i128::from(*n),
1155            Value::BigInt(n) => i128::from(*n),
1156            // daterange (days since epoch) + ts/tstzrange (micros since epoch)
1157            // — both totally ordered as their raw integer.
1158            Value::Date(n) => i128::from(*n),
1159            Value::Timestamp(n) => i128::from(*n),
1160            _ => return None,
1161        },
1162    };
1163    Some((key, u8::from(!*lower_inc)))
1164}
1165
1166/// v7.39 (round 214) — PROVE (soundly) that no two candidate rows' ranges at
1167/// `pos` overlap, in O(N log N). Returns `true` only when disjointness is
1168/// certain; returns `false` if an overlap exists OR any candidate can't be
1169/// keyed (non-range, empty handled as exempt, numrange, short row) — in which
1170/// case the caller runs the exact pairwise loop. NULL and empty ranges never
1171/// conflict, so they leave the candidate set. The authoritative overlap
1172/// decision on each adjacent pair delegates to `&&` (`apply_binary`), so the
1173/// only thing the fast path relies on is the sort order being correct — which
1174/// the integer key guarantees for the kinds it accepts.
1175fn intra_batch_proven_disjoint(
1176    pos: usize,
1177    rows: &[Vec<Value<'static>>],
1178) -> Result<bool, EngineError> {
1179    let mut keyed: Vec<((i128, u8), usize)> = Vec::with_capacity(rows.len());
1180    for (i, r) in rows.iter().enumerate() {
1181        match r.get(pos) {
1182            None => return Ok(false),      // short row — let the exact loop handle it
1183            Some(Value::Null) => continue, // NULL exempts the row
1184            Some(v @ Value::Range { empty, .. }) => {
1185                if *empty {
1186                    continue; // empty range never overlaps
1187                }
1188                match range_lower_sort_key(v) {
1189                    Some(k) => keyed.push((k, i)),
1190                    None => return Ok(false), // unkeyable range kind → exact loop
1191                }
1192            }
1193            Some(_) => return Ok(false), // not a range → exact loop
1194        }
1195    }
1196    if keyed.len() < 2 {
1197        return Ok(true); // 0 or 1 candidate ranges can't overlap each other
1198    }
1199    keyed.sort_by_key(|k| k.0);
1200    for w in keyed.windows(2) {
1201        let a = rows[w[0].1][pos].clone();
1202        let b = rows[w[1].1][pos].clone();
1203        // overlap → let the exact loop produce PG's byte-identical error
1204        if let Value::Bool(true) = eval::apply_binary(spg_sql::ast::BinOp::InetOverlap, a, b)? {
1205            return Ok(false);
1206        }
1207    }
1208    Ok(true) // adjacency proved the whole set disjoint
1209}
1210
1211/// v7.39 (round 210) — enforce `EXCLUDE` constraints for an UPDATE. Each
1212/// planned `(row_pos, new_values)` is checked against every live row EXCEPT
1213/// the rows being updated in this same statement (their pre-images leave the
1214/// set — otherwise a no-op UPDATE would collide with itself), plus pairwise
1215/// among the planned new rows.
1216pub(crate) fn enforce_exclusion_updates(
1217    catalog: &Catalog,
1218    table_name: &str,
1219    constraints: &[spg_storage::ExclusionConstraint],
1220    planned: &[(usize, Vec<Value<'static>>)],
1221) -> Result<(), EngineError> {
1222    if constraints.is_empty() || planned.is_empty() {
1223        return Ok(());
1224    }
1225    let table = catalog.get(table_name).ok_or_else(|| {
1226        EngineError::Storage(StorageError::TableNotFound {
1227            name: table_name.into(),
1228        })
1229    })?;
1230    let updated: hashbrown::HashSet<usize> = planned.iter().map(|(p, _)| *p).collect();
1231    let conflicts = excl_rows_conflict;
1232    for ex in constraints {
1233        // v7.39 (round 216) — the indexed `&&` element, if any: each planned
1234        // new row probes O(log n) (excluding the rows being updated, whose
1235        // pre-images are replaced) instead of scanning every existing row.
1236        let idx_col = ex
1237            .elements
1238            .iter()
1239            .find(|(pos, op)| op == "&&" && table.excl_range_index(*pos).is_some())
1240            .map(|(pos, _)| *pos);
1241        for (_pos, newr) in planned {
1242            let mut proved_clear = false;
1243            if let Some(col) = idx_col {
1244                match excl_probe_existing(table, ex, col, newr, Some(&updated))? {
1245                    ExclProbe::Conflict(old) => {
1246                        return Err(exclusion_violation(table, ex, table_name, newr, &old));
1247                    }
1248                    ExclProbe::NoOverlap => proved_clear = true,
1249                    ExclProbe::Inconclusive => {}
1250                }
1251            }
1252            if proved_clear {
1253                continue;
1254            }
1255            for (row_idx, prow) in table.rows().iter().enumerate() {
1256                if table.headers().get(row_idx).is_some_and(|h| h.is_deleted()) {
1257                    continue;
1258                }
1259                if updated.contains(&row_idx) {
1260                    continue;
1261                }
1262                if conflicts(ex, newr, &prow.values)? {
1263                    return Err(exclusion_violation(
1264                        table,
1265                        ex,
1266                        table_name,
1267                        newr,
1268                        &prow.values,
1269                    ));
1270                }
1271            }
1272        }
1273        for i in 0..planned.len() {
1274            for j in (i + 1)..planned.len() {
1275                if conflicts(ex, &planned[j].1, &planned[i].1)? {
1276                    return Err(exclusion_violation(
1277                        table,
1278                        ex,
1279                        table_name,
1280                        &planned[j].1,
1281                        &planned[i].1,
1282                    ));
1283                }
1284            }
1285        }
1286    }
1287    Ok(())
1288}
1289
1290/// v7.39 (round 210) — PG's 23P01 exclusion-violation error + DETAIL. PG:
1291/// `conflicting key value violates exclusion constraint "<name>"` with
1292/// `DETAIL: Key (during)=([3,7)) conflicts with existing key (during)=([1,5)).`
1293/// The ` on table "…"` suffix mirrors the uniqueness path; the pgwire layer
1294/// strips it (PG's message has none) and lifts the name into PG_DIAG `n`.
1295fn exclusion_violation(
1296    table: &spg_storage::Table,
1297    ex: &spg_storage::ExclusionConstraint,
1298    child_table: &str,
1299    newr: &[Value<'static>],
1300    oldr: &[Value<'static>],
1301) -> EngineError {
1302    let render = |vals: &[Value<'static>]| -> (String, String) {
1303        let cols = ex
1304            .elements
1305            .iter()
1306            .map(|(p, _)| table.schema().columns[*p].name.clone())
1307            .collect::<Vec<_>>()
1308            .join(", ");
1309        let rendered = ex
1310            .elements
1311            .iter()
1312            .map(|(p, _)| {
1313                let v = vals.get(*p).cloned().unwrap_or(Value::Null);
1314                match v {
1315                    Value::Text(s) => s.to_string(),
1316                    other => crate::eval::value_to_text(&other),
1317                }
1318            })
1319            .collect::<Vec<_>>()
1320            .join(", ");
1321        (cols, rendered)
1322    };
1323    let (cols, new_vals) = render(newr);
1324    let (_, old_vals) = render(oldr);
1325    EngineError::Unsupported(alloc::format!(
1326        "conflicting key value violates exclusion constraint \"{}\" \
1327         on table \"{child_table}\" DETAIL: Key ({cols})=({new_vals}) \
1328         conflicts with existing key ({cols})=({old_vals}).",
1329        ex.name
1330    ))
1331}
1332
1333/// v7.39 (SQLSTATE fidelity) — PG's 23505 DETAIL body:
1334/// ` DETAIL: Key (a, b)=(1, x) already exists.` Appended to the main
1335/// message (the engine error is a single string; psql-style separate
1336/// DETAIL packets are a wire-layer follow-up).
1337fn unique_key_detail(cols: &[String], key: &[Value<'_>]) -> String {
1338    let vals = key
1339        .iter()
1340        .map(|v| match v {
1341            Value::Text(s) => s.to_string(),
1342            // v7.39 (round 473) — PG writes a NULL key part lowercase here:
1343            // `Key (a, b)=(1, null) already exists.` Measured on PG18.
1344            Value::Null => alloc::string::String::from("null"),
1345            other => crate::eval::value_to_text(other),
1346        })
1347        .collect::<Vec<_>>()
1348        .join(", ");
1349    alloc::format!(
1350        " DETAIL: Key ({})=({vals}) already exists.",
1351        cols.join(", ")
1352    )
1353}
1354
1355/// v7.39 (SQLSTATE fidelity) — PG's 23503 phrasing helper: the FK
1356/// constraint name by PG convention plus the local-column key DETAIL.
1357fn fk_violation_message(
1358    child: &spg_storage::Table,
1359    child_table: &str,
1360    fk: &spg_storage::ForeignKeyConstraint,
1361    key_vals: &[&Value<'_>],
1362) -> String {
1363    let conname = crate::system_catalog::pg_fk_conname(child, fk, child_table);
1364    let cols = fk
1365        .local_columns
1366        .iter()
1367        .map(|&p| {
1368            child
1369                .schema()
1370                .columns
1371                .get(p)
1372                .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
1373        })
1374        .collect::<Vec<_>>()
1375        .join(", ");
1376    let vals = key_vals
1377        .iter()
1378        .map(|v| match v {
1379            Value::Text(s) => s.to_string(),
1380            other => crate::eval::value_to_text(other),
1381        })
1382        .collect::<Vec<_>>()
1383        .join(", ");
1384    alloc::format!(
1385        "insert or update on table \"{child_table}\" violates foreign key \
1386         constraint \"{conname}\" DETAIL: Key ({cols})=({vals}) is not present \
1387         in table \"{}\".",
1388        fk.parent_table
1389    )
1390}
1391
1392/// v7.39 (SQLSTATE fidelity) — PG's parent-side 23503 phrasing:
1393/// `update or delete on table "p" violates foreign key constraint
1394/// "c_col_fkey" on table "c"` with the still-referenced key DETAIL.
1395fn fk_restrict_message(
1396    catalog: &Catalog,
1397    parent_name: &str,
1398    child: &spg_storage::Table,
1399    child_name: &str,
1400    fk: &spg_storage::ForeignKeyConstraint,
1401    parent_key: &[&Value<'_>],
1402    action: spg_storage::FkAction,
1403) -> String {
1404    let conname = crate::system_catalog::pg_fk_conname(child, fk, child_name);
1405    let pcols = match catalog.get(parent_name) {
1406        Some(parent) => fk
1407            .parent_columns
1408            .iter()
1409            .map(|&p| {
1410                parent
1411                    .schema()
1412                    .columns
1413                    .get(p)
1414                    .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
1415            })
1416            .collect::<Vec<_>>()
1417            .join(", "),
1418        None => "?".into(),
1419    };
1420    let vals = parent_key
1421        .iter()
1422        .map(|v| match v {
1423            Value::Text(s) => s.to_string(),
1424            other => crate::eval::value_to_text(other),
1425        })
1426        .collect::<Vec<_>>()
1427        .join(", ");
1428    // v7.39 (round 695) — PG18 distinguishes RESTRICT from NO ACTION in
1429    // BOTH halves of this message, and SPG had been giving NO ACTION's
1430    // wording for both. Measured:
1431    //   RESTRICT   `violates RESTRICT setting of foreign key constraint …`
1432    //              `… is referenced from table "…"`
1433    //   NO ACTION  `violates foreign key constraint …`
1434    //              `… is still referenced from table "…"`
1435    // The distinction is not cosmetic: the two differ in WHEN they fire (a
1436    // deferred NO ACTION is checked at commit, RESTRICT immediately), so a
1437    // reader who sees the wrong word draws the wrong conclusion about why.
1438    if matches!(action, spg_storage::FkAction::Restrict) {
1439        return alloc::format!(
1440            "update or delete on table \"{parent_name}\" violates RESTRICT \
1441             setting of foreign key constraint \"{conname}\" on table \"{child_name}\" \
1442             DETAIL: Key ({pcols})=({vals}) is referenced from table \"{child_name}\"."
1443        );
1444    }
1445    alloc::format!(
1446        "update or delete on table \"{parent_name}\" violates foreign key \
1447         constraint \"{conname}\" on table \"{child_name}\" \
1448         DETAIL: Key ({pcols})=({vals}) is still referenced from table \"{child_name}\"."
1449    )
1450}
1451
1452/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
1453/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
1454/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
1455/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
1456/// values, the cell passes through unchanged. The caller compares the
1457/// folded values with `==`.
1458fn collated_key_cell(
1459    v: &spg_storage::Value,
1460    column_position: usize,
1461    schema: &spg_storage::TableSchema,
1462    mysql: bool,
1463) -> spg_storage::Value<'static> {
1464    // v7.39 (round 364/365, M4 P2/P3) — the MySQL dialect's default
1465    // collation folds case AND accent, so its UNIQUE / index keys must
1466    // fold the same way the read path (P2) does, or a value the read
1467    // path treats as a duplicate could still be inserted. A binary-typed
1468    // column stores `Bytea`, not `Text`, so it naturally keeps both
1469    // byte-distinct values — matching MariaDB's VARBINARY UNIQUE.
1470    // v7.39 (round 370, M4 P4a) — an explicit `COLLATE utf8mb4_bin` text
1471    // column (stored `Binary`) is byte-wise: its UNIQUE keeps both 'a' and
1472    // 'A'. The folding default column stores `CaseInsensitive`, so only an
1473    // explicit binary column is `Binary` here and skips the fold.
1474    let explicit_binary = schema
1475        .columns
1476        .get(column_position)
1477        .is_some_and(|c| matches!(c.collation, spg_storage::Collation::Binary));
1478    if mysql && !explicit_binary {
1479        match v {
1480            spg_storage::Value::Text(s) => {
1481                return spg_storage::Value::text(spg_storage::mysql_compare_fold(s));
1482            }
1483            spg_storage::Value::BpChar(s) => {
1484                return spg_storage::Value::text(spg_storage::mysql_ci_fold(
1485                    s.trim_end_matches(' '),
1486                ));
1487            }
1488            _ => return v.clone().into_owned(),
1489        }
1490    }
1491    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
1492        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
1493            spg_storage::Value::text(s.to_ascii_lowercase())
1494        }
1495        _ => v.clone().into_owned(),
1496    }
1497}
1498
1499/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
1500/// WHERE-style predicate. NULL → false (three-valued logic
1501/// collapses to "skip this row" for index inclusion). Numeric
1502/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
1503/// Everything else (strings, vectors, JSON, …) is not a valid
1504/// predicate result and surfaces as `false` so a malformed
1505/// predicate degrades to "row not in index" rather than panicking.
1506fn predicate_truthy(v: &spg_storage::Value) -> bool {
1507    use spg_storage::Value as V;
1508    match v {
1509        V::Bool(b) => *b,
1510        V::Int(n) => *n != 0,
1511        V::BigInt(n) => *n != 0,
1512        V::SmallInt(n) => *n != 0,
1513        _ => false,
1514    }
1515}
1516
1517/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
1518/// committed rows for pre-existing duplicates. If any pair of rows
1519/// matches the predicate AND has the same index key, refuse to
1520/// create the index so the user fixes the data before retrying.
1521pub(crate) fn check_existing_unique_violation(
1522    idx: &spg_storage::Index,
1523    schema: &spg_storage::TableSchema,
1524    rows: &[spg_storage::Row<'static>],
1525    mysql: bool,
1526) -> Result<(), EngineError> {
1527    let predicate_expr = match idx.partial_predicate.as_deref() {
1528        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
1529            EngineError::Unsupported(alloc::format!(
1530                "stored partial predicate {s:?} failed to re-parse: {e:?}"
1531            ))
1532        })?),
1533        None => None,
1534    };
1535    let ctx = eval::EvalContext::new(&schema.columns, None);
1536    let key_positions = unique_key_positions(idx);
1537    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value<'static>>> =
1538        alloc::vec::Vec::new();
1539    for row in rows {
1540        if let Some(expr) = &predicate_expr {
1541            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
1542                EngineError::Unsupported(alloc::format!(
1543                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
1544                ))
1545            })?;
1546            if !predicate_truthy(&v) {
1547                continue;
1548            }
1549        }
1550        let key: alloc::vec::Vec<spg_storage::Value<'static>> = key_positions
1551            .iter()
1552            .map(|&p| {
1553                let v = row
1554                    .values
1555                    .get(p)
1556                    .cloned()
1557                    .unwrap_or(spg_storage::Value::Null);
1558                collated_key_cell(&v, p, schema, mysql)
1559            })
1560            .collect();
1561        // v7.39 (read01 round 52) — NULLS NOT DISTINCT keeps NULL keys in the
1562        // check, so CREATE UNIQUE INDEX … NULLS NOT DISTINCT over two all-NULL
1563        // rows is rejected (PG: "could not create unique index").
1564        if !idx.nulls_not_distinct && key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
1565            continue;
1566        }
1567        if seen.iter().any(|other| *other == key) {
1568            // v7.39 (read01 round 52) — PG wording (23505 at the wire).
1569            return Err(EngineError::Unsupported(alloc::format!(
1570                "could not create unique index {:?}",
1571                idx.name
1572            )));
1573        }
1574        seen.push(key);
1575    }
1576    Ok(())
1577}
1578
1579/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
1580/// extra positions). For single-column indexes this is just
1581/// `[column_position]`.
1582fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
1583    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
1584    out.push(idx.column_position);
1585    out.extend_from_slice(&idx.extra_column_positions);
1586    out
1587}
1588
1589/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
1590/// target table. For each, eval the index's optional predicate
1591/// against (a) the candidate row and (b) every committed row plus
1592/// earlier batch rows; only rows where the predicate is truthy
1593/// participate. A duplicate key among predicate-matching rows is a
1594/// uniqueness violation. NULL keys lift the row out of the check
1595/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
1596pub(crate) fn enforce_unique_index_inserts(
1597    catalog: &Catalog,
1598    table_name: &str,
1599    rows: &[alloc::vec::Vec<spg_storage::Value<'static>>],
1600    mysql: bool,
1601) -> Result<(), EngineError> {
1602    let table = catalog.get(table_name).ok_or_else(|| {
1603        EngineError::Storage(StorageError::TableNotFound {
1604            name: table_name.into(),
1605        })
1606    })?;
1607    let schema = table.schema();
1608    let ctx = eval::EvalContext::new(&schema.columns, None);
1609    for idx in table.indices() {
1610        if !idx.is_unique {
1611            continue;
1612        }
1613        // Re-parse the predicate once per index per batch.
1614        let predicate_expr = match idx.partial_predicate.as_deref() {
1615            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
1616                EngineError::Unsupported(alloc::format!(
1617                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
1618                    idx.name
1619                ))
1620            })?),
1621            None => None,
1622        };
1623        // v7.38 (read01 U1) — an expression index (`CREATE UNIQUE INDEX ON
1624        // t (lower(email))`) carries its key as a parseable expression, not
1625        // a column position. Re-parse once per batch and evaluate per row so
1626        // the key reflects the expression; without this the uniqueness was
1627        // silently not enforced (duplicate `lower(email)` values slipped in).
1628        let expr_key = match idx.expression.as_deref() {
1629            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
1630                EngineError::Unsupported(alloc::format!(
1631                    "UNIQUE INDEX {:?} expression {s:?} failed to re-parse: {e:?}",
1632                    idx.name
1633                ))
1634            })?),
1635            None => None,
1636        };
1637        let key_positions = unique_key_positions(idx);
1638        // v7.39 (round 473) — the key's column names, for the 23505 DETAIL.
1639        // An expression index reports the expression, as PG does.
1640        let key_col_names: alloc::vec::Vec<alloc::string::String> = match &expr_key {
1641            Some(_) => alloc::vec![idx.expression.clone().unwrap_or_else(|| idx.name.clone())],
1642            None => key_positions
1643                .iter()
1644                .map(|&p| {
1645                    schema
1646                        .columns
1647                        .get(p)
1648                        .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
1649                })
1650                .collect(),
1651        };
1652        let key_of = |values: &[spg_storage::Value<'static>]| -> Result<alloc::vec::Vec<spg_storage::Value<'static>>, EngineError> {
1653            if let Some(expr) = &expr_key {
1654                let tmp_row = spg_storage::Row {
1655                    values: values.to_vec(),
1656                };
1657                let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
1658                    EngineError::Unsupported(alloc::format!(
1659                        "UNIQUE INDEX {:?} expression eval: {e:?}",
1660                        idx.name
1661                    ))
1662                })?;
1663                return Ok(alloc::vec![v]);
1664            }
1665            Ok(key_positions
1666                .iter()
1667                .map(|&p| {
1668                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
1669                    collated_key_cell(&v, p, schema, mysql)
1670                })
1671                .collect())
1672        };
1673        let participates = |values: &[spg_storage::Value<'static>]| -> Result<bool, EngineError> {
1674            let Some(expr) = &predicate_expr else {
1675                return Ok(true);
1676            };
1677            let tmp_row = spg_storage::Row {
1678                values: values.to_vec(),
1679            };
1680            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
1681                EngineError::Unsupported(alloc::format!(
1682                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
1683                    idx.name
1684                ))
1685            })?;
1686            Ok(predicate_truthy(&v))
1687        };
1688        // v7.39 (round 166, attack A2) — a plain (non-expression,
1689        // non-partial) unique index IS its own probe btree: check each
1690        // batch row via lookup_eq instead of folding the whole table.
1691        // Same qualification rules as the constraint path (A1).
1692        if idx.expression.is_none()
1693            && idx.partial_predicate.is_none()
1694            && !idx.nulls_not_distinct
1695            && matches!(idx.kind, spg_storage::IndexKind::BTree(_))
1696        {
1697            let positions = unique_key_positions(idx);
1698            let schema_ok = !mysql
1699                && positions.iter().all(|&i| {
1700                    schema.columns.get(i).is_some_and(|c| {
1701                        !matches!(c.collation, spg_storage::Collation::CaseInsensitive)
1702                    })
1703                })
1704                && schema
1705                    .columns
1706                    .get(idx.column_position)
1707                    .is_some_and(|c| indexkeyable_type(&c.ty));
1708            if schema_ok {
1709                let fold =
1710                    |values: &[spg_storage::Value<'static>]| -> Vec<spg_storage::Value<'static>> {
1711                        positions
1712                            .iter()
1713                            .map(|&p| {
1714                                let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
1715                                collated_key_cell(&v, p, schema, mysql)
1716                            })
1717                            .collect()
1718                    };
1719                let mut batch_seen: hashbrown::HashSet<String> =
1720                    hashbrown::HashSet::with_capacity(rows.len());
1721                let mut probe_ok = true;
1722                for row_values in rows.iter() {
1723                    let key = fold(row_values);
1724                    if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
1725                        continue;
1726                    }
1727                    let leading = row_values
1728                        .get(idx.column_position)
1729                        .cloned()
1730                        .unwrap_or(spg_storage::Value::Null);
1731                    if spg_storage::IndexKey::from_value(&leading).is_none() {
1732                        probe_ok = false;
1733                        break;
1734                    }
1735                    if !batch_seen.insert(aggregate::encode_key(&key))
1736                        || probe_key_conflict(table, idx, &leading, &key, &fold).is_some()
1737                    {
1738                        // v7.39 (round 473) — a unique INDEX is a unique
1739                        // constraint to a client, and PG gives it the same
1740                        // DETAIL a table constraint gets. This path had none.
1741                        let detail = unique_key_detail(&key_col_names, &key);
1742                        return Err(EngineError::Unsupported(alloc::format!(
1743                            "duplicate key value violates unique constraint \"{}\" \
1744                             on table \"{table_name}\"{detail}",
1745                            idx.name
1746                        )));
1747                    }
1748                }
1749                if probe_ok {
1750                    continue;
1751                }
1752            }
1753        }
1754        // v7.29 (mailrs round-23b) — set-based: one O(table) pass
1755        // (predicate evaluated once per existing row instead of once
1756        // per row PAIR), then probe per batch row. The previous
1757        // nested scans made bulk import O(n²).
1758        let mut seen: hashbrown::HashSet<String> =
1759            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
1760        for (row_idx, prow) in table.rows().iter().enumerate() {
1761            // v7.37.15 (Phase C.3) — skip gate-on tombstones so a
1762            // re-insert of a freed key succeeds. See the twin guard in
1763            // `enforce_uniqueness_inserts`; `is_deleted()` is never true
1764            // under the default gate (physical delete), so the gate-off
1765            // path is byte-for-byte unchanged.
1766            if table.headers().get(row_idx).is_some_and(|h| h.is_deleted()) {
1767                continue;
1768            }
1769            if !participates(&prow.values)? {
1770                continue;
1771            }
1772            let key = key_of(&prow.values)?;
1773            // v7.39 (read01 round 52) — NULLS NOT DISTINCT keeps NULL keys in
1774            // the uniqueness check (PG 15+); the default exempts them.
1775            if !idx.nulls_not_distinct && key.iter().any(|v| matches!(v, spg_storage::Value::Null))
1776            {
1777                continue;
1778            }
1779            seen.insert(aggregate::encode_key(&key));
1780        }
1781        for (batch_idx, row_values) in rows.iter().enumerate() {
1782            if !participates(row_values)? {
1783                continue;
1784            }
1785            let key = key_of(row_values)?;
1786            if !idx.nulls_not_distinct && key.iter().any(|v| matches!(v, spg_storage::Value::Null))
1787            {
1788                continue;
1789            }
1790            if !seen.insert(aggregate::encode_key(&key)) {
1791                // v7.39 (SQLSTATE fidelity) — a unique INDEX is a unique
1792                // constraint to clients; same PG 23505 phrasing.
1793                let detail = unique_key_detail(&key_col_names, &key);
1794                return Err(EngineError::Unsupported(alloc::format!(
1795                    "duplicate key value violates unique constraint \"{}\" \
1796                     on table \"{table_name}\"{detail}",
1797                    idx.name
1798                )));
1799            }
1800        }
1801    }
1802    Ok(())
1803}
1804
1805/// v7.38 (read01 U1) — UPDATE-time uniqueness enforcement. INSERT has
1806/// `enforce_uniqueness_inserts` + `enforce_unique_index_inserts`, but the
1807/// UPDATE path checked FK / CHECK / NOT NULL and silently skipped every
1808/// UNIQUE constraint and unique index — so an UPDATE could move a row onto
1809/// a key another row already holds (`UPDATE t SET x=1 WHERE x=2` with a
1810/// second row at `x=1`, or `UPDATE t SET email='A' ...` colliding on
1811/// `lower(email)`). PG rejects these; SPG now does too.
1812///
1813/// `planned` is the update batch as `(row_position, new_values)`. The key
1814/// difference from the INSERT check is that the pre-image of every updated
1815/// row must be *excluded* from the "existing keys" set — otherwise a row
1816/// whose key is unchanged would collide with its own old key, and a valid
1817/// key swap would false-positive. So the existing-key scan skips the
1818/// updated positions, then the new values probe against the remainder and
1819/// against each other.
1820///
1821/// `changed_cols` is the set of column positions the UPDATE may have
1822/// altered (SET targets + ON UPDATE overrides + stored-generated columns).
1823/// A UNIQUE constraint or plain unique index whose key columns are all
1824/// untouched cannot gain a new duplicate, so it is skipped — this keeps a
1825/// hot `UPDATE … WHERE id=$1 SET non_key=…` off the O(table) scan.
1826/// Expression / partial indexes may depend on any column, so they are
1827/// always checked when present.
1828///
1829/// The check models PG's non-deferrable (immediate) semantics: it seeds a
1830/// key set from every current row, then replays each update as
1831/// remove-old-key + insert-new-key. Inserting a key that is still present
1832/// is a violation — so a straight duplicate, a two-row swap
1833/// (`SET x = CASE …`), and a shift (`SET x = x + 1` over adjacent keys)
1834/// are all rejected exactly as PG rejects them, while a row whose key is
1835/// unchanged, or reassigned to a genuinely free value, passes.
1836///
1837/// v7.39 (round 166, attack A3) — probe-based twin of the UPDATE
1838/// `replay` closure: instead of seeding a HashSet from the whole table,
1839/// membership(k) is modelled as `(table \ removed) ∪ added` with the
1840/// table part answered by a btree probe. Semantically identical to the
1841/// fold replay (same key function, same ordering); returns Ok(false)
1842/// when an unprobeable value forces the caller back onto the fold path.
1843#[allow(clippy::too_many_lines)]
1844fn probe_replay(
1845    table: &spg_storage::Table,
1846    idx: &spg_storage::Index,
1847    columns: &[usize],
1848    planned: &[(usize, Vec<Value<'static>>)],
1849    schema: &spg_storage::TableSchema,
1850    key_str: &KeyStrFn<'_>,
1851    on_conflict: &dyn Fn(usize) -> EngineError,
1852    mysql: bool,
1853) -> Result<bool, EngineError> {
1854    let fold = |values: &[Value<'static>]| -> Vec<Value<'static>> {
1855        columns
1856            .iter()
1857            .map(|&i| {
1858                let v = values.get(i).cloned().unwrap_or(Value::Null);
1859                collated_key_cell(&v, i, schema, mysql)
1860            })
1861            .collect()
1862    };
1863    let mut added: hashbrown::HashSet<String> = hashbrown::HashSet::new();
1864    let mut removed: hashbrown::HashSet<String> = hashbrown::HashSet::new();
1865    for (pos, new_vals) in planned {
1866        let old_key = match table.rows().get(*pos) {
1867            Some(r) => key_str(&r.values)?,
1868            None => None,
1869        };
1870        let new_key = key_str(new_vals)?;
1871        if old_key == new_key {
1872            continue;
1873        }
1874        if let Some(ok) = old_key {
1875            if !added.remove(&ok) {
1876                removed.insert(ok);
1877            }
1878        }
1879        if let Some(nk) = new_key {
1880            if added.contains(&nk) {
1881                return Err(on_conflict(*pos));
1882            }
1883            if !removed.contains(&nk) {
1884                let key_vec = fold(new_vals);
1885                let leading = new_vals.get(columns[0]).cloned().unwrap_or(Value::Null);
1886                if spg_storage::IndexKey::from_value(&leading).is_none() {
1887                    return Ok(false);
1888                }
1889                if let Some(ri) = probe_key_conflict(table, idx, &leading, &key_vec, &fold)
1890                    && ri != *pos
1891                {
1892                    return Err(on_conflict(*pos));
1893                }
1894            }
1895            added.insert(nk);
1896        }
1897    }
1898    Ok(true)
1899}
1900
1901pub(crate) fn enforce_unique_updates(
1902    catalog: &Catalog,
1903    table_name: &str,
1904    planned: &[(usize, Vec<Value<'static>>)],
1905    changed_cols: &hashbrown::HashSet<usize>,
1906    mysql: bool,
1907) -> Result<(), EngineError> {
1908    if planned.is_empty() {
1909        return Ok(());
1910    }
1911    let table = catalog.get(table_name).ok_or_else(|| {
1912        EngineError::Storage(StorageError::TableNotFound {
1913            name: table_name.into(),
1914        })
1915    })?;
1916    let schema = table.schema();
1917
1918    // Seed the key set from all current rows, then replay each update as
1919    // remove-old + insert-new; `key_str` returns None for a row that isn't
1920    // in the index (NULL key, or partial-predicate false) so it neither
1921    // seeds nor conflicts.
1922    let replay = |key_str: &KeyStrFn<'_>,
1923                  on_conflict: &dyn Fn(usize) -> EngineError|
1924     -> Result<(), EngineError> {
1925        let mut index: hashbrown::HashSet<String> =
1926            hashbrown::HashSet::with_capacity(table.rows().len());
1927        for (row_idx, prow) in table.rows().iter().enumerate() {
1928            if table.headers().get(row_idx).is_some_and(|h| h.is_deleted()) {
1929                continue;
1930            }
1931            if let Some(k) = key_str(&prow.values)? {
1932                index.insert(k);
1933            }
1934        }
1935        for (pos, new_vals) in planned {
1936            let old_key = match table.rows().get(*pos) {
1937                Some(r) => key_str(&r.values)?,
1938                None => None,
1939            };
1940            let new_key = key_str(new_vals)?;
1941            if old_key == new_key {
1942                continue; // key unchanged (incl. both absent) — no effect
1943            }
1944            if let Some(ok) = &old_key {
1945                index.remove(ok);
1946            }
1947            if let Some(nk) = new_key
1948                && !index.insert(nk)
1949            {
1950                return Err(on_conflict(*pos));
1951            }
1952        }
1953        Ok(())
1954    };
1955
1956    // ── composite / column UNIQUE + PRIMARY KEY constraints ──
1957    for uc in &schema.uniqueness_constraints {
1958        if !uc.columns.iter().any(|c| changed_cols.contains(c)) {
1959            continue;
1960        }
1961        let key_str = |values: &[Value<'static>]| -> Result<Option<String>, EngineError> {
1962            let key: Vec<Value<'static>> = uc
1963                .columns
1964                .iter()
1965                .map(|&i| {
1966                    let v = values.get(i).cloned().unwrap_or(Value::Null);
1967                    collated_key_cell(&v, i, schema, mysql)
1968                })
1969                .collect();
1970            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
1971                return Ok(None);
1972            }
1973            Ok(Some(aggregate::encode_key(&key)))
1974        };
1975        let on_conflict = |_pos: usize| -> EngineError {
1976            // v7.39 (SQLSTATE fidelity) — PG's 23505 phrasing (see the
1977            // INSERT-path twin above).
1978            let conname = if uc.is_primary_key {
1979                alloc::format!("{table_name}_pkey")
1980            } else {
1981                let cols = uc
1982                    .columns
1983                    .iter()
1984                    .map(|&i| schema.columns[i].name.clone())
1985                    .collect::<Vec<_>>()
1986                    .join("_");
1987                alloc::format!("{table_name}_{cols}_key")
1988            };
1989            EngineError::Unsupported(alloc::format!(
1990                "duplicate key value violates unique constraint \"{conname}\" \
1991                 on table \"{table_name}\""
1992            ))
1993        };
1994        // v7.39 (round 166, attack A3) — probe path first.
1995        if let Some(pidx) = uc_probe_index(table, &uc.columns, uc.nulls_not_distinct, mysql)
1996            && probe_replay(
1997                table,
1998                pidx,
1999                &uc.columns,
2000                planned,
2001                schema,
2002                &key_str,
2003                &on_conflict,
2004                mysql,
2005            )?
2006        {
2007            continue;
2008        }
2009        replay(&key_str, &on_conflict)?;
2010    }
2011
2012    // ── CREATE UNIQUE INDEX (incl. expression / partial) ──
2013    let ctx = eval::EvalContext::new(&schema.columns, None);
2014    for idx in table.indices() {
2015        if !idx.is_unique {
2016            continue;
2017        }
2018        let is_expr_or_partial = idx.expression.is_some() || idx.partial_predicate.is_some();
2019        let key_positions = unique_key_positions(idx);
2020        // A plain unique index whose key columns are untouched can't gain
2021        // a duplicate; an expression/partial index may read any column.
2022        if !is_expr_or_partial && !key_positions.iter().any(|c| changed_cols.contains(c)) {
2023            continue;
2024        }
2025        let predicate_expr = match idx.partial_predicate.as_deref() {
2026            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
2027                EngineError::Unsupported(alloc::format!(
2028                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
2029                    idx.name
2030                ))
2031            })?),
2032            None => None,
2033        };
2034        let expr_key = match idx.expression.as_deref() {
2035            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
2036                EngineError::Unsupported(alloc::format!(
2037                    "UNIQUE INDEX {:?} expression {s:?} failed to re-parse: {e:?}",
2038                    idx.name
2039                ))
2040            })?),
2041            None => None,
2042        };
2043        let key_str = |values: &[Value<'static>]| -> Result<Option<String>, EngineError> {
2044            // Partial index: rows failing the predicate are not indexed.
2045            if let Some(pred) = &predicate_expr {
2046                let tmp_row = spg_storage::Row {
2047                    values: values.to_vec(),
2048                };
2049                let v = eval::eval_expr(pred, &tmp_row, &ctx).map_err(|e| {
2050                    EngineError::Unsupported(alloc::format!(
2051                        "UNIQUE INDEX {:?} predicate eval: {e:?}",
2052                        idx.name
2053                    ))
2054                })?;
2055                if !predicate_truthy(&v) {
2056                    return Ok(None);
2057                }
2058            }
2059            let key: Vec<Value<'static>> = if let Some(expr) = &expr_key {
2060                let tmp_row = spg_storage::Row {
2061                    values: values.to_vec(),
2062                };
2063                let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
2064                    EngineError::Unsupported(alloc::format!(
2065                        "UNIQUE INDEX {:?} expression eval: {e:?}",
2066                        idx.name
2067                    ))
2068                })?;
2069                alloc::vec![v]
2070            } else {
2071                key_positions
2072                    .iter()
2073                    .map(|&p| {
2074                        let v = values.get(p).cloned().unwrap_or(Value::Null);
2075                        collated_key_cell(&v, p, schema, mysql)
2076                    })
2077                    .collect()
2078            };
2079            if key.iter().any(|v| matches!(v, Value::Null)) {
2080                return Ok(None);
2081            }
2082            Ok(Some(aggregate::encode_key(&key)))
2083        };
2084        let on_conflict = |pos: usize| -> EngineError {
2085            EngineError::Unsupported(alloc::format!(
2086                "UNIQUE INDEX {:?} violation on {table_name:?}: \
2087                 UPDATE of row #{pos} duplicates an existing key",
2088                idx.name
2089            ))
2090        };
2091        // v7.39 (round 166, attack A3) — a plain unique index probes its
2092        // own btree (expression / partial / NULLS-NOT-DISTINCT / collated
2093        // shapes stay on the fold replay).
2094        if !mysql
2095            && !is_expr_or_partial
2096            && !idx.nulls_not_distinct
2097            && matches!(idx.kind, spg_storage::IndexKind::BTree(_))
2098            && key_positions.iter().all(|&i| {
2099                schema.columns.get(i).is_some_and(|c| {
2100                    !matches!(c.collation, spg_storage::Collation::CaseInsensitive)
2101                })
2102            })
2103            && schema
2104                .columns
2105                .get(idx.column_position)
2106                .is_some_and(|c| indexkeyable_type(&c.ty))
2107            && probe_replay(
2108                table,
2109                idx,
2110                &key_positions,
2111                planned,
2112                schema,
2113                &key_str,
2114                &on_conflict,
2115                mysql,
2116            )?
2117        {
2118            continue;
2119        }
2120        replay(&key_str, &on_conflict)?;
2121    }
2122    Ok(())
2123}
2124
2125/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
2126/// Returns `true` when at least one of `filter_cols` has a
2127/// different value in `new_row` vs `old_row`. Column lookup is
2128/// case-insensitive against `schema_cols`; unknown filter columns
2129/// are treated as "not changed" (the trigger therefore won't
2130/// fire on them — surfacing a parse-time error would be too
2131/// strict for catalog reloads where the schema may have drifted).
2132pub(crate) fn any_column_changed(
2133    filter_cols: &[String],
2134    schema_cols: &[ColumnSchema],
2135    old_row: &Row<'static>,
2136    new_row: &Row<'static>,
2137) -> bool {
2138    for col_name in filter_cols {
2139        let Some(pos) = schema_cols
2140            .iter()
2141            .position(|c| c.name.eq_ignore_ascii_case(col_name))
2142        else {
2143            continue;
2144        };
2145        let old_v = old_row.values.get(pos);
2146        let new_v = new_row.values.get(pos);
2147        if old_v != new_v {
2148            return true;
2149        }
2150    }
2151    false
2152}
2153
2154/// v7.39 (read01 round 117) — PG's "Failing row contains (...)" tuple text,
2155/// shared by the 23514 (CHECK) and 23502 (NOT NULL) DETAIL lines. Each cell is
2156/// rendered as PG prints it in a row constructor: a JSON `null` → `null`, text
2157/// verbatim (unquoted, commas and all), everything else via `value_to_text`.
2158pub(crate) fn format_failing_row(row_values: &[Value<'static>]) -> String {
2159    row_values
2160        .iter()
2161        .map(|v| match v {
2162            Value::Null => "null".to_string(),
2163            Value::Text(s) => s.to_string(),
2164            other => crate::eval::value_to_text(other),
2165        })
2166        .collect::<Vec<_>>()
2167        .join(", ")
2168}
2169
2170/// v7.39 (read01 round 117) — PG's 23502 NOT NULL check over a batch of
2171/// fully-assembled rows (defaults / generated columns already applied).
2172/// Raised PRE-WRITE alongside the FK / CHECK guards, so a violating row aborts
2173/// the whole statement before any row is written (no partial rows) and carries
2174/// PG's `DETAIL: Failing row contains (...)`. Nullability is the schema's own
2175/// per-column flag — the same one the storage insert path checks — so this is a
2176/// pre-write mirror with the row context, not a second policy.
2177pub(crate) fn enforce_not_null(
2178    catalog: &Catalog,
2179    table_name: &str,
2180    rows: &[alloc::vec::Vec<Value<'static>>],
2181) -> Result<(), EngineError> {
2182    let table = catalog.get(table_name).ok_or_else(|| {
2183        EngineError::Storage(StorageError::TableNotFound {
2184            name: table_name.into(),
2185        })
2186    })?;
2187    let cols = &table.schema().columns;
2188    for row in rows {
2189        for (val, col) in row.iter().zip(cols) {
2190            if val.is_null() && !col.nullable {
2191                // v7.39 (round 220) — a NOT NULL that comes from the
2192                // column's DOMAIN reports PG's domain wording, not the
2193                // column-level 23502 form.
2194                if let Some(dname) = &col.user_domain_type
2195                    && catalog
2196                        .domain_types()
2197                        .get(dname)
2198                        .is_some_and(|d| !d.nullable)
2199                {
2200                    return Err(EngineError::Unsupported(alloc::format!(
2201                        "domain {dname} does not allow null values"
2202                    )));
2203                }
2204                return Err(EngineError::Unsupported(alloc::format!(
2205                    "null value in column \"{}\" of relation \"{table_name}\" \
2206                     violates not-null constraint DETAIL: Failing row contains ({}).",
2207                    col.name,
2208                    format_failing_row(row)
2209                )));
2210            }
2211        }
2212    }
2213    Ok(())
2214}
2215
2216/// v7.13.0 — evaluate every CHECK predicate on the schema against
2217/// each candidate row. Mirrors PG semantics: a `false` result
2218/// rejects the mutation; a NULL result *passes* (CHECK rejects
2219/// only on definite-false, not on unknown). mailrs round-5 G3.
2220pub(crate) fn enforce_check_constraints(
2221    catalog: &Catalog,
2222    table_name: &str,
2223    rows: &[alloc::vec::Vec<spg_storage::Value<'static>>],
2224    // v7.39 (round 525) — the session. A CHECK may name a session
2225    // setting, and PG evaluates it in the session that is writing;
2226    // without it `CHECK (a = current_setting('app.tenant'))` failed the
2227    // INSERT outright with "unrecognized configuration parameter".
2228    sess: Option<&crate::eval::DmlSession>,
2229) -> Result<(), EngineError> {
2230    let table = catalog.get(table_name).ok_or_else(|| {
2231        EngineError::Storage(StorageError::TableNotFound {
2232            name: table_name.into(),
2233        })
2234    })?;
2235    let schema = table.schema();
2236    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
2237    // parallel with table-level CHECKs. Collect both lists up
2238    // front; if neither exists we early-out.
2239    // v7.39 (round 260) — each parsed CHECK carries its constraint name.
2240    let mut domain_checks_per_col: alloc::vec::Vec<(
2241        usize,
2242        String,
2243        alloc::vec::Vec<(String, Expr)>,
2244    )> = alloc::vec::Vec::new();
2245    for (idx, col) in schema.columns.iter().enumerate() {
2246        let Some(dname) = &col.user_domain_type else {
2247            continue;
2248        };
2249        let Some(dom) = catalog.domain_types().get(dname) else {
2250            continue;
2251        };
2252        // v7.39 (round 260) — carry each CHECK's NAME so the violation
2253        // message can report the constraint that actually failed rather
2254        // than the auto-name of the domain itself (they differ once a
2255        // domain has more than one check, or an ALTER-added named one).
2256        let mut parsed_for_col: alloc::vec::Vec<(alloc::string::String, Expr)> =
2257            alloc::vec::Vec::with_capacity(dom.checks.len());
2258        for chk in &dom.checks {
2259            let src = &chk.expr;
2260            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
2261                EngineError::Unsupported(alloc::format!(
2262                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
2263                    col.name
2264                ))
2265            })?;
2266            parsed_for_col.push((chk.name.clone(), expr));
2267        }
2268        if !parsed_for_col.is_empty() {
2269            domain_checks_per_col.push((idx, dname.clone(), parsed_for_col));
2270        }
2271    }
2272    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
2273        return Ok(());
2274    }
2275    let mut ctx = eval::EvalContext::new(&schema.columns, None);
2276    if let Some(s) = sess {
2277        ctx = ctx.with_session(s);
2278    }
2279    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
2280    for (i, src) in schema.checks.iter().enumerate() {
2281        let expr = spg_sql::parser::parse_expression(&src.expr).map_err(|e| {
2282            let pred = &src.expr;
2283            EngineError::Unsupported(alloc::format!(
2284                "CHECK constraint #{i} on {table_name:?} ({pred:?}) failed to re-parse: {e:?}"
2285            ))
2286        })?;
2287        parsed.push((i, expr));
2288    }
2289    for (batch_idx, row_values) in rows.iter().enumerate() {
2290        let tmp_row = spg_storage::Row {
2291            values: row_values.clone(),
2292        };
2293        for (i, expr) in &parsed {
2294            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
2295                EngineError::Unsupported(alloc::format!(
2296                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
2297                ))
2298            })?;
2299            // PG: NULL passes (CHECK rejects on definite-false only).
2300            if matches!(v, spg_storage::Value::Bool(false)) {
2301                // v7.39 (SQLSTATE fidelity) — PG's exact 23514 phrasing.
2302                let names =
2303                    crate::system_catalog::pg_check_connames(table, table_name, &schema.checks);
2304                let conname = names
2305                    .get(*i)
2306                    .cloned()
2307                    .unwrap_or_else(|| alloc::format!("{table_name}_check"));
2308                let failing = format_failing_row(row_values);
2309                return Err(EngineError::Unsupported(alloc::format!(
2310                    "new row for relation \"{table_name}\" violates check constraint \
2311                     \"{conname}\" DETAIL: Failing row contains ({failing})."
2312                )));
2313            }
2314        }
2315        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
2316        // expression references VALUE as a column-name; we
2317        // substitute the per-row cell into the eval context by
2318        // synthesising a single-column row of just that value
2319        // under a temporary `value` column schema.
2320        for (col_idx, dname, checks) in &domain_checks_per_col {
2321            let cell = row_values
2322                .get(*col_idx)
2323                .cloned()
2324                .unwrap_or(spg_storage::Value::Null);
2325            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
2326                "value",
2327                schema.columns[*col_idx].ty,
2328                schema.columns[*col_idx].nullable,
2329            )];
2330            let mut synth_ctx = eval::EvalContext::new(&synth_cols, None);
2331            if let Some(s) = sess {
2332                synth_ctx = synth_ctx.with_session(s);
2333            }
2334            let synth_row = spg_storage::Row {
2335                values: alloc::vec![cell],
2336            };
2337            for (ci, (cname, expr)) in checks.iter().enumerate() {
2338                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
2339                    EngineError::Unsupported(alloc::format!(
2340                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
2341                        schema.columns[*col_idx].name
2342                    ))
2343                })?;
2344                if matches!(v, spg_storage::Value::Bool(false)) {
2345                    // v7.39 (round 220) — PG's exact 23514 domain phrasing
2346                    // (constraint auto-name `<domain>_check`), matching the
2347                    // cast path's wording.
2348                    return Err(EngineError::Unsupported(alloc::format!(
2349                        "value for domain {dname} violates check constraint \"{cname}\""
2350                    )));
2351                }
2352            }
2353        }
2354    }
2355    Ok(())
2356}
2357
2358/// v7.36 — enumerate cold-tier rows of `parent` for FK / UNIQUE
2359/// validation paths that can't reach `Engine::iter_cold_rows_of_table`
2360/// (free-function callers with a `&Catalog` instead of `&Engine`).
2361/// Same shape: PK-backed BTree iteration + `resolve_cold_locator`
2362/// per cold locator, no dedup state because the PK uniqueness
2363/// contract gives per-row uniqueness.
2364pub(crate) fn iter_cold_rows_of_parent(
2365    catalog: &Catalog,
2366    parent: &spg_storage::Table,
2367) -> Vec<Row<'static>> {
2368    let schema = parent.schema();
2369    let Some(pk_col_pos) = schema
2370        .uniqueness_constraints
2371        .iter()
2372        .find(|u| u.is_primary_key && u.columns.len() == 1)
2373        .map(|u| u.columns[0])
2374    else {
2375        return Vec::new();
2376    };
2377    let Some(idx) = parent.indices().iter().find(|i| {
2378        i.column_position == pk_col_pos && matches!(i.kind, spg_storage::IndexKind::BTree(_))
2379    }) else {
2380        return Vec::new();
2381    };
2382    let table_name = schema.name.as_str();
2383    let mut out = Vec::new();
2384    for (key, locators) in idx.iter_asc() {
2385        for loc in locators {
2386            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc
2387                && let Some(row) = catalog.resolve_cold_locator(table_name, *segment_id, key)
2388            {
2389                out.push(row);
2390            }
2391        }
2392    }
2393    out
2394}
2395
2396/// v7.36 — companion to `iter_cold_rows_of_parent` that also
2397/// surfaces the PK key alongside each cold-tier row. Used by
2398/// UPDATE / DELETE non-PK WHERE paths to promote / shadow each
2399/// matching cold-tier row by its PK key (the only key
2400/// `Catalog::promote_cold_row` and `shadow_cold_row` accept).
2401/// v7.36 — companion to `iter_cold_rows_of_parent` that also
2402/// builds a `(segment_id, page_offset) → cold_offset` map for the
2403/// INL probe. Walking the PK BTree yields one cold row per
2404/// uniquely-identified locator (the PK uniqueness contract gives
2405/// per-row dedup), so the offset assigned during materialisation
2406/// is the row's index in the returned Vec. The map is then used
2407/// by `JoinSrc::Mixed::cold_locator_offset` to translate a Cold
2408/// locator coming from ANY index on the same table — locators
2409/// across indices share the same `(segment_id, page_offset)` for
2410/// the same row.
2411pub(crate) fn iter_cold_rows_with_locator_map(
2412    catalog: &Catalog,
2413    table: &spg_storage::Table,
2414) -> (Vec<Row<'static>>, hashbrown::HashMap<i64, usize>) {
2415    let schema = table.schema();
2416    let Some(pk_col_pos) = schema
2417        .uniqueness_constraints
2418        .iter()
2419        .find(|u| u.is_primary_key && u.columns.len() == 1)
2420        .map(|u| u.columns[0])
2421    else {
2422        return (Vec::new(), hashbrown::HashMap::new());
2423    };
2424    let Some(idx) = table.indices().iter().find(|i| {
2425        i.column_position == pk_col_pos && matches!(i.kind, spg_storage::IndexKind::BTree(_))
2426    }) else {
2427        return (Vec::new(), hashbrown::HashMap::new());
2428    };
2429    let table_name = schema.name.as_str();
2430    let mut rows = Vec::new();
2431    let mut map: hashbrown::HashMap<i64, usize> = hashbrown::HashMap::new();
2432    for (key, locators) in idx.iter_asc() {
2433        // Keyed by the integer PK value — the cold-tier architecture
2434        // already requires an integer PK (`index_key_as_u64` is what
2435        // `resolve_cold_locator` calls), so locators whose
2436        // `IndexKey` isn't `Int` never resolve and are skipped.
2437        let spg_storage::IndexKey::Int(pk_value) = key else {
2438            continue;
2439        };
2440        for loc in locators {
2441            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc
2442                && let Some(row) = catalog.resolve_cold_locator(table_name, *segment_id, key)
2443            {
2444                let offset = rows.len();
2445                rows.push(row);
2446                map.insert(*pk_value, offset);
2447            }
2448        }
2449    }
2450    (rows, map)
2451}
2452
2453pub(crate) fn iter_cold_rows_with_pk_key(
2454    catalog: &Catalog,
2455    table: &spg_storage::Table,
2456) -> Vec<(spg_storage::IndexKey, Row<'static>)> {
2457    let schema = table.schema();
2458    let Some(pk_col_pos) = schema
2459        .uniqueness_constraints
2460        .iter()
2461        .find(|u| u.is_primary_key && u.columns.len() == 1)
2462        .map(|u| u.columns[0])
2463    else {
2464        return Vec::new();
2465    };
2466    let Some(idx) = table.indices().iter().find(|i| {
2467        i.column_position == pk_col_pos && matches!(i.kind, spg_storage::IndexKind::BTree(_))
2468    }) else {
2469        return Vec::new();
2470    };
2471    let table_name = schema.name.as_str();
2472    let mut out = Vec::new();
2473    for (key, locators) in idx.iter_asc() {
2474        for loc in locators {
2475            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc
2476                && let Some(row) = catalog.resolve_cold_locator(table_name, *segment_id, key)
2477            {
2478                out.push((key.clone(), row));
2479            }
2480        }
2481    }
2482    out
2483}
2484
2485/// v7.36 — name of the PK BTree index on `table` if there's a
2486/// single-column PRIMARY KEY. Used by UPDATE / DELETE cold-tier
2487/// fixup paths to thread the PK index name into
2488/// `Catalog::promote_cold_row` / `shadow_cold_row`.
2489pub(crate) fn pk_btree_index_name(table: &spg_storage::Table) -> Option<String> {
2490    let schema = table.schema();
2491    let pk_col_pos = schema
2492        .uniqueness_constraints
2493        .iter()
2494        .find(|u| u.is_primary_key && u.columns.len() == 1)
2495        .map(|u| u.columns[0])?;
2496    table.indices().iter().find_map(|i| {
2497        if i.column_position == pk_col_pos && matches!(i.kind, spg_storage::IndexKind::BTree(_)) {
2498            Some(i.name.clone())
2499        } else {
2500            None
2501        }
2502    })
2503}
2504
2505pub(crate) fn enforce_fk_inserts(
2506    catalog: &Catalog,
2507    child_table: &str,
2508    fks: &[spg_storage::ForeignKeyConstraint],
2509    rows: &[Vec<Value<'static>>],
2510) -> Result<(), EngineError> {
2511    for fk in fks {
2512        let parent_is_self = fk.parent_table == child_table;
2513        let parent = if parent_is_self {
2514            // Self-ref: read the current state of the same table.
2515            // The mut borrow on child has been dropped by the caller.
2516            catalog.get(child_table).ok_or_else(|| {
2517                EngineError::Storage(StorageError::TableNotFound {
2518                    name: child_table.into(),
2519                })
2520            })?
2521        } else {
2522            catalog.get(&fk.parent_table).ok_or_else(|| {
2523                EngineError::Storage(StorageError::TableNotFound {
2524                    name: fk.parent_table.clone(),
2525                })
2526            })?
2527        };
2528        // v7.36 (cold-tier coverage) — composite FK check walks
2529        // `parent.rows().iter()` looking for a tuple match. That
2530        // skipped cold-tier parent rows, so a child INSERT whose
2531        // matching parent had been frozen to cold raised
2532        // `FOREIGN KEY violation: no parent row` falsely. Materialise
2533        // the cold parent rows ONCE per FK (the composite path only
2534        // — single-column FKs already ride `idx.lookup_eq` which
2535        // surfaces both tiers).
2536        let cold_parent_rows: alloc::vec::Vec<Row<'static>> = if fk.local_columns.len() == 1 {
2537            Vec::new()
2538        } else {
2539            iter_cold_rows_of_parent(catalog, parent)
2540        };
2541        for (batch_idx, row_values) in rows.iter().enumerate() {
2542            // Single-column FK fast path: try the parent's BTree
2543            // index for an O(log n) lookup. Composite FKs fall back
2544            // to a parent-row scan.
2545            if fk.local_columns.len() == 1 {
2546                let v = &row_values[fk.local_columns[0]];
2547                if matches!(v, Value::Null) {
2548                    continue;
2549                }
2550                let parent_col = fk.parent_columns[0];
2551                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
2552                    EngineError::Unsupported(alloc::format!(
2553                        "FOREIGN KEY column value of type {} is not index-eligible",
2554                        crate::conversions::pg_type_name_for_error_opt(v.data_type())
2555                    ))
2556                })?;
2557                let present_committed = parent.indices().iter().any(|idx| {
2558                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
2559                        && idx.column_position == parent_col
2560                        && idx.partial_predicate.is_none()
2561                        // v7.37.15 (Phase C.3) — a tombstoned parent index
2562                        // hit means the parent was DELETE-tombstoned under
2563                        // the gate-on in-place path; the parent is gone, so
2564                        // the child FK insert must FAIL "no parent" (PG
2565                        // agrees — a deleted parent violates the FK). Gate-off
2566                        // has no tombstones → every locator counts → unchanged.
2567                        && idx
2568                            .lookup_eq(&key)
2569                            .iter()
2570                            .any(|loc| !locator_is_tombstoned(parent, loc))
2571                });
2572                // v7.6.7 self-ref widening: also accept a match
2573                // against earlier rows in this same batch when the
2574                // FK points at the table being inserted into.
2575                let present_in_batch = parent_is_self
2576                    && rows[..batch_idx]
2577                        .iter()
2578                        .any(|earlier| earlier.get(parent_col) == Some(v));
2579                if !(present_committed || present_in_batch) {
2580                    // v7.39 (SQLSTATE fidelity) — PG's exact 23503 phrasing.
2581                    let child = catalog.get(child_table).ok_or_else(|| {
2582                        EngineError::Storage(StorageError::TableNotFound {
2583                            name: child_table.into(),
2584                        })
2585                    })?;
2586                    return Err(EngineError::Unsupported(fk_violation_message(
2587                        child,
2588                        child_table,
2589                        fk,
2590                        &[v],
2591                    )));
2592                }
2593            } else {
2594                // Composite FK: scan parent rows. v7.6.7 also
2595                // accepts a match against earlier rows in the same
2596                // batch (self-ref bulk-loading of hierarchies).
2597                // v7.38 (read01, T29) — MATCH SIMPLE skips the check when ANY
2598                // referencing column is NULL; MATCH FULL skips only when they
2599                // are ALL NULL, and a mixed-NULL key is an error.
2600                let null_cnt = fk
2601                    .local_columns
2602                    .iter()
2603                    .filter(|&&i| matches!(row_values.get(i), Some(Value::Null)))
2604                    .count();
2605                match fk.match_type {
2606                    spg_storage::MatchType::Simple => {
2607                        if null_cnt > 0 {
2608                            continue;
2609                        }
2610                    }
2611                    spg_storage::MatchType::Full => {
2612                        if null_cnt == fk.local_columns.len() {
2613                            continue;
2614                        }
2615                        if null_cnt > 0 {
2616                            return Err(EngineError::Unsupported(
2617                                "insert or update violates foreign key constraint: MATCH FULL \
2618                                 does not allow mixing of null and nonnull key values"
2619                                    .into(),
2620                            ));
2621                        }
2622                    }
2623                }
2624                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
2625                let matches_parent_row = |prow: &Row<'static>| {
2626                    fk.parent_columns
2627                        .iter()
2628                        .enumerate()
2629                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
2630                };
2631                // v7.37.15 (Phase C.3) — a gate-on DELETE-tombstoned hot
2632                // parent row is gone, so it must not satisfy the composite
2633                // FK (mirror of the single-column fast path above). Cold
2634                // parent rows cannot be tombstoned in place. `is_deleted()`
2635                // is never true under the default gate → gate-off unchanged.
2636                let hot_parent_match = parent.rows().iter().enumerate().any(|(row_idx, prow)| {
2637                    !parent
2638                        .headers()
2639                        .get(row_idx)
2640                        .is_some_and(|h| h.is_deleted())
2641                        && matches_parent_row(prow)
2642                });
2643                let parent_match_committed =
2644                    hot_parent_match || cold_parent_rows.iter().any(&matches_parent_row);
2645                let parent_match_in_batch = parent_is_self
2646                    && rows[..batch_idx].iter().any(|earlier| {
2647                        fk.parent_columns
2648                            .iter()
2649                            .enumerate()
2650                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
2651                    });
2652                if !(parent_match_committed || parent_match_in_batch) {
2653                    let child = catalog.get(child_table).ok_or_else(|| {
2654                        EngineError::Storage(StorageError::TableNotFound {
2655                            name: child_table.into(),
2656                        })
2657                    })?;
2658                    return Err(EngineError::Unsupported(fk_violation_message(
2659                        child,
2660                        child_table,
2661                        fk,
2662                        &local,
2663                    )));
2664                }
2665            }
2666        }
2667    }
2668    Ok(())
2669}
2670
2671/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
2672/// DELETE on a parent. The plan is a list of these steps, stacked
2673/// across the FK graph by `plan_fk_parent_deletions`.
2674#[derive(Debug, Clone)]
2675pub(crate) struct FkChildStep {
2676    child_table: String,
2677    action: FkChildAction,
2678}
2679
2680#[derive(Debug, Clone)]
2681pub(crate) enum FkChildAction {
2682    /// CASCADE — remove these rows. Sorted, deduplicated positions.
2683    Delete { positions: Vec<usize> },
2684    /// SET NULL — for each (row, column) in the flat list, write
2685    /// NULL into that child cell. Multiple FKs on the same row may
2686    /// produce overlapping entries (deduped at plan time).
2687    SetNull {
2688        positions: Vec<usize>,
2689        columns: Vec<usize>,
2690    },
2691    /// SET DEFAULT — same shape as SetNull but writes the column's
2692    /// declared DEFAULT value (resolved at plan time). Columns
2693    /// without a DEFAULT raise an error during planning.
2694    SetDefault {
2695        positions: Vec<usize>,
2696        columns: Vec<usize>,
2697        defaults: Vec<Value<'static>>,
2698    },
2699}
2700
2701/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
2702///
2703/// Walks every table in the catalog looking for FKs whose
2704/// `parent_table` is `parent_table_name`. For each such FK + each
2705/// to-be-deleted parent row:
2706///
2707///   - RESTRICT / NoAction → error, no plan returned
2708///   - CASCADE → child rows get scheduled for deletion; recursive
2709///   - SetNull → child FK column(s) scheduled to be NULL-ed.
2710///     Verified NULL-able at plan time.
2711///   - SetDefault → child FK column(s) scheduled to be reset to
2712///     their declared DEFAULT. Columns without a DEFAULT raise.
2713///
2714/// SET NULL / SET DEFAULT do NOT cascade further — the child row
2715/// stays; only one of its columns mutates.
2716/// v7.37.16 — does ANY table in the catalog declare a foreign key whose
2717/// parent is `table_name`? Cheap per-statement pre-check that lets the
2718/// DELETE path skip snapshotting old-row values when no FK enforcement
2719/// (and no trigger / RETURNING) will ever read them.
2720pub(crate) fn any_fk_child_references(catalog: &Catalog, table_name: &str) -> bool {
2721    catalog.table_names().into_iter().any(|child_name| {
2722        catalog.get(&child_name).is_some_and(|c| {
2723            c.schema()
2724                .foreign_keys
2725                .iter()
2726                .any(|fk| fk.parent_table == table_name)
2727        })
2728    })
2729}
2730
2731pub(crate) fn plan_fk_parent_deletions(
2732    catalog: &Catalog,
2733    parent_table_name: &str,
2734    to_delete_positions: &[usize],
2735    to_delete_rows: &[Vec<Value<'static>>],
2736) -> Result<Vec<FkChildStep>, EngineError> {
2737    use alloc::collections::{BTreeMap, BTreeSet};
2738    if to_delete_rows.is_empty() {
2739        return Ok(Vec::new());
2740    }
2741    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
2742    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
2743    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
2744    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
2745    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
2746    for &p in to_delete_positions {
2747        visited.insert((parent_table_name.to_string(), p));
2748    }
2749    let mut work: Vec<(String, Vec<Value<'static>>)> = to_delete_rows
2750        .iter()
2751        .map(|r| (parent_table_name.to_string(), r.clone()))
2752        .collect();
2753    while let Some((cur_parent, parent_row)) = work.pop() {
2754        for child_name in catalog.table_names() {
2755            let child = catalog
2756                .get(&child_name)
2757                .expect("table_names → catalog.get round-trip is total");
2758            for fk in &child.schema().foreign_keys {
2759                if fk.parent_table != cur_parent {
2760                    continue;
2761                }
2762                let parent_key: Vec<&Value> = fk
2763                    .parent_columns
2764                    .iter()
2765                    .map(|&pi| &parent_row[pi])
2766                    .collect();
2767                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
2768                    continue;
2769                }
2770                // v7.36 (cold-tier coverage) — DELETE-cascade FK
2771                // planner walked `child.rows()` only. Any cold-tier
2772                // child referencing the doomed parent was silently
2773                // skipped: with RESTRICT/NoAction the violation went
2774                // undetected (lost integrity); with Cascade/SetNull/
2775                // SetDefault the child row was orphaned (cold rows
2776                // can't be mutated in-place by this planner). Raise
2777                // explicitly when a cold child reference exists so
2778                // the operator sees the architectural gap rather than
2779                // silent corruption.
2780                if iter_cold_rows_of_parent(catalog, child).iter().any(|crow| {
2781                    fk.local_columns
2782                        .iter()
2783                        .enumerate()
2784                        .all(|(i, &li)| crow.values.get(li) == Some(parent_key[i]))
2785                }) {
2786                    return Err(EngineError::Unsupported(alloc::format!(
2787                        "DELETE on {cur_parent:?}: cold-tier child row in {child_name:?} \
2788                         references the doomed parent key; cold-tier mutation by this \
2789                         FK action is a v7.37 candidate. Run COMPACT or move the cold \
2790                         rows back to the hot tier and retry."
2791                    )));
2792                }
2793                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
2794                    if child_name == cur_parent
2795                        && visited.contains(&(child_name.clone(), child_row_idx))
2796                    {
2797                        continue;
2798                    }
2799                    let matches_key = fk
2800                        .local_columns
2801                        .iter()
2802                        .enumerate()
2803                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
2804                    if !matches_key {
2805                        continue;
2806                    }
2807                    match fk.on_delete {
2808                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
2809                            // v7.39 (SQLSTATE fidelity) — PG's exact phrasing.
2810                            return Err(EngineError::Unsupported(fk_restrict_message(
2811                                catalog,
2812                                &cur_parent,
2813                                child,
2814                                &child_name,
2815                                fk,
2816                                &parent_key,
2817                                fk.on_delete,
2818                            )));
2819                        }
2820                        spg_storage::FkAction::Cascade => {
2821                            if visited.insert((child_name.clone(), child_row_idx)) {
2822                                delete_plan
2823                                    .entry(child_name.clone())
2824                                    .or_default()
2825                                    .insert(child_row_idx);
2826                                work.push((child_name.clone(), child_row.values.clone()));
2827                            }
2828                        }
2829                        spg_storage::FkAction::SetNull => {
2830                            // Verify every local FK column is NULL-able.
2831                            for &li in &fk.local_columns {
2832                                let col = child.schema().columns.get(li).ok_or_else(|| {
2833                                    EngineError::Unsupported(alloc::format!(
2834                                        "FK local column {li} missing in {child_name:?}"
2835                                    ))
2836                                })?;
2837                                if !col.nullable {
2838                                    return Err(EngineError::Unsupported(alloc::format!(
2839                                        "FOREIGN KEY ON DELETE SET NULL: column \
2840                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
2841                                        col.name,
2842                                    )));
2843                                }
2844                            }
2845                            let entry = setnull_plan.entry(child_name.clone()).or_default();
2846                            for &li in &fk.local_columns {
2847                                entry.insert((child_row_idx, li));
2848                            }
2849                        }
2850                        spg_storage::FkAction::SetDefault => {
2851                            // Resolve the DEFAULT for every local FK col.
2852                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
2853                            for &li in &fk.local_columns {
2854                                let col = child.schema().columns.get(li).ok_or_else(|| {
2855                                    EngineError::Unsupported(alloc::format!(
2856                                        "FK local column {li} missing in {child_name:?}"
2857                                    ))
2858                                })?;
2859                                let default = col.default.clone().ok_or_else(|| {
2860                                    EngineError::Unsupported(alloc::format!(
2861                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
2862                                         {child_name:?}.{:?} has no DEFAULT declared",
2863                                        col.name,
2864                                    ))
2865                                })?;
2866                                entry.insert((child_row_idx, li), default);
2867                            }
2868                        }
2869                    }
2870                }
2871            }
2872        }
2873    }
2874    // Flatten the three plans into the ordered `FkChildStep` list.
2875    // Deletes are applied last per child (after any null/default
2876    // re-writes on the same child) so a child row that's both
2877    // re-written and then cascade-deleted only ends up deleted —
2878    // but in v7.6.5 SetNull/Cascade never overlap on the same row
2879    // (a single FK chooses exactly one action), so the order is
2880    // mostly a precaution.
2881    let mut steps: Vec<FkChildStep> = Vec::new();
2882    for (child_table, entries) in setnull_plan {
2883        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
2884        steps.push(FkChildStep {
2885            child_table,
2886            action: FkChildAction::SetNull { positions, columns },
2887        });
2888    }
2889    for (child_table, entries) in setdefault_plan {
2890        let mut positions = Vec::with_capacity(entries.len());
2891        let mut columns = Vec::with_capacity(entries.len());
2892        let mut defaults = Vec::with_capacity(entries.len());
2893        for ((p, c), v) in entries {
2894            positions.push(p);
2895            columns.push(c);
2896            defaults.push(v);
2897        }
2898        steps.push(FkChildStep {
2899            child_table,
2900            action: FkChildAction::SetDefault {
2901                positions,
2902                columns,
2903                defaults,
2904            },
2905        });
2906    }
2907    for (child_table, positions) in delete_plan {
2908        steps.push(FkChildStep {
2909            child_table,
2910            action: FkChildAction::Delete {
2911                positions: positions.into_iter().collect(),
2912            },
2913        });
2914    }
2915    Ok(steps)
2916}
2917
2918/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
2919/// PK/UNIQUE columns. Walks every other table whose FK references
2920/// `parent_table_name`; for each FK whose parent_columns overlap a
2921/// mutated column, decides the action by `fk.on_update`.
2922///
2923///   - RESTRICT / NoAction → error if any child references the OLD
2924///     value
2925///   - CASCADE → child FK columns get rewritten to the NEW parent
2926///     value (a SetNull-style update step with the new value)
2927///   - SetNull → child FK columns set to NULL
2928///   - SetDefault → child FK columns set to declared default
2929///
2930/// `plan_with_old` is `(row_position, old_values, new_values)` so
2931/// the planner can detect "did this row's parent key actually
2932/// change?" — only rows where at least one referenced parent
2933/// column moved trigger inbound work.
2934pub(crate) fn plan_fk_parent_updates(
2935    catalog: &Catalog,
2936    parent_table_name: &str,
2937    plan_with_old: &[(usize, Vec<Value<'static>>, Vec<Value<'static>>)],
2938) -> Result<Vec<FkChildStep>, EngineError> {
2939    use alloc::collections::BTreeMap;
2940    if plan_with_old.is_empty() {
2941        return Ok(Vec::new());
2942    }
2943    // For each child table we may touch, build per-child step
2944    // lists. UPDATE never deletes children — `delete_plan` stays
2945    // empty here but is kept structurally aligned with
2946    // `plan_fk_parent_deletions` for future use.
2947    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
2948    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
2949        BTreeMap::new();
2950    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
2951    // Cascade-update plan: child_table → row_idx → col_idx → new_value
2952    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
2953
2954    for child_name in catalog.table_names() {
2955        let child = catalog
2956            .get(&child_name)
2957            .expect("table_names → catalog.get total");
2958        for fk in &child.schema().foreign_keys {
2959            if fk.parent_table != parent_table_name {
2960                continue;
2961            }
2962            for (_pos, old_row, new_row) in plan_with_old {
2963                // Did any parent FK column change?
2964                let key_changed = fk
2965                    .parent_columns
2966                    .iter()
2967                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
2968                if !key_changed {
2969                    continue;
2970                }
2971                // The OLD parent key — used to find referring children.
2972                let old_key: Vec<&Value> =
2973                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
2974                if old_key.iter().any(|v| matches!(v, Value::Null)) {
2975                    // NULL parent has no children — skip.
2976                    continue;
2977                }
2978                let new_key: Vec<&Value> =
2979                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
2980                // v7.36 (cold-tier coverage) — UPDATE-cascade FK
2981                // planner mirrors DELETE: any cold child referencing
2982                // the OLD parent key would be silently skipped, so
2983                // RESTRICT misses violations and Cascade/SetNull/
2984                // SetDefault orphans the cold child. Raise explicitly.
2985                if iter_cold_rows_of_parent(catalog, child).iter().any(|crow| {
2986                    fk.local_columns
2987                        .iter()
2988                        .enumerate()
2989                        .all(|(i, &li)| crow.values.get(li) == Some(old_key[i]))
2990                }) {
2991                    return Err(EngineError::Unsupported(alloc::format!(
2992                        "UPDATE on {parent_table_name:?}: cold-tier child row in \
2993                         {child_name:?} references the changing parent key; cold-tier \
2994                         mutation by this FK action is a v7.37 candidate. Run COMPACT \
2995                         or move the cold rows back to the hot tier and retry."
2996                    )));
2997                }
2998                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
2999                    // Self-ref same-row updates: a row updating its
3000                    // own PK doesn't restrict itself.
3001                    if child_name == parent_table_name
3002                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
3003                    {
3004                        continue;
3005                    }
3006                    let matches_key = fk
3007                        .local_columns
3008                        .iter()
3009                        .enumerate()
3010                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
3011                    if !matches_key {
3012                        continue;
3013                    }
3014                    match fk.on_update {
3015                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
3016                            return Err(EngineError::Unsupported(fk_restrict_message(
3017                                catalog,
3018                                parent_table_name,
3019                                child,
3020                                &child_name,
3021                                fk,
3022                                &old_key,
3023                                fk.on_update,
3024                            )));
3025                        }
3026                        spg_storage::FkAction::Cascade => {
3027                            // Rewrite child FK columns to new key.
3028                            let entry = cascade_plan.entry(child_name.clone()).or_default();
3029                            for (i, &li) in fk.local_columns.iter().enumerate() {
3030                                entry.insert((child_row_idx, li), new_key[i].clone());
3031                            }
3032                        }
3033                        spg_storage::FkAction::SetNull => {
3034                            for &li in &fk.local_columns {
3035                                let col = child.schema().columns.get(li).ok_or_else(|| {
3036                                    EngineError::Unsupported(alloc::format!(
3037                                        "FK local column {li} missing in {child_name:?}"
3038                                    ))
3039                                })?;
3040                                if !col.nullable {
3041                                    return Err(EngineError::Unsupported(alloc::format!(
3042                                        "FOREIGN KEY ON UPDATE SET NULL: column \
3043                                         {child_name:?}.{:?} is NOT NULL",
3044                                        col.name,
3045                                    )));
3046                                }
3047                            }
3048                            let entry = setnull_plan.entry(child_name.clone()).or_default();
3049                            for &li in &fk.local_columns {
3050                                entry.insert((child_row_idx, li));
3051                            }
3052                        }
3053                        spg_storage::FkAction::SetDefault => {
3054                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
3055                            for &li in &fk.local_columns {
3056                                let col = child.schema().columns.get(li).ok_or_else(|| {
3057                                    EngineError::Unsupported(alloc::format!(
3058                                        "FK local column {li} missing in {child_name:?}"
3059                                    ))
3060                                })?;
3061                                let default = col.default.clone().ok_or_else(|| {
3062                                    EngineError::Unsupported(alloc::format!(
3063                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
3064                                         {child_name:?}.{:?} has no DEFAULT",
3065                                        col.name,
3066                                    ))
3067                                })?;
3068                                entry.insert((child_row_idx, li), default);
3069                            }
3070                        }
3071                    }
3072                }
3073            }
3074        }
3075    }
3076    // Flatten into FkChildStep list. UPDATE doesn't produce
3077    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
3078    let mut steps: Vec<FkChildStep> = Vec::new();
3079    for (child_table, entries) in cascade_plan {
3080        let mut positions = Vec::with_capacity(entries.len());
3081        let mut columns = Vec::with_capacity(entries.len());
3082        let mut defaults = Vec::with_capacity(entries.len());
3083        for ((p, c), v) in entries {
3084            positions.push(p);
3085            columns.push(c);
3086            defaults.push(v);
3087        }
3088        // We reuse `FkChildAction::SetDefault` for cascade-update:
3089        // both shapes are "write a known value into specific cells"
3090        // — `apply_per_cell_writes` doesn't care whether the value
3091        // came from a DEFAULT declaration or a new parent key.
3092        steps.push(FkChildStep {
3093            child_table,
3094            action: FkChildAction::SetDefault {
3095                positions,
3096                columns,
3097                defaults,
3098            },
3099        });
3100    }
3101    for (child_table, entries) in setnull_plan {
3102        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
3103        steps.push(FkChildStep {
3104            child_table,
3105            action: FkChildAction::SetNull { positions, columns },
3106        });
3107    }
3108    for (child_table, entries) in setdefault_plan {
3109        let mut positions = Vec::with_capacity(entries.len());
3110        let mut columns = Vec::with_capacity(entries.len());
3111        let mut defaults = Vec::with_capacity(entries.len());
3112        for ((p, c), v) in entries {
3113            positions.push(p);
3114            columns.push(c);
3115            defaults.push(v);
3116        }
3117        steps.push(FkChildStep {
3118            child_table,
3119            action: FkChildAction::SetDefault {
3120                positions,
3121                columns,
3122                defaults,
3123            },
3124        });
3125    }
3126    let _ = delete_plan; // UPDATE never deletes children.
3127    Ok(steps)
3128}
3129
3130/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
3131/// the three action variants so the DELETE executor stays a
3132/// simple loop over the planned steps.
3133pub(crate) fn apply_fk_child_step(
3134    catalog: &mut Catalog,
3135    step: &FkChildStep,
3136) -> Result<(), EngineError> {
3137    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
3138        EngineError::Storage(StorageError::TableNotFound {
3139            name: step.child_table.clone(),
3140        })
3141    })?;
3142    match &step.action {
3143        FkChildAction::Delete { positions } => {
3144            let _ = child.delete_rows(positions);
3145        }
3146        FkChildAction::SetNull { positions, columns } => {
3147            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
3148        }
3149        FkChildAction::SetDefault {
3150            positions,
3151            columns,
3152            defaults,
3153        } => {
3154            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
3155        }
3156    }
3157    Ok(())
3158}
3159
3160/// v7.6.5 — write new values into selected child cells via
3161/// `Table::update_row` (the catalog's existing UPDATE entry).
3162/// Groups writes by row position so multi-column updates on the
3163/// same row only call `update_row` once. `value_for(i)` produces
3164/// the new value for the i-th (position, column) entry.
3165fn apply_per_cell_writes(
3166    child: &mut spg_storage::Table,
3167    positions: &[usize],
3168    columns: &[usize],
3169    mut value_for: impl FnMut(usize) -> Value<'static>,
3170) -> Result<(), EngineError> {
3171    use alloc::collections::BTreeMap;
3172    let mut by_row: BTreeMap<usize, Vec<(usize, Value<'static>)>> = BTreeMap::new();
3173    for i in 0..positions.len() {
3174        by_row
3175            .entry(positions[i])
3176            .or_default()
3177            .push((columns[i], value_for(i)));
3178    }
3179    for (pos, mutations) in by_row {
3180        let mut new_values = child.rows()[pos].values.clone();
3181        for (col, v) in mutations {
3182            if let Some(slot) = new_values.get_mut(col) {
3183                *slot = v;
3184            }
3185        }
3186        child
3187            .update_row(pos, new_values)
3188            .map_err(EngineError::Storage)?;
3189    }
3190    Ok(())
3191}
3192
3193fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
3194    match a {
3195        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
3196        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
3197        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
3198        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
3199        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
3200    }
3201}
3202
3203impl Engine {
3204    /// v7.14.0 — resolve every queued FK whose installation was
3205    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
3206    /// `set_session_param` when checks flip back on and by the
3207    /// drop-import release gate. Each FK is resolved against the
3208    /// current catalog; remaining missing-parent errors propagate
3209    /// up so the caller knows the import was incomplete.
3210    pub(crate) fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
3211        let pending = core::mem::take(&mut self.pending_foreign_keys);
3212        for (child, fk) in pending {
3213            // Resolve against the current catalog. Skip silently
3214            // when the child table itself was dropped between
3215            // queue + drain.
3216            let cols_snapshot = match self.active_catalog().get(&child) {
3217                Some(t) => t.schema().columns.clone(),
3218                None => continue,
3219            };
3220            let storage_fk =
3221                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
3222            let table = self
3223                .active_catalog_mut()
3224                .get_mut(&child)
3225                .expect("checked above");
3226            table.schema_mut().foreign_keys.push(storage_fk);
3227        }
3228        Ok(())
3229    }
3230}
3231
3232impl Engine {
3233    /// v7.39 (round 288) — is this constraint deferred for the
3234    /// transaction currently running?
3235    ///
3236    /// A constraint must be DEFERRABLE to be deferred at all; among
3237    /// those, `SET CONSTRAINTS` overrides the declared timing for the
3238    /// rest of the transaction. Outside a transaction nothing can be
3239    /// deferred — there is no later point to check at.
3240    pub(crate) fn fk_is_deferred_now(&self, fk: &spg_storage::ForeignKeyConstraint) -> bool {
3241        if !fk.deferrable {
3242            return false;
3243        }
3244        let Some(tx_id) = self.current_tx else {
3245            return false;
3246        };
3247        let Some(st) = self.tx_catalogs.get(&tx_id) else {
3248            return false;
3249        };
3250        fk_deferred_in(st, fk)
3251    }
3252
3253    /// The FKs of `table` that must be checked at THIS statement.
3254    pub(crate) fn immediate_fks(
3255        &self,
3256        fks: &[spg_storage::ForeignKeyConstraint],
3257    ) -> alloc::vec::Vec<spg_storage::ForeignKeyConstraint> {
3258        fks.iter()
3259            .filter(|fk| !self.fk_is_deferred_now(fk))
3260            .cloned()
3261            .collect()
3262    }
3263
3264    /// v7.39 (round 288) — run every deferred FK check that this
3265    /// transaction has postponed. Called at COMMIT, and by
3266    /// `SET CONSTRAINTS … IMMEDIATE`, which is where PG runs them too.
3267    ///
3268    /// The whole table is re-verified rather than a queue of rows
3269    /// replayed: a row inserted early can be updated or deleted later
3270    /// in the same transaction, and a queued copy would then be
3271    /// checked against a value that no longer exists.
3272    pub(crate) fn run_deferred_fk_checks(&mut self) -> Result<(), EngineError> {
3273        self.run_deferred_fk_checks_inner(None)
3274    }
3275
3276    /// v7.39 (round 308, V29) — the same sweep, narrowed to the
3277    /// constraints a NAMED `SET CONSTRAINTS … IMMEDIATE` listed. The
3278    /// ones it did not name stay queued for COMMIT, which is what PG
3279    /// does: draining everything would report a violation the statement
3280    /// never asked about.
3281    pub(crate) fn run_deferred_fk_checks_for(
3282        &mut self,
3283        names: &[String],
3284    ) -> Result<(), EngineError> {
3285        self.run_deferred_fk_checks_inner(Some(names))
3286    }
3287
3288    fn run_deferred_fk_checks_inner(&mut self, only: Option<&[String]>) -> Result<(), EngineError> {
3289        let Some(tx_id) = self.current_tx else {
3290            return Ok(());
3291        };
3292        let Some(st) = self.tx_catalogs.get(&tx_id) else {
3293            return Ok(());
3294        };
3295        let tables: alloc::vec::Vec<String> = st.touched_tables.iter().cloned().collect();
3296        let deferred_now = |fk: &spg_storage::ForeignKeyConstraint| {
3297            if let Some(names) = only
3298                && !fk
3299                    .name
3300                    .as_deref()
3301                    .is_some_and(|n| names.iter().any(|w| w == n))
3302            {
3303                return false;
3304            }
3305            fk.deferrable && fk_deferred_in(st, fk)
3306        };
3307        for tname in &tables {
3308            let Some(t) = st.catalog.get(tname) else {
3309                continue;
3310            };
3311            let fks: alloc::vec::Vec<_> = t
3312                .schema()
3313                .foreign_keys
3314                .iter()
3315                .filter(|f| deferred_now(f))
3316                .cloned()
3317                .collect();
3318            if fks.is_empty() {
3319                continue;
3320            }
3321            // `rows()` includes MVCC tombstones. A row inserted and then
3322            // deleted inside this same transaction must NOT be checked —
3323            // PG commits that cleanly — so skip the dead ones, the way
3324            // the rest of this module already does.
3325            let rows: alloc::vec::Vec<alloc::vec::Vec<Value<'static>>> = t
3326                .rows()
3327                .iter()
3328                .enumerate()
3329                .filter(|(i, _)| !t.headers().get(*i).is_some_and(|h| h.is_deleted()))
3330                .map(|(_, r)| r.values.clone())
3331                .collect();
3332            enforce_fk_inserts(&st.catalog, tname, &fks, &rows)?;
3333        }
3334        // v7.39 (round 712) — and the deferred PK/UNIQUE constraints,
3335        // through the whole-table validator (the rows are already in the
3336        // table at this point; see its doc for why the insert-time probe
3337        // cannot be reused).
3338        for tname in &tables {
3339            let Some(t) = st.catalog.get(tname) else {
3340                continue;
3341            };
3342            let deferred_ucs: alloc::vec::Vec<(
3343                spg_storage::UniquenessConstraint,
3344                alloc::string::String,
3345            )> = t
3346                .schema()
3347                .uniqueness_constraints
3348                .iter()
3349                .filter(|uc| uc.deferrable)
3350                .map(|uc| {
3351                    let conname = crate::system_catalog::pg_unique_conname(t, uc, tname);
3352                    (uc.clone(), conname)
3353                })
3354                .filter(|(uc, conname)| {
3355                    if let Some(names) = only
3356                        && !names.iter().any(|w| w == conname)
3357                    {
3358                        return false;
3359                    }
3360                    uc_deferred_in(st, uc, conname)
3361                })
3362                .collect();
3363            for (uc, _) in &deferred_ucs {
3364                validate_uniqueness_whole_table(&st.catalog, tname, uc, self.backslash_escapes)?;
3365            }
3366        }
3367        Ok(())
3368    }
3369}
3370
3371/// v7.39 (round 308, V29) — is this FK deferred right now, per the
3372/// transaction's `SET CONSTRAINTS` state?
3373///
3374/// A NAMED setting wins over the blanket one, so `ALL DEFERRED` followed
3375/// by `fk_a IMMEDIATE` leaves fk_a immediate and the rest deferred; with
3376/// neither, the constraint's own declared timing decides. A constraint
3377/// the catalog holds without a name is reachable only by the blanket
3378/// form, which is also true in PG for a constraint nobody named.
3379///
3380/// One function, because the COMMIT-time sweep and the per-statement
3381/// check both ask — and the pair drifting apart is exactly how a
3382/// deferred violation would slip through a successful COMMIT.
3383/// Answers the timing question only; `deferrable` is the caller's gate.
3384/// v7.39 (round 712) — the PK/UNIQUE twin of [`fk_deferred_in`], now that
3385/// round 711 stores the flags. `conname` is the RESOLVED name (stored, or
3386/// the `<table>_pkey` form `pg_unique_conname` synthesises) so that
3387/// `SET CONSTRAINTS d711_pkey …` reaches an unnamed constraint the same
3388/// way it does in PG.
3389pub(crate) fn uc_deferred_in(
3390    st: &crate::TxState,
3391    uc: &spg_storage::UniquenessConstraint,
3392    conname: &str,
3393) -> bool {
3394    if let Some(explicit) = st.constraints_deferred_by_name.get(conname) {
3395        return *explicit;
3396    }
3397    st.constraints_deferred.unwrap_or(uc.initially_deferred)
3398}
3399
3400/// v7.39 (round 712) — whole-table uniqueness validation, for the COMMIT
3401/// sweep. `enforce_uniqueness_inserts` probes NEW rows against the table;
3402/// at COMMIT the rows are already IN the table, so probing them there
3403/// would collide with themselves. This walks the live rows once per
3404/// constraint and asks the only question left: do two of them share a key?
3405pub(crate) fn validate_uniqueness_whole_table(
3406    catalog: &Catalog,
3407    tname: &str,
3408    uc: &spg_storage::UniquenessConstraint,
3409    mysql: bool,
3410) -> Result<(), EngineError> {
3411    let Some(table) = catalog.get(tname) else {
3412        return Ok(());
3413    };
3414    let schema = table.schema();
3415    let mut seen: hashbrown::HashSet<alloc::string::String> = hashbrown::HashSet::new();
3416    for (i, row) in table.rows().iter().enumerate() {
3417        if table.headers().get(i).is_some_and(|h| h.is_deleted()) {
3418            continue;
3419        }
3420        let key: Vec<Value<'static>> = uc
3421            .columns
3422            .iter()
3423            .map(|&ci| {
3424                let v = row.values.get(ci).cloned().unwrap_or(Value::Null);
3425                collated_key_cell(&v, ci, schema, mysql)
3426            })
3427            .collect();
3428        // NULL keys pass each other unless NULLS NOT DISTINCT — the same
3429        // rule the statement-time check applies.
3430        if !uc.nulls_not_distinct && key.iter().any(Value::is_null) {
3431            continue;
3432        }
3433        let encoded = alloc::format!("{key:?}");
3434        if !seen.insert(encoded) {
3435            let conname = crate::system_catalog::pg_unique_conname(table, uc, tname);
3436            let detail = unique_key_detail(
3437                &uc.columns
3438                    .iter()
3439                    .map(|&ci| schema.columns[ci].name.clone())
3440                    .collect::<Vec<_>>(),
3441                &key,
3442            );
3443            return Err(EngineError::Unsupported(alloc::format!(
3444                "duplicate key value violates unique constraint \"{conname}\" \
3445                 on table \"{tname}\"{detail}"
3446            )));
3447        }
3448    }
3449    Ok(())
3450}
3451
3452pub(crate) fn fk_deferred_in(st: &crate::TxState, fk: &spg_storage::ForeignKeyConstraint) -> bool {
3453    if let Some(name) = fk.name.as_deref()
3454        && let Some(explicit) = st.constraints_deferred_by_name.get(name)
3455    {
3456        return *explicit;
3457    }
3458    st.constraints_deferred.unwrap_or(fk.initially_deferred)
3459}
3460
3461impl crate::Engine {
3462    /// v7.39 (round 308, V29) — `SET CONSTRAINTS { ALL | name [, …] }
3463    /// { DEFERRED | IMMEDIATE }`.
3464    ///
3465    /// The named form used to be parsed as if it said ALL, so
3466    /// `SET CONSTRAINTS fk_a DEFERRED` deferred every deferrable
3467    /// constraint in the transaction — a violation on some OTHER table
3468    /// then sailed past the statement that caused it. Measured against
3469    /// PG 18.4: naming a constraint affects only that one, an unknown
3470    /// name is an error, and naming a constraint that is not deferrable
3471    /// is a different error.
3472    pub(crate) fn exec_set_constraints(
3473        &mut self,
3474        names: &[alloc::string::String],
3475        deferred: bool,
3476    ) -> Result<crate::QueryResult, EngineError> {
3477        // v7.39 (round 318, V41) — outside a transaction block the command
3478        // succeeds but cannot do anything: the setting dies with the
3479        // implicit single-statement transaction it was made in. PG says so
3480        // and still reports SET CONSTRAINTS; SPG used to succeed silently.
3481        // Per-SLOT, not the global flag: another connection's open block
3482        // must not make this one look like it is inside one.
3483        if !self.current_tx.is_some_and(|tx| self.is_tx_open(tx)) {
3484            self.warning(alloc::string::String::from(
3485                "SET CONSTRAINTS can only be used in transaction blocks",
3486            ));
3487        }
3488        // Validate every name BEFORE anything changes, so a list with a
3489        // bad entry leaves the transaction's timing untouched.
3490        for n in names {
3491            match self.find_fk_by_name(n) {
3492                Some(fk) if fk.deferrable => {}
3493                Some(_) => {
3494                    return Err(EngineError::Unsupported(alloc::format!(
3495                        "constraint \"{n}\" is not deferrable"
3496                    )));
3497                }
3498                // v7.39 (round 712) — a PK/UNIQUE constraint answers to
3499                // SET CONSTRAINTS too, by stored or synthesised name.
3500                None => match self.find_uc_by_name(n) {
3501                    Some(uc) if uc.deferrable => {}
3502                    Some(_) => {
3503                        return Err(EngineError::Unsupported(alloc::format!(
3504                            "constraint \"{n}\" is not deferrable"
3505                        )));
3506                    }
3507                    None => {
3508                        return Err(EngineError::Unsupported(alloc::format!(
3509                            "constraint \"{n}\" does not exist"
3510                        )));
3511                    }
3512                },
3513            }
3514        }
3515        // Order matters: run what is CURRENTLY deferred first, then
3516        // change the mode. Flipping to immediate first empties the set
3517        // the check walks, so the pending violation sailed through to a
3518        // successful COMMIT (round 288's lesson). With names, only the
3519        // named constraints are drained — the others stay queued.
3520        if !deferred {
3521            if names.is_empty() {
3522                self.run_deferred_fk_checks()?;
3523            } else {
3524                self.run_deferred_fk_checks_for(names)?;
3525            }
3526        }
3527        if let Some(tx_id) = self.current_tx
3528            && let Some(st) = self.tx_catalogs.get_mut(&tx_id)
3529        {
3530            if names.is_empty() {
3531                // A blanket setting replaces the whole picture, so the
3532                // per-name overrides go with it — that is what lets a
3533                // later `ALL DEFERRED` win over an earlier named one.
3534                st.constraints_deferred = Some(deferred);
3535                st.constraints_deferred_by_name.clear();
3536            } else {
3537                for n in names {
3538                    st.constraints_deferred_by_name.insert(n.clone(), deferred);
3539                }
3540            }
3541        }
3542        Ok(crate::QueryResult::CommandOk {
3543            affected: 0,
3544            modified_catalog: false,
3545        })
3546    }
3547
3548    /// The FK carrying this constraint name, from anywhere in the active
3549    /// catalog. PG resolves a bare name across the search path and does
3550    /// not complain when two tables share one — every match is affected —
3551    /// so this only has to answer whether SOME constraint owns the name,
3552    /// and what its deferrability is.
3553    /// v7.39 (round 712) — the PK/UNIQUE twin, matching the stored name or
3554    /// the synthesised `<table>_pkey` / `<table>_<col>_key` form.
3555    fn find_uc_by_name(&self, name: &str) -> Option<spg_storage::UniquenessConstraint> {
3556        let cat = self.active_catalog();
3557        cat.table_names().into_iter().find_map(|tname| {
3558            let t = cat.get(&tname)?;
3559            t.schema()
3560                .uniqueness_constraints
3561                .iter()
3562                .find(|uc| crate::system_catalog::pg_unique_conname(t, uc, &tname) == name)
3563                .cloned()
3564        })
3565    }
3566
3567    fn find_fk_by_name(&self, name: &str) -> Option<spg_storage::ForeignKeyConstraint> {
3568        let cat = self.active_catalog();
3569        cat.table_names().into_iter().find_map(|t| {
3570            cat.get(&t).and_then(|tbl| {
3571                tbl.schema()
3572                    .foreign_keys
3573                    .iter()
3574                    .find(|fk| fk.name.as_deref() == Some(name))
3575                    .cloned()
3576            })
3577        })
3578    }
3579}
3580
3581/// v7.39 (round 652) — scan the rows already in `table` against one CHECK
3582/// predicate, the way PG does when `ALTER TABLE … ADD CONSTRAINT … CHECK`
3583/// arrives without `NOT VALID` (and when `VALIDATE CONSTRAINT` runs later).
3584///
3585/// Returns `Ok(())` when every live row satisfies it. A row that evaluates
3586/// to definite-false gets PG's 23514 wording for this case, which is NOT
3587/// the per-row INSERT wording: PG names the relation and says "is violated
3588/// by some row" without quoting the row.
3589///
3590/// Tombstoned rows are skipped. They are physically present until vacuum,
3591/// and a row someone already deleted must not be able to refuse a
3592/// constraint the visible table satisfies.
3593pub fn validate_check_against_existing_rows(
3594    table: &spg_storage::Table,
3595    table_name: &str,
3596    conname: &str,
3597    expr_src: &str,
3598) -> Result<(), EngineError> {
3599    let expr = spg_sql::parser::parse_expression(expr_src).map_err(|e| {
3600        EngineError::Unsupported(alloc::format!(
3601            "CHECK constraint {conname:?} on {table_name:?} ({expr_src:?}) failed to parse: {e:?}"
3602        ))
3603    })?;
3604    let schema = table.schema();
3605    let ctx = eval::EvalContext::new(&schema.columns, None);
3606    let headers = table.headers();
3607    for (i, row) in table.rows().iter().enumerate() {
3608        if headers
3609            .get(i)
3610            .is_some_and(|h| h.xmax != spg_storage::row_header::XMAX_ALIVE)
3611        {
3612            continue;
3613        }
3614        let v = eval::eval_expr(&expr, row, &ctx).map_err(|e| {
3615            EngineError::Unsupported(alloc::format!(
3616                "CHECK constraint {conname:?} on {table_name:?} eval at row #{i}: {e:?}"
3617            ))
3618        })?;
3619        // As on the INSERT path: NULL passes, only definite-false refuses.
3620        if matches!(v, spg_storage::Value::Bool(false)) {
3621            return Err(EngineError::Unsupported(alloc::format!(
3622                "check constraint \"{conname}\" of relation \"{table_name}\" \
3623                 is violated by some row"
3624            )));
3625        }
3626    }
3627    Ok(())
3628}