Skip to main content

spg_engine/
ddl.rs

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