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