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