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