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