Skip to main content

spg_engine/
constraints.rs

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