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