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