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