Skip to main content

spg_engine/
ddl.rs

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