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