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}