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