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