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