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