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