Skip to main content

pgevolve_core/parse/builder/
alter_table_stmt.rs

1//! `ALTER TABLE` support for source DDL.
2//!
3//! Source SQL is declarative: tables, columns, constraints, etc. are stated as
4//! the desired end-state via `CREATE`. Two classes of `ALTER TABLE` are accepted
5//! because they cannot always be expressed inline:
6//!
7//! 1. **`ADD CONSTRAINT FOREIGN KEY`** — forward-referencing FKs: when two
8//!    tables reference each other, neither can declare its FK inline because the
9//!    other side does not exist yet at parse time.
10//!
11//! 2. **`ALTER COLUMN … SET STORAGE / SET COMPRESSION`** — per-column storage
12//!    strategy and compression codec. These may appear after the `CREATE TABLE`
13//!    (for example when the source is derived from `pg_dump`).
14//!
15//! Everything else raises [`ParseError::Structural`] pointing the user to the
16//! declarative source-of-truth model.
17
18use pg_query::NodeEnum;
19use pg_query::protobuf::{
20    AlterTableCmd, AlterTableStmt, AlterTableType, ConstrType, Constraint as PgConstraint,
21    ObjectType, RoleSpecType,
22};
23
24use crate::identifier::{Identifier, QualifiedName};
25use crate::ir::catalog::Catalog;
26use crate::ir::column::{Compression, StorageKind};
27use crate::ir::constraint::Constraint;
28use crate::ir::reloptions::TableStorageOptions;
29use crate::parse::builder::create_stmt;
30use crate::parse::builder::shared;
31use crate::parse::error::{ParseError, SourceLocation};
32
33/// One forward-reference FK constraint to merge into a [`crate::ir::table::Table`]
34/// after all tables have been built.
35#[derive(Debug, Clone)]
36pub struct PendingFk {
37    /// Target table to attach the constraint to.
38    pub target: QualifiedName,
39    /// The constraint itself.
40    pub constraint: Constraint,
41}
42
43/// A per-column attribute update (`SET STORAGE` / `SET COMPRESSION`) to apply
44/// to an already-built [`crate::ir::column::Column`] once all tables exist.
45#[derive(Debug, Clone)]
46pub struct PendingColumnAttr {
47    /// Table that owns the column.
48    pub target: QualifiedName,
49    /// Column to update.
50    pub column: Identifier,
51    /// The attribute change to apply.
52    pub kind: PendingColumnAttrKind,
53}
54
55/// Which column attribute is being set.
56#[derive(Debug, Clone)]
57pub enum PendingColumnAttrKind {
58    /// `ALTER COLUMN … SET STORAGE <strategy>`.
59    Storage(StorageKind),
60    /// `ALTER COLUMN … SET COMPRESSION <codec>` — `None` means `DEFAULT`
61    /// (revert to cluster GUC).
62    Compression(Option<Compression>),
63}
64
65/// An ownership assignment pending merge into the catalog.
66#[derive(Debug, Clone)]
67pub struct PendingOwner {
68    /// Relation whose owner should be updated.
69    pub target: QualifiedName,
70    /// New owner role name.
71    pub new_owner: Identifier,
72}
73
74/// An RLS mode toggle (`ENABLE / DISABLE / FORCE / NO FORCE ROW LEVEL SECURITY`)
75/// pending application to its target table.
76#[derive(Debug, Clone)]
77pub struct PendingRlsToggle {
78    /// Table to update.
79    pub target: QualifiedName,
80    /// The exact subcommand type — one of the four RLS `AlterTableType` variants.
81    pub subtype: AlterTableType,
82}
83
84/// A `SET (...)` reloptions update from `ALTER TABLE / MATERIALIZED VIEW ... SET
85/// (key = value, ...)`, pending merge into the target relation's `storage` field.
86#[derive(Debug, Clone)]
87pub struct PendingRelOptions {
88    /// Relation whose storage options should be updated.
89    pub target: QualifiedName,
90    /// The decoded options to merge.
91    pub options: TableStorageOptions,
92}
93
94/// A tablespace assignment from `ALTER TABLE … SET TABLESPACE <name>`, pending
95/// merge into the target table's `tablespace` field.
96#[derive(Debug, Clone)]
97pub struct PendingTablespace {
98    /// Table whose tablespace should be updated.
99    pub target: QualifiedName,
100    /// The new tablespace name.
101    pub tablespace: crate::identifier::Identifier,
102}
103
104/// The combined output of processing one `ALTER TABLE` statement.
105#[derive(Debug, Default)]
106pub struct AlterTableOutput {
107    /// Forward-reference FK constraints to merge after all tables are parsed.
108    pub pending_fks: Vec<PendingFk>,
109    /// Per-column attribute updates to apply after all tables are parsed.
110    pub pending_column_attrs: Vec<PendingColumnAttr>,
111    /// Ownership assignments for relation-family objects.
112    pub pending_owners: Vec<PendingOwner>,
113    /// RLS mode toggles (ENABLE/DISABLE/FORCE/NO FORCE ROW LEVEL SECURITY).
114    pub pending_rls_toggles: Vec<PendingRlsToggle>,
115    /// Reloption SET (...) updates to apply after all tables/MVs are parsed.
116    pub pending_rel_options: Vec<PendingRelOptions>,
117    /// Tablespace assignments from `ALTER TABLE … SET TABLESPACE <name>`.
118    pub pending_tablespaces: Vec<PendingTablespace>,
119}
120
121/// Process an `ALTER TABLE` statement.
122///
123/// Returns a combined [`AlterTableOutput`] covering the two supported
124/// subcommand classes. Any other subcommand raises [`ParseError::Structural`]
125/// pointing the user to the declarative source-of-truth model.
126pub fn build_alter_table(
127    stmt: &AlterTableStmt,
128    default_schema: Option<&Identifier>,
129    location: &SourceLocation,
130) -> Result<AlterTableOutput, ParseError> {
131    let relation = stmt
132        .relation
133        .as_ref()
134        .ok_or_else(|| ParseError::Structural {
135            location: location.clone(),
136            message: "ALTER TABLE missing relation".into(),
137        })?;
138    let target = shared::resolve_qname(relation, default_schema, location)?;
139
140    let mut out = AlterTableOutput::default();
141    for cmd_node in &stmt.cmds {
142        let Some(NodeEnum::AlterTableCmd(cmd)) = cmd_node.node.as_ref() else {
143            return Err(unsupported_alter(location));
144        };
145        process_cmd(cmd, &target, default_schema, location, &mut out)?;
146    }
147    Ok(out)
148}
149
150fn process_cmd(
151    cmd: &AlterTableCmd,
152    target: &QualifiedName,
153    default_schema: Option<&Identifier>,
154    location: &SourceLocation,
155    out: &mut AlterTableOutput,
156) -> Result<(), ParseError> {
157    let subtype = AlterTableType::try_from(cmd.subtype).unwrap_or(AlterTableType::Undefined);
158    match subtype {
159        AlterTableType::AtAddConstraint => {
160            let pending = process_add_constraint_cmd(cmd, target, default_schema, location)?;
161            out.pending_fks.push(pending);
162        }
163        AlterTableType::AtSetStorage => {
164            let pending = process_set_storage_cmd(cmd, target, location)?;
165            out.pending_column_attrs.push(pending);
166        }
167        AlterTableType::AtSetCompression => {
168            let pending = process_set_compression_cmd(cmd, target, location)?;
169            out.pending_column_attrs.push(pending);
170        }
171        AlterTableType::AtChangeOwner => {
172            let pending = process_change_owner_cmd(cmd, target, location)?;
173            out.pending_owners.push(pending);
174        }
175        AlterTableType::AtEnableRowSecurity
176        | AlterTableType::AtDisableRowSecurity
177        | AlterTableType::AtForceRowSecurity
178        | AlterTableType::AtNoForceRowSecurity => {
179            out.pending_rls_toggles.push(PendingRlsToggle {
180                target: target.clone(),
181                subtype,
182            });
183        }
184        AlterTableType::AtSetRelOptions => {
185            let pending = process_set_rel_options_cmd(cmd, target, location)?;
186            out.pending_rel_options.push(pending);
187        }
188        AlterTableType::AtSetTableSpace => {
189            let pending = process_set_tablespace_cmd(cmd, target, location)?;
190            out.pending_tablespaces.push(pending);
191        }
192        AlterTableType::AtResetRelOptions | AlterTableType::AtReplaceRelOptions => {
193            return Err(ParseError::Structural {
194                location: location.clone(),
195                message: "ALTER TABLE ... RESET (...) in source is not supported — \
196                          clear options out-of-band, then remove from source"
197                    .into(),
198            });
199        }
200        _ => return Err(unsupported_alter(location)),
201    }
202    Ok(())
203}
204
205fn process_add_constraint_cmd(
206    cmd: &AlterTableCmd,
207    target: &QualifiedName,
208    default_schema: Option<&Identifier>,
209    location: &SourceLocation,
210) -> Result<PendingFk, ParseError> {
211    let con = cmd
212        .def
213        .as_ref()
214        .and_then(|d| d.node.as_ref())
215        .and_then(|n| match n {
216            NodeEnum::Constraint(c) => Some(c.as_ref()),
217            _ => None,
218        })
219        .ok_or_else(|| ParseError::Structural {
220            location: location.clone(),
221            message: "ALTER TABLE ADD CONSTRAINT missing constraint definition".into(),
222        })?;
223
224    let kind = ConstrType::try_from(con.contype).unwrap_or(ConstrType::Undefined);
225    if !matches!(kind, ConstrType::ConstrForeign) {
226        return Err(ParseError::Structural {
227            location: location.clone(),
228            message: "ALTER TABLE may only ADD CONSTRAINT FOREIGN KEY in source DDL — \
229                     other constraint kinds belong inline in the CREATE TABLE that \
230                     declares them"
231                .into(),
232        });
233    }
234
235    let constraint = build_fk_constraint(con, target, default_schema, location)?;
236    Ok(PendingFk {
237        target: target.clone(),
238        constraint,
239    })
240}
241
242fn process_set_storage_cmd(
243    cmd: &AlterTableCmd,
244    target: &QualifiedName,
245    location: &SourceLocation,
246) -> Result<PendingColumnAttr, ParseError> {
247    // cmd.name = column name; cmd.def = String node with lowercase strategy keyword.
248    let column = shared::ident(&cmd.name, location)?;
249    let keyword = def_as_string(cmd, location)?;
250    let storage = match keyword.to_ascii_lowercase().as_str() {
251        "plain" => StorageKind::Plain,
252        "external" => StorageKind::External,
253        "extended" => StorageKind::Extended,
254        "main" => StorageKind::Main,
255        other => {
256            return Err(ParseError::Structural {
257                location: location.clone(),
258                message: format!("unknown STORAGE attribute '{other}'"),
259            });
260        }
261    };
262    Ok(PendingColumnAttr {
263        target: target.clone(),
264        column,
265        kind: PendingColumnAttrKind::Storage(storage),
266    })
267}
268
269fn process_set_compression_cmd(
270    cmd: &AlterTableCmd,
271    target: &QualifiedName,
272    location: &SourceLocation,
273) -> Result<PendingColumnAttr, ParseError> {
274    // cmd.name = column name; cmd.def = String node with lowercase codec name.
275    let column = shared::ident(&cmd.name, location)?;
276    let keyword = def_as_string(cmd, location)?;
277    let compression = match keyword.to_ascii_lowercase().as_str() {
278        "default" => None,
279        "pglz" => Some(Compression::Pglz),
280        "lz4" => Some(Compression::Lz4),
281        other => {
282            return Err(ParseError::Structural {
283                location: location.clone(),
284                message: format!("unknown COMPRESSION codec '{other}'"),
285            });
286        }
287    };
288    Ok(PendingColumnAttr {
289        target: target.clone(),
290        column,
291        kind: PendingColumnAttrKind::Compression(compression),
292    })
293}
294
295/// Decode an `AT_ChangeOwner` sub-command into a [`PendingOwner`].
296///
297/// `pg_query` encodes the new owner as a `RoleSpec` in `cmd.newowner` (a
298/// dedicated field on [`AlterTableCmd`], not in the generic `cmd.def`).
299fn process_change_owner_cmd(
300    cmd: &AlterTableCmd,
301    target: &QualifiedName,
302    location: &SourceLocation,
303) -> Result<PendingOwner, ParseError> {
304    let rs = cmd
305        .newowner
306        .as_ref()
307        .ok_or_else(|| ParseError::Structural {
308            location: location.clone(),
309            message: "ALTER TABLE OWNER TO missing role specification".into(),
310        })?;
311    let roletype = RoleSpecType::try_from(rs.roletype).unwrap_or(RoleSpecType::Undefined);
312    if roletype == RoleSpecType::RolespecPublic {
313        return Err(ParseError::Structural {
314            location: location.clone(),
315            message: "ALTER TABLE OWNER TO PUBLIC is not valid — PUBLIC is not a role name".into(),
316        });
317    }
318    let new_owner = shared::ident(&rs.rolename, location)?;
319    Ok(PendingOwner {
320        target: target.clone(),
321        new_owner,
322    })
323}
324
325fn process_set_rel_options_cmd(
326    cmd: &AlterTableCmd,
327    target: &QualifiedName,
328    location: &SourceLocation,
329) -> Result<PendingRelOptions, ParseError> {
330    let items = crate::parse::builder::reloptions::extract_def_list(cmd.def.as_deref(), location)?;
331    let options = crate::parse::builder::reloptions::decode_table_options(&items, location)?;
332    Ok(PendingRelOptions {
333        target: target.clone(),
334        options,
335    })
336}
337
338/// Decode an `AT_SetTableSpace` sub-command.
339///
340/// `pg_query` encodes the tablespace name in `cmd.name` for this subtype.
341fn process_set_tablespace_cmd(
342    cmd: &AlterTableCmd,
343    target: &QualifiedName,
344    location: &SourceLocation,
345) -> Result<PendingTablespace, ParseError> {
346    if cmd.name.is_empty() {
347        return Err(ParseError::Structural {
348            location: location.clone(),
349            message: "ALTER TABLE SET TABLESPACE missing tablespace name".into(),
350        });
351    }
352    let tablespace = shared::ident(&cmd.name, location)?;
353    Ok(PendingTablespace {
354        target: target.clone(),
355        tablespace,
356    })
357}
358
359/// Apply accumulated `ALTER TABLE ... SET (...)` reloption updates to the
360/// catalog. Tables and materialized views are both searched.
361///
362/// Called from `parse/mod.rs` after all relations are built.
363pub fn apply_pending_rel_options(
364    catalog: &mut Catalog,
365    pending: Vec<PendingRelOptions>,
366    location: &SourceLocation,
367) -> Result<(), ParseError> {
368    for p in pending {
369        // Search tables first.
370        if let Some(table) = catalog.tables.iter_mut().find(|t| t.qname == p.target) {
371            merge_table_options(&mut table.storage, p.options);
372            continue;
373        }
374        // Then materialized views.
375        if let Some(mv) = catalog
376            .materialized_views
377            .iter_mut()
378            .find(|m| m.qname == p.target)
379        {
380            merge_table_options(&mut mv.storage, p.options);
381            continue;
382        }
383        return Err(ParseError::Structural {
384            location: location.clone(),
385            message: format!(
386                "ALTER ... SET (...) referenced unknown relation {}",
387                p.target
388            ),
389        });
390    }
391    Ok(())
392}
393
394/// Merge `src` options into `dst`, overwriting only the fields that are `Some`
395/// in `src`. Fields that are `None` in `src` are left unchanged in `dst`.
396fn merge_table_options(
397    dst: &mut crate::ir::reloptions::TableStorageOptions,
398    src: crate::ir::reloptions::TableStorageOptions,
399) {
400    if src.fillfactor.is_some() {
401        dst.fillfactor = src.fillfactor;
402    }
403    if src.parallel_workers.is_some() {
404        dst.parallel_workers = src.parallel_workers;
405    }
406    if src.toast_tuple_target.is_some() {
407        dst.toast_tuple_target = src.toast_tuple_target;
408    }
409    if src.user_catalog_table.is_some() {
410        dst.user_catalog_table = src.user_catalog_table;
411    }
412    if src.vacuum_truncate.is_some() {
413        dst.vacuum_truncate = src.vacuum_truncate;
414    }
415    merge_autovacuum(&mut dst.autovacuum, &src.autovacuum);
416    for (k, v) in src.extra {
417        dst.extra.insert(k, v);
418    }
419}
420
421const fn merge_autovacuum(
422    dst: &mut crate::ir::reloptions::AutovacuumOptions,
423    src: &crate::ir::reloptions::AutovacuumOptions,
424) {
425    if src.enabled.is_some() {
426        dst.enabled = src.enabled;
427    }
428    if src.vacuum_threshold.is_some() {
429        dst.vacuum_threshold = src.vacuum_threshold;
430    }
431    if src.vacuum_scale_factor.is_some() {
432        dst.vacuum_scale_factor = src.vacuum_scale_factor;
433    }
434    if src.vacuum_cost_delay.is_some() {
435        dst.vacuum_cost_delay = src.vacuum_cost_delay;
436    }
437    if src.vacuum_cost_limit.is_some() {
438        dst.vacuum_cost_limit = src.vacuum_cost_limit;
439    }
440    if src.analyze_threshold.is_some() {
441        dst.analyze_threshold = src.analyze_threshold;
442    }
443    if src.analyze_scale_factor.is_some() {
444        dst.analyze_scale_factor = src.analyze_scale_factor;
445    }
446    if src.freeze_max_age.is_some() {
447        dst.freeze_max_age = src.freeze_max_age;
448    }
449    if src.freeze_min_age.is_some() {
450        dst.freeze_min_age = src.freeze_min_age;
451    }
452    if src.freeze_table_age.is_some() {
453        dst.freeze_table_age = src.freeze_table_age;
454    }
455    if src.multixact_freeze_max_age.is_some() {
456        dst.multixact_freeze_max_age = src.multixact_freeze_max_age;
457    }
458    if src.multixact_freeze_min_age.is_some() {
459        dst.multixact_freeze_min_age = src.multixact_freeze_min_age;
460    }
461    if src.multixact_freeze_table_age.is_some() {
462        dst.multixact_freeze_table_age = src.multixact_freeze_table_age;
463    }
464    if src.vacuum_insert_threshold.is_some() {
465        dst.vacuum_insert_threshold = src.vacuum_insert_threshold;
466    }
467    if src.vacuum_insert_scale_factor.is_some() {
468        dst.vacuum_insert_scale_factor = src.vacuum_insert_scale_factor;
469    }
470    if src.log_min_duration.is_some() {
471        dst.log_min_duration = src.log_min_duration;
472    }
473}
474
475/// Apply a list of ownership assignments to the catalog.
476///
477/// Called from `parse/mod.rs` after all relation-family objects are built.
478pub fn apply_pending_owners(
479    catalog: &mut Catalog,
480    pending: Vec<PendingOwner>,
481    location: &SourceLocation,
482) -> Result<(), ParseError> {
483    for po in pending {
484        super::owner_stmt::set_owner_for_relation(
485            catalog,
486            &po.target,
487            ObjectType::ObjectTable, // hint: try all relation types
488            po.new_owner,
489            location,
490        )?;
491    }
492    Ok(())
493}
494
495/// Apply accumulated RLS mode toggles to the catalog.
496///
497/// Called from `parse/mod.rs` after all tables are built.
498pub fn apply_pending_rls_toggles(
499    catalog: &mut Catalog,
500    pending: Vec<PendingRlsToggle>,
501    location: &SourceLocation,
502) -> Result<(), ParseError> {
503    for toggle in pending {
504        let table = catalog
505            .tables
506            .iter_mut()
507            .find(|t| t.qname == toggle.target)
508            .ok_or_else(|| ParseError::Structural {
509                location: location.clone(),
510                message: format!(
511                    "ALTER TABLE … ROW LEVEL SECURITY referenced unknown table {}",
512                    toggle.target
513                ),
514            })?;
515        match toggle.subtype {
516            AlterTableType::AtEnableRowSecurity => {
517                table.rls_enabled = true;
518            }
519            AlterTableType::AtDisableRowSecurity => {
520                table.rls_enabled = false;
521            }
522            AlterTableType::AtForceRowSecurity => {
523                table.rls_forced = true;
524            }
525            AlterTableType::AtNoForceRowSecurity => {
526                table.rls_forced = false;
527            }
528            _ => {
529                return Err(ParseError::Structural {
530                    location: location.clone(),
531                    message: "unexpected subtype in PendingRlsToggle".into(),
532                });
533            }
534        }
535    }
536    Ok(())
537}
538
539/// Apply accumulated `ALTER TABLE … SET TABLESPACE` updates to the catalog.
540///
541/// Called from `parse/mod.rs` after all tables are built.
542pub fn apply_pending_tablespaces(
543    catalog: &mut crate::ir::catalog::Catalog,
544    pending: Vec<PendingTablespace>,
545    location: &SourceLocation,
546) -> Result<(), ParseError> {
547    for p in pending {
548        let table = catalog
549            .tables
550            .iter_mut()
551            .find(|t| t.qname == p.target)
552            .ok_or_else(|| ParseError::Structural {
553                location: location.clone(),
554                message: format!(
555                    "ALTER TABLE … SET TABLESPACE referenced unknown table {}",
556                    p.target
557                ),
558            })?;
559        table.tablespace = Some(p.tablespace);
560    }
561    Ok(())
562}
563
564/// Extract the String node from `cmd.def` and return its `sval`.
565fn def_as_string(cmd: &AlterTableCmd, location: &SourceLocation) -> Result<String, ParseError> {
566    cmd.def
567        .as_ref()
568        .and_then(|d| d.node.as_ref())
569        .and_then(|n| match n {
570            NodeEnum::String(s) => Some(s.sval.clone()),
571            _ => None,
572        })
573        .ok_or_else(|| ParseError::Structural {
574            location: location.clone(),
575            message: "ALTER COLUMN SET STORAGE/COMPRESSION missing keyword node".into(),
576        })
577}
578
579/// Reuse the FK builder from `create_stmt` so source ALTER and inline
580/// `REFERENCES` produce identical IR.
581fn build_fk_constraint(
582    con: &PgConstraint,
583    target_table: &QualifiedName,
584    default_schema: Option<&Identifier>,
585    location: &SourceLocation,
586) -> Result<Constraint, ParseError> {
587    // Delegate to a public helper exposed by create_stmt to avoid duplicating
588    // FK extraction logic.
589    create_stmt::build_fk_for_alter(con, target_table, default_schema, location)
590}
591
592fn unsupported_alter(location: &SourceLocation) -> ParseError {
593    ParseError::Structural {
594        location: location.clone(),
595        message: "ALTER TABLE in source DDL is restricted to ADD CONSTRAINT FOREIGN KEY, \
596                 ALTER COLUMN SET STORAGE/COMPRESSION, SET (reloptions), and SET TABLESPACE; \
597                 pgevolve treats source SQL as declarative — express the desired schema \
598                 state via CREATE statements"
599            .into(),
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use crate::ir::constraint::ConstraintKind;
607    use std::path::PathBuf;
608
609    fn loc() -> SourceLocation {
610        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
611    }
612
613    fn build(sql: &str) -> Result<AlterTableOutput, ParseError> {
614        let parsed = pg_query::parse(sql).expect("parses");
615        let stmt = parsed
616            .protobuf
617            .stmts
618            .into_iter()
619            .next()
620            .and_then(|raw| raw.stmt)
621            .and_then(|n| n.node)
622            .expect("stmt");
623        let NodeEnum::AlterTableStmt(s) = stmt else {
624            panic!("not AlterTableStmt")
625        };
626        build_alter_table(&s, None, &loc())
627    }
628
629    #[test]
630    fn allowed_add_fk() {
631        let out = build(
632            "ALTER TABLE app.invoices ADD CONSTRAINT invoices_customer_fk \
633             FOREIGN KEY (customer_id) REFERENCES app.customers (id);",
634        )
635        .expect("builds");
636        assert_eq!(out.pending_fks.len(), 1);
637        let p = &out.pending_fks[0];
638        assert_eq!(p.target.to_string(), "app.invoices");
639        assert!(matches!(p.constraint.kind, ConstraintKind::ForeignKey(_)));
640    }
641
642    #[test]
643    fn alter_column_set_storage_external() {
644        let out =
645            build("ALTER TABLE app.t ALTER COLUMN doc SET STORAGE EXTERNAL;").expect("builds");
646        assert_eq!(out.pending_column_attrs.len(), 1);
647        let attr = &out.pending_column_attrs[0];
648        assert_eq!(attr.target.to_string(), "app.t");
649        assert_eq!(attr.column.as_str(), "doc");
650        assert!(matches!(
651            attr.kind,
652            PendingColumnAttrKind::Storage(StorageKind::External)
653        ));
654    }
655
656    #[test]
657    fn alter_column_set_storage_plain() {
658        let out = build("ALTER TABLE app.t ALTER COLUMN n SET STORAGE PLAIN;").expect("builds");
659        let attr = &out.pending_column_attrs[0];
660        assert!(matches!(
661            attr.kind,
662            PendingColumnAttrKind::Storage(StorageKind::Plain)
663        ));
664    }
665
666    #[test]
667    fn alter_column_set_storage_main() {
668        let out = build("ALTER TABLE app.t ALTER COLUMN n SET STORAGE MAIN;").expect("builds");
669        let attr = &out.pending_column_attrs[0];
670        assert!(matches!(
671            attr.kind,
672            PendingColumnAttrKind::Storage(StorageKind::Main)
673        ));
674    }
675
676    #[test]
677    fn alter_column_set_compression_lz4() {
678        let out = build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION lz4;").expect("builds");
679        assert_eq!(out.pending_column_attrs.len(), 1);
680        let attr = &out.pending_column_attrs[0];
681        assert!(matches!(
682            attr.kind,
683            PendingColumnAttrKind::Compression(Some(Compression::Lz4))
684        ));
685    }
686
687    #[test]
688    fn alter_column_set_compression_pglz() {
689        let out =
690            build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION pglz;").expect("builds");
691        let attr = &out.pending_column_attrs[0];
692        assert!(matches!(
693            attr.kind,
694            PendingColumnAttrKind::Compression(Some(Compression::Pglz))
695        ));
696    }
697
698    #[test]
699    fn alter_column_set_compression_default() {
700        let out =
701            build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION DEFAULT;").expect("builds");
702        let attr = &out.pending_column_attrs[0];
703        assert!(matches!(
704            attr.kind,
705            PendingColumnAttrKind::Compression(None)
706        ));
707    }
708
709    #[test]
710    fn rejects_drop_column() {
711        let err = build("ALTER TABLE app.users DROP COLUMN email;").unwrap_err();
712        match err {
713            ParseError::Structural { message, .. } => {
714                assert!(
715                    message.contains("declarative"),
716                    "expected declarative message, got: {message}"
717                );
718            }
719            other => panic!("expected Structural, got {other:?}"),
720        }
721    }
722
723    #[test]
724    fn rejects_add_column() {
725        let err = build("ALTER TABLE app.users ADD COLUMN email text;").unwrap_err();
726        match err {
727            ParseError::Structural { message, .. } => {
728                assert!(
729                    message.contains("declarative") || message.contains("FOREIGN KEY"),
730                    "got: {message}"
731                );
732            }
733            other => panic!("expected Structural, got {other:?}"),
734        }
735    }
736
737    /// Verify that `SET STORAGE BOGUS` always surfaces as an error, regardless
738    /// of whether `pg_query` catches it at parse time or our decoder catches it.
739    ///
740    /// The error path under test is `process_set_storage_cmd` line 176-181
741    /// (`unknown STORAGE attribute '…'`). If `pg_query` happens to accept the
742    /// keyword and pass it down, that arm is exercised. If `pg_query` rejects it
743    /// first, we confirm via a parse-level error — either way the contract holds.
744    #[test]
745    fn alter_column_set_storage_unknown_errors() {
746        let sql = "ALTER TABLE app.t ALTER COLUMN doc SET STORAGE BOGUS;";
747        // pg_query may reject this SQL outright (returning Err), or it may
748        // accept it and pass the unknown keyword to our decoder.
749        match pg_query::parse(sql) {
750            Err(_pg_err) => {
751                // pg_query rejected BOGUS before our decoder was reached.
752                // The contract is satisfied: malformed SQL fails at parse time.
753            }
754            Ok(parsed) => {
755                // pg_query accepted the keyword — our decoder must reject it.
756                let stmt = parsed
757                    .protobuf
758                    .stmts
759                    .into_iter()
760                    .next()
761                    .and_then(|raw| raw.stmt)
762                    .and_then(|n| n.node)
763                    .expect("stmt");
764                let NodeEnum::AlterTableStmt(s) = stmt else {
765                    panic!("expected AlterTableStmt");
766                };
767                let err = build_alter_table(&s, None, &loc())
768                    .expect_err("BOGUS storage keyword must be rejected by our decoder");
769                match err {
770                    ParseError::Structural { ref message, .. } => {
771                        assert!(
772                            message.contains("STORAGE"),
773                            "expected error to mention STORAGE, got: {message}"
774                        );
775                    }
776                    other => panic!("expected Structural error, got {other:?}"),
777                }
778            }
779        }
780    }
781
782    #[test]
783    fn rejects_add_check_via_alter() {
784        let err = build("ALTER TABLE app.t ADD CONSTRAINT c1 CHECK (n > 0);").unwrap_err();
785        assert!(matches!(err, ParseError::Structural { .. }));
786    }
787
788    /// `pg_query` encodes the new owner in `cmd.newowner` (a dedicated
789    /// [`pg_query::protobuf::RoleSpec`] field), not in `cmd.def`.  This test
790    /// guards that `process_change_owner_cmd` reads from the right field.
791    #[test]
792    fn alter_table_owner_to_role_name() {
793        let out = build("ALTER TABLE app.t OWNER TO app_owner;").expect("builds");
794        assert_eq!(out.pending_owners.len(), 1);
795        let po = &out.pending_owners[0];
796        assert_eq!(po.target.to_string(), "app.t");
797        assert_eq!(po.new_owner.as_str(), "app_owner");
798    }
799
800    // ── RLS toggle tests ──────────────────────────────────────────────────────
801
802    #[test]
803    fn enable_row_security_produces_toggle() {
804        let out = build("ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY;").expect("builds");
805        assert_eq!(out.pending_rls_toggles.len(), 1);
806        let t = &out.pending_rls_toggles[0];
807        assert_eq!(t.target.to_string(), "app.docs");
808        assert!(matches!(t.subtype, AlterTableType::AtEnableRowSecurity));
809    }
810
811    #[test]
812    fn disable_row_security_produces_toggle() {
813        let out = build("ALTER TABLE app.docs DISABLE ROW LEVEL SECURITY;").expect("builds");
814        assert_eq!(out.pending_rls_toggles.len(), 1);
815        let t = &out.pending_rls_toggles[0];
816        assert!(matches!(t.subtype, AlterTableType::AtDisableRowSecurity));
817    }
818
819    #[test]
820    fn force_row_security_produces_toggle() {
821        let out = build("ALTER TABLE app.docs FORCE ROW LEVEL SECURITY;").expect("builds");
822        assert_eq!(out.pending_rls_toggles.len(), 1);
823        let t = &out.pending_rls_toggles[0];
824        assert!(matches!(t.subtype, AlterTableType::AtForceRowSecurity));
825    }
826
827    #[test]
828    fn no_force_row_security_produces_toggle() {
829        let out = build("ALTER TABLE app.docs NO FORCE ROW LEVEL SECURITY;").expect("builds");
830        assert_eq!(out.pending_rls_toggles.len(), 1);
831        let t = &out.pending_rls_toggles[0];
832        assert!(matches!(t.subtype, AlterTableType::AtNoForceRowSecurity));
833    }
834
835    // ── SET / RESET reloption tests ───────────────────────────────────────────
836
837    #[test]
838    fn alter_table_set_reloption_fillfactor() {
839        let out = build("ALTER TABLE app.t SET (fillfactor = 80);").expect("builds");
840        assert_eq!(out.pending_rel_options.len(), 1);
841        let p = &out.pending_rel_options[0];
842        assert_eq!(p.target.to_string(), "app.t");
843        assert_eq!(p.options.fillfactor, Some(80));
844    }
845
846    #[test]
847    fn alter_table_set_reloption_autovacuum_enabled() {
848        let out = build("ALTER TABLE app.t SET (autovacuum_enabled = false);").expect("builds");
849        assert_eq!(out.pending_rel_options.len(), 1);
850        let p = &out.pending_rel_options[0];
851        assert_eq!(p.options.autovacuum.enabled, Some(false));
852    }
853
854    #[test]
855    fn alter_table_set_reloption_multiple_options() {
856        let out = build("ALTER TABLE app.t SET (fillfactor = 70, parallel_workers = 2);")
857            .expect("builds");
858        let p = &out.pending_rel_options[0];
859        assert_eq!(p.options.fillfactor, Some(70));
860        assert_eq!(p.options.parallel_workers, Some(2));
861    }
862
863    #[test]
864    fn alter_table_reset_reloption_errors() {
865        let err = build("ALTER TABLE app.t RESET (fillfactor);").unwrap_err();
866        assert!(
867            matches!(err, ParseError::Structural { ref message, .. }
868                if message.contains("RESET") || message.contains("not supported")),
869            "unexpected error: {err:?}"
870        );
871    }
872
873    #[test]
874    fn alter_table_set_fillfactor_out_of_range_errors() {
875        let err = build("ALTER TABLE app.t SET (fillfactor = 5);").unwrap_err();
876        assert!(
877            matches!(err, ParseError::Structural { ref message, .. } if message.contains("out of range")),
878            "unexpected error: {err:?}"
879        );
880    }
881
882    // ── SET TABLESPACE tests ──────────────────────────────────────────────────
883
884    #[test]
885    fn alter_table_set_tablespace_produces_pending() {
886        let out = build("ALTER TABLE app.t SET TABLESPACE ts;").expect("builds");
887        assert_eq!(out.pending_tablespaces.len(), 1);
888        let p = &out.pending_tablespaces[0];
889        assert_eq!(p.target.to_string(), "app.t");
890        assert_eq!(p.tablespace.as_str(), "ts");
891    }
892}