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