spg_engine/ddl.rs
1//! DDL execution — every CREATE / DROP / ALTER for schema objects:
2//! tables and indexes, plus users, functions, triggers, sequences,
3//! views, types, domains, schemas, and materialized views. Lifted out
4//! of `lib.rs` (v7.32 engine modularisation). These `impl Engine`
5//! methods are dispatched from `Engine::execute` (hence pub(crate)) and
6//! drive the catalog / storage schema mutations.
7
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10
11use spg_sql::ast::{
12 ColumnDef, CreateIndexStatement, CreateTableStatement, CreateUserStatement, Expr, IndexMethod,
13 Literal, PartitionKindAst, PartitionOfBoundsAst, Statement, VecEncoding as SqlVecEncoding,
14};
15use spg_storage::{
16 ColumnSchema, DataType, ExclusionConstraint, PartitionKind, PartitionRole, RangeKind,
17 StorageError, TableSchema, Value, VecEncoding,
18};
19
20/// v7.39 (round 215) — the column an EXCLUDE constraint's range-overlap index
21/// should key on: the `&&` element sitting on an integer-keyable range column
22/// (int4/int8/date/ts/tstz range — the kinds `range_excl_index_key` reduces to
23/// an `i128`). `None` when no element qualifies (numrange, or a non-`&&`
24/// operator only), in which case the constraint keeps the O(n) enforcement.
25fn excl_index_column(schema: &TableSchema, ex: &ExclusionConstraint) -> Option<usize> {
26 for (pos, op) in &ex.elements {
27 if op == "&&"
28 && let Some(col) = schema.columns.get(*pos)
29 && matches!(
30 col.ty,
31 DataType::Range(
32 RangeKind::Int4
33 | RangeKind::Int8
34 | RangeKind::Date
35 | RangeKind::Ts
36 | RangeKind::TsTz
37 )
38 )
39 {
40 return Some(*pos);
41 }
42 }
43 None
44}
45
46/// v7.39 (round 215) — rebuild the range-exclusion indexes for every table in
47/// a freshly-deserialized catalog. The indexes aren't persisted (like BRIN,
48/// they re-derive), so a catalog load must re-emit them from the persisted
49/// exclusion constraints + rows before the first EXCLUDE enforcement runs.
50pub(crate) fn rebuild_all_excl_indexes(cat: &mut spg_storage::Catalog) {
51 for name in cat.table_names() {
52 let Some(table) = cat.get_mut(&name) else {
53 continue;
54 };
55 let cols: Vec<usize> = table
56 .schema()
57 .exclusion_constraints
58 .iter()
59 .filter_map(|ex| excl_index_column(table.schema(), ex))
60 .collect();
61 for c in cols {
62 table.ensure_excl_range_index(c);
63 }
64 }
65}
66
67use crate::{
68 CancelToken, ClockFn, Engine, EngineError, QueryResult, check_existing_unique_violation,
69 coerce_value, column_type_to_data_type, enforce_fk_inserts, eval, infer_column_types,
70 literal_expr_to_value, resolve_foreign_key, rewrite_column_in_source, users,
71};
72
73/// v7.39 (round 475) — the column a `to_tsvector(…)` index key reads.
74///
75/// PG's full-text idiom is `CREATE INDEX … USING gin (to_tsvector('simple',
76/// body))`, and it is the reason a PG schema reaches the expression path at
77/// all. SPG already builds a fulltext GIN over a column for MySQL's
78/// `FULLTEXT KEY`; this recognises the shape so the PG spelling lands on the
79/// same index instead of being refused.
80///
81/// `None` for anything else, including `to_tsvector` over an expression
82/// rather than a bare column — indexing a derived value is a different
83/// build, and guessing at it would be worse than refusing.
84fn tsvector_source_column(e: &spg_sql::ast::Expr) -> Option<String> {
85 let spg_sql::ast::Expr::FunctionCall { name, args } = e else {
86 return None;
87 };
88 if !name.eq_ignore_ascii_case("to_tsvector") {
89 return None;
90 }
91 // `to_tsvector(col)` or `to_tsvector(config, col)` — either way the
92 // column is the last argument.
93 match args.last() {
94 Some(spg_sql::ast::Expr::Column(c)) => Some(c.name.clone()),
95 _ => None,
96 }
97}
98
99impl Engine {
100 /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
101 /// arm. Currently the only setting is `hot_tier_bytes`; later
102 /// v6.7.x can extend `AlterTableTarget` without touching this
103 /// arm structure.
104 pub(crate) fn exec_alter_table(
105 &mut self,
106 s: spg_sql::ast::AlterTableStatement,
107 ) -> Result<QueryResult, EngineError> {
108 // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
109 // On first error the statement aborts; subactions already
110 // applied stay (no transactional rollback in v7.13 — wrap in
111 // BEGIN/COMMIT if atomicity matters).
112 let table_name = s.name.clone();
113 // v7.39 (round 735, S14/B3) — any table-shape change invalidates
114 // a dependent materialized view's refresh watermark.
115 self.bump_table_change(&table_name);
116 for target in s.targets {
117 self.exec_alter_table_subaction(&table_name, target)?;
118 }
119 // v7.39 (round 215) — (re)build range-exclusion indexes after any
120 // ALTER: ADD EXCLUDE installs a new one; DROP COLUMN cleared them (it
121 // shifts positions), so this restores them from the constraints'
122 // updated column positions. Idempotent for the untouched case.
123 self.install_excl_range_indexes(&table_name);
124 Ok(QueryResult::CommandOk {
125 affected: 0,
126 modified_catalog: self.catalog_change_is_committed(),
127 })
128 }
129
130 pub(crate) fn exec_alter_table_subaction(
131 &mut self,
132 table_name_outer: &str,
133 target: spg_sql::ast::AlterTableTarget,
134 ) -> Result<(), EngineError> {
135 use spg_sql::ast::AlterTableTarget as T;
136 let tbl = table_name_outer;
137 match target {
138 // v7.39 (round 647) — attach or detach an inheritance child.
139 // Accepted-and-ignored since v7.37.18, whose reasoning ("SPG
140 // doesn't support PG-style inheritance") round 645 made
141 // false. `NO INHERIT` reporting success while the child
142 // stayed attached is the worst shape a statement can have.
143 T::Inherit { parent, detach } => self.alter_inherit(tbl, &parent, detach),
144 T::SetHotTierBytes(n) => self.alter_set_hot_tier_bytes(tbl, n),
145 T::AddForeignKey(fk) => self.alter_add_foreign_key(tbl, fk),
146 T::DropForeignKey { name, if_exists } => {
147 self.alter_drop_foreign_key(tbl, name, if_exists)
148 }
149 // v7.39 (round 431) — `ALTER TABLE t DROP {INDEX|KEY} name`
150 // shares the standalone DROP INDEX path, so the two spellings
151 // cannot diverge on the not-found / IF EXISTS behaviour.
152 T::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists).map(|_| ()),
153 T::AddColumn {
154 column,
155 if_not_exists,
156 } => self.alter_add_column(tbl, column, if_not_exists),
157 T::AlterColumnType {
158 column,
159 new_type,
160 using,
161 collation,
162 } => self.alter_column_type(tbl, column, new_type, using, collation),
163 T::AddTableConstraint(tc) => self.alter_add_table_constraint(tbl, tc),
164 T::ValidateConstraint { name } => self.alter_validate_constraint(tbl, &name),
165 // v7.39 (round 652) — SPG is single-owner and has no
166 // clustered storage, so both of these remain no-ops once the
167 // name checks out. What was missing was the check.
168 T::OwnerTo { role } => {
169 if self.role_exists(&role) {
170 Ok(())
171 } else {
172 Err(EngineError::Unsupported(alloc::format!(
173 "role \"{role}\" does not exist"
174 )))
175 }
176 }
177 // v7.39 (round 710) — same shape as OwnerTo/ClusterOn above:
178 // the ACTION no-ops, the NAME check is what was missing.
179 T::OfType { type_name } => {
180 let cat = self.active_catalog();
181 if cat.enum_types().contains_key(&type_name)
182 || cat.domain_types().contains_key(&type_name)
183 || cat.composite_types().contains_key(&type_name)
184 {
185 Ok(())
186 } else {
187 Err(EngineError::Unsupported(alloc::format!(
188 "type \"{type_name}\" does not exist"
189 )))
190 }
191 }
192 T::ReplicaIdentityUsingIndex { index } => {
193 let table = self.active_catalog().get(tbl).ok_or_else(|| {
194 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
195 })?;
196 if table
197 .indices()
198 .iter()
199 .any(|i| i.name.eq_ignore_ascii_case(&index))
200 {
201 Ok(())
202 } else {
203 Err(EngineError::Unsupported(alloc::format!(
204 "index \"{index}\" for table \"{tbl}\" does not exist"
205 )))
206 }
207 }
208 T::ClusterOn { index } => {
209 let Some(index) = index else { return Ok(()) };
210 let table = self.active_catalog().get(tbl).ok_or_else(|| {
211 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
212 })?;
213 if table
214 .indices()
215 .iter()
216 .any(|i| i.name.eq_ignore_ascii_case(&index))
217 {
218 Ok(())
219 } else {
220 Err(EngineError::Unsupported(alloc::format!(
221 "index \"{index}\" for table \"{tbl}\" does not exist"
222 )))
223 }
224 }
225 T::DropColumn {
226 column,
227 if_exists,
228 cascade,
229 } => self.alter_drop_column(tbl, column, if_exists, cascade),
230 T::SetTriggerEnabled { which, enabled } => {
231 self.alter_set_trigger_enabled(tbl, which, enabled)
232 }
233 T::SetColumnAutoIncrement { column, seq_name } => {
234 self.alter_set_column_auto_increment(tbl, column, seq_name)
235 }
236 T::RenameTable { new } => self.alter_rename_table(tbl, new),
237 T::RenameColumn { old, new } => self.alter_rename_column(tbl, old, new),
238 T::RenameConstraint { old, new } => self.alter_rename_constraint(tbl, &old, new),
239 T::AttachPartition { child, bounds } => self.alter_attach_partition(tbl, child, bounds),
240 T::DetachPartition {
241 child,
242 concurrently,
243 finalize,
244 } => self.alter_detach_partition(tbl, child, concurrently, finalize),
245 T::AlterColumnSetDefault {
246 column,
247 default_expr,
248 } => self.alter_column_set_default(tbl, column, default_expr),
249 T::AlterColumnDropDefault { column } => self.alter_column_drop_default(tbl, column),
250 T::AlterColumnSetNotNull { column } => self.alter_column_set_not_null(tbl, column),
251 T::AlterColumnDropNotNull { column } => self.alter_column_drop_not_null(tbl, column),
252 // v7.39 (round 220) — RESTART [WITH n]: record the next-value
253 // floor on the identity column (max+1 alloc takes the max).
254 T::AlterColumnRestart { column, with } => {
255 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
256 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
257 })?;
258 let Some(col) = table
259 .schema_mut()
260 .columns
261 .iter_mut()
262 .find(|c| c.name.eq_ignore_ascii_case(&column))
263 else {
264 return Err(EngineError::Unsupported(alloc::format!(
265 "column \"{column}\" of relation \"{tbl}\" does not exist"
266 )));
267 };
268 col.auto_restart = Some(with.unwrap_or(1));
269 Ok(())
270 }
271 T::AlterColumnDropExpression { column, if_exists } => {
272 self.alter_column_drop_expression(tbl, column, if_exists)
273 }
274 T::AlterColumnDropIdentity { column, if_exists } => {
275 self.alter_column_drop_identity(tbl, column, if_exists)
276 }
277 T::AlterColumnSetExpression { column, expr } => {
278 self.alter_column_set_expression(tbl, column, expr)
279 }
280 T::SetRowSecurity { enabled, force } => {
281 self.alter_set_row_security(tbl, enabled, force)
282 }
283 }
284 }
285
286 /// v7.39 (RLS) — `ALTER TABLE t { ENABLE|DISABLE|FORCE|NO FORCE } ROW LEVEL
287 /// SECURITY`. Sets the schema flags (`relrowsecurity` / `relforcerowsecurity`
288 /// mirrors). Enforcement is gated on the session role (Phase 1); Phase 0
289 /// only records the flags for catalog / pg_dump fidelity.
290 fn alter_set_row_security(
291 &mut self,
292 tbl: &str,
293 enabled: Option<bool>,
294 force: Option<bool>,
295 ) -> Result<(), EngineError> {
296 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
297 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
298 })?;
299 if let Some(e) = enabled {
300 table.schema_mut().row_security = e;
301 }
302 if let Some(fo) = force {
303 table.schema_mut().force_row_security = fo;
304 }
305 Ok(())
306 }
307
308 /// v7.38 (read01 U12) — `ALTER COLUMN col SET EXPRESSION AS (expr)`
309 /// (PG 17): swap a stored generated column's expression and recompute
310 /// every existing row against the new expression.
311 fn alter_column_set_expression(
312 &mut self,
313 tbl: &str,
314 column: String,
315 expr: spg_sql::ast::Expr,
316 ) -> Result<(), EngineError> {
317 let expr_str = alloc::format!("{expr}");
318 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
319 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
320 })?;
321 let pos = table
322 .schema()
323 .columns
324 .iter()
325 .position(|c| c.name.eq_ignore_ascii_case(&column))
326 .ok_or_else(|| {
327 EngineError::Unsupported(alloc::format!(
328 "ALTER COLUMN SET EXPRESSION: column {column:?} not in table {tbl:?}"
329 ))
330 })?;
331 if table.schema().columns[pos].generated_stored_expr.is_none() {
332 return Err(EngineError::Unsupported(alloc::format!(
333 "ALTER COLUMN SET EXPRESSION: column {column:?} is not a stored generated column"
334 )));
335 }
336 table.schema_mut().columns[pos].generated_stored_expr = Some(expr_str);
337 // Recompute existing rows against the new expression.
338 let schema_cols = table.schema().columns.clone();
339 let col_ty = schema_cols[pos].ty;
340 let ctx = crate::eval::EvalContext::new(&schema_cols, None);
341 let mut new_values: Vec<Value<'static>> = Vec::with_capacity(table.rows().len());
342 for row in table.rows().iter() {
343 let v = eval::eval_expr(&expr, row, &ctx).map_err(|e| {
344 EngineError::Unsupported(alloc::format!(
345 "ALTER COLUMN SET EXPRESSION: recompute failed: {e:?}"
346 ))
347 })?;
348 new_values.push(coerce_value(v, col_ty, &column, pos)?);
349 }
350 for (i, v) in new_values.into_iter().enumerate() {
351 let mut row_values = table
352 .rows()
353 .get(i)
354 .expect("bounds-checked by the loop above")
355 .values
356 .clone();
357 row_values[pos] = v;
358 table.update_row(i, row_values)?;
359 }
360 Ok(())
361 }
362
363 /// v7.38 (read01 U10) — `ALTER COLUMN col DROP EXPRESSION` converts a
364 /// stored generated column to a plain column: clear the generation
365 /// expression so future INSERT/UPDATE accept a supplied value instead
366 /// of recomputing it. Existing stored values are left as-is.
367 fn alter_column_drop_expression(
368 &mut self,
369 tbl: &str,
370 column: String,
371 if_exists: bool,
372 ) -> Result<(), EngineError> {
373 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
374 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
375 })?;
376 let pos = table
377 .schema()
378 .columns
379 .iter()
380 .position(|c| c.name.eq_ignore_ascii_case(&column))
381 .ok_or_else(|| {
382 EngineError::Unsupported(alloc::format!(
383 "ALTER COLUMN DROP EXPRESSION: column {column:?} not in table {tbl:?}"
384 ))
385 })?;
386 if table.schema().columns[pos].generated_stored_expr.is_none() {
387 // v7.39 (round 187, U10) — PG's wordings, live-verified
388 // 2026-07-18: plain form errors, IF EXISTS raises a NOTICE
389 // and skips (`ALTER TABLE` still succeeds — pg_dump
390 // restore scripts rely on that).
391 if if_exists {
392 self.notice(alloc::format!(
393 "column \"{column}\" of relation \"{tbl}\" is not a generated column, skipping"
394 ));
395 return Ok(());
396 }
397 return Err(EngineError::Unsupported(alloc::format!(
398 "column \"{column}\" of relation \"{tbl}\" is not a generated column"
399 )));
400 }
401 table.schema_mut().columns[pos].generated_stored_expr = None;
402 Ok(())
403 }
404
405 /// v7.38 (read01, T28) — `ALTER COLUMN col DROP IDENTITY [IF EXISTS]`:
406 /// de-generate an identity column into a plain column. Errors when the
407 /// column is not an identity column, unless `IF EXISTS` was given.
408 fn alter_column_drop_identity(
409 &mut self,
410 tbl: &str,
411 column: String,
412 if_exists: bool,
413 ) -> Result<(), EngineError> {
414 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
415 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
416 })?;
417 let pos = table
418 .schema()
419 .columns
420 .iter()
421 .position(|c| c.name.eq_ignore_ascii_case(&column))
422 .ok_or_else(|| {
423 EngineError::Unsupported(alloc::format!(
424 "ALTER COLUMN DROP IDENTITY: column {column:?} not in table {tbl:?}"
425 ))
426 })?;
427 if !table.schema().columns[pos].auto_increment {
428 if if_exists {
429 return Ok(());
430 }
431 // PG18.4: `column "a" of relation "t3" is not an identity column`.
432 return Err(EngineError::Unsupported(alloc::format!(
433 "column {column:?} of relation {tbl:?} is not an identity column"
434 )));
435 }
436 table.schema_mut().columns[pos].auto_increment = false;
437 // v7.38 (read01) — a dropped identity is a plain column: clear the
438 // ALWAYS marker too so explicit INSERT values are accepted again.
439 table.schema_mut().columns[pos].identity_always = false;
440 Ok(())
441 }
442
443 /// v7.37.18 (18.1) — set / drop column default.
444 fn alter_column_set_default(
445 &mut self,
446 tbl: &str,
447 column: String,
448 default_expr: spg_sql::ast::Expr,
449 ) -> Result<(), EngineError> {
450 // Volatile defaults (now(), nextval(), …) go through the
451 // runtime_default path; literal defaults freeze into `default`.
452 let display = alloc::format!("{}", default_expr);
453 let is_runtime = matches!(default_expr, spg_sql::ast::Expr::FunctionCall { .. });
454 let literal_value = if is_runtime {
455 None
456 } else {
457 crate::conversions::literal_expr_to_value(default_expr.clone()).ok()
458 };
459 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
460 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
461 })?;
462 let pos = table
463 .schema()
464 .columns
465 .iter()
466 .position(|c| c.name.eq_ignore_ascii_case(&column))
467 .ok_or_else(|| {
468 EngineError::Unsupported(alloc::format!(
469 "column {column:?} of relation {tbl:?} does not exist"
470 ))
471 })?;
472 let col = &mut table.schema_mut().columns[pos];
473 if is_runtime {
474 col.runtime_default = Some(display);
475 col.default = None;
476 } else if let Some(v) = literal_value {
477 col.default = Some(v);
478 col.runtime_default = None;
479 } else {
480 // Could not evaluate; fall back to runtime path.
481 col.runtime_default = Some(display);
482 col.default = None;
483 }
484 Ok(())
485 }
486
487 fn alter_column_drop_default(&mut self, tbl: &str, column: String) -> Result<(), EngineError> {
488 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
489 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
490 })?;
491 let pos = table
492 .schema()
493 .columns
494 .iter()
495 .position(|c| c.name.eq_ignore_ascii_case(&column))
496 .ok_or_else(|| {
497 EngineError::Unsupported(alloc::format!(
498 "ALTER COLUMN DROP DEFAULT: column {column:?} not in table {tbl:?}"
499 ))
500 })?;
501 let col = &mut table.schema_mut().columns[pos];
502 col.default = None;
503 col.runtime_default = None;
504 Ok(())
505 }
506
507 /// v7.37.18 (18.2) — set / drop column NOT NULL flag.
508 fn alter_column_set_not_null(&mut self, tbl: &str, column: String) -> Result<(), EngineError> {
509 // Validate no existing row holds NULL in this column
510 // before flipping the flag. PG raises on first NULL hit.
511 // v7.39 (read01 round 49) — scan VISIBLE rows, not physical ones.
512 // Under in-place MVCC a DELETE leaves a tombstoned physical row
513 // behind; counting it made `DELETE FROM t; ALTER TABLE t ALTER c SET
514 // NOT NULL` fail on a table PG sees as empty (the flip-regression
515 // family: same shape as the ATTACH PARTITION empty-check and the
516 // ALTER TYPE rewrite bug).
517 let snap = self.current_snapshot();
518 let table = self.active_catalog().get(tbl).ok_or_else(|| {
519 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
520 })?;
521 let pos = table
522 .schema()
523 .columns
524 .iter()
525 .position(|c| c.name.eq_ignore_ascii_case(&column))
526 .ok_or_else(|| {
527 EngineError::Unsupported(alloc::format!(
528 "column {column:?} of relation {tbl:?} does not exist"
529 ))
530 })?;
531 for (_, row) in table.scan_visible(&snap) {
532 if matches!(row.values.get(pos), Some(spg_storage::Value::Null)) {
533 // v7.39 (read01 round 49) — PG wording (23502 at the wire).
534 return Err(EngineError::Unsupported(alloc::format!(
535 "column {column:?} of relation {tbl:?} contains null values"
536 )));
537 }
538 }
539 let table = self
540 .active_catalog_mut()
541 .get_mut(tbl)
542 .expect("checked above");
543 table.schema_mut().columns[pos].nullable = false;
544 Ok(())
545 }
546
547 fn alter_column_drop_not_null(&mut self, tbl: &str, column: String) -> Result<(), EngineError> {
548 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
549 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
550 })?;
551 let pos = table
552 .schema()
553 .columns
554 .iter()
555 .position(|c| c.name.eq_ignore_ascii_case(&column))
556 .ok_or_else(|| {
557 EngineError::Unsupported(alloc::format!(
558 "ALTER COLUMN DROP NOT NULL: column {column:?} not in table {tbl:?}"
559 ))
560 })?;
561 table.schema_mut().columns[pos].nullable = true;
562 Ok(())
563 }
564
565 /// v7.37.16 (16.3) — `ALTER TABLE parent ATTACH PARTITION child <bounds>`.
566 ///
567 /// Promotes an existing standalone table `child` into a partition
568 /// of `parent`. Enforces:
569 /// 1. `parent` is a partition parent (`PartitionRole::Parent`).
570 /// 2. `child` is currently standalone (`partition_role == None`).
571 /// 3. `child`'s column list is layout-compatible with `parent`
572 /// (same column names, types and ordering — PG also requires
573 /// this and uses it to delegate the actual storage).
574 /// 4. `bounds` shape matches `parent.kind` (Range/List/Hash).
575 /// 5. New range / list / hash bounds don't overlap any existing
576 /// sibling — same gates as the CREATE TABLE … PARTITION OF
577 /// path.
578 /// 6. Every existing row in `child` satisfies the bound predicate
579 /// (PG's "partition constraint" check). Mis-fits raise; no
580 /// silent re-routing.
581 fn alter_attach_partition(
582 &mut self,
583 parent_name: &str,
584 child_name: String,
585 bounds: spg_sql::ast::PartitionOfBoundsAst,
586 ) -> Result<(), EngineError> {
587 use spg_sql::ast::PartitionOfBoundsAst;
588 use spg_storage::{PartitionKind, PartitionRole};
589 // Parent gate.
590 let (parent_kind, parent_columns) = {
591 let parent = self.active_catalog().get(parent_name).ok_or_else(|| {
592 EngineError::Storage(StorageError::TableNotFound {
593 name: parent_name.into(),
594 })
595 })?;
596 match &parent.schema().partition_role {
597 Some(PartitionRole::Parent { kind, .. }) => {
598 (*kind, parent.schema().columns.clone())
599 }
600 _ => {
601 return Err(EngineError::Unsupported(alloc::format!(
602 "ALTER TABLE … ATTACH PARTITION: {parent_name:?} is not a partition parent"
603 )));
604 }
605 }
606 };
607 // Child gate: must exist + be standalone + share parent's
608 // column layout.
609 {
610 let child = self.active_catalog().get(&child_name).ok_or_else(|| {
611 EngineError::Storage(StorageError::TableNotFound {
612 name: child_name.clone(),
613 })
614 })?;
615 if child.schema().partition_role.is_some() {
616 return Err(EngineError::Unsupported(alloc::format!(
617 "ALTER TABLE … ATTACH PARTITION: {child_name:?} is already a partition; \
618 DETACH it first"
619 )));
620 }
621 let child_cols = &child.schema().columns;
622 if child_cols.len() != parent_columns.len() {
623 return Err(EngineError::Unsupported(alloc::format!(
624 "ALTER TABLE … ATTACH PARTITION: column-count mismatch \
625 ({child_name:?} has {}, {parent_name:?} has {})",
626 child_cols.len(),
627 parent_columns.len()
628 )));
629 }
630 for (c, p) in child_cols.iter().zip(parent_columns.iter()) {
631 if !c.name.eq_ignore_ascii_case(&p.name) || c.ty != p.ty {
632 return Err(EngineError::Unsupported(alloc::format!(
633 "ALTER TABLE … ATTACH PARTITION: column {:?} of {child_name:?} \
634 (type {:?}) doesn't match column {:?} of {parent_name:?} (type {:?})",
635 c.name,
636 c.ty,
637 p.name,
638 p.ty
639 )));
640 }
641 }
642 }
643 // Resolve bounds (same gates as CREATE TABLE … PARTITION OF).
644 let role = match bounds {
645 PartitionOfBoundsAst::Default => PartitionRole::Default {
646 parent_name: parent_name.into(),
647 },
648 PartitionOfBoundsAst::Range { lower, upper } => {
649 if !matches!(parent_kind, PartitionKind::Range) {
650 return Err(EngineError::Unsupported(alloc::format!(
651 "ATTACH PARTITION: FOR VALUES FROM/TO only valid for a RANGE-partitioned \
652 parent (parent {parent_name:?} is {parent_kind:?})"
653 )));
654 }
655 let lower_b = crate::partition::evaluate_partition_bound(*lower)?;
656 let upper_b = crate::partition::evaluate_partition_bound(*upper)?;
657 if !crate::partition::ranges_overlap(&lower_b, &upper_b, &lower_b, &upper_b) {
658 return Err(EngineError::Unsupported(alloc::format!(
659 "ATTACH PARTITION: FROM ({}) TO ({}) is empty (lower must be < upper)",
660 crate::partition::bound_to_diag(&lower_b),
661 crate::partition::bound_to_diag(&upper_b),
662 )));
663 }
664 for sib in crate::partition::children_of_parent(self.active_catalog(), parent_name)
665 {
666 let Some(t) = self.active_catalog().get(&sib) else {
667 continue;
668 };
669 if let Some(PartitionRole::Range {
670 lower: sl,
671 upper: su,
672 ..
673 }) = &t.schema().partition_role
674 {
675 if crate::partition::ranges_overlap(&lower_b, &upper_b, sl, su) {
676 return Err(EngineError::Unsupported(alloc::format!(
677 "ATTACH PARTITION: range FROM ({}) TO ({}) overlaps sibling \
678 {sib:?} (FROM ({}) TO ({}))",
679 crate::partition::bound_to_diag(&lower_b),
680 crate::partition::bound_to_diag(&upper_b),
681 crate::partition::bound_to_diag(sl),
682 crate::partition::bound_to_diag(su),
683 )));
684 }
685 }
686 }
687 PartitionRole::Range {
688 parent_name: parent_name.into(),
689 lower: lower_b,
690 upper: upper_b,
691 }
692 }
693 PartitionOfBoundsAst::List { values } => {
694 if !matches!(parent_kind, PartitionKind::List) {
695 return Err(EngineError::Unsupported(alloc::format!(
696 "ATTACH PARTITION: FOR VALUES IN only valid for a LIST-partitioned \
697 parent (parent {parent_name:?} is {parent_kind:?})"
698 )));
699 }
700 let mut bounds_v = Vec::with_capacity(values.len());
701 for v in values {
702 bounds_v.push(crate::partition::evaluate_partition_bound(v)?);
703 }
704 for sib in crate::partition::children_of_parent(self.active_catalog(), parent_name)
705 {
706 let Some(t) = self.active_catalog().get(&sib) else {
707 continue;
708 };
709 if let Some(PartitionRole::List {
710 values: existing, ..
711 }) = &t.schema().partition_role
712 {
713 for new_b in &bounds_v {
714 if existing.iter().any(|e| e == new_b) {
715 // v7.39 (round 770) — PG's overlap sentence.
716 let _ = crate::partition::bound_to_diag(new_b);
717 return Err(EngineError::Unsupported(alloc::format!(
718 "partition \"{child_name}\" would overlap partition \"{sib}\"",
719 )));
720 }
721 }
722 }
723 }
724 PartitionRole::List {
725 parent_name: parent_name.into(),
726 values: bounds_v,
727 }
728 }
729 PartitionOfBoundsAst::Hash { modulus, remainder } => {
730 if !matches!(parent_kind, PartitionKind::Hash) {
731 return Err(EngineError::Unsupported(alloc::format!(
732 "ATTACH PARTITION: FOR VALUES WITH only valid for a HASH-partitioned \
733 parent (parent {parent_name:?} is {parent_kind:?})"
734 )));
735 }
736 if modulus == 0 || remainder >= modulus {
737 return Err(EngineError::Unsupported(alloc::format!(
738 "ATTACH PARTITION: HASH (MODULUS={modulus}, REMAINDER={remainder}) \
739 must satisfy modulus > 0 and remainder < modulus"
740 )));
741 }
742 for sib in crate::partition::children_of_parent(self.active_catalog(), parent_name)
743 {
744 let Some(t) = self.active_catalog().get(&sib) else {
745 continue;
746 };
747 if let Some(PartitionRole::Hash {
748 modulus: m,
749 remainder: r,
750 ..
751 }) = &t.schema().partition_role
752 {
753 if *m != modulus {
754 return Err(EngineError::Unsupported(alloc::format!(
755 "ATTACH PARTITION: HASH MODULUS {modulus} differs from sibling \
756 {sib:?} MODULUS {m} (mixed moduli not yet supported)"
757 )));
758 }
759 if *r == remainder {
760 return Err(EngineError::Unsupported(alloc::format!(
761 "ATTACH PARTITION: HASH REMAINDER {remainder} already used \
762 by sibling {sib:?}"
763 )));
764 }
765 }
766 }
767 PartitionRole::Hash {
768 parent_name: parent_name.into(),
769 modulus,
770 remainder,
771 }
772 }
773 };
774 // PG-style "partition constraint" check — every existing row
775 // in child must satisfy the new role's predicate. For now we
776 // leave row-validation as TODO (16.3.b): pre-existing rows
777 // could violate the bound. v7.37.16.3 ships with a
778 // pessimistic gate: refuse ATTACH if the child has any rows
779 // and require the operator to either DROP them first or use
780 // a fresh empty child. This matches PG's safest behaviour
781 // (PG actually scans the rows; our scan path lands in
782 // 16.3.b). Match the spirit, not the letter.
783 // Count *visible* rows: under in-place MVCC a DELETE leaves a
784 // tombstoned physical row behind, which must not fail the
785 // empty-child gate (legacy path removed it physically).
786 // v7.39 (round 621) — 16.3.b, the row scan the gate above promised.
787 //
788 // The pessimistic "child must be empty" gate refused the ordinary
789 // migration — build a table, load it, attach it — that partitioned
790 // setups are adopted FOR. PG scans the rows; now so does this. Every
791 // visible row's key must satisfy the new bound, and one that does not
792 // raises PG's wording (`partition constraint of relation … is violated
793 // by some row`) BEFORE the role is installed, so a failed attach
794 // changes nothing.
795 let key_pos = {
796 let parent = self.active_catalog().get(parent_name);
797 match parent.and_then(|p| p.schema().partition_role.as_ref()) {
798 Some(spg_storage::PartitionRole::Parent {
799 key_column_positions,
800 ..
801 }) => key_column_positions.first().copied().unwrap_or(0),
802 _ => 0,
803 }
804 };
805 let snap = self.current_snapshot();
806 if let Some(t) = self.active_catalog().get(&child_name) {
807 for (_, row) in t.scan_visible(&snap) {
808 let key = row.values.get(key_pos).cloned().unwrap_or(Value::Null);
809 let fits = match &role {
810 PartitionRole::Range { lower, upper, .. } => {
811 crate::partition::value_to_bound(&key)
812 .is_some_and(|b| crate::partition::value_in_range(&b, lower, upper))
813 }
814 PartitionRole::List { values, .. } => {
815 values.iter().any(|b| b.equals_value(&key))
816 }
817 PartitionRole::Hash {
818 modulus, remainder, ..
819 } => {
820 crate::partition::pg_compatible_hash(&key).rem_euclid(u64::from(*modulus))
821 == u64::from(*remainder)
822 }
823 // A DEFAULT partition takes whatever no sibling claims, so
824 // any existing row satisfies it.
825 // v7.39 (round 645) — an inheritance child has no key
826 // constraint at all: nothing it holds can fail to fit.
827 PartitionRole::Default { .. }
828 | PartitionRole::Parent { .. }
829 | PartitionRole::Inherits { .. } => true,
830 };
831 if !fits {
832 return Err(EngineError::Unsupported(alloc::format!(
833 "partition constraint of relation {child_name:?} is violated by some row"
834 )));
835 }
836 }
837 }
838 // Install role.
839 let child = self
840 .active_catalog_mut()
841 .get_mut(&child_name)
842 .expect("child existed above");
843 child.schema_mut().partition_role = Some(role);
844 Ok(())
845 }
846
847 /// v7.37.16 (16.4 + 16.5) — `ALTER TABLE parent DETACH PARTITION
848 /// child [CONCURRENTLY] [FINALIZE]`.
849 ///
850 /// Demotes a partition back to a standalone table by clearing
851 /// `partition_role`. CONCURRENTLY + FINALIZE are accepted at the
852 /// parser; semantically SPG's single-engine model lets us detach
853 /// atomically (PG's two-phase split addresses replication lag,
854 /// which doesn't apply here).
855 fn alter_detach_partition(
856 &mut self,
857 parent_name: &str,
858 child_name: String,
859 _concurrently: bool,
860 _finalize: bool,
861 ) -> Result<(), EngineError> {
862 use spg_storage::PartitionRole;
863 // Parent gate.
864 {
865 let parent = self.active_catalog().get(parent_name).ok_or_else(|| {
866 EngineError::Storage(StorageError::TableNotFound {
867 name: parent_name.into(),
868 })
869 })?;
870 if !matches!(
871 parent.schema().partition_role,
872 Some(PartitionRole::Parent { .. })
873 ) {
874 return Err(EngineError::Unsupported(alloc::format!(
875 "ALTER TABLE … DETACH PARTITION: {parent_name:?} is not a partition parent"
876 )));
877 }
878 }
879 // Child gate: must be a partition of THIS parent.
880 {
881 let child = self.active_catalog().get(&child_name).ok_or_else(|| {
882 EngineError::Storage(StorageError::TableNotFound {
883 name: child_name.clone(),
884 })
885 })?;
886 let parent_of_child = match &child.schema().partition_role {
887 Some(PartitionRole::Range { parent_name, .. })
888 | Some(PartitionRole::List { parent_name, .. })
889 | Some(PartitionRole::Hash { parent_name, .. })
890 | Some(PartitionRole::Default { parent_name }) => parent_name.clone(),
891 _ => {
892 return Err(EngineError::Unsupported(alloc::format!(
893 "DETACH PARTITION: {child_name:?} is not a partition"
894 )));
895 }
896 };
897 if parent_of_child != parent_name {
898 return Err(EngineError::Unsupported(alloc::format!(
899 "DETACH PARTITION: {child_name:?} is a partition of {parent_of_child:?}, \
900 not {parent_name:?}"
901 )));
902 }
903 }
904 // Clear role.
905 let child = self
906 .active_catalog_mut()
907 .get_mut(&child_name)
908 .expect("child existed above");
909 child.schema_mut().partition_role = None;
910 Ok(())
911 }
912
913 /// v7.39 (round 647) — `ALTER TABLE c INHERIT p` / `NO INHERIT p`.
914 ///
915 /// Measured on PG18: after `NO INHERIT`, the parent stops seeing the
916 /// child's rows, `pg_inherits` loses the row, and the child keeps
917 /// everything it had. `INHERIT` puts it back. Neither moves a row.
918 ///
919 /// A child of several parents keeps the others; the parent list is
920 /// ordered, and dropping one from the middle leaves the rest in
921 /// place — which is also what makes `pg_inherits.inhseqno` keep
922 /// meaning what it means.
923 fn alter_inherit(
924 &mut self,
925 child: &str,
926 parent: &str,
927 detach: bool,
928 ) -> Result<(), EngineError> {
929 use spg_storage::PartitionRole;
930 if self.active_catalog().get(parent).is_none() {
931 return Err(EngineError::Storage(
932 spg_storage::StorageError::TableNotFound {
933 name: parent.to_string(),
934 },
935 ));
936 }
937 let Some(t) = self.active_catalog_mut().get_mut(child) else {
938 return Err(EngineError::Storage(
939 spg_storage::StorageError::TableNotFound {
940 name: child.to_string(),
941 },
942 ));
943 };
944 let current = match &t.schema().partition_role {
945 Some(PartitionRole::Inherits { parent_names }) => parent_names.clone(),
946 Some(_) => {
947 return Err(EngineError::Unsupported(alloc::format!(
948 "{child:?} is a partition, not an inheritance child"
949 )));
950 }
951 None => Vec::new(),
952 };
953 let mut names = current;
954 if detach {
955 let before = names.len();
956 names.retain(|p| !p.eq_ignore_ascii_case(parent));
957 if names.len() == before {
958 // v7.39 (round 652) — PG names the PARENT first:
959 // `relation "parent" is not a parent of relation "child"`.
960 // SPG had the two the other way round, so a client
961 // matching on the message read the wrong relation as the
962 // one at fault.
963 return Err(EngineError::Unsupported(alloc::format!(
964 "relation {parent:?} is not a parent of relation {child:?}"
965 )));
966 }
967 } else {
968 if names.iter().any(|p| p.eq_ignore_ascii_case(parent)) {
969 return Err(EngineError::Unsupported(alloc::format!(
970 "relation {child:?} would be inherited from {parent:?} more than once"
971 )));
972 }
973 names.push(parent.to_string());
974 }
975 t.schema_mut().partition_role = if names.is_empty() {
976 None
977 } else {
978 Some(PartitionRole::Inherits {
979 parent_names: names,
980 })
981 };
982 Ok(())
983 }
984
985 fn alter_set_hot_tier_bytes(&mut self, tbl: &str, n: u64) -> Result<(), EngineError> {
986 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
987 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
988 })?;
989 table.schema_mut().hot_tier_bytes = Some(n);
990 Ok(())
991 }
992
993 fn alter_add_foreign_key(
994 &mut self,
995 tbl: &str,
996 fk: spg_sql::ast::ForeignKeyConstraint,
997 ) -> Result<(), EngineError> {
998 // v7.6.8 — resolve FK against the live catalog first
999 // (validates parent table, columns, indices). Then
1000 // verify every existing row in the child table
1001 // satisfies the new constraint. Then install it.
1002 let cols_snapshot = self
1003 .active_catalog()
1004 .get(tbl)
1005 .ok_or_else(|| EngineError::Storage(StorageError::TableNotFound { name: tbl.into() }))?
1006 .schema()
1007 .columns
1008 .clone();
1009 let storage_fk = resolve_foreign_key(tbl, &cols_snapshot, fk, self.active_catalog())?;
1010 // Verify existing rows. Treat them as a virtual
1011 // INSERT batch — reusing the v7.6.2 enforce helper.
1012 let existing_rows: Vec<Vec<Value<'static>>> = self
1013 .active_catalog()
1014 .get(tbl)
1015 .expect("checked above")
1016 .rows()
1017 .iter()
1018 .map(|r| r.values.clone())
1019 .collect();
1020 enforce_fk_inserts(
1021 self.active_catalog(),
1022 tbl,
1023 core::slice::from_ref(&storage_fk),
1024 &existing_rows,
1025 )?;
1026 // Reject duplicate constraint name.
1027 let table = self
1028 .active_catalog_mut()
1029 .get_mut(tbl)
1030 .expect("checked above");
1031 if let Some(name) = &storage_fk.name
1032 && table
1033 .schema()
1034 .foreign_keys
1035 .iter()
1036 .any(|f| f.name.as_ref() == Some(name))
1037 {
1038 // v7.39 (read01 round 47) — PG wording (42710).
1039 return Err(EngineError::Unsupported(alloc::format!(
1040 "constraint {name:?} for relation {tbl:?} already exists"
1041 )));
1042 }
1043 table.schema_mut().foreign_keys.push(storage_fk);
1044 Ok(())
1045 }
1046
1047 /// v7.13.2 / v7.37.18 (18.17 widened) — DROP CONSTRAINT for
1048 /// FK + PK/UNIQUE + CHECK. Originally FK-only; widened to
1049 /// match PG's behaviour where `ALTER TABLE t DROP CONSTRAINT
1050 /// t_pkey` removes a PRIMARY KEY just like it would an FK.
1051 fn alter_drop_foreign_key(
1052 &mut self,
1053 tbl: &str,
1054 name: String,
1055 if_exists: bool,
1056 ) -> Result<(), EngineError> {
1057 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
1058 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
1059 })?;
1060 // v7.39 (read01 round 48) — 0) the stored name wins. A constraint
1061 // created with `ADD CONSTRAINT <name> …` (or the inline `CONSTRAINT
1062 // <name>` form) now carries that name, so DROP finds it directly.
1063 // Catalogs written before FILE_VERSION 60 have no stored names and
1064 // fall through to the synthesised-name lookups below, which stay
1065 // exactly as they were.
1066 {
1067 let ucs = &mut table.schema_mut().uniqueness_constraints;
1068 let before = ucs.len();
1069 ucs.retain(|u| u.name.as_deref() != Some(name.as_str()));
1070 if ucs.len() != before {
1071 return Ok(());
1072 }
1073 let checks = &mut table.schema_mut().checks;
1074 let before = checks.len();
1075 checks.retain(|c| c.name.as_deref() != Some(name.as_str()));
1076 if checks.len() != before {
1077 return Ok(());
1078 }
1079 }
1080 // 1) Try foreign keys.
1081 let fks = &mut table.schema_mut().foreign_keys;
1082 let fk_before = fks.len();
1083 fks.retain(|f| f.name.as_ref() != Some(&name));
1084 if fks.len() != fk_before {
1085 return Ok(());
1086 }
1087 // 2) Try PK / UNIQUE constraints by their SYNTHESISED name.
1088 // v7.39 (read01 round 48) — resolve through the very
1089 // synthesisers pg_constraint / pg_get_constraintdef report from
1090 // (`pg_unique_conname` / `pg_check_connames`), so a name the
1091 // catalog shows is always a name DROP accepts. The old ad-hoc
1092 // `<table>_uniqN` / `<table>_checkN` prefixes never matched what
1093 // the views printed (`<table>_<col>_key` / `<table>_<col>_check`).
1094 // (Single-column UNIQUE indices that don't have a UC entry need to go
1095 // through `DROP INDEX <name>` instead — indices are a slice, not a Vec.)
1096 let uc_hit = table.schema().uniqueness_constraints.iter().position(|uc| {
1097 uc.name.is_none() && crate::system_catalog::pg_unique_conname(table, uc, tbl) == name
1098 });
1099 if let Some(idx) = uc_hit {
1100 table.schema_mut().uniqueness_constraints.remove(idx);
1101 return Ok(());
1102 }
1103 // 3) CHECK constraints by their synthesised name.
1104 let check_names =
1105 crate::system_catalog::pg_check_connames(table, tbl, &table.schema().checks);
1106 let check_hit = check_names.iter().position(|n| *n == name);
1107 if let Some(idx) = check_hit {
1108 let checks = &mut table.schema_mut().checks;
1109 if idx < checks.len() {
1110 checks.remove(idx);
1111 return Ok(());
1112 }
1113 }
1114 // Nothing matched; respect IF EXISTS.
1115 if if_exists {
1116 return Ok(());
1117 }
1118 // v7.39 (read01 round 47) — PG wording (42704). Note PG's own
1119 // inconsistency: DROP CONSTRAINT says "of relation" while ADD
1120 // CONSTRAINT says "for relation" — both are matched verbatim.
1121 Err(EngineError::Unsupported(alloc::format!(
1122 "constraint {name:?} of relation {tbl:?} does not exist"
1123 )))
1124 }
1125
1126 fn alter_add_column(
1127 &mut self,
1128 tbl: &str,
1129 column: ColumnDef,
1130 if_not_exists: bool,
1131 ) -> Result<(), EngineError> {
1132 // v7.13.0 — mailrs round-5 G1. Append-only column add
1133 // with back-fill of the DEFAULT (or NULL) into every
1134 // existing row. Column positions don't shift, so we
1135 // skip index rebuild.
1136 let clock = self.clock;
1137 let add_mysql = self.backslash_escapes;
1138 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
1139 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
1140 })?;
1141 if table
1142 .schema()
1143 .columns
1144 .iter()
1145 .any(|c| c.name.eq_ignore_ascii_case(&column.name))
1146 {
1147 if if_not_exists {
1148 // v7.39 (read01 round 46) — PG's IF NOT EXISTS skip NOTICE.
1149 self.notice(alloc::format!(
1150 "column {:?} of relation {:?} already exists, skipping",
1151 column.name,
1152 tbl
1153 ));
1154 return Ok(());
1155 }
1156 // v7.39 (read01 round 45) — PG wording (42701 at the wire).
1157 return Err(EngineError::Unsupported(alloc::format!(
1158 "column {:?} of relation {:?} already exists",
1159 column.name,
1160 tbl
1161 )));
1162 }
1163 let col_name = column.name.clone();
1164 let nullable = column.nullable;
1165 let has_default = column.default.is_some() || column.auto_increment;
1166 // v7.38.3 (sentori 2.2) — the inline `CHECK (…)` on an ADD COLUMN.
1167 // The parser has always put it on the ColumnDef and this path has
1168 // never read it, so `ALTER TABLE t ADD COLUMN env text CHECK (env
1169 // IN ('a','b'))` was ACCEPTED and registered nothing: pg_constraint
1170 // showed no row and a violating INSERT went in. A constraint that
1171 // silently does not exist is worse than one that loudly does not
1172 // work. (The separate `ADD CONSTRAINT` form was always enforced —
1173 // only the inline-on-ADD-COLUMN spelling vanished.)
1174 let inline_check = column.check.clone().map(|e| e.to_string());
1175 let col_schema = column_def_to_schema(column, add_mysql)?;
1176 let row_count = table.row_count();
1177 // Compute the back-fill value. Literal / runtime DEFAULT
1178 // funnels through the same resolver that INSERT uses
1179 // (v7.9.21 `resolve_column_default_free`). NULL when
1180 // the column is nullable and has no DEFAULT. NOT NULL
1181 // without DEFAULT errors when the table has existing
1182 // rows — same as PG.
1183 let fill_value: Value<'static> = if has_default || col_schema.runtime_default.is_some() {
1184 resolve_column_default_free(&col_schema, clock, None)?
1185 } else if nullable || row_count == 0 {
1186 Value::Null
1187 } else {
1188 // v7.39 (read01 round 89) — PG's exact wording (23502):
1189 // `column "req" of relation "t" contains null values`.
1190 return Err(EngineError::Unsupported(alloc::format!(
1191 "column \"{col_name}\" of relation \"{tbl}\" contains null values"
1192 )));
1193 };
1194 table.add_column(col_schema, fill_value);
1195 // The column exists before the CHECK is validated, because the
1196 // predicate is written in terms of it. PG validates against the
1197 // rows already there and refuses the whole statement if any fails
1198 // — measured: adding `e text CHECK (e IS NOT NULL)` to a table
1199 // with a row errors ("is violated by some row"), while the same
1200 // column with a DEFAULT that satisfies it succeeds. On refusal the
1201 // column has to come back out; nothing else has happened yet.
1202 if let Some(src) = inline_check {
1203 let pos = table.schema().columns.len() - 1;
1204 let name = alloc::format!("{tbl}_{col_name}_check");
1205 if let Err(e) =
1206 crate::constraints::validate_check_against_existing_rows(table, tbl, &name, &src)
1207 {
1208 table.drop_column(pos);
1209 return Err(e);
1210 }
1211 table
1212 .schema_mut()
1213 .checks
1214 .push(spg_storage::CheckConstraint {
1215 // Unnamed: `pg_check_connames` synthesises PG's
1216 // `<table>_<column>_check` from the referenced column, the
1217 // same name the CREATE TABLE spelling gets.
1218 name: None,
1219 expr: src,
1220 validated: true,
1221 });
1222 }
1223 Ok(())
1224 }
1225
1226 fn alter_column_type(
1227 &mut self,
1228 tbl: &str,
1229 column: String,
1230 new_type: spg_sql::ast::ColumnTypeName,
1231 using: Option<Expr>,
1232 collation: Option<(spg_sql::ast::Collation, alloc::string::String)>,
1233 ) -> Result<(), EngineError> {
1234 // v7.13.0 — mailrs round-5 G8. Re-evaluate each
1235 // row's column value (either through the USING
1236 // expression if supplied, or as a direct CAST of
1237 // the existing value) and re-coerce to the new
1238 // type. Indices on the column get rebuilt.
1239 let new_data_type = column_type_to_data_type(new_type);
1240 // v7.39 (round 713) — `TYPE <ty> COLLATE <name>`. PG refuses a
1241 // collation on a non-collatable type; on a collatable one it
1242 // re-collates, and NO clause resets to the type default (both
1243 // measured round 713). The clause parsed here all along and was
1244 // dropped — the statement succeeded, the ordering never changed.
1245 let is_collatable = matches!(
1246 new_data_type,
1247 DataType::Text | DataType::Varchar(_) | DataType::Char(_)
1248 );
1249 if collation.is_some() && !is_collatable {
1250 let spelled = crate::conversions::regtype_oid_to_name(
1251 crate::system_catalog::pg_type_oid(new_data_type),
1252 )
1253 .unwrap_or("this type");
1254 return Err(EngineError::Unsupported(alloc::format!(
1255 "collations are not supported by type {spelled}"
1256 )));
1257 }
1258 // v7.38.18 (G2) — a collation PostgreSQL does not have is not a
1259 // collation, and PG 18.4 says so: `collation "x" for encoding
1260 // "UTF8" does not exist`. Round 670 chose warn-not-refuse under
1261 // the zero-customer-change ruling, when this build could perform
1262 // almost nothing and refusing would have failed working DDL.
1263 // That calculus has inverted: 880 names are performable now, so
1264 // the only ones refused here are the ones PG refuses too, and
1265 // refusing is what keeps a customer's DDL behaving the same.
1266 //
1267 // The dialect decides, because MySQL's names are not in PG's
1268 // catalogue and PG rejects them — measured on 18.4.
1269 if let Some((_, name)) = &collation
1270 && !crate::collate::is_known(name)
1271 {
1272 return Err(crate::collate::unknown_collation_error(name));
1273 }
1274 // v7.38.18 — the warning that used to stand here said range
1275 // comparisons "still compare by bytes". That stopped being true
1276 // in this version: a declared collation reaches `<`, `BETWEEN`
1277 // and the index keys, verified against PG 18.4. A warning that
1278 // is false is worse than none, so only the unperformable case
1279 // keeps one.
1280 if let Some((_, name)) = &collation
1281 && !crate::collate::is_supported(name)
1282 {
1283 self.warning(alloc::format!(
1284 "column \"{column}\" declares COLLATE \"{name}\", which this build \
1285 cannot perform; SPG records the declaration and orders this column \
1286 by bytes (the C collation)"
1287 ));
1288 }
1289 let mysql_dialect = self.backslash_escapes;
1290 // v7.39 — under in-place MVCC the row store carries tombstoned
1291 // versions; their dead values must not join the rewrite (an
1292 // INT corpse under a TEXT conversion would abort the whole
1293 // ALTER). Snapshot BEFORE the &mut borrow.
1294 let scan_snapshot = self.current_snapshot();
1295 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
1296 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
1297 })?;
1298 let col_pos = table
1299 .schema()
1300 .columns
1301 .iter()
1302 .position(|c| c.name.eq_ignore_ascii_case(&column))
1303 .ok_or_else(|| {
1304 EngineError::Unsupported(alloc::format!(
1305 "column {column:?} of relation {:?} does not exist",
1306 tbl
1307 ))
1308 })?;
1309 // v7.36 (cold-tier coverage) — ALTER COLUMN TYPE rewrites
1310 // every row's value to the new representation. Cold-tier
1311 // rows live in segments encoded against the OLD type and
1312 // can't be rewritten in-place from this path; doing the
1313 // ALTER anyway would leave the segments unreadable under
1314 // the new schema. Match PG / MariaDB's invariant of "never
1315 // half-apply a schema change" by raising explicitly.
1316 // v7.39 (round 456) — O(1) predicate first; see the DELETE path.
1317 if table.has_cold_rows_fast() && table.count_cold_locators() > 0 {
1318 return Err(EngineError::Unsupported(alloc::format!(
1319 "ALTER COLUMN TYPE on {tbl:?}: cold-tier rows exist for this table; \
1320 cold-tier schema rewrite is a v7.37 candidate. Run COMPACT to bring \
1321 the cold rows back to the hot tier and retry."
1322 )));
1323 }
1324 let schema_cols = table.schema().columns.clone();
1325 let ctx = eval::EvalContext::new(&schema_cols, None);
1326 // `None` = a tombstoned version: left untouched entirely (its
1327 // slot is never rewritten, so the update_row type check on the
1328 // NEW schema never sees the old-type corpse).
1329 let mut new_values: alloc::vec::Vec<Option<Value<'static>>> =
1330 alloc::vec::Vec::with_capacity(table.row_count());
1331 for (ri, row) in table.rows().iter().enumerate() {
1332 if !table.is_row_visible(ri, &scan_snapshot) {
1333 new_values.push(None);
1334 continue;
1335 }
1336 let raw = match &using {
1337 Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
1338 EngineError::Unsupported(alloc::format!(
1339 "ALTER COLUMN TYPE: USING expression failed: {e:?}"
1340 ))
1341 })?,
1342 None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
1343 };
1344 // v7.39 — PG's ALTER TYPE without USING applies the
1345 // assignment cast, which is wider than INSERT's strict
1346 // coercion: any value casts to the text family through
1347 // its output function (INT -> TEXT rewrites the column),
1348 // while a narrowing like TEXT -> INT is refused with
1349 // PG's phrasing + HINT. A USING expression bypasses this
1350 // (its result must strictly coerce).
1351 let coerced = match coerce_value(raw.clone(), new_data_type, &column, col_pos) {
1352 Ok(v) => v,
1353 Err(_)
1354 if using.is_none()
1355 && matches!(
1356 new_data_type,
1357 DataType::Text | DataType::Varchar(_) | DataType::Char(_)
1358 ) =>
1359 {
1360 coerce_value(
1361 Value::text(crate::eval::value_to_text(&raw)),
1362 new_data_type,
1363 &column,
1364 col_pos,
1365 )?
1366 }
1367 Err(e) => {
1368 if using.is_none() {
1369 return Err(EngineError::Unsupported(alloc::format!(
1370 "column \"{column}\" cannot be cast automatically to type \
1371 {new_data_type:?}; You might need to specify a USING expression"
1372 )));
1373 }
1374 return Err(e);
1375 }
1376 };
1377 new_values.push(Some(coerced));
1378 }
1379 table.schema_mut().columns[col_pos].ty = new_data_type;
1380 // v7.39 (round 713) — the collation lands with the type, exactly
1381 // as CREATE TABLE lands it (the round-370/676 pair of fields).
1382 // An absent clause is a RESET, not a keep: PG re-derives the
1383 // collation from the new type, so `TYPE text` alone takes the
1384 // column back to the default — under the MySQL dialect that
1385 // default is the folding collation, everywhere else byte order.
1386 {
1387 let sc = &mut table.schema_mut().columns[col_pos];
1388 match &collation {
1389 Some((cenum, name)) => {
1390 sc.collation_name = Some(name.clone());
1391 sc.collation = match cenum {
1392 spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
1393 spg_sql::ast::Collation::CaseInsensitive => {
1394 spg_storage::Collation::CaseInsensitive
1395 }
1396 };
1397 }
1398 None => {
1399 sc.collation_name = None;
1400 sc.collation = if mysql_dialect && is_collatable {
1401 spg_storage::Collation::CaseInsensitive
1402 } else {
1403 spg_storage::Collation::Binary
1404 };
1405 }
1406 }
1407 }
1408 for (i, v) in new_values.into_iter().enumerate() {
1409 let Some(v) = v else { continue };
1410 let mut row_values = table
1411 .rows()
1412 .get(i)
1413 .expect("bounds-checked above")
1414 .values
1415 .clone();
1416 row_values[col_pos] = v;
1417 table.update_row(i, row_values)?;
1418 }
1419 Ok(())
1420 }
1421
1422 /// v7.39 (round 652) — `ALTER TABLE … VALIDATE CONSTRAINT <name>`.
1423 /// Scans the rows against a CHECK added `NOT VALID`; on success the
1424 /// constraint becomes validated and `pg_constraint.convalidated`
1425 /// flips, which is what makes the next pg_dump stop emitting the
1426 /// `NOT VALID` suffix. Validating an already-valid constraint is a
1427 /// no-op, as in PG.
1428 fn alter_validate_constraint(&mut self, tbl: &str, name: &str) -> Result<(), EngineError> {
1429 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
1430 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
1431 })?;
1432 let names = crate::system_catalog::pg_check_connames(table, tbl, &table.schema().checks);
1433 let Some(idx) = names.iter().position(|n| n.eq_ignore_ascii_case(name)) else {
1434 // PG names the relation it looked in. A constraint that is
1435 // not a CHECK lands here too — SPG has no unvalidated shape
1436 // for the others, so there is nothing this could validate.
1437 return Err(EngineError::Unsupported(alloc::format!(
1438 "constraint \"{name}\" of relation \"{tbl}\" does not exist"
1439 )));
1440 };
1441 if table.schema().checks[idx].validated {
1442 return Ok(());
1443 }
1444 let src = table.schema().checks[idx].expr.clone();
1445 crate::constraints::validate_check_against_existing_rows(table, tbl, name, &src)?;
1446 table.schema_mut().checks[idx].validated = true;
1447 Ok(())
1448 }
1449
1450 #[allow(clippy::too_many_lines)]
1451 fn alter_add_table_constraint(
1452 &mut self,
1453 tbl: &str,
1454 tc: spg_sql::ast::TableConstraint,
1455 ) -> Result<(), EngineError> {
1456 // v7.14.0 — pg_dump emits PKs as a separate
1457 // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
1458 // For PRIMARY KEY / UNIQUE, install a UC entry
1459 // and the implicit BTree index on the leading
1460 // column. CHECK: append predicate to schema.
1461 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
1462 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
1463 })?;
1464 let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
1465 // v7.39 (read01 round 48) — a constraint name must be unique on the
1466 // table. PG rejects a re-used name with 42710; SPG used to drop the
1467 // name on the floor entirely, so the collision was invisible.
1468 let con_name: Option<String> = match &tc {
1469 spg_sql::ast::TableConstraint::PrimaryKey { name, .. }
1470 | spg_sql::ast::TableConstraint::Unique { name, .. }
1471 | spg_sql::ast::TableConstraint::Check { name, .. } => name.clone(),
1472 _ => None,
1473 };
1474 if let Some(n) = &con_name
1475 && constraint_name_taken(table, n)
1476 {
1477 return Err(EngineError::Unsupported(alloc::format!(
1478 "constraint {n:?} for relation {tbl:?} already exists"
1479 )));
1480 }
1481 // v7.39 (read01 round 45) — a table may have at most one PRIMARY
1482 // KEY. PG rejects a second one (even on the same column) with
1483 // 42P16; SPG used to install it silently. SPG's own dumps emit PK
1484 // inline, so restore never reaches this ALTER path.
1485 if is_pk
1486 && table
1487 .schema()
1488 .uniqueness_constraints
1489 .iter()
1490 .any(|u| u.is_primary_key)
1491 {
1492 return Err(EngineError::Unsupported(alloc::format!(
1493 "multiple primary keys for table {tbl:?} are not allowed"
1494 )));
1495 }
1496 // v7.22 (mailrs round-13 gap 6) — carry the parsed
1497 // NULLS NOT DISTINCT flag through the ALTER path;
1498 // it was hardcoded false here while the CREATE
1499 // TABLE path honoured it since v7.13.
1500 let nnd = matches!(
1501 tc,
1502 spg_sql::ast::TableConstraint::Unique {
1503 nulls_not_distinct: true,
1504 ..
1505 }
1506 );
1507 // v7.39 (round 711) — carry the timing through the ALTER path too.
1508 let timing = match tc {
1509 spg_sql::ast::TableConstraint::PrimaryKey {
1510 deferrable,
1511 initially_deferred,
1512 ..
1513 }
1514 | spg_sql::ast::TableConstraint::Unique {
1515 deferrable,
1516 initially_deferred,
1517 ..
1518 } => (deferrable, initially_deferred),
1519 _ => (false, false),
1520 };
1521 match tc {
1522 spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
1523 | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
1524 let positions: Vec<usize> = columns
1525 .iter()
1526 .map(|c| {
1527 table
1528 .schema()
1529 .columns
1530 .iter()
1531 .position(|sc| sc.name.eq_ignore_ascii_case(c))
1532 .ok_or_else(|| {
1533 EngineError::Unsupported(alloc::format!(
1534 "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
1535 tbl
1536 ))
1537 })
1538 })
1539 .collect::<Result<Vec<_>, _>>()?;
1540 // Skip if an equivalent UC is already there
1541 // (idempotent — pg_dump's PK + a prior inline
1542 // PK shouldn't double-install).
1543 let already = table
1544 .schema()
1545 .uniqueness_constraints
1546 .iter()
1547 .any(|u| u.columns == positions);
1548 if !already {
1549 table.schema_mut().uniqueness_constraints.push(
1550 spg_storage::UniquenessConstraint {
1551 is_primary_key: is_pk,
1552 columns: positions.clone(),
1553 nulls_not_distinct: nnd,
1554 name: con_name.clone(),
1555 deferrable: timing.0,
1556 initially_deferred: timing.1,
1557 },
1558 );
1559 // PK implies NOT NULL on referenced cols.
1560 if is_pk {
1561 for p in &positions {
1562 if let Some(c) = table.schema_mut().columns.get_mut(*p) {
1563 c.nullable = false;
1564 }
1565 }
1566 }
1567 // Add a BTree index on the leading
1568 // column for INSERT-side enforcement.
1569 let leading = &columns[0];
1570 let already_idx = table.indices().iter().any(|idx| {
1571 matches!(idx.kind, spg_storage::IndexKind::BTree(_))
1572 && table.schema().columns[idx.column_position].name == *leading
1573 });
1574 if !already_idx {
1575 let suffix = if is_pk { "pkey" } else { "key" };
1576 let idx_name = alloc::format!("{}_{leading}_{suffix}", tbl);
1577 let _ = table.add_index(idx_name, leading);
1578 }
1579 }
1580 }
1581 spg_sql::ast::TableConstraint::Check {
1582 expr, not_valid, ..
1583 } => {
1584 let src = alloc::format!("{expr}");
1585 // v7.39 (round 652) — PG scans the rows already in the
1586 // table unless the user wrote NOT VALID, and refuses the
1587 // whole ALTER if any of them violates the predicate. SPG
1588 // used to skip that scan unconditionally, so it accepted
1589 // constraints PG rejects and left the table holding rows
1590 // that contradict its own declared CHECK — with every
1591 // reader, pg_dump included, believing otherwise.
1592 if !not_valid {
1593 // The name PG puts in the message is the one the
1594 // constraint would end up with, dedup suffix included,
1595 // so ask for the whole prospective list and take the
1596 // entry the new one occupies.
1597 let mut prospective = table.schema().checks.clone();
1598 prospective.push(spg_storage::CheckConstraint {
1599 name: con_name.clone(),
1600 expr: src.clone(),
1601 validated: true,
1602 });
1603 let conname =
1604 crate::system_catalog::pg_check_connames(table, tbl, &prospective)
1605 .pop()
1606 .unwrap_or_else(|| alloc::format!("{tbl}_check"));
1607 crate::constraints::validate_check_against_existing_rows(
1608 table, tbl, &conname, &src,
1609 )?;
1610 }
1611 table
1612 .schema_mut()
1613 .checks
1614 .push(spg_storage::CheckConstraint {
1615 name: con_name.clone(),
1616 expr: src,
1617 validated: !not_valid,
1618 });
1619 }
1620 spg_sql::ast::TableConstraint::Index { name, columns } => {
1621 // v7.15.0 — ALTER TABLE ADD KEY (cols).
1622 // mysqldump occasionally emits this
1623 // post-CREATE-TABLE shape; build a BTree
1624 // on the leading column using the
1625 // user-supplied or synthesised name.
1626 //
1627 // v7.39 (round 431) — the outcome now matches a measured
1628 // MariaDB 11 run in three ways it did not before:
1629 // * a second index on an already-indexed column is
1630 // BUILT, not skipped. Skipping it made the following
1631 // `DROP INDEX <that name>` fail with "does not
1632 // exist" — the name was never registered.
1633 // * a name collision raises 42710 (MariaDB: 1061
1634 // "Duplicate key name") instead of being swallowed.
1635 // * an unknown column raises 42703 (MariaDB: 1072 "Key
1636 // column doesn't exist in table") instead of being
1637 // swallowed into a no-op.
1638 let leading = &columns[0];
1639 let idx_name = match name {
1640 Some(n) => n.clone(),
1641 // Unnamed `ADD INDEX (col)` takes the column's own
1642 // name, with `_2`, `_3`, … on collision — measured
1643 // on MariaDB 11.
1644 None => {
1645 let mut candidate = leading.clone();
1646 let mut n = 1;
1647 while table.indices().iter().any(|idx| idx.name == candidate) {
1648 n += 1;
1649 candidate = alloc::format!("{leading}_{n}");
1650 }
1651 candidate
1652 }
1653 };
1654 table
1655 .add_index(idx_name, leading)
1656 .map_err(EngineError::Storage)?;
1657 }
1658 spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
1659 // v7.17.0 Phase 2.2 — ALTER TABLE ADD
1660 // FULLTEXT KEY (cols). Builds one
1661 // fulltext-GIN per named column so MATCH
1662 // AGAINST gets a real inverted index.
1663 // Multi-column declarations expand to
1664 // per-column GINs (the leading column
1665 // drives MATCH AGAINST planning).
1666 for (k, col) in columns.iter().enumerate() {
1667 let already_idx = table.indices().iter().any(|idx| {
1668 matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
1669 && table.schema().columns[idx.column_position].name == *col
1670 });
1671 if already_idx {
1672 continue;
1673 }
1674 let idx_name = match (&name, columns.len(), k) {
1675 (Some(n), 1, _) => n.clone(),
1676 (Some(n), _, k) => alloc::format!("{n}_{k}"),
1677 (None, _, _) => {
1678 alloc::format!("{}_{col}_ftidx", tbl)
1679 }
1680 };
1681 let _ = table.add_gin_fulltext_index(idx_name, col);
1682 }
1683 }
1684 spg_sql::ast::TableConstraint::Exclude {
1685 name,
1686 method,
1687 elements,
1688 } => {
1689 // v7.39 (round 210/211) — ALTER TABLE ADD EXCLUDE. Resolve
1690 // element columns to positions and synthesise PG's
1691 // `<table>_<col…>_excl` name (ALL element columns joined by
1692 // `_`, e.g. `book_room_during_excl`) when unnamed.
1693 let mut els = Vec::with_capacity(elements.len());
1694 let cols_joined = elements
1695 .iter()
1696 .map(|(c, _)| c.clone())
1697 .collect::<Vec<_>>()
1698 .join("_");
1699 for (col, op) in elements {
1700 let pos = table
1701 .schema()
1702 .columns
1703 .iter()
1704 .position(|c| c.name.eq_ignore_ascii_case(&col))
1705 .ok_or_else(|| {
1706 EngineError::Unsupported(alloc::format!(
1707 "ALTER TABLE ADD EXCLUDE: column {col:?} not found on {tbl:?}"
1708 ))
1709 })?;
1710 els.push((pos, op));
1711 }
1712 let ex_name = name.unwrap_or_else(|| alloc::format!("{tbl}_{cols_joined}_excl"));
1713 table
1714 .schema_mut()
1715 .exclusion_constraints
1716 .push(spg_storage::ExclusionConstraint {
1717 name: ex_name,
1718 method,
1719 elements: els,
1720 });
1721 }
1722 }
1723 Ok(())
1724 }
1725
1726 fn alter_drop_column(
1727 &mut self,
1728 tbl: &str,
1729 column: String,
1730 if_exists: bool,
1731 cascade: bool,
1732 ) -> Result<(), EngineError> {
1733 // v7.13.3 — mailrs round-7 S8. Remove the column +
1734 // every row's value at that position; drop any index
1735 // on the column. RESTRICT (default) rejects when an
1736 // FK on this table or partial-index predicate
1737 // references the column; CASCADE removes those
1738 // dependents first.
1739 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
1740 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
1741 })?;
1742 let col_pos = match table
1743 .schema()
1744 .columns
1745 .iter()
1746 .position(|c| c.name.eq_ignore_ascii_case(&column))
1747 {
1748 Some(p) => p,
1749 None => {
1750 if if_exists {
1751 // v7.39 (read01 round 46) — PG's IF EXISTS skip NOTICE.
1752 self.notice(alloc::format!(
1753 "column {column:?} of relation {:?} does not exist, skipping",
1754 tbl
1755 ));
1756 return Ok(());
1757 }
1758 // v7.39 (read01 round 45) — PG wording (42703 at the wire).
1759 return Err(EngineError::Unsupported(alloc::format!(
1760 "column {column:?} of relation {:?} does not exist",
1761 tbl
1762 )));
1763 }
1764 };
1765 // Dependent check: FKs whose local columns include
1766 // col_pos. CASCADE drops them; otherwise reject.
1767 let dependent_fks: Vec<usize> = table
1768 .schema()
1769 .foreign_keys
1770 .iter()
1771 .enumerate()
1772 .filter_map(|(i, fk)| {
1773 if fk.local_columns.contains(&col_pos) {
1774 Some(i)
1775 } else {
1776 None
1777 }
1778 })
1779 .collect();
1780 if !dependent_fks.is_empty() && !cascade {
1781 return Err(EngineError::Unsupported(alloc::format!(
1782 "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
1783 use DROP COLUMN ... CASCADE to remove them"
1784 )));
1785 }
1786 // CASCADE the FK removals first.
1787 if cascade {
1788 // Drop in reverse so indices stay valid.
1789 let mut sorted = dependent_fks.clone();
1790 sorted.sort();
1791 sorted.reverse();
1792 let fks = &mut table.schema_mut().foreign_keys;
1793 for i in sorted {
1794 fks.remove(i);
1795 }
1796 }
1797 // v7.38.2 (sentori report 5) — PG's ALTER TABLE rule: "Indexes
1798 // and table constraints involving the column will be
1799 // automatically dropped as well." A CHECK left behind after its
1800 // column made the table permanently un-insertable (every later
1801 // INSERT hit ColumnNotFound on the ghost column). Any CHECK
1802 // whose expression references the dropped column goes with it;
1803 // an expression we can't parse can't be evaluated either way,
1804 // so it is kept untouched.
1805 let dropped = table.schema().columns[col_pos].name.clone();
1806 table.schema_mut().checks.retain(|chk| {
1807 let Ok(expr) = spg_sql::parser::parse_expression(&chk.expr) else {
1808 return true;
1809 };
1810 let mut involves = false;
1811 crate::visit_expr_columns_and_subqueries(
1812 &expr,
1813 &mut |c: &spg_sql::ast::ColumnName| {
1814 if c.name.eq_ignore_ascii_case(&dropped) {
1815 involves = true;
1816 }
1817 },
1818 &mut |_| {},
1819 );
1820 !involves
1821 });
1822 // Drop the column. New helper on Table does the
1823 // row + schema + index shift atomically.
1824 table.drop_column(col_pos);
1825 Ok(())
1826 }
1827
1828 fn alter_set_trigger_enabled(
1829 &mut self,
1830 tbl: &str,
1831 which: spg_sql::ast::TriggerSelector,
1832 enabled: bool,
1833 ) -> Result<(), EngineError> {
1834 // v7.16.1 — mailrs round-9 A.2.b. pg_dump
1835 // --disable-triggers wraps each table's data
1836 // block with `ALTER TABLE … DISABLE TRIGGER ALL`
1837 // / `… ENABLE TRIGGER ALL`. Toggle the enabled
1838 // flag on every matching trigger so the row-
1839 // write paths skip them; the catalog snapshot
1840 // persists the new state across restarts.
1841 let table_name = tbl.to_string();
1842 let trigs = self.active_catalog_mut().triggers_mut();
1843 let mut touched = false;
1844 for t in trigs.iter_mut() {
1845 if !t.table.eq_ignore_ascii_case(&table_name) {
1846 continue;
1847 }
1848 match &which {
1849 spg_sql::ast::TriggerSelector::All => {
1850 t.enabled = enabled;
1851 touched = true;
1852 }
1853 spg_sql::ast::TriggerSelector::Named(name) => {
1854 if t.name.eq_ignore_ascii_case(name) {
1855 t.enabled = enabled;
1856 touched = true;
1857 }
1858 }
1859 }
1860 }
1861 // PG semantics: `ALL` on a table with no
1862 // triggers is a no-op (no error). A `Named`
1863 // form pointing at a non-existent trigger
1864 // raises in PG; v7.16.1 also raises so we
1865 // don't silently lose state.
1866 if !touched {
1867 if let spg_sql::ast::TriggerSelector::Named(name) = &which {
1868 return Err(EngineError::Unsupported(alloc::format!(
1869 "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
1870 if enabled { "ENABLE" } else { "DISABLE" },
1871 )));
1872 }
1873 }
1874 Ok(())
1875 }
1876
1877 fn alter_set_column_auto_increment(
1878 &mut self,
1879 tbl: &str,
1880 column: String,
1881 seq_name: Option<String>,
1882 ) -> Result<(), EngineError> {
1883 // pg_dump's identity form names an IMPLICIT sequence
1884 // (`… AS IDENTITY ( SEQUENCE NAME s … )`) that never
1885 // gets its own CREATE SEQUENCE statement, while the
1886 // data section still calls `setval(s, …)`. Make the
1887 // sequence exist (idempotent) so those calls land.
1888 if let Some(seq) = seq_name {
1889 let _ = self.exec_create_sequence(spg_sql::ast::CreateSequenceStatement {
1890 name: seq,
1891 if_not_exists: true,
1892 temporary: false,
1893 data_type: None,
1894 options: spg_sql::ast::SequenceOptions::default(),
1895 })?;
1896 }
1897 // v7.22 (round-13 T2) — pg_dump's serial/identity
1898 // spellings (`SET DEFAULT nextval(…)` / `ADD
1899 // GENERATED … AS IDENTITY`) lower here: flip the
1900 // column's auto-increment flag so post-import
1901 // INSERTs without an explicit value keep numbering
1902 // (max+1 semantics; the dump's setval() calls are
1903 // no-ops by construction).
1904 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
1905 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
1906 })?;
1907 let pos = table
1908 .schema()
1909 .columns
1910 .iter()
1911 .position(|c| c.name.eq_ignore_ascii_case(&column))
1912 .ok_or_else(|| {
1913 EngineError::Unsupported(alloc::format!(
1914 "ALTER COLUMN {column:?}: no such column on {:?}",
1915 tbl
1916 ))
1917 })?;
1918 let col = &table.schema().columns[pos];
1919 if !matches!(
1920 col.ty,
1921 spg_storage::DataType::SmallInt
1922 | spg_storage::DataType::Int
1923 | spg_storage::DataType::BigInt
1924 ) {
1925 return Err(EngineError::Unsupported(alloc::format!(
1926 "auto-increment applies to integer columns only ({column:?} is {:?})",
1927 col.ty
1928 )));
1929 }
1930 table.schema_mut().columns[pos].auto_increment = true;
1931 Ok(())
1932 }
1933
1934 /// v7.39 (read01 round 48) — `ALTER TABLE t RENAME CONSTRAINT old TO new`.
1935 /// Only constraints that carry a stored name can be renamed: an unnamed
1936 /// one has no name to change, and its synthesised `pg_constraint` name
1937 /// is derived, not stored. PG's wording here says "for table" (while
1938 /// DROP CONSTRAINT says "of relation") — matched verbatim.
1939 /// v7.39 (read01 round 50) — `COMMENT ON <kind> <name> IS { 'text' | NULL }`.
1940 /// The object must exist (PG errors otherwise); `IS NULL` removes the
1941 /// comment. Stored in the catalog's comment map under `"<kind>:<name>"`
1942 /// and read back by obj_description / col_description / pg_description.
1943 pub(crate) fn exec_comment_on(
1944 &mut self,
1945 kind: &str,
1946 name: &str,
1947 comment: Option<&str>,
1948 ) -> Result<QueryResult, EngineError> {
1949 let cat = self.active_catalog();
1950 // Validate existence for the kinds SPG catalogues. PG's wording for a
1951 // missing relation is "relation \"x\" does not exist" (42P01).
1952 match kind {
1953 "table" | "view" => {
1954 if cat.get(name).is_none() {
1955 return Err(EngineError::Unsupported(alloc::format!(
1956 "relation {name:?} does not exist"
1957 )));
1958 }
1959 }
1960 "column" => {
1961 let (tbl, col) = name.split_once('.').ok_or_else(|| {
1962 EngineError::Unsupported(alloc::format!("column {name:?} does not exist"))
1963 })?;
1964 let t = cat.get(tbl).ok_or_else(|| {
1965 EngineError::Unsupported(alloc::format!("relation {tbl:?} does not exist"))
1966 })?;
1967 if !t
1968 .schema()
1969 .columns
1970 .iter()
1971 .any(|c| c.name.eq_ignore_ascii_case(col))
1972 {
1973 return Err(EngineError::Unsupported(alloc::format!(
1974 "column {col:?} of relation {tbl:?} does not exist"
1975 )));
1976 }
1977 }
1978 "index" => {
1979 let found = cat.table_names().iter().any(|tn| {
1980 cat.get(tn)
1981 .is_some_and(|t| t.indices().iter().any(|i| i.name == name))
1982 });
1983 if !found {
1984 return Err(EngineError::Unsupported(alloc::format!(
1985 "relation {name:?} does not exist"
1986 )));
1987 }
1988 }
1989 "sequence" => {
1990 if !cat.has_sequence(name) {
1991 return Err(EngineError::Unsupported(alloc::format!(
1992 "relation {name:?} does not exist"
1993 )));
1994 }
1995 }
1996 // schema / type / database / function: accepted and stored without
1997 // a catalogue lookup (SPG's registries for these are partial).
1998 _ => {}
1999 }
2000 let key = alloc::format!("{kind}:{name}");
2001 self.active_catalog_mut().set_comment(&key, comment);
2002 Ok(QueryResult::CommandOk {
2003 affected: 0,
2004 modified_catalog: self.catalog_change_is_committed(),
2005 })
2006 }
2007
2008 fn alter_rename_constraint(
2009 &mut self,
2010 tbl: &str,
2011 old: &str,
2012 new: String,
2013 ) -> Result<(), EngineError> {
2014 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
2015 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
2016 })?;
2017 if !constraint_name_taken(table, old) {
2018 return Err(EngineError::Unsupported(alloc::format!(
2019 "constraint {old:?} for table {tbl:?} does not exist"
2020 )));
2021 }
2022 if constraint_name_taken(table, &new) {
2023 return Err(EngineError::Unsupported(alloc::format!(
2024 "constraint {new:?} for relation {tbl:?} already exists"
2025 )));
2026 }
2027 let sch = table.schema_mut();
2028 for f in &mut sch.foreign_keys {
2029 if f.name.as_deref() == Some(old) {
2030 f.name = Some(new);
2031 return Ok(());
2032 }
2033 }
2034 for u in &mut sch.uniqueness_constraints {
2035 if u.name.as_deref() == Some(old) {
2036 u.name = Some(new);
2037 return Ok(());
2038 }
2039 }
2040 for c in &mut sch.checks {
2041 if c.name.as_deref() == Some(old) {
2042 c.name = Some(new);
2043 return Ok(());
2044 }
2045 }
2046 Ok(())
2047 }
2048
2049 fn alter_rename_table(&mut self, tbl: &str, new: String) -> Result<(), EngineError> {
2050 // v7.16.2 — table-level rename (mailrs round-10
2051 // A.5 — used by migrate-042's `ALTER TABLE
2052 // contacts RENAME TO email_contacts`). Storage
2053 // helper updates the schema + by_name index +
2054 // dangling FK / trigger references in one
2055 // atomic step.
2056 let old = tbl.to_string();
2057 // v7.39 (read01 round 47) — PG rejects a rename onto a name that
2058 // already names a relation (42P07), including a rename onto the
2059 // table's own name. SPG used to accept both silently.
2060 if self.active_catalog().get(&new).is_some() {
2061 return Err(EngineError::Unsupported(alloc::format!(
2062 "relation {new:?} already exists"
2063 )));
2064 }
2065 self.active_catalog_mut()
2066 .rename_table(&old, &new)
2067 .map_err(EngineError::Storage)?;
2068 // r192 — carry the non-transactional DML counters to the new
2069 // name (PG keeps stats across a rename). After the storage
2070 // rename succeeded, so a failed rename leaves them keyed as-is.
2071 if let Some(stats) = self.table_write_stats.remove(&old) {
2072 self.table_write_stats.insert(new.clone(), stats);
2073 }
2074 Ok(())
2075 }
2076
2077 fn alter_rename_column(
2078 &mut self,
2079 tbl: &str,
2080 old: String,
2081 new: String,
2082 ) -> Result<(), EngineError> {
2083 // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
2084 // new`. Rename the column in the schema; rewrite
2085 // every stored source string on this table that
2086 // references it as a (potentially-qualified)
2087 // column identifier: CHECK predicates, partial-
2088 // index predicates, runtime DEFAULT expressions.
2089 // Then walk catalog triggers on this table and
2090 // patch any `UPDATE OF` column list. Function and
2091 // trigger bodies are NOT auto-rewritten — that
2092 // surface is dynamic SQL territory; users update
2093 // those separately (matches PG plpgsql behavior:
2094 // a column rename invalidates name-referencing
2095 // plpgsql at call time, not rename time).
2096 let table = self.active_catalog_mut().get_mut(tbl).ok_or_else(|| {
2097 EngineError::Storage(StorageError::TableNotFound { name: tbl.into() })
2098 })?;
2099 let col_pos = table
2100 .schema()
2101 .columns
2102 .iter()
2103 .position(|c| c.name.eq_ignore_ascii_case(&old))
2104 .ok_or_else(|| {
2105 // v7.39 (read01 round 47) — PG wording (42703). PG omits
2106 // the "of relation" qualifier on RENAME COLUMN (unlike the
2107 // ALTER COLUMN family below) — match it exactly.
2108 EngineError::Unsupported(alloc::format!("column {old:?} does not exist"))
2109 })?;
2110 // Reject same-name (case-insensitive) collision.
2111 if table
2112 .schema()
2113 .columns
2114 .iter()
2115 .enumerate()
2116 .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
2117 {
2118 // v7.39 (read01 round 47) — PG wording (42701).
2119 return Err(EngineError::Unsupported(alloc::format!(
2120 "column {new:?} of relation {:?} already exists",
2121 tbl
2122 )));
2123 }
2124 // Schema rename first — even idempotent same-name
2125 // rename (`ALTER TABLE t RENAME a TO a`) needs to
2126 // be a no-op, not an error.
2127 if old.eq_ignore_ascii_case(&new) {
2128 return Ok(());
2129 }
2130 table.rename_column(col_pos, &new);
2131 // Rewrite per-column runtime_default sources on
2132 // every column of this table — a DEFAULT expression
2133 // on column X may reference column Y by name (rare,
2134 // but legal in PG when the value is supplied via a
2135 // function that takes the row).
2136 let n_cols = table.schema().columns.len();
2137 for i in 0..n_cols {
2138 let rt = table.schema().columns[i].runtime_default.clone();
2139 if let Some(src) = rt {
2140 let rewritten = rewrite_column_in_source(&src, &old, &new)?;
2141 table.schema_mut().columns[i].runtime_default = Some(rewritten);
2142 }
2143 }
2144 // Rewrite table-level CHECK predicates.
2145 let checks = table.schema().checks.clone();
2146 let mut new_checks = Vec::with_capacity(checks.len());
2147 for chk in checks {
2148 // v7.39 (read01 round 48) — rewrite the predicate, keep the name.
2149 new_checks.push(spg_storage::CheckConstraint {
2150 name: chk.name,
2151 expr: rewrite_column_in_source(&chk.expr, &old, &new)?,
2152 // Renaming a column does not re-scan the rows, so it cannot
2153 // turn an unvalidated constraint into a valid one.
2154 validated: chk.validated,
2155 });
2156 }
2157 table.schema_mut().checks = new_checks;
2158 // Rewrite per-index partial_predicate sources.
2159 let n_idx = table.indices().len();
2160 for i in 0..n_idx {
2161 let pred = table.indices()[i].partial_predicate.clone();
2162 if let Some(src) = pred {
2163 let rewritten = rewrite_column_in_source(&src, &old, &new)?;
2164 // SAFETY: indices_mut would be cleanest, but
2165 // partial_predicate is the only mutable field
2166 // here; reach in via the public mut accessor.
2167 table.set_partial_predicate(i, Some(rewritten));
2168 }
2169 }
2170 // Walk catalog triggers; patch `update_columns` on
2171 // triggers attached to this table.
2172 let table_name = tbl.to_string();
2173 for trig in self.active_catalog_mut().triggers_mut() {
2174 if !trig.table.eq_ignore_ascii_case(&table_name) {
2175 continue;
2176 }
2177 for c in &mut trig.update_columns {
2178 if c.eq_ignore_ascii_case(&old) {
2179 *c = new.clone();
2180 }
2181 }
2182 }
2183 Ok(())
2184 }
2185
2186 /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
2187 /// (encoding = …)]`. Walks every table in the active catalog
2188 /// looking for an index matching `stmt.name`, then delegates the
2189 /// rebuild (including any encoding switch) to
2190 /// `Table::rebuild_nsw_index`. The "live" non-blocking
2191 /// optimisation is v6.0.4.1 / v6.1.x territory.
2192 pub(crate) fn exec_alter_index(
2193 &mut self,
2194 stmt: spg_sql::ast::AlterIndexStatement,
2195 ) -> Result<QueryResult, EngineError> {
2196 // Translate the optional SQL-side encoding choice into the
2197 // storage-side enum; the same SqlVecEncoding -> VecEncoding
2198 // bridge `column_type_to_data_type` uses.
2199 let spg_sql::ast::AlterIndexStatement {
2200 name: idx_name,
2201 target,
2202 } = stmt;
2203 // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
2204 // IF EXISTS makes a missing index a no-op rather than an
2205 // error, mirroring PG semantics.
2206 if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
2207 let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
2208 return match renamed {
2209 Ok(()) => Ok(QueryResult::CommandOk {
2210 affected: 0,
2211 modified_catalog: self.catalog_change_is_committed(),
2212 }),
2213 Err(StorageError::IndexNotFound { .. }) if if_exists => {
2214 Ok(QueryResult::CommandOk {
2215 affected: 0,
2216 modified_catalog: false,
2217 })
2218 }
2219 // v7.39 (round 700) — PG18 answers `relation "x" does not
2220 // exist` here, not `index "x" …`. An index IS a relation
2221 // there, and the wire classifier reads the relation wording
2222 // for 42P01; SPG's own spelling missed both.
2223 Err(StorageError::IndexNotFound { .. }) => Err(EngineError::Unsupported(
2224 alloc::format!("relation \"{idx_name}\" does not exist"),
2225 )),
2226 Err(e) => Err(EngineError::Storage(e)),
2227 };
2228 }
2229 // v7.39 (round 710) — SET/RESET storage params: validate the
2230 // index, no-op the parameters (PG resolves the relation first —
2231 // `relation "x" does not exist` — and SPG engine-manages storage
2232 // parameters, as the ALTER TABLE arms already record).
2233 if matches!(target, spg_sql::ast::AlterIndexTarget::StorageParams) {
2234 let cat = self.active_catalog();
2235 let exists = cat.table_names().iter().any(|tn| {
2236 cat.get(tn.as_str())
2237 .is_some_and(|t| t.indices().iter().any(|i| i.name == idx_name))
2238 });
2239 if !exists {
2240 return Err(EngineError::Unsupported(alloc::format!(
2241 "relation \"{idx_name}\" does not exist"
2242 )));
2243 }
2244 return Ok(QueryResult::CommandOk {
2245 affected: 0,
2246 modified_catalog: false,
2247 });
2248 }
2249 let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
2250 unreachable!("Rename branch returned above");
2251 };
2252 let target = encoding.map(|e| match e {
2253 SqlVecEncoding::F32 => VecEncoding::F32,
2254 SqlVecEncoding::Sq8 => VecEncoding::Sq8,
2255 SqlVecEncoding::F16 => VecEncoding::F16,
2256 });
2257 // Linear scan: index names are globally unique within a
2258 // catalog (enforced by add_nsw_index_inner) so the first
2259 // match is the only one. Save the table name to avoid
2260 // borrowing while we then take a mut borrow.
2261 let table_name = {
2262 let cat = self.active_catalog();
2263 let mut found: Option<String> = None;
2264 for tname in cat.table_names() {
2265 if let Some(t) = cat.get(&tname)
2266 && t.indices().iter().any(|i| i.name == idx_name)
2267 {
2268 found = Some(tname);
2269 break;
2270 }
2271 }
2272 found.ok_or_else(|| {
2273 EngineError::Storage(StorageError::IndexNotFound {
2274 name: idx_name.clone(),
2275 })
2276 })?
2277 };
2278 let table = self
2279 .active_catalog_mut()
2280 .get_mut(&table_name)
2281 .expect("table found above");
2282 table.rebuild_nsw_index(&idx_name, target)?;
2283 // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
2284 // changes cost characteristics; evict any cached plans.
2285 self.plan_cache.evict_referencing(&table_name);
2286 Ok(QueryResult::CommandOk {
2287 affected: 0,
2288 modified_catalog: self.catalog_change_is_committed(),
2289 })
2290 }
2291
2292 /// v7.39 (read01 round 93) — derive PG's generated index name for an
2293 /// unnamed `CREATE INDEX`. PG's `ChooseIndexName` builds
2294 /// `<table>_<label1>_<label2>…_idx`, where each label is a key
2295 /// column's name, an expression's leading function name, or `expr`
2296 /// for a non-function expression; INCLUDE columns contribute labels
2297 /// too. On a name clash within the relation an integer counter is
2298 /// appended (`_idx`, `_idx1`, `_idx2`, …).
2299 fn choose_auto_index_name(&self, stmt: &CreateIndexStatement) -> String {
2300 let mut labels: Vec<String> = Vec::new();
2301 match &stmt.expression {
2302 Some(Expr::FunctionCall { name, .. }) => labels.push(name.to_ascii_lowercase()),
2303 Some(_) => labels.push("expr".to_string()),
2304 None => labels.push(stmt.column.clone()),
2305 }
2306 labels.extend(stmt.extra_columns.iter().cloned());
2307 labels.extend(stmt.included_columns.iter().cloned());
2308 let mut base = alloc::format!("{}_{}_idx", stmt.table, labels.join("_"));
2309 // PG truncates the generated name to NAMEDATALEN-1 (63) bytes.
2310 truncate_ident(&mut base);
2311 // Collision counter — index names live in the relation's index
2312 // list (SPG keys index-name uniqueness per table), which is where
2313 // a same-column repeat collides, matching PG's observable output.
2314 let existing: Vec<String> = self
2315 .active_catalog()
2316 .get(&stmt.table)
2317 .map(|t| t.indices().iter().map(|i| i.name.clone()).collect())
2318 .unwrap_or_default();
2319 if !existing.iter().any(|n| *n == base) {
2320 return base;
2321 }
2322 let mut counter = 1u32;
2323 loop {
2324 let mut cand = alloc::format!("{base}{counter}");
2325 truncate_ident(&mut cand);
2326 if !existing.iter().any(|n| *n == cand) {
2327 return cand;
2328 }
2329 counter += 1;
2330 }
2331 }
2332
2333 pub(crate) fn exec_create_index(
2334 &mut self,
2335 mut stmt: CreateIndexStatement,
2336 ) -> Result<QueryResult, EngineError> {
2337 // v7.39 (read01 round 93) — an omitted index name (`CREATE INDEX
2338 // ON t (a)`) is filled in with a PG-style generated name here, so
2339 // the name is chosen against the live catalog (for the collision
2340 // counter). Done before the partition-parent fan-out so children
2341 // inherit a fully-named template.
2342 if stmt.name.is_empty() {
2343 stmt.name = self.choose_auto_index_name(&stmt);
2344 }
2345 // v7.37.6-B(sentori Epic 2 P0)— `CREATE INDEX … ON parent`
2346 // when `parent` is a partition-parent fans out to every
2347 // existing child and records the Display-form source so
2348 // future children also build the same index at creation.
2349 // Parent itself holds no rows, so the build is skipped on
2350 // the parent table.
2351 if crate::partition::is_partition_parent(self.active_catalog(), &stmt.table) {
2352 return self.exec_create_index_on_partition_parent(stmt);
2353 }
2354 // v7.36 — collect cold-tier rows BEFORE taking the mutable
2355 // borrow on the table (the duplicate-scan post-CREATE UNIQUE
2356 // INDEX consumes them). `iter_cold_rows_of_parent` borrows
2357 // the catalog immutably so it would conflict with the
2358 // `active_catalog_mut` borrow below.
2359 let cold_rows_for_unique_scan: alloc::vec::Vec<spg_storage::Row> =
2360 if let Some(t) = self.active_catalog().get(&stmt.table) {
2361 crate::constraints::iter_cold_rows_of_parent(self.active_catalog(), t)
2362 } else {
2363 alloc::vec::Vec::new()
2364 };
2365 let table = self
2366 .active_catalog_mut()
2367 .get_mut(&stmt.table)
2368 .ok_or_else(|| {
2369 EngineError::Storage(StorageError::TableNotFound {
2370 name: stmt.table.clone(),
2371 })
2372 })?;
2373 // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
2374 if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
2375 // v7.39 (read01 round 46) — PG's IF NOT EXISTS skip NOTICE
2376 // (an index is a relation, so PG says "relation").
2377 self.notice(alloc::format!(
2378 "relation {:?} already exists, skipping",
2379 stmt.name
2380 ));
2381 return Ok(QueryResult::CommandOk {
2382 affected: 0,
2383 modified_catalog: false,
2384 });
2385 }
2386 // v7.9.14 — multi-column index parses through; engine
2387 // builds a single-column BTree on the leading column only.
2388 // The trailing index columns are resolved + persisted below
2389 // (for every index, not just UNIQUE) so the catalog reports the
2390 // full column list; the BTree still keys on the leading column.
2391 let table_name = stmt.table.clone();
2392 // v6.8.0 — resolve INCLUDE column names to positions. Done
2393 // before `add_index` so a typo error surfaces before any
2394 // catalog mutation lands.
2395 let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
2396 Vec::new()
2397 } else {
2398 let schema = table.schema();
2399 stmt.included_columns
2400 .iter()
2401 .map(|c| {
2402 schema.column_position(c).ok_or_else(|| {
2403 EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
2404 })
2405 })
2406 .collect::<Result<Vec<_>, _>>()?
2407 };
2408 // r1038 — an operator class that does not exist is refused here,
2409 // with PG's wording and its access method.
2410 //
2411 // The parser recognises an opclass by its position, so it no longer
2412 // rejects an unknown NAME as a syntax error the way its old
2413 // eighteen-name whitelist did as a side effect. That whitelist was
2414 // the sentori defect (`jsonb_path_ops` is ordinary PG and did not
2415 // parse); the refusal it was also doing belongs here, where the
2416 // access method is known and the error can carry it.
2417 if let Some(op) = &stmt.opclass
2418 && !crate::opclass::exists_for_access_method(op, stmt.method_name.as_deref())
2419 {
2420 return Err(EngineError::Unsupported(alloc::format!(
2421 "operator class {op:?} does not exist for access method {:?}",
2422 stmt.method_name.as_deref().unwrap_or("btree")
2423 )));
2424 }
2425 // v7.39 (round 475) — an expression key a method cannot take is
2426 // refused BEFORE anything is built.
2427 //
2428 // The check used to run after the index was created, so
2429 // `CREATE INDEX gx ON g USING gin (to_tsvector('simple', doc))`
2430 // raised an error AND left a btree index named `gx` on `doc`
2431 // behind. The message said nothing had happened, the catalog said
2432 // otherwise, and a dump carried an index the user never wrote.
2433 let gin_fulltext_col = match (&stmt.expression, stmt.method) {
2434 (Some(e), IndexMethod::Gin) => tsvector_source_column(e),
2435 _ => None,
2436 };
2437 // v7.38.16 — a GIN index on an expression is PG's ordinary
2438 // spelling for full-text search, and SPG refused it outright:
2439 // `USING gin (to_tsvector('english', title || ' ' || body))` and
2440 // `USING gin (coalesce(title,''))` and `USING gin ((meta ->
2441 // 'tags'))` all failed the DDL, so a customer's schema did not
2442 // load at all. Only `to_tsvector(col)` worked, because
2443 // `tsvector_source_column` recognises a bare column as the last
2444 // argument and nothing else.
2445 //
2446 // The index kind follows the EXPRESSION's result type, since
2447 // there is no column whose type could decide it.
2448 let gin_expr_kind = match (&stmt.expression, stmt.method) {
2449 // Every GIN expression key, including `to_tsvector(col)`.
2450 // That one used to route to the MySQL FULLTEXT posting list,
2451 // which tokenises with the `simple` rule — so a query written
2452 // `to_tsvector('english', body) @@ to_tsquery('english','lazy')`
2453 // looked for the stem `lazi` in a list that held `lazy`, found
2454 // nothing, and returned NO ROWS where the same query without
2455 // the index returned one. Keying on the evaluated tsvector
2456 // puts the query's own configuration in the index.
2457 (Some(e), IndexMethod::Gin) => {
2458 crate::describe::describe_expr_type(e, &table.schema().columns)
2459 }
2460 _ => None,
2461 };
2462 if let Some(key_expr) = &stmt.expression
2463 && gin_fulltext_col.is_none()
2464 && gin_expr_kind.is_none()
2465 && matches!(
2466 stmt.method,
2467 IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
2468 )
2469 {
2470 // The old wording named HNSW and BRIN while also covering GIN,
2471 // so a refused GIN index reported two methods it was not.
2472 let method = match stmt.method {
2473 IndexMethod::Hnsw => "HNSW",
2474 IndexMethod::Brin => "BRIN",
2475 _ => "GIN",
2476 };
2477 return Err(EngineError::Unsupported(alloc::format!(
2478 "expression keys are not supported on {method} indexes: {key_expr}"
2479 )));
2480 }
2481 if let Some(ty) = gin_expr_kind {
2482 // The expression's own type picks the posting-list shape.
2483 // `column_position` still names the expression's leading
2484 // column so the catalog stays well-formed; the ENTRIES come
2485 // from `expr_index::refresh` below, never from that column.
2486 let anchor = stmt.column.clone();
2487 match ty {
2488 spg_storage::DataType::TsVector => table
2489 .add_gin_index_on_expression(stmt.name.clone(), &anchor)
2490 .map_err(EngineError::Storage)?,
2491 spg_storage::DataType::Json | spg_storage::DataType::Jsonb => table
2492 .add_gin_jsonb_index(stmt.name.clone(), &anchor)
2493 .map_err(EngineError::Storage)?,
2494 spg_storage::DataType::Text | spg_storage::DataType::Varchar(_) => table
2495 .add_gin_trgm_index(stmt.name.clone(), &anchor)
2496 .map_err(EngineError::Storage)?,
2497 _ => {
2498 return Err(EngineError::Unsupported(alloc::format!(
2499 "GIN cannot index an expression of type {ty:?}: {}",
2500 stmt.expression.as_ref().map_or_else(
2501 alloc::string::String::new,
2502 alloc::string::ToString::to_string
2503 )
2504 )));
2505 }
2506 }
2507 } else if let Some(col) = gin_fulltext_col.clone() {
2508 table
2509 .add_gin_fulltext_index(stmt.name.clone(), &col)
2510 .map_err(EngineError::Storage)?;
2511 } else {
2512 match stmt.method {
2513 IndexMethod::BTree => {
2514 table.add_index(stmt.name.clone(), &stmt.column)?;
2515 // v7.38 P0 元机制 A — index has been pushed onto
2516 // the table's index vector. Tests use this point
2517 // to race a sealed index against a concurrent
2518 // read.
2519 crate::injection_point!("index_build_post_seal", &stmt.name);
2520 }
2521 IndexMethod::Hnsw => {
2522 if !included_positions.is_empty() {
2523 return Err(EngineError::Unsupported(
2524 "INCLUDE columns are not supported on HNSW indexes".into(),
2525 ));
2526 }
2527 table.add_nsw_index(
2528 stmt.name.clone(),
2529 &stmt.column,
2530 spg_storage::NSW_DEFAULT_M,
2531 )?;
2532 }
2533 // v6.7.1 — BRIN. Pure metadata; no in-memory data.
2534 IndexMethod::Brin => {
2535 if !included_positions.is_empty() {
2536 return Err(EngineError::Unsupported(
2537 "INCLUDE columns are not supported on BRIN indexes".into(),
2538 ));
2539 }
2540 table.add_brin_index(stmt.name.clone(), &stmt.column)?;
2541 }
2542 // v7.12.3 — GIN inverted index. Real posting-list-backed
2543 // GIN when the indexed column is `tsvector`; falls back
2544 // to a BTree on the leading column for any other column
2545 // type so v7.9.26b's `pg_dump` compatibility (GIN on
2546 // JSONB etc. silently loading as BTree) is preserved.
2547 // Operators see the real GIN only where it matters; old
2548 // schemas keep loading.
2549 IndexMethod::Gin => {
2550 if !included_positions.is_empty() {
2551 return Err(EngineError::Unsupported(
2552 "INCLUDE columns are not supported on GIN indexes".into(),
2553 ));
2554 }
2555 let col_pos =
2556 table
2557 .schema()
2558 .column_position(&stmt.column)
2559 .ok_or_else(|| {
2560 EngineError::Storage(StorageError::ColumnNotFound {
2561 column: stmt.column.clone(),
2562 })
2563 })?;
2564 let col_ty = table.schema().columns[col_pos].ty;
2565 // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
2566 // column dispatches to the real trigram-shingle
2567 // GIN build (LIKE / similarity acceleration).
2568 // Other GIN opclasses fall through to the regular
2569 // tsvector-vs-BTree split below.
2570 let is_trgm = stmt
2571 .opclass
2572 .as_deref()
2573 .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
2574 if is_trgm
2575 && matches!(
2576 col_ty,
2577 spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
2578 )
2579 {
2580 table
2581 .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
2582 .map_err(EngineError::Storage)?;
2583 } else if col_ty == spg_storage::DataType::TsVector {
2584 table
2585 .add_gin_index(stmt.name.clone(), &stmt.column)
2586 .map_err(EngineError::Storage)?;
2587 } else if matches!(
2588 col_ty,
2589 spg_storage::DataType::Json | spg_storage::DataType::Jsonb
2590 ) {
2591 // v7.37.8(sentori Epic 5 P2)— real JSONB-GIN
2592 // posting list. Pre-7.37.8 the same DDL loaded
2593 // as a BTree fallback so `pg_dump` scripts that
2594 // named GIN on JSONB stayed loadable but the
2595 // posting-list acceleration was missing; the
2596 // sentori dashboard's `labels @> '...'` queries
2597 // fell back to full scan. The planner picks
2598 // this index up via the `@>` seek in
2599 // `index_access::try_gin_jsonb_seek`.
2600 table
2601 .add_gin_jsonb_index(stmt.name.clone(), &stmt.column)
2602 .map_err(EngineError::Storage)?;
2603 } else {
2604 // v7.9.26b BTree fallback — the catalog still
2605 // gets an index entry on the leading column so
2606 // pg_dump scripts that name GIN on other column
2607 // types load clean; query-time gain stays opt-in
2608 // for tsvector / JSONB callers.
2609 table.add_index(stmt.name.clone(), &stmt.column)?;
2610 }
2611 }
2612 }
2613 }
2614 if !included_positions.is_empty()
2615 && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
2616 {
2617 idx.included_columns = included_positions;
2618 }
2619 // v6.8.1 — persist partial-index predicate. Stored as the
2620 // expression's Display form so the catalog snapshot stays
2621 // pure (storage has no spg-sql dependency). The runtime
2622 // maintenance path treats partial indexes identically to
2623 // full indexes for v6.8.1 (over-maintenance is safe; the
2624 // planner-side "use partial when query WHERE implies the
2625 // predicate" pass is STABILITY carve-out).
2626 if let Some(pred_expr) = &stmt.partial_predicate {
2627 let canonical = pred_expr.to_string();
2628 // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
2629 // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
2630 // routinely to slim trigram indexes. SPG now persists
2631 // the predicate for GIN / BRIN / HNSW the same way it
2632 // already does for BTree — same v6.8.1 "over-maintain
2633 // is safe; planner-side partial routing is STABILITY
2634 // carve-out" semantics. HNSW carries an additional
2635 // caveat: the predicate isn't applied at index build
2636 // time (would require per-row eval inside the NSW
2637 // construction loop), so the index oversamples; query
2638 // time the WHERE clause still filters correctly.
2639 if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
2640 idx.partial_predicate = Some(canonical);
2641 }
2642 }
2643 // v6.8.2 — persist expression index key. Same Display-form
2644 // storage; the runtime maintenance pass evaluates each
2645 // row's expression to derive the index key, but for v6.8.2
2646 // the engine falls through to the bare-column-reference
2647 // path and the expression is preserved for format-layer
2648 // round-trip + future planner work. Carved-out in
2649 // STABILITY § "Out of v6.8".
2650 if let Some(key_expr) = &stmt.expression {
2651 // v7.39 (round 475) — the method check moved above, before
2652 // anything is built.
2653 let canonical = key_expr.to_string();
2654 if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
2655 idx.expression = Some(canonical);
2656 }
2657 // v7.38.16 — and now FILL it with the expression's values.
2658 // Until this call the B-tree holds the leading column's
2659 // values, which is what the index was built from and what no
2660 // lookup of `lower(s) = …` could ever match. `refresh` is a
2661 // no-op for a GIN full-text index, whose expression names a
2662 // source column that its own maintenance path already reads.
2663 crate::expr_index::refresh(table)?;
2664 }
2665 // v7.38.18 (S0) — and a locale-collated column index, for the
2666 // same reason: `Table::add_index` deliberately leaves its tree
2667 // EMPTY because only this crate can encode ICU sort keys, so
2668 // without this the index would exist, be skipped by every seek
2669 // (`Table::index_on` declines an incomplete one), and cost
2670 // maintenance for nothing.
2671 crate::expr_index::refresh(table)?;
2672 // v7.9.29 — persist `is_unique` flag on the storage Index.
2673 // Combined with `partial_predicate`, INSERT enforcement
2674 // checks that no other row whose predicate evaluates true
2675 // shares the same indexed key. Parser already rejected
2676 // `UNIQUE` on HNSW / BRIN, so plain BTree here.
2677 // Resolve the trailing index columns to positions and persist
2678 // them on EVERY index, unique or not — the BTree keys on the
2679 // leading column, but the extras drive uniqueness enforcement
2680 // (unique) and the catalog / pg_get_indexdef column list
2681 // (both), so a plain `CREATE INDEX t (a, b)` reports (a, b).
2682 {
2683 let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
2684 for col_name in &stmt.extra_columns {
2685 let pos = table
2686 .schema()
2687 .columns
2688 .iter()
2689 .position(|c| c.name.eq_ignore_ascii_case(col_name))
2690 .ok_or_else(|| {
2691 EngineError::Unsupported(alloc::format!(
2692 "INDEX {:?}: extra column {col_name:?} not in table {:?}",
2693 stmt.name,
2694 stmt.table
2695 ))
2696 })?;
2697 extra_positions.push(pos);
2698 }
2699 if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
2700 idx.extra_column_positions = extra_positions;
2701 }
2702 // v7.38.1 (L12) — a multi-column CREATE INDEX becomes a REAL
2703 // composite B-tree: the key is the whole column tuple, so an
2704 // equality on any prefix seeks instead of filtering a
2705 // leading-column candidate flood. Expression / partial /
2706 // GIN-shaped indexes are declined inside and stay as built;
2707 // the indexdef already printed the full column list either
2708 // way, so nothing catalog-visible changes.
2709 table
2710 .convert_index_to_multi(&stmt.name)
2711 .map_err(EngineError::Storage)?;
2712 }
2713 // v7.39 (round 537) — the key column's ordering clause, as
2714 // written. It changes no lookup; `indexdef` reproduces the DDL,
2715 // and dropping it made `(a DESC NULLS LAST)` read back as `(a)`.
2716 if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
2717 idx.descending = stmt.key_order.descending;
2718 idx.nulls_first = stmt.key_order.nulls_first;
2719 idx.collation.clone_from(&stmt.key_collation);
2720 }
2721 if stmt.is_unique {
2722 if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
2723 idx.is_unique = true;
2724 // v7.39 (read01 round 52) — NULLS NOT DISTINCT (PG 15+).
2725 idx.nulls_not_distinct = stmt.nulls_not_distinct;
2726 }
2727 // At index-creation time, check the existing rows for
2728 // pre-existing duplicates that would have violated the
2729 // new constraint — otherwise CREATE UNIQUE INDEX would
2730 // silently leave duplicates in place.
2731 let snapshot_indices = table.indices().to_vec();
2732 let mut snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
2733 table.rows().iter().cloned().collect();
2734 // v7.36 (cold-tier coverage) — CREATE UNIQUE INDEX must
2735 // detect a duplicate that would violate the new
2736 // uniqueness contract even when the duplicate is in the
2737 // cold tier; otherwise the constraint declaration
2738 // succeeds but the on-disk segments carry stale
2739 // duplicates and later INSERTs see phantom-conflict
2740 // behaviour. Use the catalog-borrowing variant from
2741 // `constraints` so we don't double-borrow `self` mut.
2742 snapshot_rows.extend(cold_rows_for_unique_scan);
2743 let snapshot_schema = table.schema().clone();
2744 let idx_ref = snapshot_indices
2745 .iter()
2746 .find(|i| i.name == stmt.name)
2747 .expect("just-added index");
2748 // v7.39 (read01 round 52) — the index was already installed above,
2749 // so a validation failure must ROLL IT BACK. PG's CREATE UNIQUE
2750 // INDEX is atomic; SPG used to leave the half-built index in the
2751 // catalog (pg_indexes listed an index that "failed" to create).
2752 if let Err(e) = check_existing_unique_violation(
2753 idx_ref,
2754 &snapshot_schema,
2755 &snapshot_rows,
2756 self.backslash_escapes,
2757 ) {
2758 let name = stmt.name.clone();
2759 self.active_catalog_mut().drop_named_index(&name);
2760 return Err(e);
2761 }
2762 }
2763 // v6.3.1 — adding an index can change the optimal plan for
2764 // any cached query that references this table.
2765 self.plan_cache.evict_referencing(&table_name);
2766 Ok(QueryResult::CommandOk {
2767 affected: 0,
2768 modified_catalog: self.catalog_change_is_committed(),
2769 })
2770 }
2771
2772 /// v7.37.6-B(sentori Epic 2 P0)— `CREATE INDEX … ON parent`
2773 /// fans the index out to every existing child plus records
2774 /// the Display-form source so future children build it too.
2775 /// The parent itself stays index-less because it holds no rows.
2776 fn exec_create_index_on_partition_parent(
2777 &mut self,
2778 stmt: CreateIndexStatement,
2779 ) -> Result<QueryResult, EngineError> {
2780 let parent_name = stmt.table.clone();
2781 // Display-form source (round-trips through fmt::Display)
2782 // → store on parent's PartitionRole::Parent template list.
2783 let template_source = alloc::format!("{stmt}");
2784 let children = crate::partition::children_of_parent(self.active_catalog(), &parent_name);
2785 // Append the template to the parent schema before fanning
2786 // out, so a child whose CREATE FAILS halfway through still
2787 // records the template the user asked for. Idempotency is
2788 // handled at child-create time via `IF NOT EXISTS`.
2789 {
2790 let parent = self
2791 .active_catalog_mut()
2792 .get_mut(&parent_name)
2793 .ok_or_else(|| {
2794 EngineError::Storage(StorageError::TableNotFound {
2795 name: parent_name.clone(),
2796 })
2797 })?;
2798 if let Some(PartitionRole::Parent {
2799 index_template_sources,
2800 ..
2801 }) = parent.schema_mut().partition_role.as_mut()
2802 {
2803 index_template_sources.push(template_source.clone());
2804 }
2805 }
2806 for child in children {
2807 self.execute_partition_index_template(&child, &template_source)?;
2808 }
2809 Ok(QueryResult::CommandOk {
2810 affected: 0,
2811 modified_catalog: self.catalog_change_is_committed(),
2812 })
2813 }
2814
2815 /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
2816 /// for `CREATE TABLE IF NOT EXISTS` when the table already
2817 /// exists. Adds missing columns + inline FKs from the new
2818 /// definition; existing columns / constraints stay untouched.
2819 /// New columns with a `NOT NULL` declaration without a
2820 /// `DEFAULT` are reported as a clear error rather than
2821 /// silently dropped — this is the "fail loud on real
2822 /// incompatibility, fail silent on schema-superset" tradeoff.
2823 fn reconcile_table_if_not_exists(
2824 &mut self,
2825 stmt: CreateTableStatement,
2826 ) -> Result<QueryResult, EngineError> {
2827 let table_name = stmt.name.clone();
2828 let clock = self.clock;
2829 let existing_col_names: alloc::collections::BTreeSet<String> = self
2830 .active_catalog()
2831 .get(&table_name)
2832 .expect("checked above")
2833 .schema()
2834 .columns
2835 .iter()
2836 .map(|c| c.name.to_ascii_lowercase())
2837 .collect();
2838 let row_count = self
2839 .active_catalog()
2840 .get(&table_name)
2841 .expect("checked above")
2842 .row_count();
2843 // Collect missing column defs in source order.
2844 let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
2845 .columns
2846 .iter()
2847 .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
2848 .cloned()
2849 .collect();
2850 for col_def in new_columns {
2851 let col_name = col_def.name.clone();
2852 let nullable = col_def.nullable;
2853 let has_default = col_def.default.is_some() || col_def.auto_increment;
2854 let col_schema = column_def_to_schema(col_def, self.backslash_escapes)?;
2855 let fill_value: Value<'static> = if has_default || col_schema.runtime_default.is_some()
2856 {
2857 resolve_column_default_free(&col_schema, clock, None)?
2858 } else if nullable || row_count == 0 {
2859 Value::Null
2860 } else {
2861 return Err(EngineError::Unsupported(alloc::format!(
2862 "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
2863 column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
2864 )));
2865 };
2866 let table = self
2867 .active_catalog_mut()
2868 .get_mut(&table_name)
2869 .expect("checked above");
2870 table.add_column(col_schema, fill_value);
2871 }
2872 // Resolve any newly-added inline FKs (column-level
2873 // REFERENCES forms) and install. Skip FKs whose local
2874 // columns we didn't have in the existing table.
2875 let table_cols_now = self
2876 .active_catalog()
2877 .get(&table_name)
2878 .expect("checked above")
2879 .schema()
2880 .columns
2881 .clone();
2882 for fk in stmt.foreign_keys {
2883 // Only install FKs whose every local column resolves
2884 // — older catalogs may have a column the new FK
2885 // references but not the column the new FK declares.
2886 let all_resolved = fk.columns.iter().all(|c| {
2887 table_cols_now
2888 .iter()
2889 .any(|sc| sc.name.eq_ignore_ascii_case(c))
2890 });
2891 if !all_resolved {
2892 continue;
2893 }
2894 let already_present = {
2895 let table = self
2896 .active_catalog()
2897 .get(&table_name)
2898 .expect("checked above");
2899 table.schema().foreign_keys.iter().any(|f| {
2900 f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
2901 && f.local_columns.len() == fk.columns.len()
2902 })
2903 };
2904 if already_present {
2905 continue;
2906 }
2907 let storage_fk =
2908 resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
2909 let table = self
2910 .active_catalog_mut()
2911 .get_mut(&table_name)
2912 .expect("checked above");
2913 table.schema_mut().foreign_keys.push(storage_fk);
2914 }
2915 Ok(QueryResult::CommandOk {
2916 affected: 0,
2917 modified_catalog: self.catalog_change_is_committed(),
2918 })
2919 }
2920
2921 /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
2922 pub(crate) fn exec_drop_table(
2923 &mut self,
2924 names: Vec<String>,
2925 if_exists: bool,
2926 ) -> Result<QueryResult, EngineError> {
2927 for name in names {
2928 // v7.39 (round 642) — dropping a partition parent drops its
2929 // partitions with it.
2930 //
2931 // v7.37.6-B refused instead, on the premise that PG needs an
2932 // explicit CASCADE here. Measured on PG18, it does not: a
2933 // plain `DROP TABLE pp` takes pp and every partition, and so
2934 // does the CASCADE spelling. The refusal made the parent
2935 // undroppable by either spelling — `DROP TABLE IF EXISTS pp
2936 // CASCADE` at the head of a script failed, and every
2937 // statement after it failed on the leftovers.
2938 //
2939 // v7.39 (round 645) — inheritance is the other way round.
2940 // Measured on PG18: `DROP TABLE <inheritance parent>` with a
2941 // child is "cannot drop table par because other objects
2942 // depend on it / table ch depends on table par", and the
2943 // child survives. Only a PARTITION parent takes its children
2944 // with it.
2945 if crate::partition::has_inheritance_children(self.active_catalog(), &name) {
2946 let kids = crate::partition::children_of_parent(self.active_catalog(), &name);
2947 return Err(EngineError::Unsupported(alloc::format!(
2948 "cannot drop table {name} because other objects depend on it\n\
2949 DETAIL: table {} depends on table {name}",
2950 kids.first().map_or("?", |k| k.as_str())
2951 )));
2952 }
2953 // Depth-first: a partition may itself be partitioned, and
2954 // its children have to go before it does.
2955 let mut to_drop = alloc::vec::Vec::new();
2956 let mut frontier = alloc::vec![name.clone()];
2957 while let Some(cur) = frontier.pop() {
2958 for kid in crate::partition::children_of_parent(self.active_catalog(), &cur) {
2959 frontier.push(kid.clone());
2960 to_drop.push(kid);
2961 }
2962 }
2963 // Deepest first, so no parent is removed while a child of it
2964 // is still listed.
2965 for kid in to_drop.into_iter().rev() {
2966 let kid_was_temp = self.temp_tables.contains(&kid);
2967 if self.active_catalog_mut().drop_table(&kid) {
2968 if kid_was_temp {
2969 self.temp_tables.remove(&kid);
2970 self.refresh_temp_prefix();
2971 }
2972 self.table_write_stats.remove(&kid);
2973 }
2974 }
2975 // v7.39 (round 436) — if this was one of the session's TEMPORARY
2976 // tables, forget it too, so a permanent namesake becomes visible
2977 // again and `end_session` does not chase a gone table.
2978 let was_temp = self.temp_tables.contains(&name);
2979 let dropped = self.active_catalog_mut().drop_table(&name);
2980 if dropped && was_temp {
2981 self.temp_tables.remove(&name);
2982 self.refresh_temp_prefix();
2983 }
2984 if dropped {
2985 // r192 — drop the non-transactional DML counters so a
2986 // later same-named table starts at zero (PG resets
2987 // stats on DROP).
2988 self.table_write_stats.remove(&name);
2989 // v7.39 (read01 round 50) — purge the table's comments (and its
2990 // columns') so a later table of the same name can't inherit them.
2991 self.active_catalog_mut().drop_comments_for("table", &name);
2992 }
2993 if !dropped {
2994 if !if_exists {
2995 // v7.39 (read01 round 45) — PG wording (42P01 at the wire);
2996 // PG says "table", not "relation", for DROP TABLE.
2997 return Err(EngineError::Unsupported(alloc::format!(
2998 "table {name:?} does not exist"
2999 )));
3000 }
3001 // v7.39 (read01 round 46) — PG's IF EXISTS skip NOTICE.
3002 self.notice(alloc::format!("table {name:?} does not exist, skipping"));
3003 }
3004 }
3005 Ok(QueryResult::CommandOk {
3006 affected: 0,
3007 modified_catalog: self.catalog_change_is_committed(),
3008 })
3009 }
3010
3011 /// v7.14.0 — DROP INDEX handler.
3012 pub(crate) fn exec_drop_index(
3013 &mut self,
3014 name: String,
3015 if_exists: bool,
3016 ) -> Result<QueryResult, EngineError> {
3017 let dropped = self.active_catalog_mut().drop_named_index(&name);
3018 if !dropped {
3019 if !if_exists {
3020 return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
3021 }
3022 // v7.39 (read01 round 46) — PG's IF EXISTS skip NOTICE.
3023 self.notice(alloc::format!("index {name:?} does not exist, skipping"));
3024 }
3025 Ok(QueryResult::CommandOk {
3026 affected: 0,
3027 modified_catalog: self.catalog_change_is_committed(),
3028 })
3029 }
3030
3031 pub(crate) fn exec_create_table(
3032 &mut self,
3033 mut stmt: CreateTableStatement,
3034 ) -> Result<QueryResult, EngineError> {
3035 // v7.39 — an ENGINE MySQL does not know is refused, as MySQL does.
3036 // The clause was consumed and dropped, so `ENGINE=NONSUCH` built a
3037 // table while `sql_mode` claimed `NO_ENGINE_SUBSTITUTION` — a typo
3038 // in a dump quietly became SPG's storage.
3039 //
3040 // SPG has one storage engine and substitutes for every name in the
3041 // list, so it cannot honour that flag in MySQL's full sense. What
3042 // it can honour is the half a client can act on: a name MySQL
3043 // rejects is rejected here, with MySQL's own message and errno 1286
3044 // (measured on 9.7.2, `ERROR 1286 (42000) Unknown storage engine`).
3045 // Checked before anything is created, so a refused statement leaves
3046 // nothing behind.
3047 if let Some(engine) = &stmt.engine
3048 && !crate::MYSQL_KNOWN_ENGINES
3049 .iter()
3050 .any(|k| k.eq_ignore_ascii_case(engine))
3051 {
3052 return Err(EngineError::Unsupported(alloc::format!(
3053 "Unknown storage engine '{engine}'"
3054 )));
3055 }
3056 // v7.39 (round 436) — a TEMPORARY table is created under the calling
3057 // session's namespace prefix and remembered there, so it shadows a
3058 // permanent table of the same name, stays invisible to other
3059 // sessions, and goes away with the session. Everything downstream
3060 // (the whole DDL body, and every later statement) then works on an
3061 // ordinary table: name resolution happens at the ONE place a name
3062 // becomes an index, `Catalog::resolve_index`.
3063 if stmt.temporary {
3064 let logical = stmt.name.clone();
3065 let mangled = self.session_temp_name(&logical);
3066 let mut inner = stmt;
3067 inner.temporary = false;
3068 inner.name = mangled;
3069 let result = self.exec_create_table(inner)?;
3070 self.temp_tables.insert(logical);
3071 self.refresh_temp_prefix();
3072 return Ok(result);
3073 }
3074 if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
3075 // v7.39 (read01 round 46) — PG's IF NOT EXISTS skip NOTICE.
3076 self.notice(alloc::format!(
3077 "relation {:?} already exists, skipping",
3078 stmt.name
3079 ));
3080 // v7.16.2 — PG-strict silent no-op (mailrs round-10
3081 // surfaced this). v7.13.3's "reconcile by adding
3082 // missing columns" was friendly for mailrs round-7
3083 // where init-schema's `contacts` and migrate-023's
3084 // CardDAV `contacts` collided; but it ALSO silently
3085 // added columns to existing tables when later
3086 // migrations had a duplicate `CREATE TABLE IF NOT
3087 // EXISTS <t> (different-shape-cols)` shape. mailrs's
3088 // migrate-030 has exactly that — re-declares
3089 // system_config with `key` even though init-schema
3090 // already created it with `config_key`. PG's silent
3091 // no-op leaves system_config at `config_key`;
3092 // v7.13.3 added a phantom `key` column that then
3093 // tripped migrate-040's idempotent rename guard.
3094 // mailrs v1.7.106 ships the proper PG-style
3095 // contacts rename via DO + IF EXISTS, so SPG can
3096 // revert to PG-strict here without re-breaking the
3097 // round-7 case.
3098 return Ok(QueryResult::CommandOk {
3099 affected: 0,
3100 modified_catalog: false,
3101 });
3102 }
3103 // v7.37.6-B(sentori Epic 2 P0)— `CREATE TABLE c PARTITION
3104 // OF parent <bounds>`: the child inherits its column list
3105 // from the parent and gets a `PartitionRole::Range` or
3106 // `Default` tag. Parent-table bookkeeping (index template
3107 // fan-out) runs in `register_partition_child`.
3108 if stmt.partition_of.is_some() {
3109 return self.exec_create_table_partition_of(stmt);
3110 }
3111 let table_name = stmt.name.clone();
3112 // v7.9.13 — pluck the names of any columns marked
3113 // `PRIMARY KEY` inline so the post-create-table pass can
3114 // build an implicit BTree index. mailrs F1.
3115 let inline_pk_columns: Vec<String> = stmt
3116 .columns
3117 .iter()
3118 .filter(|c| c.is_primary_key)
3119 .map(|c| c.name.clone())
3120 .collect();
3121 let like_specs = core::mem::take(&mut stmt.like_specs);
3122 let mut schema = self.build_create_table_schema(
3123 &table_name,
3124 stmt.columns,
3125 &stmt.table_constraints,
3126 stmt.foreign_keys,
3127 &inline_pk_columns,
3128 )?;
3129 // v7.39 (round 531) — expand each `LIKE <table>` in the column
3130 // list. The source's shape lives in the catalog, so the parser
3131 // recorded the clause and it is copied here, at the position it
3132 // was written.
3133 let mut like_indexes: Vec<CreateIndexStatement> = Vec::new();
3134 self.apply_like_specs(&mut schema, &like_specs, &mut like_indexes)?;
3135 // v7.39 (round 645) — `INHERITS (p1, p2)`. Each parent's columns
3136 // land BEFORE the child's own, in the order the parents were
3137 // written, which is the order PG uses and the order
3138 // `pg_inherits.inhseqno` numbers them in.
3139 //
3140 // NOT NULL, DEFAULT and CHECK come with a column; PRIMARY KEY
3141 // and UNIQUE do not — measured on PG18, a child of a table with
3142 // a primary key has no `contype = 'p'` row of its own.
3143 //
3144 // A name the child also declares is not duplicated: PG merges
3145 // the two, keeping one column, and requires the types to agree.
3146 if !stmt.inherits.is_empty() {
3147 let mut merged: Vec<spg_storage::ColumnSchema> = Vec::new();
3148 for parent in &stmt.inherits {
3149 let Some(p) = self.active_catalog().get(parent) else {
3150 return Err(EngineError::Storage(
3151 spg_storage::StorageError::TableNotFound {
3152 name: parent.clone(),
3153 },
3154 ));
3155 };
3156 for col in &p.schema().columns {
3157 if merged
3158 .iter()
3159 .any(|c| c.name.eq_ignore_ascii_case(&col.name))
3160 {
3161 continue;
3162 }
3163 if let Some(own) = schema
3164 .columns
3165 .iter()
3166 .find(|c| c.name.eq_ignore_ascii_case(&col.name))
3167 && own.ty != col.ty
3168 {
3169 return Err(EngineError::Unsupported(alloc::format!(
3170 "column \"{}\" inherited from \"{parent}\" has type {} but the child declares {}",
3171 col.name,
3172 crate::conversions::pg_type_name_for_error(col.ty),
3173 crate::conversions::pg_type_name_for_error(own.ty)
3174 )));
3175 }
3176 merged.push(col.clone());
3177 }
3178 }
3179 // The child's own columns follow, minus any the parents
3180 // already supplied.
3181 for col in &schema.columns {
3182 if !merged
3183 .iter()
3184 .any(|c| c.name.eq_ignore_ascii_case(&col.name))
3185 {
3186 merged.push(col.clone());
3187 }
3188 }
3189 schema.columns = merged;
3190 // v7.39 (round 646) — CHECK constraints inherit too. Measured
3191 // on PG18: a child of a table with `CHECK (a > 0)` gets its
3192 // own `contype = 'c'` row. PRIMARY KEY and UNIQUE do NOT —
3193 // the same probe reads 0 for `contype = 'p'` — so only the
3194 // checks are copied.
3195 //
3196 // A constraint the child already declares by the same name is
3197 // left alone; PG merges the two rather than carrying both.
3198 for parent in &stmt.inherits {
3199 let Some(p) = self.active_catalog().get(parent) else {
3200 continue;
3201 };
3202 // The NAME travels with the constraint. An unnamed CHECK
3203 // is auto-named per table, so copying it as-is would give
3204 // the child `<child>_a_check` where PG reports the
3205 // parent's `<parent>_a_check` — measured in the violation
3206 // message, which is where a user meets the name. Resolve
3207 // the parent's name once and carry it explicitly.
3208 let names = crate::system_catalog::pg_check_connames(p, parent, &p.schema().checks);
3209 for (ci, (chk, name)) in p.schema().checks.iter().zip(names).enumerate() {
3210 let dup = schema.checks.iter().any(|c| match (&c.name, &chk.name) {
3211 (Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
3212 _ => c.expr == chk.expr,
3213 });
3214 if !dup {
3215 // A child copies the parent's constraint, validation
3216 // state and all.
3217 schema.checks.push(spg_storage::CheckConstraint {
3218 name: Some(name),
3219 expr: chk.expr.clone(),
3220 validated: chk.validated,
3221 });
3222 }
3223 }
3224 }
3225 schema.partition_role = Some(spg_storage::PartitionRole::Inherits {
3226 parent_names: stmt.inherits.clone(),
3227 });
3228 }
3229 // v7.37.6-B — `CREATE TABLE p (...) PARTITION BY RANGE (key)`:
3230 // attach the parent role to the freshly-built schema before
3231 // it lands in the catalog. Key column must be TIMESTAMPTZ
3232 // at v7.37.6-B (the only sentori shape); other key types are
3233 // a phase-2 carve-out.
3234 if let Some(by) = stmt.partition_by {
3235 let kind = match by.kind {
3236 PartitionKindAst::Range => PartitionKind::Range,
3237 PartitionKindAst::List => PartitionKind::List,
3238 PartitionKindAst::Hash => PartitionKind::Hash,
3239 };
3240 let mut key_column_positions = Vec::with_capacity(by.key_columns.len());
3241 for col_name in &by.key_columns {
3242 let pos = schema
3243 .columns
3244 .iter()
3245 .position(|c| c.name.eq_ignore_ascii_case(col_name))
3246 .ok_or_else(|| {
3247 EngineError::Unsupported(alloc::format!(
3248 "PARTITION BY: key column {col_name:?} not in column list"
3249 ))
3250 })?;
3251 // v7.37.16 (16.1/16.2/16.6) — accept the typed PG
3252 // builtins per partition strategy:
3253 // RANGE → TIMESTAMPTZ / TIMESTAMP / DATE / BIGINT
3254 // / INTEGER / SMALLINT
3255 // LIST → BIGINT / INTEGER / SMALLINT / DATE / TEXT
3256 // HASH → BIGINT / INTEGER / SMALLINT / TEXT / DATE
3257 // / TIMESTAMPTZ
3258 let key_ty = &schema.columns[pos].ty;
3259 let key_ok = matches!(
3260 key_ty,
3261 DataType::Timestamptz
3262 | DataType::Timestamp
3263 | DataType::Date
3264 | DataType::BigInt
3265 | DataType::Int
3266 | DataType::SmallInt
3267 | DataType::Text
3268 | DataType::Varchar(_)
3269 );
3270 if !key_ok {
3271 return Err(EngineError::Unsupported(alloc::format!(
3272 "PARTITION BY {:?}: key column {col_name:?} type {key_ty:?} \
3273 is not yet supported (16.1/16.2/16.6 accept TIMESTAMPTZ, \
3274 TIMESTAMP, DATE, BIGINT, INTEGER, SMALLINT, TEXT/VARCHAR)",
3275 kind,
3276 )));
3277 }
3278 key_column_positions.push(pos);
3279 }
3280 schema.partition_role = Some(PartitionRole::Parent {
3281 kind,
3282 key_column_positions,
3283 index_template_sources: Vec::new(),
3284 });
3285 }
3286 self.active_catalog_mut().create_table(schema)?;
3287 // v7.39 (round 621) — the indexes an `INCLUDING INDEXES` asked for,
3288 // created once the table they sit on exists.
3289 for mut ci in like_indexes {
3290 ci.table = table_name.clone();
3291 self.exec_create_index(ci)?;
3292 }
3293 self.install_implicit_indexes(&table_name, &inline_pk_columns, &stmt.table_constraints)?;
3294 self.install_excl_range_indexes(&table_name);
3295 Ok(QueryResult::CommandOk {
3296 affected: 0,
3297 modified_catalog: self.catalog_change_is_committed(),
3298 })
3299 }
3300
3301 /// v7.37.6-B — child-table branch of `CREATE TABLE`. The parser
3302 /// guarantees `stmt.partition_of.is_some()` + `stmt.columns`
3303 /// is empty before we land here.
3304 fn exec_create_table_partition_of(
3305 &mut self,
3306 stmt: CreateTableStatement,
3307 ) -> Result<QueryResult, EngineError> {
3308 let spec = stmt
3309 .partition_of
3310 .expect("caller checked partition_of.is_some()");
3311 // Lift parent schema bits (columns + partition_role + index
3312 // template list) so we don't trip the active_catalog_mut()
3313 // borrow when we splice the child in.
3314 let (parent_columns, parent_kind, index_template_sources) = {
3315 let parent = self
3316 .active_catalog()
3317 .get(&spec.parent_name)
3318 .ok_or_else(|| {
3319 EngineError::Storage(StorageError::TableNotFound {
3320 name: spec.parent_name.clone(),
3321 })
3322 })?;
3323 match &parent.schema().partition_role {
3324 Some(PartitionRole::Parent {
3325 kind,
3326 index_template_sources,
3327 ..
3328 }) => (
3329 parent.schema().columns.clone(),
3330 *kind,
3331 index_template_sources.clone(),
3332 ),
3333 _ => {
3334 return Err(EngineError::Unsupported(alloc::format!(
3335 "CREATE TABLE … PARTITION OF: table {:?} is not a \
3336 partitioned parent",
3337 spec.parent_name
3338 )));
3339 }
3340 }
3341 };
3342 // Resolve bounds before we mutate the catalog so a bad
3343 // literal surfaces before any visible state changes.
3344 let role = match spec.bounds {
3345 PartitionOfBoundsAst::Default => PartitionRole::Default {
3346 parent_name: spec.parent_name.clone(),
3347 },
3348 PartitionOfBoundsAst::Range { lower, upper } => {
3349 let lower_b = crate::partition::evaluate_partition_bound(*lower)?;
3350 let upper_b = crate::partition::evaluate_partition_bound(*upper)?;
3351 // Half-open: lower must be < upper. Same-bound or
3352 // inverted ranges accept no rows in PG; SPG raises
3353 // because every sentori migration shapes intentional
3354 // calendar windows.
3355 if !crate::partition::ranges_overlap(&lower_b, &upper_b, &lower_b, &upper_b) {
3356 return Err(EngineError::Unsupported(alloc::format!(
3357 "PARTITION OF: FROM ({}) TO ({}) is empty (lower must be < upper)",
3358 crate::partition::bound_to_diag(&lower_b),
3359 crate::partition::bound_to_diag(&upper_b),
3360 )));
3361 }
3362 // Overlap check against every existing sibling Range
3363 // child of the same parent. DEFAULT siblings don't
3364 // participate(they're a catch-all, not a range).
3365 let siblings =
3366 crate::partition::children_of_parent(self.active_catalog(), &spec.parent_name);
3367 // Partition-key column of the parent (RANGE uses one key).
3368 let key_pos = match &self
3369 .active_catalog()
3370 .get(&spec.parent_name)
3371 .and_then(|p| p.schema().partition_role.clone())
3372 {
3373 Some(PartitionRole::Parent {
3374 key_column_positions,
3375 ..
3376 }) => key_column_positions.first().copied().unwrap_or(0),
3377 _ => 0,
3378 };
3379 for sib in &siblings {
3380 let Some(t) = self.active_catalog().get(sib) else {
3381 continue;
3382 };
3383 match &t.schema().partition_role {
3384 Some(PartitionRole::Range {
3385 lower: sl,
3386 upper: su,
3387 ..
3388 }) => {
3389 if crate::partition::ranges_overlap(&lower_b, &upper_b, sl, su) {
3390 return Err(EngineError::Unsupported(alloc::format!(
3391 "PARTITION OF: range FROM ({}) TO ({}) overlaps existing \
3392 child {sib:?} (FROM ({}) TO ({}))",
3393 crate::partition::bound_to_diag(&lower_b),
3394 crate::partition::bound_to_diag(&upper_b),
3395 crate::partition::bound_to_diag(sl),
3396 crate::partition::bound_to_diag(su),
3397 )));
3398 }
3399 }
3400 // v7.38 (read01) — DEFAULT-partition cross-check:
3401 // any row already parked in the default partition
3402 // that falls in the new range means adding it would
3403 // strand that row in the wrong partition. PG rejects
3404 // rather than allow the inconsistency.
3405 Some(PartitionRole::Default { .. }) => {
3406 for row in t.rows().iter() {
3407 let Some(v) = row.values.get(key_pos) else {
3408 continue;
3409 };
3410 if v.is_null() {
3411 continue;
3412 }
3413 let Some(kb) = crate::partition::value_to_bound(v) else {
3414 continue;
3415 };
3416 if crate::partition::value_in_range(&kb, &lower_b, &upper_b) {
3417 return Err(EngineError::Unsupported(alloc::format!(
3418 "updated partition constraint for default partition \
3419 {sib:?} would be violated by some row"
3420 )));
3421 }
3422 }
3423 }
3424 _ => {}
3425 }
3426 }
3427 PartitionRole::Range {
3428 parent_name: spec.parent_name.clone(),
3429 lower: lower_b,
3430 upper: upper_b,
3431 }
3432 }
3433 // v7.37.16 (16.1) — LIST child create.
3434 PartitionOfBoundsAst::List { values } => {
3435 if !matches!(parent_kind, PartitionKind::List) {
3436 return Err(EngineError::Unsupported(alloc::format!(
3437 "PARTITION OF: FOR VALUES IN (...) only valid for \
3438 a LIST-partitioned parent (parent {:?} is {:?})",
3439 spec.parent_name,
3440 parent_kind,
3441 )));
3442 }
3443 let mut bounds = Vec::with_capacity(values.len());
3444 for v in values {
3445 bounds.push(crate::partition::evaluate_partition_bound(v)?);
3446 }
3447 // Reject duplicate values across siblings (PG raises
3448 // "is already specified in partition X" at create
3449 // time so the dispatch never sees ambiguity).
3450 let siblings =
3451 crate::partition::children_of_parent(self.active_catalog(), &spec.parent_name);
3452 for sib in &siblings {
3453 let Some(t) = self.active_catalog().get(sib) else {
3454 continue;
3455 };
3456 if let Some(PartitionRole::List {
3457 values: existing, ..
3458 }) = &t.schema().partition_role
3459 {
3460 for new_b in &bounds {
3461 if existing.iter().any(|e| e == new_b) {
3462 // v7.39 (round 770, F31 tranche 6 #170) —
3463 // PG's sentence, measured: `partition "b"
3464 // would overlap partition "a"`.
3465 let _ = crate::partition::bound_to_diag(new_b);
3466 return Err(EngineError::Unsupported(alloc::format!(
3467 "partition \"{}\" would overlap partition \"{sib}\"",
3468 stmt.name,
3469 )));
3470 }
3471 }
3472 }
3473 }
3474 PartitionRole::List {
3475 parent_name: spec.parent_name.clone(),
3476 values: bounds,
3477 }
3478 }
3479 // v7.37.16 (16.2) — HASH child create.
3480 PartitionOfBoundsAst::Hash { modulus, remainder } => {
3481 if !matches!(parent_kind, PartitionKind::Hash) {
3482 return Err(EngineError::Unsupported(alloc::format!(
3483 "PARTITION OF: FOR VALUES WITH (MODULUS, REMAINDER) only \
3484 valid for a HASH-partitioned parent (parent {:?} is {:?})",
3485 spec.parent_name,
3486 parent_kind,
3487 )));
3488 }
3489 if modulus == 0 || remainder >= modulus {
3490 return Err(EngineError::Unsupported(alloc::format!(
3491 "PARTITION OF HASH: invalid (MODULUS={modulus}, REMAINDER={remainder}); \
3492 require modulus > 0 and remainder < modulus",
3493 )));
3494 }
3495 // Reject duplicate (modulus, remainder) and partial overlap
3496 // (different modulus / same residue class) — PG handles
3497 // multi-modulus by requiring divisibility; we keep it
3498 // simple and demand modulus equality across HASH siblings.
3499 let siblings =
3500 crate::partition::children_of_parent(self.active_catalog(), &spec.parent_name);
3501 for sib in &siblings {
3502 let Some(t) = self.active_catalog().get(sib) else {
3503 continue;
3504 };
3505 if let Some(PartitionRole::Hash {
3506 modulus: m,
3507 remainder: r,
3508 ..
3509 }) = &t.schema().partition_role
3510 {
3511 if *m != modulus {
3512 return Err(EngineError::Unsupported(alloc::format!(
3513 "PARTITION OF HASH: MODULUS {modulus} differs from \
3514 sibling {sib:?} MODULUS {m} (mixed moduli not yet \
3515 supported in v7.37.16.2)",
3516 )));
3517 }
3518 if *r == remainder {
3519 return Err(EngineError::Unsupported(alloc::format!(
3520 "PARTITION OF HASH: REMAINDER {remainder} already \
3521 used by sibling {sib:?}",
3522 )));
3523 }
3524 }
3525 }
3526 PartitionRole::Hash {
3527 parent_name: spec.parent_name.clone(),
3528 modulus,
3529 remainder,
3530 }
3531 }
3532 };
3533 // For DEFAULT children, reject when the parent already has
3534 // one(PG semantics — exactly 0 or 1 DEFAULT per parent).
3535 if matches!(role, PartitionRole::Default { .. }) {
3536 for sib in
3537 crate::partition::children_of_parent(self.active_catalog(), &spec.parent_name)
3538 {
3539 if let Some(t) = self.active_catalog().get(&sib)
3540 && matches!(
3541 t.schema().partition_role,
3542 Some(PartitionRole::Default { .. })
3543 )
3544 {
3545 return Err(EngineError::Unsupported(alloc::format!(
3546 "PARTITION OF DEFAULT: parent {:?} already has a DEFAULT \
3547 partition ({sib:?})",
3548 spec.parent_name
3549 )));
3550 }
3551 }
3552 }
3553 let _ = parent_kind; // v7.37.6-B locks RANGE; future kinds key off this.
3554 let mut schema = TableSchema::new(stmt.name.clone(), parent_columns);
3555 // v7.39 (read01 round 57) — whoever runs CREATE TABLE owns it.
3556 schema.owner = Some(alloc::string::String::from(self.current_role()));
3557 schema.partition_role = Some(role);
3558 self.active_catalog_mut().create_table(schema)?;
3559 // Replay parent's CREATE INDEX templates against the new
3560 // child so every parent-declared index materialises now.
3561 for tmpl in &index_template_sources {
3562 self.execute_partition_index_template(&stmt.name, tmpl)?;
3563 }
3564 Ok(QueryResult::CommandOk {
3565 affected: 0,
3566 modified_catalog: self.catalog_change_is_committed(),
3567 })
3568 }
3569
3570 /// v7.37.6-B — parse a stored `CREATE INDEX ON parent (…)`
3571 /// template and re-execute it against `child_name`(by rewriting
3572 /// the table reference on the AST before dispatch). Used both
3573 /// at child-create time and after `CREATE INDEX ON parent` for
3574 /// existing children.
3575 fn execute_partition_index_template(
3576 &mut self,
3577 child_name: &str,
3578 template_source: &str,
3579 ) -> Result<(), EngineError> {
3580 let stmt = spg_sql::parser::parse_statement(template_source).map_err(EngineError::Parse)?;
3581 let Statement::CreateIndex(mut ci) = stmt else {
3582 return Err(EngineError::Unsupported(alloc::format!(
3583 "PARTITION index template is not CREATE INDEX: {template_source:?}"
3584 )));
3585 };
3586 ci.table = child_name.to_string();
3587 // Name suffix per child so different children don't collide
3588 // on the same `<idx_name>`. Skip when the original index has
3589 // no explicit name(SPG auto-generates).
3590 if !ci.name.is_empty() {
3591 ci.name = alloc::format!("{}__{}", ci.name, child_name);
3592 }
3593 // IF NOT EXISTS to make replay idempotent — when this is
3594 // called from the CREATE INDEX ON parent fan-out we want to
3595 // tolerate the case where a child already has the index
3596 // from an earlier CREATE INDEX run.
3597 ci.if_not_exists = true;
3598 self.exec_create_index(ci)?;
3599 Ok(())
3600 }
3601
3602 /// Build the `TableSchema` for a CREATE TABLE: column schemas with
3603 /// ENUM / DOMAIN bindings resolved, table-level + inline PRIMARY KEY
3604 /// NOT NULL marking, FK resolution (deferring to `pending_foreign_keys`
3605 /// when checks are off and the parent is absent), and uniqueness /
3606 /// CHECK constraint translation.
3607 #[allow(clippy::too_many_lines)]
3608 /// v7.39 (round 531) — copy a source table's shape into the new one.
3609 ///
3610 /// Measured on PG18: a bare `LIKE` copies names, types and NOT NULL
3611 /// and nothing else — a copied generated column becomes a plain one
3612 /// and a copied identity column loses its identity. Each INCLUDING
3613 /// adds one property back, and `INCLUDING ALL` adds them all.
3614 #[allow(clippy::too_many_lines)]
3615 fn apply_like_specs(
3616 &mut self,
3617 schema: &mut spg_storage::TableSchema,
3618 specs: &[spg_sql::ast::LikeSpec],
3619 out_indexes: &mut Vec<CreateIndexStatement>,
3620 ) -> Result<(), EngineError> {
3621 // Applied back to front so an earlier spec's insert position is
3622 // still the one it was written at.
3623 for spec in specs.iter().rev() {
3624 let src = self.active_catalog().get(&spec.source).ok_or_else(|| {
3625 EngineError::Storage(spg_storage::StorageError::TableNotFound {
3626 name: spec.source.clone(),
3627 })
3628 })?;
3629 let src_schema = src.schema();
3630 let o = spec.options;
3631 let mut copied: Vec<spg_storage::ColumnSchema> = Vec::new();
3632 for c in &src_schema.columns {
3633 let mut col = c.clone();
3634 if !o.defaults {
3635 col.default = None;
3636 col.default_text = None;
3637 col.runtime_default = None;
3638 }
3639 if !o.identity {
3640 col.auto_increment = false;
3641 col.identity_always = false;
3642 col.auto_restart = None;
3643 }
3644 if !o.generated {
3645 col.generated_stored_expr = None;
3646 }
3647 if !o.comments {
3648 // Comments live in the catalog's comment map, not on
3649 // the column, so there is nothing to clear here; the
3650 // copy below simply does not carry them.
3651 }
3652 copied.push(col);
3653 }
3654 let at = spec.at.min(schema.columns.len());
3655 for (i, col) in copied.into_iter().enumerate() {
3656 schema.columns.insert(at + i, col);
3657 }
3658 if o.constraints {
3659 for chk in &src_schema.checks {
3660 schema.checks.push(chk.clone());
3661 }
3662 }
3663 // v7.39 (round 621) — INCLUDING INDEXES copies them.
3664 //
3665 // Round 531 refused it rather than dropping them silently, and the
3666 // reason it gave was right: "a table that reports the right columns
3667 // and none of the indexes is the shape that looks fine until it is
3668 // slow". But refusing takes `INCLUDING ALL` down with it, which is
3669 // what schema tools write, so the restore stopped instead.
3670 //
3671 // The index is rebuilt from its own definition rather than copied
3672 // as a structure, so it goes through the same path a written-out
3673 // CREATE INDEX takes. PG names the copies after the new table and
3674 // lets the auto-namer resolve collisions, which is what an empty
3675 // name asks for here.
3676 if o.indexes {
3677 for idx in src.indices() {
3678 let Some(col) = src_schema.columns.get(idx.column_position) else {
3679 continue;
3680 };
3681 out_indexes.push(CreateIndexStatement {
3682 concurrently: false,
3683 name: String::new(),
3684 key_order: spg_sql::ast::IndexColumnOrder::default(),
3685 key_collation: None,
3686 table: String::new(),
3687 column: col.name.clone(),
3688 nulls_not_distinct: idx.nulls_not_distinct,
3689 method: spg_sql::ast::IndexMethod::BTree,
3690 if_not_exists: false,
3691 included_columns: Vec::new(),
3692 partial_predicate: None,
3693 expression: None,
3694 extra_columns: Vec::new(),
3695 is_unique: idx.is_unique,
3696 opclass: None,
3697 method_name: None,
3698 });
3699 }
3700 }
3701 }
3702 Ok(())
3703 }
3704
3705 fn build_create_table_schema(
3706 &mut self,
3707 table_name: &str,
3708 columns: Vec<ColumnDef>,
3709 table_constraints: &[spg_sql::ast::TableConstraint],
3710 foreign_keys: Vec<spg_sql::ast::ForeignKeyConstraint>,
3711 inline_pk_columns: &[String],
3712 ) -> Result<TableSchema, EngineError> {
3713 // v7.39 (round 711) — the inline PK's timing clause, captured
3714 // before `columns` is consumed into the schema below.
3715 let inline_pk_timing: (bool, bool) =
3716 columns
3717 .iter()
3718 .filter(|c| c.is_primary_key)
3719 .fold((false, false), |acc, c| {
3720 (
3721 acc.0 | c.constraint_deferrable,
3722 acc.1 | c.constraint_initially_deferred,
3723 )
3724 });
3725 // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
3726 // and UNIQUE (a, b, ...). Each builds a BTree index on the
3727 // leading column (the existing single-column storage tier)
3728 // and registers a UniquenessConstraint on the schema for
3729 // INSERT-time enforcement of the full tuple. mailrs G1/G6.
3730 let mysql = self.backslash_escapes;
3731 let cols = columns
3732 .into_iter()
3733 .map(|c| column_def_to_schema(c, mysql))
3734 .collect::<Result<Vec<_>, _>>()?;
3735 // v7.39 (round 679) — say so when a declared collation is stored but
3736 // not applied.
3737 //
3738 // Round 670 measured three rules colliding here: refusing the DDL
3739 // breaks a customer's pg_dump restore (zero-customer-change), while
3740 // accepting it silently is what F36 records as the defect — the
3741 // declaration taken and ignored. A WARNING is the option that was
3742 // not available then: rounds 676-677 gave the name somewhere to
3743 // live, and round 678 gave `collate::is_supported` a way to say
3744 // whether this build can perform it. The restore still succeeds;
3745 // the gap stops being silent.
3746 //
3747 // SPG performs C and POSIX, so those warn about nothing.
3748 for c in &cols {
3749 let Some(name) = c.collation_name.as_deref() else {
3750 continue;
3751 };
3752 // v7.38.22 — the type has to be able to carry one.
3753 //
3754 // PostgreSQL 18.4 refuses `CREATE TABLE t (c INT COLLATE
3755 // "en_US.utf8")` with 42804; SPG took the declaration and
3756 // stored it, which is the same "taken and ignored" shape F36
3757 // was opened for, one level up — and it then travels into
3758 // every comparison the column takes part in.
3759 if !crate::collate::is_collatable(&c.ty) {
3760 return Err(crate::collate::not_collatable_error(
3761 crate::eval::pg_typeof_name_for_datatype(c.ty).unwrap_or("unknown"),
3762 ));
3763 }
3764 if crate::collate::is_supported(name)
3765 && (name.eq_ignore_ascii_case("C")
3766 || name.eq_ignore_ascii_case("POSIX")
3767 || name.eq_ignore_ascii_case("default"))
3768 {
3769 continue;
3770 }
3771 // v7.39 (round 692) — the message says what is true TODAY.
3772 // Rounds 683–692 made ORDER BY, DISTINCT, GROUP BY, joins,
3773 // min/max and window ordering follow a declared collation, so
3774 // the old wording ("orders this column by bytes") had become
3775 // the wrong warning — and a wrong warning is worse than none,
3776 // because a customer reads it and plans around it.
3777 //
3778 // What is still true is the range comparison: `BETWEEN`, `<`,
3779 // `>` go through `binop::compare`, which takes two values and
3780 // no column. That one is not wiring; it needs collation
3781 // derivation at a comparison, and `compare` is the dominant
3782 // cost of a scan, so it needs a bench with it.
3783 if !crate::collate::is_known(name) {
3784 // v7.38.18 (G2) — see the ALTER site: PG 18.4 refuses a
3785 // name that is not in its catalogue, and so does this.
3786 return Err(crate::collate::unknown_collation_error(name));
3787 }
3788 if !crate::collate::is_supported(name) {
3789 self.warning(alloc::format!(
3790 "column \"{}\" declares COLLATE \"{name}\", which this build cannot \
3791 perform; SPG records the declaration and orders this column by bytes \
3792 (the C collation)",
3793 c.name
3794 ));
3795 }
3796 }
3797 // v7.17.0 Phase 1.4 + 1.5 — classify every raw
3798 // user_type_ref (parked as user_enum_type by
3799 // column_def_to_schema) into either an enum binding or a
3800 // domain binding. For domains, also rewrite the column's
3801 // base DataType from the placeholder Text to the domain's
3802 // declared base. Unknown idents are still a hard error
3803 // here (same as Phase 1.4) so silent acceptance never
3804 // happens.
3805 let mut cols = cols;
3806 for col in cols.iter_mut() {
3807 let Some(name) = col.user_enum_type.take() else {
3808 continue;
3809 };
3810 let cat = self.active_catalog();
3811 if cat.enum_types().contains_key(&name) {
3812 col.user_enum_type = Some(name);
3813 continue;
3814 }
3815 if let Some(dom) = cat.domain_types().get(&name) {
3816 let base_type = dom.base_type;
3817 let dom_default = dom.default.clone();
3818 col.ty = base_type;
3819 col.user_domain_type = Some(name);
3820 if !dom.nullable {
3821 col.nullable = false;
3822 }
3823 // v7.39 (round 259) — two DEFAULT problems on a domain
3824 // column, both because the column was typed Text (the
3825 // parser's placeholder for an unknown type name) while its
3826 // DEFAULT was being resolved, and only re-typed here:
3827 // * a COLUMN-level default failed to coerce and the
3828 // whole CREATE TABLE errored ("type mismatch") — a
3829 // hard failure on valid SQL;
3830 // * the DOMAIN's own default was never adopted, so an
3831 // omitted column landed NULL where PG gives the
3832 // domain default (probed: 42, and a column default
3833 // of 7 overrides it).
3834 if let Some(d) = col.default.take() {
3835 col.default = Some(crate::conversions::coerce_value(
3836 d, base_type, &col.name, 0,
3837 )?);
3838 } else if let Some(src) = dom_default {
3839 let expr = spg_sql::parser::parse_expression(&src).map_err(|e| {
3840 EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3841 "domain default {src:?} failed to re-parse: {e:?}"
3842 )))
3843 })?;
3844 let empty: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
3845 let ctx = crate::eval::EvalContext::new(&empty, None);
3846 let row = spg_storage::Row {
3847 values: alloc::vec::Vec::new(),
3848 };
3849 let v = crate::eval::eval_expr(&expr, &row, &ctx).map_err(EngineError::Eval)?;
3850 col.default = Some(crate::conversions::coerce_value(
3851 v, base_type, &col.name, 0,
3852 )?);
3853 }
3854 continue;
3855 }
3856 // v7.37.42-T2 ζ-B — composite type bound to a column.
3857 // Stored as JSONB at the storage tier (positional + named
3858 // field access via JSONB path operators is the canonical
3859 // PG-compatible surface until Value::Composite lands).
3860 // The composite identity stays in `catalog.composite_types`
3861 // for introspection / DROP TYPE / column-type-DDL
3862 // round-trip.
3863 if cat.composite_types().contains_key(&name) {
3864 // v7.39 (read01 round 56) — the on-disk form stays JSONB, but
3865 // the column now RECORDS which composite type it holds. The
3866 // engine rehydrates the stored JSON into a Value::Composite on
3867 // read, so field access / ROW comparison / ordering / the
3868 // canonical `(2,b)` text form all work — every one of those was
3869 // already implemented on Value::Composite; the column simply
3870 // never remembered its type.
3871 col.ty = spg_storage::DataType::Jsonb;
3872 col.user_composite_type = Some(name.clone());
3873 continue;
3874 }
3875 // v7.38.19 — a PSEUDO-type is a different refusal. The name
3876 // exists; it just cannot hold a value, which PG reports as an
3877 // INVALID TABLE DEFINITION (42P16) naming the column rather
3878 // than an undefined type (42704) naming the type.
3879 if let Some(pseudo) = crate::conversions::pseudo_type(&name) {
3880 return Err(EngineError::Unsupported(alloc::format!(
3881 "column \"{}\" has pseudo-type {pseudo}",
3882 col.name
3883 )));
3884 }
3885 // v7.39 (read01 round 89) — PG's 42704 wording. The old
3886 // "column X: unknown column type Y (...)" carried SPG's own
3887 // vocabulary and fell to the generic error class; PG says
3888 // simply `type "Y" does not exist`.
3889 return Err(EngineError::Unsupported(alloc::format!(
3890 "type \"{name}\" does not exist"
3891 )));
3892 }
3893 for tc in table_constraints {
3894 if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
3895 for col_name in columns {
3896 if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
3897 col.nullable = false;
3898 }
3899 }
3900 }
3901 }
3902 // v7.6.1 — resolve every FK in the statement against the
3903 // already-known catalog. Validates: parent table exists,
3904 // parent column names exist, arity matches, parent columns
3905 // have a PK / UNIQUE index. Self-referencing FKs (parent
3906 // table == this table) resolve against the column list we
3907 // just built — they don't need the catalog yet.
3908 let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
3909 Vec::with_capacity(foreign_keys.len());
3910 for fk in foreign_keys {
3911 // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
3912 // (mysqldump preamble + bulk imports), defer FK
3913 // resolution if the parent table isn't in the catalog
3914 // yet. The FK is queued and resolved when checks flip
3915 // back on. Self-references stay in-band (the parent is
3916 // the same as the child we're building).
3917 let needs_parent = !fk.parent_table.eq_ignore_ascii_case(table_name);
3918 if !self.foreign_key_checks
3919 && needs_parent
3920 && self.active_catalog().get(&fk.parent_table).is_none()
3921 {
3922 self.pending_foreign_keys.push((table_name.to_string(), fk));
3923 continue;
3924 }
3925 fks.push(resolve_foreign_key(
3926 table_name,
3927 &cols,
3928 fk,
3929 self.active_catalog(),
3930 )?);
3931 }
3932 let mut schema = TableSchema::new(table_name.to_string(), cols);
3933 // v7.39 (read01 round 57) — whoever runs CREATE TABLE owns it (PG
3934 // `pg_class.relowner`); the owner holds every privilege implicitly.
3935 schema.owner = Some(alloc::string::String::from(self.current_role()));
3936 schema.foreign_keys = fks;
3937 // v7.9.19 — translate AST table_constraints to storage
3938 // UniquenessConstraints (column name → position) so the
3939 // INSERT enforcement helper sees positions directly.
3940 let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
3941 // v7.39 (read01 round 48) — the AST has carried `name` all along;
3942 // the schema now keeps it instead of dropping it on the floor.
3943 let mut check_exprs: Vec<spg_storage::CheckConstraint> = Vec::new();
3944 // v7.39 (round 210) — EXCLUDE constraints translate column names to
3945 // positions and synthesise PG's `<table>_<leading-col>_excl` name
3946 // when the user left it unnamed.
3947 let mut excl_storage: Vec<spg_storage::ExclusionConstraint> = Vec::new();
3948 for tc in table_constraints {
3949 let (is_pk, names, nnd, con_name, timing) = match tc {
3950 spg_sql::ast::TableConstraint::PrimaryKey {
3951 name,
3952 columns,
3953 deferrable,
3954 initially_deferred,
3955 } => (
3956 true,
3957 columns.clone(),
3958 false,
3959 name.clone(),
3960 (*deferrable, *initially_deferred),
3961 ),
3962 spg_sql::ast::TableConstraint::Unique {
3963 name,
3964 columns,
3965 nulls_not_distinct,
3966 deferrable,
3967 initially_deferred,
3968 } => (
3969 false,
3970 columns.clone(),
3971 *nulls_not_distinct,
3972 name.clone(),
3973 (*deferrable, *initially_deferred),
3974 ),
3975 spg_sql::ast::TableConstraint::Check { name, expr, .. } => {
3976 // v7.13.0 — collect CHECK predicate sources;
3977 // they get attached to the schema below.
3978 // A CREATE TABLE CHECK has no rows to grandfather; the
3979 // parser refuses NOT VALID there, as PG does, so every
3980 // one of these is validated and none needs a mark.
3981 check_exprs.push(spg_storage::CheckConstraint {
3982 name: name.clone(),
3983 expr: alloc::format!("{expr}"),
3984 validated: true,
3985 });
3986 continue;
3987 }
3988 spg_sql::ast::TableConstraint::Exclude {
3989 name,
3990 method,
3991 elements,
3992 } => {
3993 let mut els = Vec::with_capacity(elements.len());
3994 for (col, op) in elements {
3995 let pos = schema
3996 .columns
3997 .iter()
3998 .position(|c| c.name == *col)
3999 .ok_or_else(|| {
4000 EngineError::Unsupported(alloc::format!(
4001 "EXCLUDE constraint references unknown column {col:?}"
4002 ))
4003 })?;
4004 els.push((pos, op.clone()));
4005 }
4006 // v7.39 (round 211) — PG auto-names an unnamed EXCLUDE
4007 // `<table>_<col…>_excl`, joining ALL element columns
4008 // (e.g. `book_room_during_excl`), not just the leading one.
4009 let cols_joined = elements
4010 .iter()
4011 .map(|(c, _)| c.clone())
4012 .collect::<Vec<_>>()
4013 .join("_");
4014 let con_name = name
4015 .clone()
4016 .unwrap_or_else(|| alloc::format!("{table_name}_{cols_joined}_excl"));
4017 excl_storage.push(spg_storage::ExclusionConstraint {
4018 name: con_name,
4019 method: method.clone(),
4020 elements: els,
4021 });
4022 continue;
4023 }
4024 // v7.15.0 — plain `KEY (cols)` from MySQL inline
4025 // is NOT a uniqueness constraint; skip the UC
4026 // build path entirely. The BTree index lands in
4027 // the post-create loop below alongside the PK/UQ
4028 // implicit indexes.
4029 spg_sql::ast::TableConstraint::Index { .. } => continue,
4030 // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
4031 // a uniqueness constraint either; its GIN gets
4032 // built in the post-create loop below.
4033 spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
4034 };
4035 let mut positions = Vec::with_capacity(names.len());
4036 for n in &names {
4037 let pos = schema
4038 .columns
4039 .iter()
4040 .position(|c| c.name == *n)
4041 .ok_or_else(|| {
4042 EngineError::Unsupported(alloc::format!(
4043 "table constraint references unknown column {n:?}"
4044 ))
4045 })?;
4046 positions.push(pos);
4047 }
4048 uc_storage.push(spg_storage::UniquenessConstraint {
4049 is_primary_key: is_pk,
4050 columns: positions,
4051 nulls_not_distinct: nnd,
4052 name: con_name,
4053 deferrable: timing.0,
4054 initially_deferred: timing.1,
4055 });
4056 }
4057 // v7.24 (round-16 collateral) — inline `PRIMARY KEY` column
4058 // constraints used to build only the implicit BTree index;
4059 // uniqueness was NEVER registered, so duplicate keys were
4060 // silently accepted (table-level PRIMARY KEY did enforce).
4061 // Register the same UniquenessConstraint the table-level
4062 // form gets, unless one already covers the column set.
4063 if !inline_pk_columns.is_empty() {
4064 let mut positions = Vec::with_capacity(inline_pk_columns.len());
4065 for n in inline_pk_columns {
4066 if let Some(pos) = schema.columns.iter().position(|c| c.name == *n) {
4067 positions.push(pos);
4068 }
4069 }
4070 if !uc_storage
4071 .iter()
4072 .any(|uc| uc.is_primary_key || uc.columns == positions)
4073 {
4074 uc_storage.push(spg_storage::UniquenessConstraint {
4075 is_primary_key: true,
4076 columns: positions,
4077 nulls_not_distinct: false,
4078 deferrable: inline_pk_timing.0,
4079 initially_deferred: inline_pk_timing.1,
4080 // Inline `col INT PRIMARY KEY` carries no name.
4081 name: None,
4082 });
4083 }
4084 }
4085 schema.uniqueness_constraints = uc_storage.clone();
4086 schema.checks = check_exprs;
4087 schema.exclusion_constraints = excl_storage;
4088 Ok(schema)
4089 }
4090
4091 /// Install the implicit BTree / fulltext-GIN indexes a freshly-created
4092 /// table needs: one per inline PRIMARY KEY column, plus one per
4093 /// v7.39 (round 215) — build a range-overlap index for every EXCLUDE
4094 /// constraint whose `&&` element sits on an integer-keyable range column
4095 /// (int4/int8/date/ts/tstz range). Turns the O(n) enforcement scan into an
4096 /// O(log n) predecessor+successor probe. Idempotent — safe to call again
4097 /// after ALTER or on catalog load. Constraints the index can't cover
4098 /// (numrange, `@>`/`<@`/geometry operators) simply get no index and keep
4099 /// the correct O(n) scan.
4100 pub(crate) fn install_excl_range_indexes(&mut self, table_name: &str) {
4101 let Some(table) = self.active_catalog_mut().get_mut(table_name) else {
4102 return;
4103 };
4104 let cols: Vec<usize> = table
4105 .schema()
4106 .exclusion_constraints
4107 .iter()
4108 .filter_map(|ex| excl_index_column(table.schema(), ex))
4109 .collect();
4110 for c in cols {
4111 table.ensure_excl_range_index(c);
4112 }
4113 }
4114
4115 /// table-level PRIMARY KEY / UNIQUE / KEY / FULLTEXT constraint.
4116 fn install_implicit_indexes(
4117 &mut self,
4118 table_name: &str,
4119 inline_pk_columns: &[String],
4120 table_constraints: &[spg_sql::ast::TableConstraint],
4121 ) -> Result<(), EngineError> {
4122 // v7.9.13 — implicit BTree per inline PK column +
4123 // v7.9.19 — implicit BTree on the leading column of every
4124 // table-level PRIMARY KEY / UNIQUE constraint.
4125 let table = self
4126 .active_catalog_mut()
4127 .get_mut(table_name)
4128 .expect("just created");
4129 let mut inline_lead_added: Option<alloc::string::String> = None;
4130 for (i, col_name) in inline_pk_columns.iter().enumerate() {
4131 let idx_name = if inline_pk_columns.len() == 1 {
4132 alloc::format!("{table_name}_pkey")
4133 } else {
4134 alloc::format!("{table_name}_pkey_{i}")
4135 };
4136 if let Err(e) = table.add_index(idx_name.clone(), col_name) {
4137 return Err(EngineError::Storage(e));
4138 }
4139 if i == 0 {
4140 inline_lead_added = Some(idx_name);
4141 }
4142 }
4143 // v7.38.1 (L12) — a multi-column PRIMARY KEY's leading index
4144 // becomes a REAL composite B-tree over the whole key, exactly
4145 // like PG's one `t_pkey` index. The k≥1 per-column B-trees
4146 // stay: they serve probes on non-leading columns, which a
4147 // composite cannot (a prefix must start at the front).
4148 if inline_pk_columns.len() >= 2
4149 && let Some(lead_name) = inline_lead_added
4150 {
4151 let mut extras: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
4152 for col_name in &inline_pk_columns[1..] {
4153 if let Some(p) = table
4154 .schema()
4155 .columns
4156 .iter()
4157 .position(|c| c.name.eq_ignore_ascii_case(col_name))
4158 {
4159 extras.push(p);
4160 }
4161 }
4162 if extras.len() == inline_pk_columns.len() - 1 {
4163 if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == lead_name) {
4164 idx.extra_column_positions = extras;
4165 }
4166 table
4167 .convert_index_to_multi(&lead_name)
4168 .map_err(EngineError::Storage)?;
4169 }
4170 }
4171 for (i, tc) in table_constraints.iter().enumerate() {
4172 // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
4173 // tsvector-GIN per declared column instead of the
4174 // BTree the PK / UQ / KEY paths build. Branch early
4175 // so the BTree loop never sees the FULLTEXT shape.
4176 if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
4177 for (k, col) in columns.iter().enumerate() {
4178 let already = table.indices().iter().any(|idx| {
4179 matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
4180 && table.schema().columns[idx.column_position].name == *col
4181 });
4182 if already {
4183 continue;
4184 }
4185 let idx_name = match (name.as_ref(), columns.len(), k) {
4186 (Some(n), 1, _) => n.clone(),
4187 (Some(n), _, k) => alloc::format!("{n}_{k}"),
4188 (None, _, _) => {
4189 alloc::format!("{table_name}_{col}_ftidx")
4190 }
4191 };
4192 if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
4193 return Err(EngineError::Storage(e));
4194 }
4195 }
4196 continue;
4197 }
4198 // v7.15.0 — plain KEY/INDEX rides this same loop so
4199 // the implicit BTree gets built. It carries its own
4200 // user-supplied name; PK/UQ still synthesise.
4201 let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
4202 spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
4203 ("pkey", columns, None)
4204 }
4205 spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
4206 spg_sql::ast::TableConstraint::Index { name, columns } => {
4207 ("idx", columns, name.as_ref())
4208 }
4209 spg_sql::ast::TableConstraint::Check { .. } => continue,
4210 // Handled by the early-branch above.
4211 spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
4212 // v7.39 (round 210) — EXCLUDE builds no implicit index in
4213 // Phase 0 (O(n)-scan enforcement); a real GiST index is a
4214 // later perf phase.
4215 spg_sql::ast::TableConstraint::Exclude { .. } => continue,
4216 };
4217 // 7.38.1 S7 (tpcc decomposition finding) — a composite
4218 // PRIMARY KEY / UNIQUE built a BTree on the LEADING column
4219 // only, and TPC-C's keys all lead with the warehouse id:
4220 // at scale=1 every "index scan" selected the WHOLE table
4221 // (customer point lookup measured 19.9 ms over 30k rows).
4222 // SPG's BTree keys one column, so until composite-keyed
4223 // BTrees land (ledgered), the constraint builds one BTree
4224 // PER KEY COLUMN — the planner can then pick the selective
4225 // one (c_id: 10 rows) instead of the degenerate leading
4226 // one (c_w_id: all 30k). Mirrors what the inline-PK loop
4227 // above has always done.
4228 let mut lead_added: Option<alloc::string::String> = None;
4229 for (k, col_name) in names.iter().enumerate() {
4230 let already = table.indices().iter().any(|idx| {
4231 matches!(idx.kind, spg_storage::IndexKind::BTree(_))
4232 && table.schema().columns[idx.column_position].name == *col_name
4233 });
4234 if already {
4235 continue;
4236 }
4237 let idx_name = if let (Some(n), 0) = (explicit_name, k) {
4238 n.clone()
4239 } else if names.len() == 1 {
4240 alloc::format!("{table_name}_{col_name}_{suffix}")
4241 } else {
4242 alloc::format!("{table_name}_{col_name}_{suffix}_{i}_{k}")
4243 };
4244 if let Err(e) = table.add_index(idx_name.clone(), col_name) {
4245 return Err(EngineError::Storage(e));
4246 }
4247 if k == 0 {
4248 lead_added = Some(idx_name);
4249 }
4250 }
4251 // v7.38.1 (L12) — same upgrade as the inline-PK path: the
4252 // leading index of a composite PK / UNIQUE / KEY becomes a
4253 // real multi-column B-tree over the whole declared tuple.
4254 if names.len() >= 2
4255 && let Some(lead_name) = lead_added
4256 {
4257 let mut extras: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
4258 for col_name in &names[1..] {
4259 if let Some(p) = table
4260 .schema()
4261 .columns
4262 .iter()
4263 .position(|c| c.name.eq_ignore_ascii_case(col_name))
4264 {
4265 extras.push(p);
4266 }
4267 }
4268 if extras.len() == names.len() - 1 {
4269 if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == lead_name)
4270 {
4271 idx.extra_column_positions = extras;
4272 }
4273 table
4274 .convert_index_to_multi(&lead_name)
4275 .map_err(EngineError::Storage)?;
4276 }
4277 }
4278 }
4279 Ok(())
4280 }
4281}
4282
4283impl Engine {
4284 /// v7.39 (RLS) — `CREATE POLICY`. Stores the policy on the table schema
4285 /// (independent of the RLS enable flag). Enforcement is Phase 1.
4286 pub(crate) fn exec_create_policy(
4287 &mut self,
4288 s: spg_sql::ast::CreatePolicyStatement,
4289 ) -> Result<QueryResult, EngineError> {
4290 let cmd = policy_cmd_to_storage(s.cmd);
4291 let using_expr = s.using.as_ref().map(deparse_policy_qual);
4292 let with_check_expr = s.with_check.as_ref().map(deparse_policy_qual);
4293 let table = self.active_catalog_mut().get_mut(&s.table).ok_or_else(|| {
4294 EngineError::Storage(StorageError::TableNotFound {
4295 name: s.table.clone(),
4296 })
4297 })?;
4298 if table.schema().policies.iter().any(|p| p.name == s.name) {
4299 return Err(EngineError::Unsupported(alloc::format!(
4300 "policy {:?} for table {:?} already exists",
4301 s.name,
4302 s.table
4303 )));
4304 }
4305 table.schema_mut().policies.push(spg_storage::PolicyDef {
4306 name: s.name,
4307 cmd,
4308 permissive: s.permissive,
4309 roles: s.roles,
4310 using_expr,
4311 with_check_expr,
4312 });
4313 Ok(QueryResult::CommandOk {
4314 affected: 0,
4315 modified_catalog: self.catalog_change_is_committed(),
4316 })
4317 }
4318
4319 /// v7.39 (RLS) — `ALTER POLICY … { RENAME TO | [TO roles] [USING] [WITH
4320 /// CHECK] }`.
4321 pub(crate) fn exec_alter_policy(
4322 &mut self,
4323 s: spg_sql::ast::AlterPolicyStatement,
4324 ) -> Result<QueryResult, EngineError> {
4325 let new_using = s.using.as_ref().map(deparse_policy_qual);
4326 let new_check = s.with_check.as_ref().map(deparse_policy_qual);
4327 let table = self.active_catalog_mut().get_mut(&s.table).ok_or_else(|| {
4328 EngineError::Storage(StorageError::TableNotFound {
4329 name: s.table.clone(),
4330 })
4331 })?;
4332 // Duplicate-name pre-check for RENAME (before taking the mutable slot).
4333 if let Some(new) = &s.rename_to
4334 && table.schema().policies.iter().any(|p| &p.name == new)
4335 {
4336 return Err(EngineError::Unsupported(alloc::format!(
4337 "policy {new:?} for table {:?} already exists",
4338 s.table
4339 )));
4340 }
4341 let pol = table
4342 .schema_mut()
4343 .policies
4344 .iter_mut()
4345 .find(|p| p.name == s.name)
4346 .ok_or_else(|| {
4347 EngineError::Unsupported(alloc::format!(
4348 "policy {:?} for table {:?} does not exist",
4349 s.name,
4350 s.table
4351 ))
4352 })?;
4353 if let Some(new) = s.rename_to {
4354 pol.name = new;
4355 } else {
4356 if let Some(roles) = s.roles {
4357 pol.roles = roles;
4358 }
4359 if new_using.is_some() {
4360 pol.using_expr = new_using;
4361 }
4362 if new_check.is_some() {
4363 pol.with_check_expr = new_check;
4364 }
4365 }
4366 Ok(QueryResult::CommandOk {
4367 affected: 0,
4368 modified_catalog: self.catalog_change_is_committed(),
4369 })
4370 }
4371
4372 /// v7.39 (RLS) — `DROP POLICY [IF EXISTS] name ON table`.
4373 pub(crate) fn exec_drop_policy(
4374 &mut self,
4375 s: spg_sql::ast::DropPolicyStatement,
4376 ) -> Result<QueryResult, EngineError> {
4377 let table = match self.active_catalog_mut().get_mut(&s.table) {
4378 Some(t) => t,
4379 None if s.if_exists => {
4380 return Ok(QueryResult::CommandOk {
4381 affected: 0,
4382 modified_catalog: self.catalog_change_is_committed(),
4383 });
4384 }
4385 None => {
4386 return Err(EngineError::Storage(StorageError::TableNotFound {
4387 name: s.table.clone(),
4388 }));
4389 }
4390 };
4391 let before = table.schema().policies.len();
4392 table.schema_mut().policies.retain(|p| p.name != s.name);
4393 if table.schema().policies.len() == before && !s.if_exists {
4394 return Err(EngineError::Unsupported(alloc::format!(
4395 "policy {:?} for table {:?} does not exist",
4396 s.name,
4397 s.table
4398 )));
4399 }
4400 Ok(QueryResult::CommandOk {
4401 affected: 0,
4402 modified_catalog: self.catalog_change_is_committed(),
4403 })
4404 }
4405
4406 pub(crate) fn exec_create_user(
4407 &mut self,
4408 s: &CreateUserStatement,
4409 ) -> Result<QueryResult, EngineError> {
4410 // v7.37 (round 828) — no transaction guard any more. PG treats
4411 // roles as ordinary catalog rows: BEGIN; CREATE ROLE r;
4412 // ROLLBACK leaves nothing, COMMIT publishes (measured against
4413 // PG18: count 0 after rollback, 1 after commit). The per-slot
4414 // guard that stood here since round 794 refused the statement
4415 // outright, which no drop-in client expects. Writes now go
4416 // through the TX role shadow (`role_ddl_users_mut`), so both
4417 // halves of PG's behaviour hold.
4418 let role = users::Role::parse(&s.role).ok_or_else(|| {
4419 EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
4420 })?;
4421 // Prefer the host-injected RNG. Falls back to a deterministic
4422 // salt derived from the username only when no RNG is wired —
4423 // acceptable for tests; the server always installs one.
4424 let salt = self.salt_fn.map_or_else(
4425 || {
4426 let mut s_bytes = [0u8; 16];
4427 let digest = spg_crypto::hash(s.name.as_bytes());
4428 s_bytes.copy_from_slice(&digest[..16]);
4429 s_bytes
4430 },
4431 |f| f(),
4432 );
4433 // v7.39 (TLS/SCRAM) — route through `create_user`, not `users.create`,
4434 // so the SQL path also derives the SCRAM-SHA-256 verifier. Without
4435 // this, a `CREATE USER … PASSWORD` user had `scram = None` and silently
4436 // fell back to cleartext pgwire auth.
4437 if self.effective_users().contains(&s.name) {
4438 return Err(EngineError::Unsupported(alloc::format!(
4439 "role \"{}\" already exists",
4440 s.name
4441 )));
4442 }
4443 // v7.39 (read01 round 58) — a bare `CREATE ROLE devs` carries no
4444 // password. It cannot log in (NOLOGIN is its default), so it needs no
4445 // credential; give it an unguessable one derived from its own salt so
4446 // no code path ever sees an empty-password record.
4447 let password = if s.password.is_empty() {
4448 let digest = spg_crypto::hash(&salt);
4449 hex_of(&digest[..16])
4450 } else {
4451 s.password.clone()
4452 };
4453 self.create_user(&s.name, &password, role, salt)
4454 .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
4455 // PG's attribute defaults: LOGIN iff spelled CREATE USER, INHERIT, and
4456 // NOSUPERUSER — but SPG's own coarse `ROLE 'admin'` still means
4457 // superuser, which is how the existing admin account keeps working.
4458 // v7.39 (round 548) — remember whether a password was DECLARED,
4459 // not just whether the record ended up with one: the branch
4460 // above substitutes an unguessable credential for a bare
4461 // CREATE ROLE, and the wire's open-vs-authenticated decision
4462 // has to tell the two apart.
4463 self.role_ddl_users_mut()
4464 .set_password_declared(&s.name, !s.password.is_empty());
4465 self.role_ddl_users_mut().set_attributes(
4466 &s.name,
4467 s.login.unwrap_or(s.is_user),
4468 s.inherit.unwrap_or(true),
4469 s.superuser
4470 .unwrap_or_else(|| matches!(role, users::Role::Admin)),
4471 );
4472 Ok(QueryResult::CommandOk {
4473 affected: 1,
4474 modified_catalog: true,
4475 })
4476 }
4477
4478 pub(crate) fn exec_drop_user(
4479 &mut self,
4480 name: &str,
4481 if_exists: bool,
4482 ) -> Result<QueryResult, EngineError> {
4483 // v7.37 (round 828) — transactional now; see exec_create_user.
4484 // v7.39 (read01 round 58) — PG's IF EXISTS skip NOTICE.
4485 if if_exists && !self.effective_users().contains(name) {
4486 self.notice(alloc::format!("role {name:?} does not exist, skipping"));
4487 return Ok(QueryResult::CommandOk {
4488 affected: 0,
4489 modified_catalog: false,
4490 });
4491 }
4492 // v7.39 (read01 round 58) — PG refuses to drop a role that still holds
4493 // privileges: they would become dangling aclitems. It names the tables.
4494 let depends: alloc::vec::Vec<alloc::string::String> = self
4495 .active_catalog()
4496 .table_names()
4497 .into_iter()
4498 .filter(|t| {
4499 self.active_catalog().get(t).is_some_and(|tb| {
4500 tb.schema()
4501 .acl
4502 .iter()
4503 .any(|a| a.grantee.eq_ignore_ascii_case(name))
4504 || tb
4505 .schema()
4506 .owner
4507 .as_deref()
4508 .is_some_and(|o| o.eq_ignore_ascii_case(name))
4509 })
4510 })
4511 .collect();
4512 if !depends.is_empty() {
4513 return Err(EngineError::Unsupported(alloc::format!(
4514 "role \"{name}\" cannot be dropped because some objects depend on it DETAIL: privileges for table {}",
4515 depends.join(", ")
4516 )));
4517 }
4518 self.role_ddl_users_mut()
4519 .drop(name)
4520 .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
4521 Ok(QueryResult::CommandOk {
4522 affected: 1,
4523 modified_catalog: true,
4524 })
4525 }
4526
4527 /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
4528 /// function metadata in the catalog. PL/pgSQL bodies are
4529 /// already parsed by the SQL parser; we re-canonicalise the
4530 /// body to source text for storage (the executor re-parses
4531 /// it at trigger fire time — see the trigger fire path).
4532 pub(crate) fn exec_create_function(
4533 &mut self,
4534 s: spg_sql::ast::CreateFunctionStatement,
4535 ) -> Result<QueryResult, EngineError> {
4536 let args_repr = render_function_args(&s.args);
4537 let returns = match &s.returns {
4538 spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
4539 spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
4540 spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
4541 spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
4542 };
4543 let body_text = match &s.body {
4544 spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
4545 spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
4546 };
4547 let def = spg_storage::FunctionDef {
4548 name: s.name.clone(),
4549 args_repr,
4550 returns,
4551 language: s.language.clone(),
4552 body: body_text,
4553 // v7.39 (read01 round 61) — whoever runs CREATE FUNCTION owns it.
4554 owner: Some(alloc::string::String::from(self.current_role())),
4555 acl: alloc::vec::Vec::new(),
4556 // v7.39 (round 322, V46) — the declared attribute clauses.
4557 volatility: match s.attrs.volatility {
4558 spg_sql::ast::FunctionVolatility::Immutable => spg_storage::FN_IMMUTABLE,
4559 spg_sql::ast::FunctionVolatility::Stable => spg_storage::FN_STABLE,
4560 spg_sql::ast::FunctionVolatility::Volatile => spg_storage::FN_VOLATILE,
4561 },
4562 strict: s.attrs.strict,
4563 security_definer: s.attrs.security_definer,
4564 leakproof: s.attrs.leakproof,
4565 parallel: match s.attrs.parallel {
4566 spg_sql::ast::FunctionParallel::Safe => spg_storage::FN_PARALLEL_SAFE,
4567 spg_sql::ast::FunctionParallel::Restricted => spg_storage::FN_PARALLEL_RESTRICTED,
4568 spg_sql::ast::FunctionParallel::Unsafe => spg_storage::FN_PARALLEL_UNSAFE,
4569 },
4570 cost: s.attrs.cost,
4571 rows: s.attrs.rows,
4572 };
4573 self.active_catalog_mut()
4574 .create_function(def, s.or_replace)
4575 .map_err(EngineError::Storage)?;
4576 Ok(QueryResult::CommandOk {
4577 affected: 0,
4578 modified_catalog: true,
4579 })
4580 }
4581
4582 /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
4583 /// function must already exist in the catalog (forward
4584 /// references defer to a later release). Persists the
4585 /// trigger metadata for the row-write hooks below to consult.
4586 pub(crate) fn exec_create_trigger(
4587 &mut self,
4588 s: spg_sql::ast::CreateTriggerStatement,
4589 ) -> Result<QueryResult, EngineError> {
4590 let timing = match s.timing {
4591 spg_sql::ast::TriggerTiming::Before => "BEFORE",
4592 spg_sql::ast::TriggerTiming::After => "AFTER",
4593 spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
4594 };
4595 let events: Vec<alloc::string::String> = s
4596 .events
4597 .iter()
4598 .map(|e| match e {
4599 spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
4600 spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
4601 spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
4602 spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
4603 })
4604 .collect();
4605 let for_each = match s.for_each {
4606 spg_sql::ast::TriggerForEach::Row => "ROW",
4607 spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
4608 };
4609 // v7.39 (round 137) — INSTEAD OF triggers may only target views; BEFORE /
4610 // AFTER row triggers may only target base tables. PG's exact wording.
4611 let target_is_view = self.active_catalog().has_view(&s.table);
4612 if matches!(s.timing, spg_sql::ast::TriggerTiming::InsteadOf) {
4613 if !target_is_view {
4614 return Err(EngineError::Unsupported(alloc::format!(
4615 "\"{}\" is a table DETAIL: Tables cannot have INSTEAD OF triggers.",
4616 s.table
4617 )));
4618 }
4619 // v7.39 (round 137) — PG: INSTEAD OF triggers must be row-level.
4620 if matches!(s.for_each, spg_sql::ast::TriggerForEach::Statement) {
4621 return Err(EngineError::Unsupported(
4622 "INSTEAD OF triggers must be FOR EACH ROW".into(),
4623 ));
4624 }
4625 // v7.39 (round 138) — PG: INSTEAD OF triggers cannot have WHEN.
4626 if s.when_condition.is_some() {
4627 return Err(EngineError::Unsupported(
4628 "INSTEAD OF triggers cannot have WHEN conditions".into(),
4629 ));
4630 }
4631 } else if target_is_view {
4632 return Err(EngineError::Unsupported(alloc::format!(
4633 "\"{}\" is a view DETAIL: Views cannot have row-level BEFORE or AFTER triggers.",
4634 s.table
4635 )));
4636 }
4637 let def = spg_storage::TriggerDef {
4638 name: s.name.clone(),
4639 table: s.table.clone(),
4640 timing: alloc::string::String::from(timing),
4641 events,
4642 for_each: alloc::string::String::from(for_each),
4643 function: s.function.clone(),
4644 update_columns: s.update_columns.clone(),
4645 // v7.16.1 — every trigger is born enabled. Toggled
4646 // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
4647 enabled: true,
4648 // v7.39 (round 138) — deparse the WHEN predicate to text; re-parsed
4649 // at fire time. Empty when there is no WHEN.
4650 when_condition: s
4651 .when_condition
4652 .as_ref()
4653 .map(|e| e.to_string())
4654 .unwrap_or_default(),
4655 };
4656 self.active_catalog_mut()
4657 .create_trigger(def, s.or_replace)
4658 .map_err(EngineError::Storage)?;
4659 Ok(QueryResult::CommandOk {
4660 affected: 0,
4661 modified_catalog: true,
4662 })
4663 }
4664
4665 pub(crate) fn exec_drop_trigger(
4666 &mut self,
4667 name: &str,
4668 table: &str,
4669 if_exists: bool,
4670 ) -> Result<QueryResult, EngineError> {
4671 let removed = self.active_catalog_mut().drop_trigger(name, table);
4672 if !removed && !if_exists {
4673 // v7.39 (round 700) — two fixes in one line, and they are the
4674 // same fix round 698 made for sequences.
4675 //
4676 // `StorageError::Corrupt` prefixes its Display with `corrupt
4677 // on-disk format: `, so a misspelt trigger name reported a
4678 // CORRUPTION to the client. And the wording was SPG's own
4679 // (`on "t"`); PG18 says `for table "t"`, which is what the
4680 // wire's classifier and any tool matching on it expect.
4681 //
4682 // Round 698 said its sweep found nothing else. It swept the
4683 // sequence / view / type shapes and not the trigger one — the
4684 // sweep was narrower than the sentence claimed.
4685 return Err(EngineError::Unsupported(alloc::format!(
4686 "trigger \"{name}\" for table \"{table}\" does not exist"
4687 )));
4688 }
4689 // v7.39 (round 282) — PG raises a NOTICE when IF EXISTS skips, and
4690 // it distinguishes the two ways a DROP TRIGGER can find nothing:
4691 // the RELATION is missing (so the trigger could not be looked up
4692 // at all), or the relation is there and the trigger is not.
4693 if !removed && if_exists {
4694 if self.active_catalog().get(table).is_none() {
4695 self.notice(alloc::format!(
4696 "relation \"{table}\" does not exist, skipping"
4697 ));
4698 } else {
4699 self.notice(alloc::format!(
4700 "trigger \"{name}\" for relation \"{table}\" does not exist, skipping"
4701 ));
4702 }
4703 }
4704 Ok(QueryResult::CommandOk {
4705 affected: usize::from(removed),
4706 modified_catalog: removed,
4707 })
4708 }
4709
4710 // v7.39 (round 139) — CREATE RULE (query-rewrite rules). Phase 1 supports
4711 // ON {INSERT|UPDATE|DELETE} TO table [WHERE cond] DO [ALSO|INSTEAD]
4712 // {NOTHING | command}. ON SELECT rules are PG's view mechanism; use CREATE
4713 // VIEW instead. The WHEN/commands are deparsed to text and re-parsed at DML
4714 // rewrite time, mirroring how triggers carry their WHEN predicate.
4715 pub(crate) fn exec_create_rule(
4716 &mut self,
4717 s: spg_sql::ast::CreateRuleStatement,
4718 ) -> Result<QueryResult, EngineError> {
4719 if s.event.eq_ignore_ascii_case("SELECT") {
4720 return Err(EngineError::Unsupported(
4721 "ON SELECT rules are not supported; use CREATE VIEW".into(),
4722 ));
4723 }
4724 // v7.39 (round 333, V59) — the conditional `DO INSTEAD <command>`
4725 // form is supported now: the rows the WHERE holds for take the
4726 // command, the rest run the original operation. It used to be
4727 // refused up front, which made a rule PG accepts a hard error.
4728 // Measured on PG 18.4: with `ON UPDATE TO r WHERE old.id > 1 DO
4729 // INSTEAD INSERT INTO log …`, `UPDATE r SET v = 999` answers
4730 // `UPDATE 1` — only the non-matching row is updated — and the
4731 // matching rows produce log entries instead.
4732 // Rules may target base tables (and, in PG, views); require the relation
4733 // to exist so a typo does not silently create a dead rule.
4734 let known = self.active_catalog().table_names().contains(&s.table)
4735 || self.active_catalog().has_view(&s.table);
4736 if !known {
4737 return Err(EngineError::Unsupported(alloc::format!(
4738 "relation \"{}\" does not exist",
4739 s.table
4740 )));
4741 }
4742 let def = spg_storage::RuleDef {
4743 name: s.name.clone(),
4744 table: s.table.clone(),
4745 event: s.event.to_ascii_uppercase(),
4746 instead: s.instead,
4747 when_condition: s
4748 .when_condition
4749 .as_ref()
4750 .map(|e| e.to_string())
4751 .unwrap_or_default(),
4752 commands: s.commands.iter().map(|c| c.to_string()).collect(),
4753 };
4754 self.active_catalog_mut()
4755 .create_rule(def, s.or_replace)
4756 .map_err(EngineError::Storage)?;
4757 Ok(QueryResult::CommandOk {
4758 affected: 0,
4759 modified_catalog: true,
4760 })
4761 }
4762
4763 pub(crate) fn exec_drop_rule(
4764 &mut self,
4765 name: &str,
4766 table: &str,
4767 if_exists: bool,
4768 ) -> Result<QueryResult, EngineError> {
4769 let removed = self.active_catalog_mut().drop_rule(name, table);
4770 if !removed && !if_exists {
4771 // v7.39 (round 708) — PG's order and words, both measured: the
4772 // RELATION resolves first (`relation "t" does not exist`), and
4773 // only then the rule, spelled `for relation`, not `on`. The old
4774 // message also rode `StorageError::Corrupt`, whose Display put
4775 // `corrupt on-disk format:` in front of a typo — the same
4776 // wrapper rounds 698 and 700 kept meeting.
4777 if self.active_catalog().get(table).is_none() {
4778 return Err(EngineError::Unsupported(alloc::format!(
4779 "relation \"{table}\" does not exist"
4780 )));
4781 }
4782 return Err(EngineError::Unsupported(alloc::format!(
4783 "rule \"{name}\" for relation \"{table}\" does not exist"
4784 )));
4785 }
4786 Ok(QueryResult::CommandOk {
4787 affected: usize::from(removed),
4788 modified_catalog: removed,
4789 })
4790 }
4791
4792 pub(crate) fn exec_drop_function(
4793 &mut self,
4794 name: &str,
4795 args: Option<&[alloc::string::String]>,
4796 if_exists: bool,
4797 ) -> Result<QueryResult, EngineError> {
4798 // v7.39 (read01 round 62) — with overloads, the signature says WHICH one.
4799 let removed = match args {
4800 Some(types) => {
4801 let repr = alloc::format!("({})", types.join(", "));
4802 let key = spg_storage::function_signature_key(name, &repr);
4803 self.active_catalog_mut().drop_function_by_key(&key)
4804 }
4805 None => {
4806 // PG refuses a bare `DROP FUNCTION f` when `f` is overloaded —
4807 // it cannot know which one is meant.
4808 if self.active_catalog().functions_named(name).len() > 1 {
4809 return Err(EngineError::Unsupported(alloc::format!(
4810 "function name \"{name}\" is not unique DETAIL: Specify the argument list to select the function unambiguously."
4811 )));
4812 }
4813 self.active_catalog_mut().drop_function(name)
4814 }
4815 };
4816 if !removed && !if_exists {
4817 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4818 alloc::format!("function {name:?} does not exist"),
4819 )));
4820 }
4821 // v7.39 (round 282) — the skipped-function NOTICE. Alone among the
4822 // IF EXISTS family PG does NOT quote the name, because it renders a
4823 // signature rather than an identifier.
4824 if !removed && if_exists {
4825 let sig = match args {
4826 Some(types) => types
4827 .iter()
4828 .map(|t| pg_signature_type_name(t))
4829 .collect::<alloc::vec::Vec<_>>()
4830 .join(","),
4831 None => alloc::string::String::new(),
4832 };
4833 self.notice(alloc::format!(
4834 "function {name}({sig}) does not exist, skipping"
4835 ));
4836 }
4837 Ok(QueryResult::CommandOk {
4838 affected: usize::from(removed),
4839 modified_catalog: removed,
4840 })
4841 }
4842
4843 /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
4844 /// `min_value` / `max_value` / `start` against PG defaults
4845 /// when omitted, then installs the SequenceDef in the catalog.
4846 pub(crate) fn exec_create_sequence(
4847 &mut self,
4848 s: spg_sql::ast::CreateSequenceStatement,
4849 ) -> Result<QueryResult, EngineError> {
4850 // v7.39 (round 469) — a TEMPORARY sequence lives in the calling
4851 // session's namespace, exactly as round 436 put temporary tables
4852 // there. Until this round the keyword parsed and was dropped, so
4853 // the sequence was permanent: another connection saw it in
4854 // pg_class and could call nextval() on it. Measured against PG18,
4855 // where a second session sees nothing and errors on use.
4856 if s.temporary {
4857 let logical = s.name.clone();
4858 let mut inner = s;
4859 inner.temporary = false;
4860 inner.name = self.session_temp_name(&logical);
4861 let result = self.exec_create_sequence(inner)?;
4862 self.temp_sequences.insert(logical);
4863 self.refresh_temp_prefix();
4864 return Ok(result);
4865 }
4866 use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
4867 use spg_storage::{SequenceDataType, SequenceDef};
4868 let dt = match s.data_type {
4869 None => SequenceDataType::BigInt,
4870 Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
4871 Some(AstDt::Int) => SequenceDataType::Int,
4872 Some(AstDt::BigInt) => SequenceDataType::BigInt,
4873 };
4874 let increment = s.options.increment.unwrap_or(1);
4875 if increment == 0 {
4876 return Err(EngineError::Unsupported(
4877 "INCREMENT must not be zero".into(),
4878 ));
4879 }
4880 let (def_min, def_max) = dt.default_bounds(increment > 0);
4881 let min_value = match s.options.min_value {
4882 None | Some(SeqBound::NoBound) => def_min,
4883 Some(SeqBound::Value(n)) => n,
4884 };
4885 let max_value = match s.options.max_value {
4886 None | Some(SeqBound::NoBound) => def_max,
4887 Some(SeqBound::Value(n)) => n,
4888 };
4889 if min_value > max_value {
4890 return Err(EngineError::Unsupported(alloc::format!(
4891 "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
4892 )));
4893 }
4894 let start = s
4895 .options
4896 .start
4897 .unwrap_or(if increment > 0 { min_value } else { max_value });
4898 // v7.39 (round 244) — PG splits the refusal into two named cases
4899 // (22023): below MINVALUE and above MAXVALUE.
4900 if start < min_value {
4901 return Err(EngineError::Unsupported(alloc::format!(
4902 "START value ({start}) cannot be less than MINVALUE ({min_value})"
4903 )));
4904 }
4905 if start > max_value {
4906 return Err(EngineError::Unsupported(alloc::format!(
4907 "START value ({start}) cannot be greater than MAXVALUE ({max_value})"
4908 )));
4909 }
4910 let cache = s.options.cache.unwrap_or(1);
4911 if cache < 1 {
4912 return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
4913 }
4914 let cycle = s.options.cycle.unwrap_or(false);
4915 let owned_by = match s.options.owned_by {
4916 None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
4917 Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
4918 };
4919 let def = SequenceDef {
4920 name: s.name.clone(),
4921 data_type: dt,
4922 start,
4923 increment,
4924 min_value,
4925 max_value,
4926 cache,
4927 cycle,
4928 owned_by,
4929 last_value: start,
4930 is_called: false,
4931 // v7.39 (read01 round 60) — whoever runs CREATE SEQUENCE owns it.
4932 owner: Some(alloc::string::String::from(self.current_role())),
4933 acl: alloc::vec::Vec::new(),
4934 };
4935 // v7.39 (read01 round 46) — PG's IF NOT EXISTS skip NOTICE. The
4936 // storage call swallows the collision when the flag is set, so
4937 // detect it here before handing over.
4938 if s.if_not_exists && self.active_catalog().has_sequence(&s.name) {
4939 self.notice(alloc::format!(
4940 "relation {:?} already exists, skipping",
4941 s.name
4942 ));
4943 }
4944 self.active_catalog_mut()
4945 .create_sequence(def, s.if_not_exists)
4946 .map_err(EngineError::Storage)?;
4947 Ok(QueryResult::CommandOk {
4948 affected: 0,
4949 modified_catalog: self.catalog_change_is_committed(),
4950 })
4951 }
4952
4953 /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
4954 /// `alter_sequence` merge helper.
4955 pub(crate) fn exec_alter_sequence(
4956 &mut self,
4957 s: spg_sql::ast::AlterSequenceStatement,
4958 ) -> Result<QueryResult, EngineError> {
4959 use spg_sql::ast::SeqBound;
4960 // v7.29 (round-23a) - implicit serial sequences materialise
4961 // on first address, ALTER SEQUENCE included.
4962 self.ensure_implicit_sequence(&s.name);
4963 // v7.39 (read01 round 49) — RENAME TO is its own form, not an option.
4964 if let Some(new) = s.rename_to {
4965 self.active_catalog_mut()
4966 .rename_sequence(&s.name, &new)
4967 .map_err(EngineError::Storage)?;
4968 return Ok(QueryResult::CommandOk {
4969 affected: 0,
4970 modified_catalog: self.catalog_change_is_committed(),
4971 });
4972 }
4973 let cat = self.active_catalog_mut();
4974 if !cat.has_sequence(&s.name) {
4975 if s.if_exists {
4976 return Ok(QueryResult::CommandOk {
4977 affected: 0,
4978 modified_catalog: false,
4979 });
4980 }
4981 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4982 alloc::format!("sequence {:?} does not exist", s.name),
4983 )));
4984 }
4985 let min_value = match s.options.min_value {
4986 None => None,
4987 Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
4988 Some(SeqBound::Value(n)) => Some(n),
4989 };
4990 let max_value = match s.options.max_value {
4991 None => None,
4992 Some(SeqBound::NoBound) => None,
4993 Some(SeqBound::Value(n)) => Some(n),
4994 };
4995 let owned_by = s.options.owned_by.map(|ob| match ob {
4996 spg_sql::ast::SequenceOwnedBy::None => None,
4997 spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
4998 });
4999 cat.alter_sequence(
5000 &s.name,
5001 s.options.increment,
5002 min_value,
5003 max_value,
5004 s.options.start,
5005 s.options.restart,
5006 s.options.cache,
5007 s.options.cycle,
5008 owned_by,
5009 )
5010 .map_err(EngineError::Storage)?;
5011 Ok(QueryResult::CommandOk {
5012 affected: 0,
5013 modified_catalog: self.catalog_change_is_committed(),
5014 })
5015 }
5016
5017 /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
5018 /// Display-rendered body verbatim in the catalog; SELECT-from-
5019 /// view at exec time re-parses + prepends as a synthetic CTE.
5020 pub(crate) fn exec_create_view(
5021 &mut self,
5022 s: spg_sql::ast::CreateViewStatement,
5023 ) -> Result<QueryResult, EngineError> {
5024 // v7.39 (round 469) — same as the temporary sequence above: the
5025 // keyword parsed and was dropped, so the view was permanent and
5026 // every other connection could select from it.
5027 if s.temporary {
5028 let logical = s.name.clone();
5029 let mut inner = s;
5030 inner.temporary = false;
5031 inner.name = self.session_temp_name(&logical);
5032 let result = self.exec_create_view(inner)?;
5033 self.temp_views.insert(logical);
5034 self.refresh_temp_prefix();
5035 return Ok(result);
5036 }
5037 // v7.39 (round 151) — PG rejects data-modifying CTEs in a view
5038 // body (DefineView, view.c): the definition would run the write
5039 // on every reference. Read-only WITH is fine.
5040 if s.body.ctes.iter().any(|c| c.body.is_modifying()) {
5041 return Err(EngineError::Unsupported(
5042 "views must not contain data-modifying statements in WITH".into(),
5043 ));
5044 }
5045 // v7.39 (read01 round 81) — CREATE OR REPLACE VIEW may only APPEND
5046 // columns; PG forbids renaming, dropping, reordering or retyping an
5047 // existing column ("cannot change name of view column …", "cannot drop
5048 // columns from view", "cannot change data type of view column …"). SPG
5049 // let every one of these through and silently swapped the view's shape,
5050 // so a downstream `SELECT known_col FROM v` would start resolving to a
5051 // different column, or vanish — data corruption disguised as a DDL.
5052 if s.or_replace && self.active_catalog().has_view(&s.name) {
5053 self.check_view_replace_columns(&s)?;
5054 }
5055 // v7.39 (round 700) — the BODY has to resolve. PG analyses a view
5056 // definition at CREATE time, so `CREATE VIEW v AS SELECT * FROM
5057 // nosuch` is `relation "nosuch" does not exist`. SPG stored it and
5058 // reported success, leaving a view that appears in `pg_views`, that
5059 // every SELECT against fails, and that a dump then carries forward
5060 // — a broken object made by a statement that said it worked.
5061 //
5062 // The probe is `view_output_columns`, which the OR REPLACE path
5063 // already runs: a `LIMIT 0` execution of the same body. It resolves
5064 // relations and columns without producing rows, so the check costs
5065 // one empty plan and cannot disagree with what the view will do,
5066 // because it IS what the view will do.
5067 self.view_output_columns(&s.body, &s.columns)?;
5068 // Render the SELECT body to canonical form so the catalog
5069 // round-trips a deterministic source (no whitespace /
5070 // comment surprises in the on-disk snapshot).
5071 let columns = s.columns.clone();
5072 let name = s.name.clone();
5073 let or_replace = s.or_replace;
5074 let if_not_exists = s.if_not_exists;
5075 // v7.39 (round 132) — persist WITH CHECK OPTION as a u8 (0/1/2).
5076 let check_option = match s.check_option {
5077 None => 0,
5078 Some(spg_sql::ast::ViewCheckOption::Local) => 1,
5079 Some(spg_sql::ast::ViewCheckOption::Cascaded) => 2,
5080 };
5081 let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
5082 let def = spg_storage::ViewDef {
5083 name,
5084 columns,
5085 body: body_repr,
5086 check_option,
5087 };
5088 self.active_catalog_mut()
5089 .create_view(def, or_replace, if_not_exists)
5090 .map_err(EngineError::Storage)?;
5091 Ok(QueryResult::CommandOk {
5092 affected: 0,
5093 modified_catalog: self.catalog_change_is_committed(),
5094 })
5095 }
5096
5097 /// The (name, type) of each column a view body produces. Runs the body
5098 /// through the real executor with a zero-row bound, so it reflects exactly
5099 /// what a SELECT from the view would return — column overrides, view-on-view
5100 /// expansion, joins and all. Types come from the empty result's schema.
5101 pub(crate) fn view_output_columns(
5102 &self,
5103 body: &spg_sql::ast::SelectStatement,
5104 overrides: &[String],
5105 ) -> Result<alloc::vec::Vec<(String, spg_storage::DataType)>, EngineError> {
5106 let mut probe = body.clone();
5107 probe.limit = Some(spg_sql::ast::LimitExpr::Literal(0));
5108 let QueryResult::Rows { mut columns, .. } =
5109 self.exec_select_cancel(&probe, crate::CancelToken::none())?
5110 else {
5111 return Err(EngineError::Unsupported(
5112 "view body must be a row-returning SELECT".into(),
5113 ));
5114 };
5115 for (i, ov) in overrides.iter().enumerate() {
5116 if let Some(c) = columns.get_mut(i) {
5117 c.name = ov.clone();
5118 }
5119 }
5120 Ok(columns.into_iter().map(|c| (c.name, c.ty)).collect())
5121 }
5122
5123 /// PG's CREATE OR REPLACE VIEW column rule: the new column list must be the
5124 /// old one, optionally with columns appended. Same names, same order, same
5125 /// types for every pre-existing position.
5126 fn check_view_replace_columns(
5127 &self,
5128 s: &spg_sql::ast::CreateViewStatement,
5129 ) -> Result<(), EngineError> {
5130 let old_def = self.active_catalog().view(&s.name).cloned();
5131 let Some(old_def) = old_def else {
5132 return Ok(());
5133 };
5134 let old_body = match spg_sql::parser::parse_statement(&old_def.body) {
5135 Ok(spg_sql::ast::Statement::Select(b)) => b,
5136 // A body we can no longer parse is not something to block a replace
5137 // on — let the replace proceed rather than wedge the view.
5138 _ => return Ok(()),
5139 };
5140 let old_cols = self.view_output_columns(&old_body, &old_def.columns)?;
5141 let new_cols = self.view_output_columns(&s.body, &s.columns)?;
5142 if new_cols.len() < old_cols.len() {
5143 return Err(EngineError::Unsupported(
5144 "cannot drop columns from view".into(),
5145 ));
5146 }
5147 for (old, new) in old_cols.iter().zip(new_cols.iter()) {
5148 if old.0 != new.0 {
5149 return Err(EngineError::Unsupported(alloc::format!(
5150 "cannot change name of view column \"{}\" to \"{}\"",
5151 old.0,
5152 new.0
5153 )));
5154 }
5155 if old.1 != new.1 {
5156 return Err(EngineError::Unsupported(alloc::format!(
5157 "cannot change data type of view column \"{}\" from {} to {}",
5158 old.0,
5159 crate::system_catalog::pg_data_type_text(old.1),
5160 crate::system_catalog::pg_data_type_text(new.1),
5161 )));
5162 }
5163 }
5164 Ok(())
5165 }
5166
5167 /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
5168 /// path. Registers the enum in the catalog with order-
5169 /// preserving labels. PG semantics: CREATE TYPE errors if the
5170 /// name is taken (no IF NOT EXISTS).
5171 pub(crate) fn exec_create_type(
5172 &mut self,
5173 s: spg_sql::ast::CreateTypeStatement,
5174 ) -> Result<QueryResult, EngineError> {
5175 // Name-collision check against tables / sequences / views /
5176 // materialized views.
5177 let cat = self.active_catalog();
5178 if cat.get(&s.name).is_some() {
5179 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5180 alloc::format!("type {:?} would shadow an existing table", s.name),
5181 )));
5182 }
5183 if cat.has_sequence(&s.name) {
5184 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5185 alloc::format!("type {:?} would shadow an existing sequence", s.name),
5186 )));
5187 }
5188 if cat.has_view(&s.name) {
5189 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5190 alloc::format!("type {:?} would shadow an existing view", s.name),
5191 )));
5192 }
5193 // v7.37.42-T2 ζ-B — pre-check collision with the
5194 // composite registry too, so creating ENUM with a name
5195 // already used by a composite (or vice versa) fails
5196 // uniformly regardless of which kind comes first.
5197 if cat.composite_types().contains_key(&s.name) {
5198 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5199 alloc::format!("type {:?} already exists", s.name),
5200 )));
5201 }
5202 if cat.enum_types().contains_key(&s.name) {
5203 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5204 alloc::format!("type {:?} already exists", s.name),
5205 )));
5206 }
5207 if cat.domain_types().contains_key(&s.name) {
5208 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5209 alloc::format!("type {:?} already exists", s.name),
5210 )));
5211 }
5212 // v7.37.42-T2 ζ-B — composite types now live in their own
5213 // catalog registry (composite_types), parallel to enum_types
5214 // / domain_types. ENUM stays in enum_types as before.
5215 match s.kind {
5216 spg_sql::ast::TypeKind::Enum { labels } => {
5217 if labels.is_empty() {
5218 return Err(EngineError::Unsupported(
5219 "CREATE TYPE … AS ENUM requires at least one label".into(),
5220 ));
5221 }
5222 // Reject duplicate labels per PG.
5223 for i in 0..labels.len() {
5224 for j in (i + 1)..labels.len() {
5225 if labels[i] == labels[j] {
5226 return Err(EngineError::Unsupported(alloc::format!(
5227 "CREATE TYPE {:?}: duplicate ENUM label {:?}",
5228 s.name,
5229 labels[i]
5230 )));
5231 }
5232 }
5233 }
5234 let def = spg_storage::EnumDef {
5235 name: s.name.clone(),
5236 labels,
5237 };
5238 self.active_catalog_mut()
5239 .create_enum_type(def)
5240 .map_err(EngineError::Storage)?;
5241 }
5242 spg_sql::ast::TypeKind::Composite {
5243 fields,
5244 field_user_types,
5245 } => {
5246 // v7.39 (round 769, F31 tranche 5 #140) — an attribute-less
5247 // composite is legal PG (`CREATE TYPE x AS ()`, measured); the
5248 // old engine-side guard doubled the parser's former refusal.
5249 // Reject duplicate field names per PG.
5250 for i in 0..fields.len() {
5251 for j in (i + 1)..fields.len() {
5252 if fields[i].0.eq_ignore_ascii_case(&fields[j].0) {
5253 return Err(EngineError::Unsupported(alloc::format!(
5254 "CREATE TYPE {:?}: duplicate composite field {:?}",
5255 s.name,
5256 fields[i].0
5257 )));
5258 }
5259 }
5260 }
5261 // Resolve each field's ColumnTypeName → DataType.
5262 let resolved_fields = fields
5263 .into_iter()
5264 .map(|(fname, fty)| (fname, column_type_to_data_type(fty)))
5265 .collect::<alloc::vec::Vec<_>>();
5266 // v7.39 (round 264) — a field naming another COMPOSITE keeps
5267 // that name; the engine resolves the inner record through it.
5268 let cat = self.active_catalog();
5269 let field_user_types: alloc::vec::Vec<Option<alloc::string::String>> =
5270 field_user_types
5271 .into_iter()
5272 .map(|n| n.filter(|n| cat.composite_types().contains_key(n)))
5273 .collect();
5274 let def = spg_storage::CompositeDef {
5275 name: s.name.clone(),
5276 fields: resolved_fields,
5277 field_user_types,
5278 };
5279 self.active_catalog_mut()
5280 .create_composite_type(def)
5281 .map_err(EngineError::Storage)?;
5282 }
5283 }
5284 Ok(QueryResult::CommandOk {
5285 affected: 0,
5286 modified_catalog: self.catalog_change_is_committed(),
5287 })
5288 }
5289 /// v7.39 (round 260) — `ALTER DOMAIN`. Every form used to be
5290 /// swallowed by the parser's pg_dump no-op arm: success reported,
5291 /// nothing changed. Constraint names and the error wordings are PG's,
5292 /// probed live.
5293 pub(crate) fn exec_alter_domain(
5294 &mut self,
5295 name: &str,
5296 action: spg_sql::ast::AlterDomainAction,
5297 ) -> Result<QueryResult, EngineError> {
5298 use spg_sql::ast::AlterDomainAction as A;
5299 let not_found = || {
5300 EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
5301 "type {name:?} does not exist"
5302 )))
5303 };
5304 if !self.active_catalog().domain_types().contains_key(name) {
5305 return Err(not_found());
5306 }
5307 match action {
5308 A::AddConstraint { name: cname, check } => {
5309 let dom = self
5310 .active_catalog()
5311 .domain_types()
5312 .get(name)
5313 .ok_or_else(not_found)?;
5314 // PG's auto-name for an unnamed ALTER-added check follows
5315 // the same `<domain>_check{n}` sequence as CREATE DOMAIN.
5316 let cname = match cname {
5317 Some(c) => c,
5318 None => {
5319 let mut i = dom.checks.len();
5320 loop {
5321 let cand = if i == 0 {
5322 alloc::format!("{name}_check")
5323 } else {
5324 alloc::format!("{name}_check{i}")
5325 };
5326 if !dom.checks.iter().any(|c| c.name == cand) {
5327 break cand;
5328 }
5329 i += 1;
5330 }
5331 }
5332 };
5333 if dom.checks.iter().any(|c| c.name == cname) {
5334 return Err(EngineError::Unsupported(alloc::format!(
5335 "constraint \"{cname}\" for domain \"{name}\" already exists"
5336 )));
5337 }
5338 let expr = alloc::format!("{check}");
5339 let mut def = dom.clone();
5340 def.checks
5341 .push(spg_storage::DomainCheck { name: cname, expr });
5342 self.replace_domain(name, def)?;
5343 }
5344 A::DropConstraint {
5345 name: cname,
5346 if_exists,
5347 } => {
5348 let mut def = self
5349 .active_catalog()
5350 .domain_types()
5351 .get(name)
5352 .ok_or_else(not_found)?
5353 .clone();
5354 let before = def.checks.len();
5355 def.checks.retain(|c| c.name != cname);
5356 if def.checks.len() == before {
5357 if if_exists {
5358 return Ok(QueryResult::CommandOk {
5359 affected: 0,
5360 modified_catalog: false,
5361 });
5362 }
5363 return Err(EngineError::Unsupported(alloc::format!(
5364 "constraint \"{cname}\" of domain \"{name}\" does not exist"
5365 )));
5366 }
5367 self.replace_domain(name, def)?;
5368 }
5369 A::SetDefault(e) => {
5370 let mut def = self
5371 .active_catalog()
5372 .domain_types()
5373 .get(name)
5374 .ok_or_else(not_found)?
5375 .clone();
5376 def.default = Some(alloc::format!("{e}"));
5377 self.replace_domain(name, def)?;
5378 }
5379 A::DropDefault => {
5380 let mut def = self
5381 .active_catalog()
5382 .domain_types()
5383 .get(name)
5384 .ok_or_else(not_found)?
5385 .clone();
5386 def.default = None;
5387 self.replace_domain(name, def)?;
5388 }
5389 A::SetNotNull | A::DropNotNull => {
5390 // v7.39 (round 260) — SET NOT NULL must reject when an
5391 // existing column of this domain already holds NULLs (PG:
5392 // `column "v" of table "adt" contains null values`).
5393 if matches!(action, A::SetNotNull) {
5394 let snap = self.current_snapshot();
5395 let cat = self.active_catalog();
5396 let mut offender: Option<(alloc::string::String, alloc::string::String)> = None;
5397 'outer: for tname in cat.table_names() {
5398 let Some(table) = cat.get(&tname) else {
5399 continue;
5400 };
5401 let cols = table.schema().columns.clone();
5402 let idxs: alloc::vec::Vec<usize> = cols
5403 .iter()
5404 .enumerate()
5405 .filter(|(_, c)| c.user_domain_type.as_deref() == Some(name))
5406 .map(|(i, _)| i)
5407 .collect();
5408 if idxs.is_empty() {
5409 continue;
5410 }
5411 for (_, row) in table.scan_visible(&snap) {
5412 for &i in &idxs {
5413 if row.values.get(i).is_none_or(spg_storage::Value::is_null) {
5414 offender = Some((tname.clone(), cols[i].name.clone()));
5415 break 'outer;
5416 }
5417 }
5418 }
5419 }
5420 if let Some((t, c)) = offender {
5421 return Err(EngineError::Unsupported(alloc::format!(
5422 "column \"{c}\" of table \"{t}\" contains null values"
5423 )));
5424 }
5425 }
5426 let mut def = self
5427 .active_catalog()
5428 .domain_types()
5429 .get(name)
5430 .ok_or_else(not_found)?
5431 .clone();
5432 def.nullable = matches!(action, A::DropNotNull);
5433 self.replace_domain(name, def)?;
5434 }
5435 A::RenameTo(new_name) => {
5436 if self.active_catalog().domain_types().contains_key(&new_name) {
5437 return Err(EngineError::Unsupported(alloc::format!(
5438 "type {new_name:?} already exists"
5439 )));
5440 }
5441 let mut def = self
5442 .active_catalog()
5443 .domain_types()
5444 .get(name)
5445 .ok_or_else(not_found)?
5446 .clone();
5447 def.name = new_name.clone();
5448 self.active_catalog_mut().drop_domain_type(name);
5449 self.active_catalog_mut()
5450 .create_domain_type(def)
5451 .map_err(EngineError::Storage)?;
5452 }
5453 }
5454 Ok(QueryResult::CommandOk {
5455 affected: 0,
5456 modified_catalog: self.catalog_change_is_committed(),
5457 })
5458 }
5459
5460 /// v7.39 (round 260) — swap a domain definition in place.
5461 fn replace_domain(
5462 &mut self,
5463 name: &str,
5464 def: spg_storage::DomainDef,
5465 ) -> Result<(), EngineError> {
5466 self.active_catalog_mut().drop_domain_type(name);
5467 self.active_catalog_mut()
5468 .create_domain_type(def)
5469 .map_err(EngineError::Storage)
5470 }
5471
5472 /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
5473 /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
5474 /// base type + Display-rendered CHECK / DEFAULT sources so
5475 /// INSERT/UPDATE on bound columns can re-eval the checks.
5476 pub(crate) fn exec_create_domain(
5477 &mut self,
5478 s: spg_sql::ast::CreateDomainStatement,
5479 ) -> Result<QueryResult, EngineError> {
5480 let cat = self.active_catalog();
5481 if cat.domain_types().contains_key(&s.name) {
5482 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5483 alloc::format!("domain {:?} already exists", s.name),
5484 )));
5485 }
5486 if cat.get(&s.name).is_some()
5487 || cat.has_sequence(&s.name)
5488 || cat.has_view(&s.name)
5489 || cat.enum_types().contains_key(&s.name)
5490 {
5491 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5492 alloc::format!("domain {:?} would shadow an existing object", s.name),
5493 )));
5494 }
5495 // v7.39 (round 259) — `CREATE DOMAIN child AS parent`: the parent
5496 // supplies the ultimate scalar type (the parser typed the unknown
5497 // name as Text), and its NAME is recorded so the check walk can
5498 // reach the parent's constraints — which an ALTER on the parent
5499 // must keep affecting, so the chain is walked at check time rather
5500 // than copied here (probed against PG).
5501 let mut base_domain: Option<alloc::string::String> = None;
5502 let mut base_type = column_type_to_data_type(s.base_type);
5503 if let Some(parent) = &s.base_domain {
5504 if let Some(pd) = cat.domain_types().get(parent) {
5505 base_type = pd.base_type;
5506 base_domain = Some(parent.clone());
5507 } else if !cat.enum_types().contains_key(parent) {
5508 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5509 alloc::format!("type {parent:?} does not exist"),
5510 )));
5511 }
5512 }
5513 let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
5514 // v7.39 (round 260) — PG names an unnamed domain CHECK
5515 // `<domain>_check`, then `_check1`, `_check2`, … (probed).
5516 let checks = s
5517 .checks
5518 .iter()
5519 .enumerate()
5520 .map(|(i, e)| spg_storage::DomainCheck {
5521 name: if i == 0 {
5522 alloc::format!("{}_check", s.name)
5523 } else {
5524 alloc::format!("{}_check{i}", s.name)
5525 },
5526 expr: alloc::format!("{e}"),
5527 })
5528 .collect::<Vec<_>>();
5529 let def = spg_storage::DomainDef {
5530 name: s.name.clone(),
5531 base_type,
5532 nullable: !s.not_null,
5533 default,
5534 checks,
5535 base_domain,
5536 };
5537 self.active_catalog_mut()
5538 .create_domain_type(def)
5539 .map_err(EngineError::Storage)?;
5540 Ok(QueryResult::CommandOk {
5541 affected: 0,
5542 modified_catalog: self.catalog_change_is_committed(),
5543 })
5544 }
5545
5546 /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
5547 pub(crate) fn exec_drop_domain(
5548 &mut self,
5549 names: &[String],
5550 if_exists: bool,
5551 ) -> Result<QueryResult, EngineError> {
5552 let mut removed = 0usize;
5553 for name in names {
5554 let was_present = self.active_catalog_mut().drop_domain_type(name);
5555 if was_present {
5556 removed += 1;
5557 } else if !if_exists {
5558 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5559 alloc::format!("domain {name:?} does not exist"),
5560 )));
5561 }
5562 }
5563 Ok(QueryResult::CommandOk {
5564 affected: removed,
5565 modified_catalog: removed > 0 && self.catalog_change_is_committed(),
5566 })
5567 }
5568
5569 /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
5570 /// Registers the schema in the catalog. Schema-qualified
5571 /// table references continue to strip the prefix at lookup
5572 /// time (prefix routing, not isolation — see project-next-
5573 /// docket for the v7.18+ real-isolation tracking).
5574 pub(crate) fn exec_create_schema(
5575 &mut self,
5576 name: String,
5577 if_not_exists: bool,
5578 ) -> Result<QueryResult, EngineError> {
5579 // v7.39 (read01 round 46) — PG's IF NOT EXISTS skip NOTICE.
5580 if if_not_exists && self.active_catalog().schema_exists(&name) {
5581 self.notice(alloc::format!("schema {name:?} already exists, skipping"));
5582 }
5583 self.active_catalog_mut()
5584 .create_schema(name, if_not_exists)
5585 .map_err(EngineError::Storage)?;
5586 Ok(QueryResult::CommandOk {
5587 affected: 0,
5588 modified_catalog: self.catalog_change_is_committed(),
5589 })
5590 }
5591
5592 /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
5593 /// Built-in schemas always reject the drop with a clear
5594 /// error.
5595 pub(crate) fn exec_drop_schema(
5596 &mut self,
5597 names: &[String],
5598 if_exists: bool,
5599 ) -> Result<QueryResult, EngineError> {
5600 let mut removed = 0usize;
5601 for name in names {
5602 let was_present = self
5603 .active_catalog_mut()
5604 .drop_schema(name)
5605 .map_err(EngineError::Storage)?;
5606 if was_present {
5607 removed += 1;
5608 } else if !if_exists {
5609 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5610 alloc::format!("schema {name:?} does not exist"),
5611 )));
5612 } else {
5613 // v7.39 (read01 round 46) — PG's IF EXISTS skip NOTICE.
5614 self.notice(alloc::format!("schema {name:?} does not exist, skipping"));
5615 }
5616 }
5617 Ok(QueryResult::CommandOk {
5618 affected: removed,
5619 modified_catalog: removed > 0 && self.catalog_change_is_committed(),
5620 })
5621 }
5622
5623 /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
5624 /// ENUM types are catalogued today; other types silently
5625 /// no-op even outside IF EXISTS to mirror the prior
5626 /// "everything's text" lax stance.
5627 pub(crate) fn exec_drop_type(
5628 &mut self,
5629 names: &[String],
5630 if_exists: bool,
5631 ) -> Result<QueryResult, EngineError> {
5632 let mut removed = 0usize;
5633 for name in names {
5634 // v7.37.42-T2 ζ-B — DROP TYPE searches ENUM + COMPOSITE
5635 // registries (PG groups CREATE TYPE … AS ENUM and
5636 // CREATE TYPE … AS (…) under the same DROP TYPE
5637 // command).
5638 let cat = self.active_catalog_mut();
5639 let was_enum = cat.drop_enum_type(name);
5640 let was_composite = cat.drop_composite_type(name);
5641 if was_enum || was_composite {
5642 removed += 1;
5643 } else if !if_exists {
5644 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5645 alloc::format!("type {name:?} does not exist"),
5646 )));
5647 } else {
5648 // v7.39 (read01 round 46) — PG's IF EXISTS skip NOTICE.
5649 self.notice(alloc::format!("type {name:?} does not exist, skipping"));
5650 }
5651 }
5652 Ok(QueryResult::CommandOk {
5653 affected: removed,
5654 modified_catalog: removed > 0 && self.catalog_change_is_committed(),
5655 })
5656 }
5657
5658 /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
5659 /// Materialises the body at CREATE time (unless WITH NO DATA),
5660 /// stores the result as a regular `Table`, and registers the
5661 /// body source in the catalog so REFRESH can re-run it.
5662 pub(crate) fn exec_create_materialized_view(
5663 &mut self,
5664 s: spg_sql::ast::CreateMaterializedViewStatement,
5665 ) -> Result<QueryResult, EngineError> {
5666 // v7.39 (round 436) — `CREATE TEMPORARY TABLE x AS <select>` arrives
5667 // here (CTAS lowers to this node with `as_plain_table`). Same
5668 // treatment as the column-list form: build it under the session's
5669 // namespace prefix and remember it there.
5670 if s.temporary && s.as_plain_table {
5671 let logical = s.name.clone();
5672 let mut inner = s;
5673 inner.temporary = false;
5674 inner.name = self.session_temp_name(&logical);
5675 let result = self.exec_create_materialized_view(inner)?;
5676 self.temp_tables.insert(logical);
5677 self.refresh_temp_prefix();
5678 return Ok(result);
5679 }
5680 // v7.39 (round 151) — PG's matview wording differs from the
5681 // plain-view one (transformCreateTableAsStmt, analyze.c).
5682 if s.body.ctes.iter().any(|c| c.body.is_modifying()) {
5683 return Err(EngineError::Unsupported(
5684 "materialized views must not use data-modifying statements in WITH".into(),
5685 ));
5686 }
5687 // Name-collision check (table / view / sequence / mat-view).
5688 let cat = self.active_catalog();
5689 if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
5690 if s.if_not_exists {
5691 return Ok(QueryResult::CommandOk {
5692 affected: 0,
5693 modified_catalog: false,
5694 });
5695 }
5696 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5697 alloc::format!("materialized view {:?} already exists", s.name),
5698 )));
5699 }
5700 if cat.has_view(&s.name) {
5701 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5702 alloc::format!(
5703 "materialized view {:?} would shadow an existing view",
5704 s.name
5705 ),
5706 )));
5707 }
5708 if cat.has_sequence(&s.name) {
5709 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5710 alloc::format!(
5711 "materialized view {:?} would shadow an existing sequence",
5712 s.name
5713 ),
5714 )));
5715 }
5716 // Render the body to canonical form for the registry.
5717 let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
5718 // Execute the body to learn the columns. With WITH DATA we
5719 // also materialise the rows; with WITH NO DATA we only need
5720 // the schema, so re-use a LIMIT 0 wrap to keep the column
5721 // inference path uniform without paying for the rows.
5722 let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
5723 let (mut cols, rows) = match result {
5724 QueryResult::Rows { columns, rows } => (columns, rows),
5725 other => {
5726 return Err(EngineError::Unsupported(alloc::format!(
5727 "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
5728 )));
5729 }
5730 };
5731 // Apply the column-rename list per PG semantics.
5732 if !s.columns.is_empty() {
5733 if s.columns.len() != cols.len() {
5734 return Err(EngineError::Unsupported(alloc::format!(
5735 "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
5736 s.name,
5737 s.columns.len(),
5738 cols.len()
5739 )));
5740 }
5741 for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
5742 c.name.clone_from(name);
5743 }
5744 }
5745 // Promote any synthetic-Text projections to their actual
5746 // observed types so the backing table accepts the rows.
5747 cols = infer_column_types(&cols, &rows);
5748 let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
5749 let cat = self.active_catalog_mut();
5750 cat.create_table(schema).map_err(EngineError::Storage)?;
5751 // v7.38.19 — the materialised row count is the statement's
5752 // answer, not a detail. PG tags CTAS and CREATE MATERIALIZED
5753 // VIEW `SELECT <n>`, and a driver reads that to learn how many
5754 // rows it wrote. Returning 0 here made every CTAS report writing
5755 // nothing while writing the right rows -- silent, and the wrong
5756 // half is the one a program acts on.
5757 let mut materialised = 0usize;
5758 if s.with_data {
5759 let table = cat
5760 .get_mut(&s.name)
5761 .expect("just-created materialized-view backing table must exist");
5762 for row in rows {
5763 table.insert(row).map_err(EngineError::Storage)?;
5764 materialised += 1;
5765 }
5766 }
5767 // v7.38 (read01 P6.49) — CTAS / SELECT INTO produce a plain table; only
5768 // a real MATERIALIZED VIEW gets a registry entry (and REFRESH support).
5769 if !s.as_plain_table {
5770 cat.register_materialized_view(s.name.clone(), body_repr);
5771 // v7.39 (round 737, S14/B3) — register for delta maintenance
5772 // when the body qualifies; the fan-out starts buffering from
5773 // the next statement on.
5774 if let Some(base) = matview_maintainable_base(&s.body) {
5775 self.matview_maintainable.insert(s.name.clone(), base);
5776 }
5777 }
5778 Ok(QueryResult::CommandOk {
5779 affected: materialised,
5780 modified_catalog: self.catalog_change_is_committed(),
5781 })
5782 }
5783
5784 /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
5785 /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
5786 /// backing table's rows.
5787 pub(crate) fn exec_refresh_materialized_view(
5788 &mut self,
5789 name: &str,
5790 with_data: bool,
5791 ) -> Result<QueryResult, EngineError> {
5792 // v7.39 (round 699) — PG18 distinguishes the two ways this fails,
5793 // and SPG gave one sentence for both:
5794 //
5795 // missing name `relation "x" does not exist`
5796 // exists, wrong kind `"x" is not a materialized view`
5797 //
5798 // The second is the one that matters to a caller: it says the name
5799 // resolved and the OBJECT is not what the statement is for, which
5800 // is a different thing to go and check.
5801 //
5802 // Both were `StorageError::Corrupt`, the same wrapper round 698
5803 // found putting `corrupt on-disk format:` in front of a plain typo.
5804 // `Unsupported` carries no banner, and the wire's classifier reads
5805 // `relation "…" does not exist` for 42P01 already.
5806 let source = match self
5807 .active_catalog()
5808 .materialized_views()
5809 .get(name)
5810 .cloned()
5811 {
5812 Some(s) => s,
5813 None => {
5814 let exists = self.active_catalog().get(name).is_some();
5815 return Err(EngineError::Unsupported(if exists {
5816 alloc::format!("\"{name}\" is not a materialized view")
5817 } else {
5818 alloc::format!("relation \"{name}\" does not exist")
5819 }));
5820 }
5821 };
5822 let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
5823 EngineError::Unsupported(alloc::format!(
5824 "materialized view {name:?} body re-parse failed: {e}"
5825 ))
5826 })?;
5827 let Statement::Select(body) = parsed else {
5828 return Err(EngineError::Unsupported(alloc::format!(
5829 "materialized view {name:?} body is not a SELECT (catalog corruption)"
5830 )));
5831 };
5832 // v7.39 (round 735, S14/B3) — the refresh watermark. When the
5833 // body's FULL dependency set is provable (plain stored tables
5834 // only — any CTE / union / subquery / expression source makes
5835 // the collector answer None) and no dependency's change
5836 // sequence moved since the last refresh, this REFRESH is an
5837 // O(1) no-op with an identical observable result. PG recomputes
5838 // unconditionally — this is the incremental-maintenance first
5839 // step its architecture doesn't have. WITH NO DATA never
5840 // no-ops (its contract is to EMPTY the view).
5841 let deps = if with_data {
5842 matview_dep_tables(&body)
5843 } else {
5844 None
5845 };
5846 if let Some(dep_tables) = &deps {
5847 let current: alloc::vec::Vec<(String, u64)> = dep_tables
5848 .iter()
5849 .map(|t| {
5850 (
5851 t.clone(),
5852 self.table_change_seq.get(t.as_str()).copied().unwrap_or(0),
5853 )
5854 })
5855 .collect();
5856 if self
5857 .matview_refresh_watermark
5858 .get(name)
5859 .is_some_and(|last| *last == current)
5860 {
5861 return Ok(QueryResult::CommandOk {
5862 affected: 0,
5863 modified_catalog: false,
5864 });
5865 }
5866 // v7.39 (round 737, S14/B3 knife 2) — INSERT-ONLY delta
5867 // application. The base changed; if this view is registered
5868 // maintainable, has a watermark (i.e. its buffer covers
5869 // everything since the last full refresh), did not
5870 // overflow, and every buffered change is an Insert, the new
5871 // rows run through the projection and APPEND — no truncate,
5872 // no rescan. Any delete / update / tombstone in the buffer
5873 // falls back to the full path this round (their row-map
5874 // machinery is the next knife). Either way the watermark
5875 // and buffer reset below.
5876 if with_data
5877 && self.matview_maintainable.contains_key(name)
5878 && self.matview_refresh_watermark.contains_key(name)
5879 && !self.matview_delta_overflow.contains(name)
5880 && self
5881 .matview_delta_buf
5882 .get(name)
5883 .is_some_and(|b| !b.is_empty())
5884 {
5885 let buf = self.matview_delta_buf.remove(name).expect("checked above");
5886 // v7.39 (round 738) — ordered application: Insert /
5887 // Delete / Tombstone in ARRIVAL order (an insert later
5888 // deleted must land then leave). None = this buffer
5889 // cannot be applied (an Update, or no row map where one
5890 // is needed) -> the full path below.
5891 let outcome = self.apply_matview_delta_ordered(name, &body, &buf)?;
5892 if outcome.is_some() {
5893 crate::MATVIEW_DELTA_APPLIED
5894 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
5895 } else {
5896 crate::MATVIEW_DELTA_BAILED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
5897 }
5898 if let Some(applied) = outcome {
5899 let current: alloc::vec::Vec<(String, u64)> = dep_tables
5900 .iter()
5901 .map(|t| {
5902 (
5903 t.clone(),
5904 self.table_change_seq.get(t.as_str()).copied().unwrap_or(0),
5905 )
5906 })
5907 .collect();
5908 self.matview_refresh_watermark
5909 .insert(String::from(name), current);
5910 return Ok(QueryResult::CommandOk {
5911 affected: applied,
5912 modified_catalog: self.catalog_change_is_committed(),
5913 });
5914 }
5915 }
5916 }
5917 // Wipe the existing rows first (PG truncates the matview
5918 // and rebuilds; we approximate with an empty INSERT loop).
5919 {
5920 let cat = self.active_catalog_mut();
5921 let table = cat.get_mut(name).ok_or_else(|| {
5922 EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
5923 "materialized view {name:?} backing table missing"
5924 )))
5925 })?;
5926 table.truncate();
5927 }
5928 if !with_data {
5929 self.matview_refresh_watermark.remove(name);
5930 return Ok(QueryResult::CommandOk {
5931 affected: 0,
5932 modified_catalog: self.catalog_change_is_committed(),
5933 });
5934 }
5935 // v7.39 (round 738, S14/B3 knife 3) — a maintainable view's FULL
5936 // refresh scans the base table internally instead of running the
5937 // body SQL: same rows (single stored table, pure projection,
5938 // pure WHERE — that is what registration means), but each output
5939 // row's base RowId is in hand, which is the only place the
5940 // delete/tombstone row map can be built. Non-maintainable views
5941 // keep the SQL path and carry no map.
5942 let internal = if let Some(base) = matview_maintainable_base(&body) {
5943 let snap = self.current_snapshot();
5944 let t = self.active_catalog().get(&base).ok_or_else(|| {
5945 EngineError::Unsupported(alloc::format!(
5946 "materialized view {name:?} base table {base:?} missing"
5947 ))
5948 })?;
5949 let base_cols = t.schema().columns.clone();
5950 let alias = body
5951 .from
5952 .as_ref()
5953 .and_then(|f| f.primary.alias.clone())
5954 .unwrap_or_else(|| base.clone());
5955 let ctx = self.ev_ctx(&base_cols, Some(alias.as_str()));
5956 let mut pairs: alloc::vec::Vec<(u64, spg_storage::Row<'static>)> =
5957 alloc::vec::Vec::new();
5958 let t = self.active_catalog().get(&base).expect("checked above");
5959 for (i, row) in t.rows().iter().enumerate() {
5960 if !t.is_row_visible(i, &snap) {
5961 continue;
5962 }
5963 if let Some(w) = &body.where_ {
5964 let cond = eval::eval_expr(w, row, &ctx).map_err(EngineError::Eval)?;
5965 if !crate::eval::predicate_is_true(&cond, "WHERE", ctx.mysql_dialect)? {
5966 continue;
5967 }
5968 }
5969 let mut vals = alloc::vec::Vec::with_capacity(body.items.len());
5970 for item in &body.items {
5971 let spg_sql::ast::SelectItem::Expr { expr, .. } = item else {
5972 unreachable!("maintainable admits Expr items only");
5973 };
5974 vals.push(eval::eval_expr(expr, row, &ctx).map_err(EngineError::Eval)?);
5975 }
5976 let rid = t
5977 .rowids()
5978 .get(i)
5979 .copied()
5980 .unwrap_or(spg_storage::row_header::RowId::UNASSIGNED);
5981 pairs.push((rid.0, spg_storage::Row::new(vals)));
5982 }
5983 Some(pairs)
5984 } else {
5985 None
5986 };
5987 if let Some(pairs) = internal {
5988 let cat = self.active_catalog_mut();
5989 let table = cat.get_mut(name).expect("backing table verified above");
5990 let mut map: alloc::collections::BTreeMap<u64, usize> =
5991 alloc::collections::BTreeMap::new();
5992 let affected = pairs.len();
5993 for (rid, row) in pairs {
5994 table.insert(row).map_err(EngineError::Storage)?;
5995 map.insert(rid, table.rows().len() - 1);
5996 }
5997 let expected = table.rows().len();
5998 self.matview_row_map
5999 .insert(String::from(name), (expected, map));
6000 if let Some(dep_tables) = deps {
6001 let current: alloc::vec::Vec<(String, u64)> = dep_tables
6002 .iter()
6003 .map(|t| {
6004 (
6005 t.clone(),
6006 self.table_change_seq.get(t.as_str()).copied().unwrap_or(0),
6007 )
6008 })
6009 .collect();
6010 self.matview_refresh_watermark
6011 .insert(String::from(name), current);
6012 }
6013 self.matview_delta_buf.remove(name);
6014 self.matview_delta_overflow.remove(name);
6015 if let Some(base) = matview_maintainable_base(&body) {
6016 self.matview_maintainable.insert(String::from(name), base);
6017 }
6018 return Ok(QueryResult::CommandOk {
6019 affected,
6020 modified_catalog: self.catalog_change_is_committed(),
6021 });
6022 }
6023 self.matview_row_map.remove(name);
6024 let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
6025 QueryResult::Rows { rows, .. } => rows,
6026 other => {
6027 return Err(EngineError::Unsupported(alloc::format!(
6028 "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
6029 )));
6030 }
6031 };
6032 let cat = self.active_catalog_mut();
6033 let table = cat.get_mut(name).expect("backing table verified above");
6034 let affected = rows.len();
6035 for row in rows {
6036 table.insert(row).map_err(EngineError::Storage)?;
6037 }
6038 // v7.39 (round 735, S14/B3) — record what this full refresh saw.
6039 // Re-read the sequences AFTER the recompute: a write that landed
6040 // mid-refresh moves a seq past what we record only if it came
6041 // first (single-writer engine), so recording the pre-read values
6042 // could mask it; the post-read cannot.
6043 if let Some(dep_tables) = deps {
6044 let current: alloc::vec::Vec<(String, u64)> = dep_tables
6045 .iter()
6046 .map(|t| {
6047 (
6048 t.clone(),
6049 self.table_change_seq.get(t.as_str()).copied().unwrap_or(0),
6050 )
6051 })
6052 .collect();
6053 self.matview_refresh_watermark
6054 .insert(String::from(name), current);
6055 }
6056 // v7.39 (round 737) — a full refresh resets the delta machinery:
6057 // stale buffered changes are superseded, overflow clears, and
6058 // (re)registration keeps a view maintainable across restarts,
6059 // where CREATE never re-runs.
6060 self.matview_delta_buf.remove(name);
6061 self.matview_delta_overflow.remove(name);
6062 if let Some(base) = matview_maintainable_base(&body) {
6063 self.matview_maintainable.insert(String::from(name), base);
6064 } else {
6065 self.matview_maintainable.remove(name);
6066 }
6067 Ok(QueryResult::CommandOk {
6068 affected,
6069 modified_catalog: self.catalog_change_is_committed(),
6070 })
6071 }
6072
6073 /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
6074 /// names`. Drops the backing table + unregisters the source.
6075 pub(crate) fn exec_drop_materialized_view(
6076 &mut self,
6077 names: &[String],
6078 if_exists: bool,
6079 ) -> Result<QueryResult, EngineError> {
6080 let mut removed = 0usize;
6081 for name in names {
6082 let was_present = self
6083 .active_catalog_mut()
6084 .drop_materialized_view_source(name);
6085 if was_present {
6086 // Drop the backing table too.
6087 self.active_catalog_mut().drop_table(name);
6088 // v7.39 (round 737, S14/B3) — retire every maintenance
6089 // structure with the view.
6090 self.matview_maintainable.remove(name);
6091 self.matview_delta_buf.remove(name);
6092 self.matview_delta_overflow.remove(name);
6093 self.matview_refresh_watermark.remove(name);
6094 self.matview_row_map.remove(name);
6095 removed += 1;
6096 } else if !if_exists {
6097 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
6098 alloc::format!("materialized view {name:?} does not exist"),
6099 )));
6100 }
6101 }
6102 Ok(QueryResult::CommandOk {
6103 affected: removed,
6104 modified_catalog: removed > 0 && self.catalog_change_is_committed(),
6105 })
6106 }
6107
6108 /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
6109 pub(crate) fn exec_drop_view(
6110 &mut self,
6111 names: &[String],
6112 if_exists: bool,
6113 ) -> Result<QueryResult, EngineError> {
6114 let mut removed = 0usize;
6115 for name in names {
6116 // v7.39 (round 469) — a bare DROP names the session's
6117 // temporary view first, the way `Catalog::drop_table` resolves
6118 // a temporary table.
6119 let key = self.active_catalog().view_key(name);
6120 let was_present = self.active_catalog_mut().drop_view(&key);
6121 if was_present && key != *name {
6122 self.temp_views.remove(name);
6123 self.refresh_temp_prefix();
6124 }
6125 if !was_present {
6126 if !if_exists {
6127 // v7.39 (read01 round 89) — PG's 42P01 wording, without the
6128 // "corrupt on-disk format:" prefix a Storage::Corrupt adds.
6129 return Err(EngineError::Unsupported(alloc::format!(
6130 "view \"{name}\" does not exist"
6131 )));
6132 }
6133 // v7.39 (read01 round 46) — PG's IF EXISTS skip NOTICE.
6134 self.notice(alloc::format!("view {name:?} does not exist, skipping"));
6135 }
6136 if was_present {
6137 removed += 1;
6138 }
6139 }
6140 Ok(QueryResult::CommandOk {
6141 affected: removed,
6142 modified_catalog: removed > 0 && self.catalog_change_is_committed(),
6143 })
6144 }
6145
6146 /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
6147 pub(crate) fn exec_drop_sequence(
6148 &mut self,
6149 names: &[String],
6150 if_exists: bool,
6151 ) -> Result<QueryResult, EngineError> {
6152 let mut removed = 0usize;
6153 for name in names {
6154 let key = self.active_catalog().sequence_key(name);
6155 let was_present = self.active_catalog_mut().drop_sequence(&key);
6156 if was_present && key != *name {
6157 self.temp_sequences.remove(name);
6158 self.refresh_temp_prefix();
6159 }
6160 if !was_present {
6161 if !if_exists {
6162 return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
6163 alloc::format!("sequence {name:?} does not exist"),
6164 )));
6165 }
6166 // v7.39 (read01 round 46) — PG's IF EXISTS skip NOTICE.
6167 self.notice(alloc::format!("sequence {name:?} does not exist, skipping"));
6168 }
6169 if was_present {
6170 removed += 1;
6171 }
6172 }
6173 Ok(QueryResult::CommandOk {
6174 affected: removed,
6175 modified_catalog: removed > 0 && self.catalog_change_is_committed(),
6176 })
6177 }
6178}
6179
6180// ---- column-definition / DEFAULT / SET / enum helpers (lib.rs split 11) ----
6181
6182/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
6183/// default-fill. Free fn (rather than `&self`) so callers
6184/// with an active `&mut Table` borrow can still use it.
6185/// Literal defaults take the cached path (`col.default`);
6186/// runtime defaults hit `clock_fn` at each call. mailrs G4.
6187/// v7.39 (read01 round 93) — truncate a generated identifier to PG's
6188/// NAMEDATALEN-1 (63) byte limit, on a UTF-8 char boundary so a
6189/// multi-byte name is never split mid-codepoint.
6190fn truncate_ident(name: &mut String) {
6191 const MAX: usize = 63;
6192 if name.len() <= MAX {
6193 return;
6194 }
6195 let mut cut = MAX;
6196 while cut > 0 && !name.is_char_boundary(cut) {
6197 cut -= 1;
6198 }
6199 name.truncate(cut);
6200}
6201
6202pub(crate) fn resolve_column_default_free(
6203 col: &ColumnSchema,
6204 clock_fn: Option<ClockFn>,
6205 // v7.39 (round 525) — the session, for a DEFAULT that names one.
6206 sess: Option<&crate::eval::DmlSession>,
6207) -> Result<Value<'static>, EngineError> {
6208 if let Some(rt) = &col.runtime_default {
6209 return eval_runtime_default_free(rt, col.ty, clock_fn, sess);
6210 }
6211 Ok(col.default.clone().unwrap_or(Value::Null))
6212}
6213
6214pub(crate) fn eval_runtime_default_free(
6215 rt: &str,
6216 ty: DataType,
6217 clock_fn: Option<ClockFn>,
6218 sess: Option<&crate::eval::DmlSession>,
6219) -> Result<Value<'static>, EngineError> {
6220 let s = rt.trim().to_ascii_lowercase();
6221 // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
6222 // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
6223 // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
6224 // microsecond resolution; the precision modifier is
6225 // parser-only.
6226 let with_no_parens = s.trim_end_matches("()");
6227 let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
6228 if with_no_parens.ends_with(')') {
6229 &with_no_parens[..open_idx]
6230 } else {
6231 with_no_parens
6232 }
6233 } else {
6234 with_no_parens
6235 };
6236 let now_us = match clock_fn {
6237 Some(f) => f(),
6238 None => 0,
6239 };
6240 let v = match canonical {
6241 "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
6242 "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
6243 "current_time" | "localtime" => Value::Timestamp(now_us),
6244 // v7.17.0 — UUID generators in DEFAULT clauses. Required
6245 // for the canonical Django / Rails / Hibernate `id UUID
6246 // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
6247 // INSERT evaluates the function fresh; the per-row UUID
6248 // is the storage value, not a cached literal.
6249 "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
6250 // v7.39 (round 525) — anything else is EVALUATED, not refused.
6251 // PG takes any expression as a DEFAULT; the eight names above are
6252 // a fast path that skips a parse per row, and this was the whole
6253 // list SPG accepted — `DEFAULT current_setting('app.tenant')`,
6254 // `DEFAULT upper(…)`, `DEFAULT 2 * 3` all failed the INSERT.
6255 _ => {
6256 let expr = spg_sql::parser::parse_expression(rt).map_err(|e| {
6257 EngineError::Unsupported(alloc::format!(
6258 "runtime DEFAULT expression {rt:?} does not parse: {e}"
6259 ))
6260 })?;
6261 let no_cols: [ColumnSchema; 0] = [];
6262 let mut ctx = eval::EvalContext::new(&no_cols, None);
6263 if let Some(sv) = sess {
6264 ctx = ctx.with_session(sv);
6265 }
6266 let row = spg_storage::Row::new(alloc::vec::Vec::new());
6267 let v = eval::eval_expr(&expr, &row, &ctx).map_err(|e| EngineError::Eval(e))?;
6268 return coerce_value(v, ty, "DEFAULT", 0);
6269 }
6270 };
6271 coerce_value(v, ty, "DEFAULT", 0)
6272}
6273
6274/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
6275/// evaluation rather than being cacheable as a literal Value.
6276/// FunctionCall is the immediate case (`now()`,
6277/// `current_timestamp`). Literal expressions and simple sign-
6278/// flipped numerics still take the static-cache path.
6279/// v7.39 (RLS) — translate the parser's `PolicyCmd` to the storage one.
6280fn policy_cmd_to_storage(c: spg_sql::ast::PolicyCmd) -> spg_storage::PolicyCmd {
6281 use spg_sql::ast::PolicyCmd as A;
6282 use spg_storage::PolicyCmd as S;
6283 match c {
6284 A::All => S::All,
6285 A::Select => S::Select,
6286 A::Insert => S::Insert,
6287 A::Update => S::Update,
6288 A::Delete => S::Delete,
6289 }
6290}
6291
6292/// v7.38.19 — a DEFAULT that is a call to `nextval`, however it spells
6293/// its argument. `nextval('s')` and `nextval('s'::regclass)` are the
6294/// same column; `pg_dump` writes the second.
6295fn is_nextval_call(e: &Expr) -> bool {
6296 matches!(e, Expr::FunctionCall { name, args }
6297 if name.eq_ignore_ascii_case("nextval") && args.len() == 1)
6298}
6299
6300fn is_runtime_default_expr(expr: &Expr) -> bool {
6301 match expr {
6302 Expr::FunctionCall { .. } => true,
6303 Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
6304 _ => false,
6305 }
6306}
6307
6308/// v7.38 (read01) — PG's canonical parenless deparse spelling for the SQL-
6309/// standard niladic keyword functions. The parser lowers `CURRENT_DATE` &c
6310/// to a synthetic `FunctionCall { name: "current_date", args: [] }`; PG's
6311/// `pg_get_expr` renders these as the bare uppercase keyword (not
6312/// `current_date()`), so a default that uses one must deparse the same way.
6313/// Returns `None` for a real function (`now()`) which keeps its call form.
6314fn pg_parenless_keyword(name: &str) -> Option<&'static str> {
6315 match name.to_ascii_lowercase().as_str() {
6316 "current_date" => Some("CURRENT_DATE"),
6317 "current_time" => Some("CURRENT_TIME"),
6318 "current_timestamp" => Some("CURRENT_TIMESTAMP"),
6319 "localtime" => Some("LOCALTIME"),
6320 "localtimestamp" => Some("LOCALTIMESTAMP"),
6321 "current_user" => Some("CURRENT_USER"),
6322 "session_user" => Some("SESSION_USER"),
6323 "current_role" => Some("CURRENT_ROLE"),
6324 "current_catalog" => Some("CURRENT_CATALOG"),
6325 _ => None,
6326 }
6327}
6328
6329/// v7.38 (read01) — deparse a column DEFAULT expression to the PG-compatible
6330/// source text cached on `ColumnSchema.default_text` (surfaced by
6331/// information_schema.columns.column_default / pg_attrdef / pg_get_expr).
6332///
6333/// SPG's `Expr` Display already matches PG's deparse for non-negative integer
6334/// / numeric / boolean literals, arithmetic (`(3 + 4)`), and ordinary function
6335/// calls (`now()`). This additionally matches PG for the shapes where Display
6336/// diverges: bare string literals (PG types them, `'hi'::text`), the parenless
6337/// SQL-standard keyword functions (`CURRENT_DATE`, not `current_date()`), and
6338/// negative numeric constants, which PG's `get_const_expr` folds into a typed
6339/// literal (`int DEFAULT -5` → `'-5'::integer`, `numeric DEFAULT -1.5` →
6340/// `'-1.5'::numeric`).
6341///
6342/// KNOWN Phase-2 residuals (fall through to Display, a valid but not
6343/// byte-identical-to-PG spelling — documented in the read01 checklist):
6344/// * integer literals wider than int4 (`bigint DEFAULT 5000000000` →
6345/// PG `'5000000000'::bigint`; SPG `5000000000`);
6346/// * string / numeric literals nested inside a larger expression, which PG
6347/// types per operand (`'hi' || 'there'` → PG `('hi'::text ||
6348/// 'there'::text)`). Full parity needs PG's recursive `get_rule_expr`
6349/// constant-typing deparser.
6350fn deparse_default(expr: &Expr, col_ty: DataType) -> alloc::string::String {
6351 match expr {
6352 // Bare string literal → PG's typed-literal form `'…'::<coltype>`.
6353 // 7.38.1 S5.2 — the typed-literal cast must name the SQL type
6354 // (`text[]`), not information_schema's category word (`ARRAY`):
6355 // pg_dump copies this text into the dumped DEFAULT, and
6356 // `'{}'::ARRAY` parses nowhere — not even back into SPG.
6357 Expr::Literal(Literal::String(s)) => alloc::format!(
6358 "'{}'::{}",
6359 s.replace('\'', "''"),
6360 crate::conversions::pg_type_name_for_error(col_ty)
6361 ),
6362 // r1054 — an ALREADY-typed string literal re-parses as a Cast
6363 // node, and the generic Display arm below rendered it
6364 // `('dflt')::text` where the first pass wrote `'dflt'::text`:
6365 // two producers of default_text, two spellings, and the dump
6366 // round-trip stopped being a fixed point on exactly that line.
6367 // Same normalized shape as the bare-literal arm (PG stores a
6368 // default through the assignment cast and reports the column's
6369 // type, so re-normalizing to `col_ty` matches PG here too).
6370 Expr::Cast { expr: inner, .. }
6371 if matches!(inner.as_ref(), Expr::Literal(Literal::String(_))) =>
6372 {
6373 let Expr::Literal(Literal::String(s)) = inner.as_ref() else {
6374 unreachable!("guarded by matches!")
6375 };
6376 alloc::format!(
6377 "'{}'::{}",
6378 s.replace('\'', "''"),
6379 crate::conversions::pg_type_name_for_error(col_ty)
6380 )
6381 }
6382 // v7.38.19 — a call renders its arguments the way PostgreSQL
6383 // prints them, which for a typed string literal is
6384 // `'zs'::regclass` and not `('zs')::regclass`.
6385 //
6386 // The generic Display arm below parenthesises a Cast, so
6387 // `nextval('zs'::regclass)` — what `pg_dump` writes for a serial
6388 // column, and what a schema-diff tool compares — read back as
6389 // `nextval(('zs')::regclass)`. It re-parses here and the dump
6390 // round-trip is a fixed point, so this never broke anything of
6391 // ours; it broke the comparison with theirs, which is the bar.
6392 //
6393 // r1054 fixed the same spelling for a default that IS a cast.
6394 // This is the same fix one level in.
6395 // Narrow on purpose: only a call that CARRIES such an argument
6396 // is re-rendered. Taking every call broke `CURRENT_DATE`, which
6397 // the parser lowers to a zero-argument `current_date` whose
6398 // Display prints the keyword — this arm printed the lowering.
6399 // The existing default-text test caught it in the same minute.
6400 Expr::FunctionCall { name, args }
6401 if args.iter().any(|a| {
6402 matches!(a, Expr::Cast { expr: inner, .. }
6403 if matches!(inner.as_ref(), Expr::Literal(Literal::String(_))))
6404 }) =>
6405 {
6406 let rendered: Vec<alloc::string::String> = args
6407 .iter()
6408 .map(|a| match a {
6409 Expr::Cast {
6410 expr: inner,
6411 target,
6412 } if matches!(inner.as_ref(), Expr::Literal(Literal::String(_))) => {
6413 let Expr::Literal(Literal::String(lit)) = inner.as_ref() else {
6414 unreachable!("guarded by matches!")
6415 };
6416 alloc::format!("'{}'::{target}", lit.replace('\'', "''"))
6417 }
6418 other => alloc::format!("{other}"),
6419 })
6420 .collect();
6421 alloc::format!("{name}({})", rendered.join(", "))
6422 }
6423 // Boolean literal → PG's lowercase `true` / `false` (SPG's Literal
6424 // Display emits uppercase `TRUE`).
6425 Expr::Literal(Literal::Bool(b)) => {
6426 alloc::string::String::from(if *b { "true" } else { "false" })
6427 }
6428 // Negative numeric constant: PG folds `- <lit>` into a typed Const.
6429 // The cast type is the *literal's* natural type (integer / numeric),
6430 // not the column type.
6431 Expr::Unary {
6432 op: spg_sql::ast::UnOp::Neg,
6433 expr: inner,
6434 } => match inner.as_ref() {
6435 Expr::Literal(Literal::Integer(n)) => alloc::format!("'-{n}'::integer"),
6436 Expr::Literal(Literal::Float(_) | Literal::NumericBig(_) | Literal::Numeric { .. }) => {
6437 alloc::format!("'-{inner}'::numeric")
6438 }
6439 _ => alloc::format!("{expr}"),
6440 },
6441 // Parenless SQL-standard keyword functions → bare uppercase keyword.
6442 Expr::FunctionCall { name, args } if args.is_empty() => {
6443 if let Some(kw) = pg_parenless_keyword(name) {
6444 alloc::string::String::from(kw)
6445 } else {
6446 alloc::format!("{expr}")
6447 }
6448 }
6449 _ => alloc::format!("{expr}"),
6450 }
6451}
6452
6453/// v7.39 (RLS) — deparse a policy `USING` / `WITH CHECK` qual to PG-compatible
6454/// text for pg_policy / pg_policies / pg_dump. SPG's `Expr` Display already
6455/// matches PG for column comparisons and operators; this recursively rewrites
6456/// the niladic SQL-standard keyword functions a policy qual commonly uses
6457/// (`current_user` → `CURRENT_USER`, &c) which Display would render as
6458/// `current_user()`. The stored form re-parses identically, so enforcement is
6459/// unaffected. (String-literal `::text` typing is the shared default_text
6460/// Phase-2 residual and is left to Display.)
6461pub(crate) fn deparse_policy_qual(e: &Expr) -> alloc::string::String {
6462 match e {
6463 Expr::FunctionCall { name, args } if args.is_empty() => pg_parenless_keyword(name)
6464 .map_or_else(|| alloc::format!("{e}"), alloc::string::String::from),
6465 Expr::Binary { lhs, op, rhs } => alloc::format!(
6466 "({} {op} {})",
6467 deparse_policy_qual(lhs),
6468 deparse_policy_qual(rhs)
6469 ),
6470 Expr::Unary { op, expr } => {
6471 use spg_sql::ast::UnOp;
6472 let inner = deparse_policy_qual(expr);
6473 match op {
6474 UnOp::Not => alloc::format!("(NOT {inner})"),
6475 UnOp::Neg => alloc::format!("(-{inner})"),
6476 UnOp::Plus => alloc::format!("(+{inner})"),
6477 UnOp::BitNot => alloc::format!("(~{inner})"),
6478 }
6479 }
6480 Expr::Cast { expr, target } => {
6481 alloc::format!("({}::{target})", deparse_policy_qual(expr))
6482 }
6483 Expr::IsNull { expr, negated } => {
6484 let inner = deparse_policy_qual(expr);
6485 if *negated {
6486 alloc::format!("({inner} IS NOT NULL)")
6487 } else {
6488 alloc::format!("({inner} IS NULL)")
6489 }
6490 }
6491 Expr::Like {
6492 expr,
6493 pattern,
6494 negated,
6495 case_insensitive,
6496 } => {
6497 let op = match (negated, case_insensitive) {
6498 (false, false) => "LIKE",
6499 (true, false) => "NOT LIKE",
6500 (false, true) => "ILIKE",
6501 (true, true) => "NOT ILIKE",
6502 };
6503 alloc::format!(
6504 "({} {op} {})",
6505 deparse_policy_qual(expr),
6506 deparse_policy_qual(pattern)
6507 )
6508 }
6509 Expr::FunctionCall { name, args } => {
6510 let rendered: alloc::vec::Vec<_> = args.iter().map(deparse_policy_qual).collect();
6511 alloc::format!("{name}({})", rendered.join(", "))
6512 }
6513 _ => alloc::format!("{e}"),
6514 }
6515}
6516
6517/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
6518/// `col_idx` has a registered label list, the cell value must be
6519/// NULL or one of the labels (case-sensitive per PG).
6520/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
6521/// SET cell. For non-SET columns this is a no-op pass-through.
6522///
6523/// Semantics:
6524/// * NULL preserved.
6525/// * Empty string → `''` (zero flags).
6526/// * Otherwise split on ',', trim each token, validate every
6527/// token against the column's variant list (error on miss),
6528/// de-dup, then re-emit in DEFINITION order joined by ','.
6529pub(crate) fn canonicalize_set_value(
6530 lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
6531 col_idx: usize,
6532 col_name: &str,
6533 value: Value<'static>,
6534) -> Result<Value<'static>, EngineError> {
6535 let Some(variants) = lookup.get(&col_idx) else {
6536 return Ok(value);
6537 };
6538 match value {
6539 Value::Null => Ok(Value::Null),
6540 Value::Text(s) => {
6541 if s.is_empty() {
6542 return Ok(Value::text(alloc::string::String::new()));
6543 }
6544 // Collect a presence-set of variant indices to keep
6545 // definition order + handle de-dup in one pass.
6546 let mut present = alloc::vec![false; variants.len()];
6547 for raw in s.split(',') {
6548 let tok = raw.trim();
6549 if tok.is_empty() {
6550 continue;
6551 }
6552 let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
6553 EngineError::Unsupported(alloc::format!(
6554 "column {col_name:?}: invalid SET token {tok:?}; \
6555 allowed: {variants:?}"
6556 ))
6557 })?;
6558 present[idx] = true;
6559 }
6560 // Re-emit in definition order.
6561 let mut out = alloc::string::String::new();
6562 let mut first = true;
6563 for (i, keep) in present.iter().enumerate() {
6564 if !keep {
6565 continue;
6566 }
6567 if !first {
6568 out.push(',');
6569 }
6570 first = false;
6571 out.push_str(&variants[i]);
6572 }
6573 Ok(Value::text(out))
6574 }
6575 other => Err(EngineError::Unsupported(alloc::format!(
6576 "column {col_name:?}: SET-typed column expects TEXT, got {}",
6577 crate::conversions::pg_type_name_for_error_opt(other.data_type())
6578 ))),
6579 }
6580}
6581
6582pub(crate) fn enforce_enum_label(
6583 lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
6584 col_idx: usize,
6585 col_name: &str,
6586 value: &Value,
6587) -> Result<(), EngineError> {
6588 if let Some(labels) = lookup.get(&col_idx) {
6589 match value {
6590 Value::Null => Ok(()),
6591 Value::Text(s) => {
6592 if labels.iter().any(|l| l == s) {
6593 Ok(())
6594 } else {
6595 Err(EngineError::Unsupported(alloc::format!(
6596 "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
6597 )))
6598 }
6599 }
6600 other => Err(EngineError::Unsupported(alloc::format!(
6601 "column {col_name:?}: enum-typed column expects TEXT, got {}",
6602 crate::conversions::pg_type_name_for_error_opt(other.data_type())
6603 ))),
6604 }
6605 } else {
6606 Ok(())
6607 }
6608}
6609
6610fn column_def_to_schema(c: ColumnDef, mysql: bool) -> Result<ColumnSchema, EngineError> {
6611 let ty = column_type_to_data_type(c.ty);
6612 let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
6613 // user_type_ref is the raw ident the parser couldn't resolve
6614 // to a built-in; classification into enum vs domain happens
6615 // at exec_create_table where we have catalog access. We
6616 // park it temporarily as user_enum_type and the engine
6617 // promotes domain bindings to user_domain_type before the
6618 // table is stored.
6619 if let Some(name) = c.user_type_ref {
6620 schema.user_enum_type = Some(name);
6621 }
6622 // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
6623 // canonical text (the engine re-parses at UPDATE time).
6624 if let Some(expr) = c.on_update_runtime {
6625 schema.on_update_runtime = Some(alloc::format!("{expr}"));
6626 }
6627 // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
6628 // storage one. Same variants, different crates (spg-storage
6629 // owns no dep on spg-sql).
6630 // v7.39 (round 370, M4 P4a) — under the MySQL dialect a TEXT column
6631 // with NO explicit `COLLATE` takes the folding default collation
6632 // (utf8mb4_uca1400_ai_ci), so it stores CaseInsensitive and the
6633 // read/write paths fold it. An explicit `COLLATE utf8mb4_bin` keeps
6634 // Binary (byte-wise) — both resolve to AST `Binary`, so the explicit
6635 // flag is what tells them apart.
6636 let is_text_col = matches!(
6637 ty,
6638 spg_storage::DataType::Text
6639 | spg_storage::DataType::Varchar(_)
6640 | spg_storage::DataType::Char(_)
6641 );
6642 // v7.39 (round 676) — carry the collation NAME as written, which
6643 // `Collation` below cannot: it folds C / POSIX / en_US / default into
6644 // one value. `pg_attribute.attcollation` reads this to answer 950 for a
6645 // column declared `COLLATE "C"` instead of the type's default 100.
6646 schema.collation_name = c.collation_name.clone();
6647 schema.collation = if mysql && is_text_col && !c.collation_explicit {
6648 spg_storage::Collation::CaseInsensitive
6649 } else {
6650 match c.collation {
6651 spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
6652 spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
6653 }
6654 };
6655 // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
6656 // storage so engine INSERT / UPDATE can range-check.
6657 schema.is_unsigned = c.is_unsigned;
6658 // v7.39 (round 386, type-fidelity epic P1) — declared TINYINT /
6659 // MEDIUMINT width, lost when the type collapsed to SmallInt / Int.
6660 // Drives the epic-P2 write-path range check.
6661 schema.mysql_int_width = c.mysql_int_width.map(|w| match w {
6662 spg_sql::ast::MysqlIntWidth::Tiny => spg_storage::MysqlIntWidth::Tiny,
6663 spg_sql::ast::MysqlIntWidth::Medium => spg_storage::MysqlIntWidth::Medium,
6664 spg_sql::ast::MysqlIntWidth::Small => spg_storage::MysqlIntWidth::Small,
6665 spg_sql::ast::MysqlIntWidth::Int => spg_storage::MysqlIntWidth::Int,
6666 spg_sql::ast::MysqlIntWidth::Big => spg_storage::MysqlIntWidth::Big,
6667 });
6668 // v7.39 (round 424, type-fidelity epic) — declared fractional-seconds
6669 // precision of a MySQL temporal column. Drives write-path truncation
6670 // and render padding; None keeps PG's full-microsecond behaviour.
6671 schema.mysql_fsp = c.mysql_fsp;
6672 // v7.39 (round 389, type-fidelity epic P4a) — a "real" SMALLINT /
6673 // INT UNSIGNED holds a range its signed storage tag cannot (65535 /
6674 // 4294967295), so widen the storage one step and record the declared
6675 // width for the range check + dump rendering. The `is_none()` guard
6676 // skips TINYINT UNSIGNED (i16 already holds 0..255) and MEDIUMINT
6677 // UNSIGNED (i32 already holds 0..16777215) — they keep their tag.
6678 if schema.is_unsigned && schema.mysql_int_width.is_none() {
6679 match schema.ty {
6680 spg_storage::DataType::SmallInt => {
6681 schema.ty = spg_storage::DataType::Int;
6682 schema.mysql_int_width = Some(spg_storage::MysqlIntWidth::Small);
6683 }
6684 spg_storage::DataType::Int => {
6685 schema.ty = spg_storage::DataType::BigInt;
6686 schema.mysql_int_width = Some(spg_storage::MysqlIntWidth::Int);
6687 }
6688 // v7.39 (round 471, epic P4b) — BIGINT UNSIGNED reaches
6689 // 18446744073709551615, which i64 cannot hold at all: SPG used
6690 // to REFUSE anything past 2^63-1 with `expected BIGINT, got
6691 // NUMERIC(0)`, so a MariaDB table with a real u64 in it could
6692 // not be loaded. Numeric is i128-backed with scale 0 and
6693 // already compares, orders, indexes and renders as an exact
6694 // integer; the width marker keeps the declared type for
6695 // SHOW CREATE and information_schema.
6696 spg_storage::DataType::BigInt => {
6697 schema.ty = spg_storage::DataType::Numeric {
6698 precision: 20,
6699 scale: 0,
6700 };
6701 schema.mysql_int_width = Some(spg_storage::MysqlIntWidth::Big);
6702 }
6703 _ => {}
6704 }
6705 }
6706 // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
6707 // INSERT validation lives in coerce_value (Text → Text path
6708 // with the column's variant list as the accept-set).
6709 schema.inline_enum_variants = c.inline_enum_variants;
6710 // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
6711 // INSERT canonicalisation (de-dup + sort by definition order)
6712 // lives in the exec_insert path next to the ENUM check.
6713 schema.inline_set_variants = c.inline_set_variants;
6714 // v7.37.7(sentori Epic 3 P1)— stored generated-column
6715 // expression. Carry the Display-form source to storage; the
6716 // engine re-parses and re-evaluates on every INSERT / UPDATE.
6717 if let Some(gen_expr) = c.generated_stored_expr {
6718 schema.generated_stored_expr = Some(alloc::format!("{gen_expr}"));
6719 }
6720 // v7.38 (read01) — GENERATED ALWAYS AS IDENTITY marker. The engine
6721 // rejects an explicit non-DEFAULT INSERT value for such a column
6722 // unless the statement carries OVERRIDING SYSTEM VALUE.
6723 schema.identity_always = c.identity_always;
6724 if let Some(default_expr) = c.default {
6725 // v7.38 (read01) — cache the PG-compatible source text of the DEFAULT
6726 // expression for catalog introspection, independent of the
6727 // literal/runtime split below (which loses the source spelling).
6728 schema.default_text = Some(deparse_default(&default_expr, ty));
6729 // v7.9.21 — distinguish literal defaults (evaluated once
6730 // at CREATE TABLE) from expression defaults (deferred to
6731 // INSERT). Function calls (`now()`, `current_timestamp`
6732 // — see v7.9.20 keyword promotion) take the runtime path.
6733 // Literals continue to cache. mailrs G4.
6734 // v7.38.19 — a `nextval(…)` DEFAULT is the column being
6735 // NUMBERED, not an expression to re-evaluate per row.
6736 //
6737 // Advancing a sequence needs a mutable catalog, and the context a
6738 // runtime DEFAULT is evaluated in does not hold one -- so this
6739 // stored the call as text and every INSERT that left the column
6740 // to its default answered `nextval() requires a sequence
6741 // resolver (read-only context)`. PostgreSQL 18.4 inserts.
6742 //
6743 // The OTHER spelling of the same column has worked since v7.22:
6744 // `ALTER TABLE … SET DEFAULT nextval(…)` lowers to the
6745 // auto-increment marker, because that is what `pg_dump` emits
6746 // for a serial column and imports were losing their numbering.
6747 // Two spellings of one column definition disagreed about whether
6748 // the column worked at all. This is the same lowering, reached
6749 // from the other side.
6750 if is_nextval_call(&default_expr) {
6751 if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
6752 return Err(EngineError::Unsupported(alloc::format!(
6753 "auto-increment applies to integer columns only ({:?} is {ty:?})",
6754 c.name
6755 )));
6756 }
6757 schema.auto_increment = true;
6758 } else if is_runtime_default_expr(&default_expr) {
6759 let display = alloc::format!("{default_expr}");
6760 schema = schema.with_runtime_default(display);
6761 } else {
6762 let raw = literal_expr_to_value(default_expr)?;
6763 // v7.39 (round 259) — a column whose type is a user type is
6764 // still typed with the parser's Text placeholder here; the
6765 // real type only arrives when the domain binding is resolved
6766 // (exec_create_table). Coercing now made `w wd DEFAULT 7`
6767 // fail outright — a hard error on valid SQL — so the domain
6768 // case keeps the raw value and is coerced there instead.
6769 let coerced = if schema.user_enum_type.is_some() {
6770 raw
6771 } else {
6772 coerce_value(raw, ty, &c.name, 0)?
6773 };
6774 schema = schema.with_default(coerced);
6775 }
6776 }
6777 if c.auto_increment {
6778 // AUTO_INCREMENT only makes sense on integer-shaped columns.
6779 if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
6780 return Err(EngineError::Unsupported(alloc::format!(
6781 "AUTO_INCREMENT requires an integer column type, got {ty:?}"
6782 )));
6783 }
6784 schema = schema.with_auto_increment();
6785 }
6786 Ok(schema)
6787}
6788
6789/// v7.12.4 — render a function arg list into the
6790/// canonical form the storage layer caches as
6791/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
6792/// this string for both display + as a coarse signature key
6793/// for the (deferred) overload resolution v7.12.5+ adds.
6794fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
6795 use core::fmt::Write;
6796 let mut out = alloc::string::String::from("(");
6797 for (i, a) in args.iter().enumerate() {
6798 if i > 0 {
6799 out.push_str(", ");
6800 }
6801 match a.mode {
6802 spg_sql::ast::FunctionArgMode::In => {}
6803 spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
6804 spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
6805 }
6806 if let Some(n) = &a.name {
6807 out.push_str(n);
6808 out.push(' ');
6809 }
6810 match &a.ty {
6811 spg_sql::ast::FunctionArgType::Typed(t) => {
6812 let _ = write!(out, "{t}");
6813 }
6814 spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
6815 }
6816 }
6817 out.push(')');
6818 out
6819}
6820
6821/// v7.39 (read01 round 48) — is `name` already taken by a constraint on this
6822/// table? Checks the stored names of foreign keys, uniqueness constraints and
6823/// CHECKs. Constraints written before FILE_VERSION 60 have no stored name, so
6824/// they can't collide here — they are still reachable by their synthesised
6825/// name through `resolve_constraint`.
6826fn constraint_name_taken(table: &spg_storage::Table, name: &str) -> bool {
6827 let sch = table.schema();
6828 sch.foreign_keys
6829 .iter()
6830 .any(|f| f.name.as_deref() == Some(name))
6831 || sch
6832 .uniqueness_constraints
6833 .iter()
6834 .any(|u| u.name.as_deref() == Some(name))
6835 || sch.checks.iter().any(|c| c.name.as_deref() == Some(name))
6836}
6837
6838/// v7.39 (read01 round 58) — lowercase hex, for the synthetic credential a
6839/// passwordless `CREATE ROLE` gets (it can't log in, but the record must not
6840/// carry an empty password).
6841fn hex_of(bytes: &[u8]) -> alloc::string::String {
6842 use core::fmt::Write as _;
6843 let mut s = alloc::string::String::with_capacity(bytes.len() * 2);
6844 for b in bytes {
6845 let _ = write!(s, "{b:02x}");
6846 }
6847 s
6848}
6849
6850/// v7.39 (round 282) — render one argument type the way PG's NOTICE does.
6851///
6852/// PG's grammar has two productions for a type name: the SQL-standard
6853/// KEYWORDS (`int`, `character varying`, `double precision`, …) become a
6854/// `SystemTypeName`, which deparses schema-qualified with the internal
6855/// name — `pg_catalog.int4`; anything else is an ordinary identifier and
6856/// survives verbatim. So `int` prints as `pg_catalog.int4` while the
6857/// equally valid `int4` prints as `int4`, and `date` — not a type keyword
6858/// in that production — prints as `date`. Every entry below was read off
6859/// live PG 18.4 rather than inferred from the list's shape.
6860fn pg_signature_type_name(raw: &str) -> alloc::string::String {
6861 let mut norm = alloc::string::String::new();
6862 for word in raw.split_whitespace() {
6863 if !norm.is_empty() {
6864 norm.push(' ');
6865 }
6866 norm.push_str(&word.to_ascii_lowercase());
6867 }
6868 let internal = match norm.as_str() {
6869 "int" | "integer" => "int4",
6870 "smallint" => "int2",
6871 "bigint" => "int8",
6872 "real" => "float4",
6873 "float" | "double precision" => "float8",
6874 "decimal" | "dec" | "numeric" => "numeric",
6875 "boolean" => "bool",
6876 "varchar" | "character varying" => "varchar",
6877 "char" | "character" => "bpchar",
6878 "time" | "time without time zone" => "time",
6879 "time with time zone" => "timetz",
6880 "timestamp" | "timestamp without time zone" => "timestamp",
6881 "timestamp with time zone" => "timestamptz",
6882 "interval" => "interval",
6883 "bit" => "bit",
6884 "bit varying" => "varbit",
6885 _ => return raw.into(),
6886 };
6887 alloc::format!("pg_catalog.{internal}")
6888}
6889
6890/// v7.39 (round 735, S14/B3) — the FULL set of stored tables a
6891/// materialized-view body reads, or `None` when that set cannot be
6892/// PROVEN (CTEs, unions, subqueries anywhere, any non-table FROM
6893/// source, a join whose ON carries a subquery…). `None` means "always
6894/// refresh fully" — the conservative direction; an under-collected set
6895/// here would be a WRONG no-op serving stale data, so every uncertain
6896/// shape bails.
6897impl Engine {
6898 /// v7.39 (round 737, S14/B3 knife 2) — run buffered INSERTs through
6899 /// the view's projection and append the survivors. The body is a
6900 /// registered-maintainable single-table pure projection, so each new
6901 /// base row maps to at most one view row: eval the WHERE (absent =
6902 /// keep), then each item, against the base row.
6903 /// v7.39 (round 738) — apply buffered changes in ARRIVAL order.
6904 /// `Ok(None)` = this buffer cannot be applied incrementally (an
6905 /// Update change; or a delete/tombstone with no valid row map) —
6906 /// the caller takes the full path. Inserts run the projection and
6907 /// append; deletes and tombstones resolve base RowIds through the
6908 /// row map and remove the view rows, keeping the map's positions
6909 /// and expected length exact after every step.
6910 fn apply_matview_delta_ordered(
6911 &mut self,
6912 name: &str,
6913 body: &spg_sql::ast::SelectStatement,
6914 buf: &[spg_storage::RowChange],
6915 ) -> Result<Option<usize>, EngineError> {
6916 use spg_sql::ast::SelectItem;
6917 let needs_map = buf
6918 .iter()
6919 .any(|c| !matches!(c, spg_storage::RowChange::Insert { .. }));
6920 if needs_map {
6921 let Some((expected, _)) = self.matview_row_map.get(name) else {
6922 return Ok(None);
6923 };
6924 let live = self
6925 .active_catalog()
6926 .get(name)
6927 .map(|t| t.rows().len())
6928 .unwrap_or(usize::MAX);
6929 if live != *expected {
6930 // A vacuum (or anything else) moved the backing rows.
6931 self.matview_row_map.remove(name);
6932 return Ok(None);
6933 }
6934 }
6935 let base = self
6936 .matview_maintainable
6937 .get(name)
6938 .cloned()
6939 .expect("caller checked registration");
6940 let base_cols = self
6941 .active_catalog()
6942 .get(&base)
6943 .ok_or_else(|| {
6944 EngineError::Unsupported(alloc::format!(
6945 "materialized view {name:?} base table {base:?} missing"
6946 ))
6947 })?
6948 .schema()
6949 .columns
6950 .clone();
6951 let alias = body
6952 .from
6953 .as_ref()
6954 .and_then(|f| f.primary.alias.clone())
6955 .unwrap_or_else(|| base.clone());
6956 let mut applied = 0usize;
6957 for ch in buf {
6958 match ch {
6959 spg_storage::RowChange::Insert { row, rowid, .. } => {
6960 let keep = if let Some(w) = &body.where_ {
6961 let ctx = self.ev_ctx(&base_cols, Some(alias.as_str()));
6962 let cond = eval::eval_expr(w, row, &ctx).map_err(EngineError::Eval)?;
6963 crate::eval::predicate_is_true(&cond, "WHERE", ctx.mysql_dialect)?
6964 } else {
6965 true
6966 };
6967 if !keep {
6968 continue;
6969 }
6970 let mut vals = alloc::vec::Vec::with_capacity(body.items.len());
6971 {
6972 let ctx = self.ev_ctx(&base_cols, Some(alias.as_str()));
6973 for item in &body.items {
6974 let SelectItem::Expr { expr, .. } = item else {
6975 unreachable!("registration admits Expr items only");
6976 };
6977 vals.push(eval::eval_expr(expr, row, &ctx).map_err(EngineError::Eval)?);
6978 }
6979 }
6980 let cat = self.active_catalog_mut();
6981 let table = cat.get_mut(name).ok_or_else(|| {
6982 EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
6983 "materialized view {name:?} backing table missing"
6984 )))
6985 })?;
6986 table
6987 .insert(spg_storage::Row::new(vals))
6988 .map_err(EngineError::Storage)?;
6989 let new_pos = table.rows().len() - 1;
6990 if let Some((expected, map)) = self.matview_row_map.get_mut(name) {
6991 map.insert(rowid.0, new_pos);
6992 *expected += 1;
6993 }
6994 applied += 1;
6995 }
6996 spg_storage::RowChange::Delete { rowids, .. }
6997 | spg_storage::RowChange::Tombstone { rowids, .. } => {
6998 // v7.39 (round 740) — TOMBSTONE the view row, never
6999 // physically remove it. delete_rows on a mid-table
7000 // position is O(table) in the persistent vec, and
7001 // every surviving map entry would need shifting —
7002 // measured 70 ms for THREE deletes over a 250k-row
7003 // view. A tombstone is O(1), keeps every physical
7004 // position (the map needs no shift and `expected`
7005 // means what it says), and the view's readers
7006 // already gate on MVCC visibility like any table.
7007 // Vacuumed/compacted views change their length and
7008 // the expected-length check catches it -> full.
7009 for rid in rowids {
7010 let Some((_, map)) = self.matview_row_map.get_mut(name) else {
7011 unreachable!("needs_map gated above");
7012 };
7013 let Some(pos) = map.remove(&rid.0) else {
7014 // A base row the WHERE filtered out — the
7015 // view never held it; nothing to remove.
7016 continue;
7017 };
7018 let v = self.writer_version_for_current_stmt();
7019 let cat = self.active_catalog_mut();
7020 let table = cat.get_mut(name).ok_or_else(|| {
7021 EngineError::Storage(spg_storage::StorageError::Corrupt(
7022 alloc::format!("materialized view {name:?} backing table missing"),
7023 ))
7024 })?;
7025 let _ = table.mark_row_deleted(pos, v);
7026 applied += 1;
7027 }
7028 }
7029 // v7.39 (round 739) — the Update arm: four quadrants of
7030 // (was the OLD row in the view?) x (does the NEW row
7031 // pass the WHERE?). In-place replacement keeps the map
7032 // untouched; a row leaving the view removes + shifts; a
7033 // row entering appends + records.
7034 spg_storage::RowChange::Update { new_row, rowid, .. } => {
7035 let keep = if let Some(w) = &body.where_ {
7036 let ctx = self.ev_ctx(&base_cols, Some(alias.as_str()));
7037 let r = spg_storage::Row::new(new_row.clone());
7038 let cond = eval::eval_expr(w, &r, &ctx).map_err(EngineError::Eval)?;
7039 crate::eval::predicate_is_true(&cond, "WHERE", ctx.mysql_dialect)?
7040 } else {
7041 true
7042 };
7043 let old_pos = self
7044 .matview_row_map
7045 .get(name)
7046 .and_then(|(_, m)| m.get(&rowid.0).copied());
7047 match (old_pos, keep) {
7048 (Some(pos), true) => {
7049 let mut vals = alloc::vec::Vec::with_capacity(body.items.len());
7050 {
7051 let ctx = self.ev_ctx(&base_cols, Some(alias.as_str()));
7052 let r = spg_storage::Row::new(new_row.clone());
7053 for item in &body.items {
7054 let SelectItem::Expr { expr, .. } = item else {
7055 unreachable!("registration admits Expr items only");
7056 };
7057 vals.push(
7058 eval::eval_expr(expr, &r, &ctx)
7059 .map_err(EngineError::Eval)?,
7060 );
7061 }
7062 }
7063 let cat = self.active_catalog_mut();
7064 let table = cat.get_mut(name).ok_or_else(|| {
7065 EngineError::Storage(spg_storage::StorageError::Corrupt(
7066 alloc::format!(
7067 "materialized view {name:?} backing table missing"
7068 ),
7069 ))
7070 })?;
7071 table.update_row(pos, vals).map_err(EngineError::Storage)?;
7072 applied += 1;
7073 }
7074 (Some(pos), false) => {
7075 let (_, map) = self
7076 .matview_row_map
7077 .get_mut(name)
7078 .expect("needs_map gated above");
7079 map.remove(&rowid.0);
7080 let v = self.writer_version_for_current_stmt();
7081 let cat = self.active_catalog_mut();
7082 let table = cat.get_mut(name).ok_or_else(|| {
7083 EngineError::Storage(spg_storage::StorageError::Corrupt(
7084 alloc::format!(
7085 "materialized view {name:?} backing table missing"
7086 ),
7087 ))
7088 })?;
7089 let _ = table.mark_row_deleted(pos, v);
7090 applied += 1;
7091 }
7092 (None, true) => {
7093 let mut vals = alloc::vec::Vec::with_capacity(body.items.len());
7094 {
7095 let ctx = self.ev_ctx(&base_cols, Some(alias.as_str()));
7096 let r = spg_storage::Row::new(new_row.clone());
7097 for item in &body.items {
7098 let SelectItem::Expr { expr, .. } = item else {
7099 unreachable!("registration admits Expr items only");
7100 };
7101 vals.push(
7102 eval::eval_expr(expr, &r, &ctx)
7103 .map_err(EngineError::Eval)?,
7104 );
7105 }
7106 }
7107 let cat = self.active_catalog_mut();
7108 let table = cat.get_mut(name).ok_or_else(|| {
7109 EngineError::Storage(spg_storage::StorageError::Corrupt(
7110 alloc::format!(
7111 "materialized view {name:?} backing table missing"
7112 ),
7113 ))
7114 })?;
7115 table
7116 .insert(spg_storage::Row::new(vals))
7117 .map_err(EngineError::Storage)?;
7118 let new_pos = table.rows().len() - 1;
7119 let (expected, map) = self
7120 .matview_row_map
7121 .get_mut(name)
7122 .expect("needs_map gated above");
7123 map.insert(rowid.0, new_pos);
7124 *expected += 1;
7125 applied += 1;
7126 }
7127 (None, false) => {}
7128 }
7129 }
7130 }
7131 }
7132 Ok(Some(applied))
7133 }
7134}
7135
7136/// v7.39 (round 737, S14/B3 knife 2) — the base table of a
7137/// DELTA-MAINTAINABLE view body, or None. Strictly narrower than
7138/// `matview_dep_tables`: ONE stored table, pure projection items, a
7139/// pure WHERE, and none of the shapes whose delta is not row-local
7140/// (aggregates / GROUP BY / DISTINCT [ON] / ORDER / LIMIT / OFFSET /
7141/// windows / SRFs — plus everything the dep collector already bails
7142/// on). Anything outside refreshes fully, as today.
7143fn matview_maintainable_base(stmt: &spg_sql::ast::SelectStatement) -> Option<String> {
7144 use spg_sql::ast::SelectItem;
7145 let deps = matview_dep_tables(stmt)?;
7146 if deps.len() != 1 {
7147 return None;
7148 }
7149 if stmt.distinct
7150 || !stmt.distinct_on.is_empty()
7151 || stmt.group_by.is_some()
7152 || stmt.group_by_all
7153 || stmt.having.is_some()
7154 || !stmt.order_by.is_empty()
7155 || stmt.limit.is_some()
7156 || stmt.offset.is_some()
7157 || !stmt.window_check_exprs.is_empty()
7158 || crate::aggregate::uses_aggregate(stmt)
7159 || crate::window::select_has_window(stmt)
7160 {
7161 return None;
7162 }
7163 for item in &stmt.items {
7164 let SelectItem::Expr { expr, .. } = item else {
7165 return None;
7166 };
7167 if !crate::eval::fully_compilable(expr) || crate::select::expr_contains_builtin_srf(expr) {
7168 return None;
7169 }
7170 }
7171 if let Some(w) = &stmt.where_
7172 && !crate::eval::fully_compilable(w)
7173 {
7174 return None;
7175 }
7176 deps.into_iter().next()
7177}
7178
7179fn matview_dep_tables(
7180 stmt: &spg_sql::ast::SelectStatement,
7181) -> Option<alloc::collections::BTreeSet<String>> {
7182 use spg_sql::ast::SelectItem;
7183 if !stmt.ctes.is_empty() || !stmt.unions.is_empty() {
7184 return None;
7185 }
7186 let from = stmt.from.as_ref()?;
7187 let mut out = alloc::collections::BTreeSet::new();
7188 let mut take = |t: &spg_sql::ast::TableRef| -> bool {
7189 if t.name.is_empty()
7190 || t.lateral_subquery.is_some()
7191 || t.unnest_expr.is_some()
7192 || t.generate_series_args.is_some()
7193 || t.as_of_segment.is_some()
7194 || t.jsonb_each_text_arg.is_some()
7195 || t.table_fn_call.is_some()
7196 || t.rows_from.is_some()
7197 || t.json_table.is_some()
7198 {
7199 return false;
7200 }
7201 out.insert(t.name.to_ascii_lowercase());
7202 true
7203 };
7204 if !take(&from.primary) {
7205 return None;
7206 }
7207 for j in &from.joins {
7208 if !take(&j.table) {
7209 return None;
7210 }
7211 if j.on.as_ref().is_some_and(crate::expr_has_subquery) {
7212 return None;
7213 }
7214 }
7215 let any_sub = stmt.items.iter().any(|i| match i {
7216 SelectItem::Expr { expr, .. } => crate::expr_has_subquery(expr),
7217 _ => false,
7218 }) || stmt.where_.as_ref().is_some_and(crate::expr_has_subquery)
7219 || stmt
7220 .group_by
7221 .as_ref()
7222 .is_some_and(|gs| gs.iter().any(crate::expr_has_subquery))
7223 || stmt.having.as_ref().is_some_and(crate::expr_has_subquery)
7224 || stmt
7225 .order_by
7226 .iter()
7227 .any(|o| crate::expr_has_subquery(&o.expr));
7228 if any_sub {
7229 return None;
7230 }
7231 Some(out)
7232}