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