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    // `extra` carries unknown keys plus the autovacuum_* family.
416    for (k, v) in src.extra {
417        dst.extra.insert(k, v);
418    }
419}
420
421/// Apply a list of ownership assignments to the catalog.
422///
423/// Called from `parse/mod.rs` after all relation-family objects are built.
424pub fn apply_pending_owners(
425    catalog: &mut Catalog,
426    pending: Vec<PendingOwner>,
427    location: &SourceLocation,
428) -> Result<(), ParseError> {
429    for po in pending {
430        super::owner_stmt::set_owner_for_relation(
431            catalog,
432            &po.target,
433            ObjectType::ObjectTable, // hint: try all relation types
434            po.new_owner,
435            location,
436        )?;
437    }
438    Ok(())
439}
440
441/// Apply accumulated RLS mode toggles to the catalog.
442///
443/// Called from `parse/mod.rs` after all tables are built.
444pub fn apply_pending_rls_toggles(
445    catalog: &mut Catalog,
446    pending: Vec<PendingRlsToggle>,
447    location: &SourceLocation,
448) -> Result<(), ParseError> {
449    for toggle in pending {
450        let table = catalog
451            .tables
452            .iter_mut()
453            .find(|t| t.qname == toggle.target)
454            .ok_or_else(|| ParseError::Structural {
455                location: location.clone(),
456                message: format!(
457                    "ALTER TABLE … ROW LEVEL SECURITY referenced unknown table {}",
458                    toggle.target
459                ),
460            })?;
461        match toggle.subtype {
462            AlterTableType::AtEnableRowSecurity => {
463                table.rls_enabled = true;
464            }
465            AlterTableType::AtDisableRowSecurity => {
466                table.rls_enabled = false;
467            }
468            AlterTableType::AtForceRowSecurity => {
469                table.rls_forced = true;
470            }
471            AlterTableType::AtNoForceRowSecurity => {
472                table.rls_forced = false;
473            }
474            _ => {
475                return Err(ParseError::Structural {
476                    location: location.clone(),
477                    message: "unexpected subtype in PendingRlsToggle".into(),
478                });
479            }
480        }
481    }
482    Ok(())
483}
484
485/// Apply accumulated `ALTER TABLE … SET TABLESPACE` updates to the catalog.
486///
487/// Called from `parse/mod.rs` after all tables are built.
488pub fn apply_pending_tablespaces(
489    catalog: &mut crate::ir::catalog::Catalog,
490    pending: Vec<PendingTablespace>,
491    location: &SourceLocation,
492) -> Result<(), ParseError> {
493    for p in pending {
494        let table = catalog
495            .tables
496            .iter_mut()
497            .find(|t| t.qname == p.target)
498            .ok_or_else(|| ParseError::Structural {
499                location: location.clone(),
500                message: format!(
501                    "ALTER TABLE … SET TABLESPACE referenced unknown table {}",
502                    p.target
503                ),
504            })?;
505        table.tablespace = Some(p.tablespace);
506    }
507    Ok(())
508}
509
510/// Extract the String node from `cmd.def` and return its `sval`.
511fn def_as_string(cmd: &AlterTableCmd, location: &SourceLocation) -> Result<String, ParseError> {
512    cmd.def
513        .as_ref()
514        .and_then(|d| d.node.as_ref())
515        .and_then(|n| match n {
516            NodeEnum::String(s) => Some(s.sval.clone()),
517            _ => None,
518        })
519        .ok_or_else(|| ParseError::Structural {
520            location: location.clone(),
521            message: "ALTER COLUMN SET STORAGE/COMPRESSION missing keyword node".into(),
522        })
523}
524
525/// Reuse the FK builder from `create_stmt` so source ALTER and inline
526/// `REFERENCES` produce identical IR.
527fn build_fk_constraint(
528    con: &PgConstraint,
529    target_table: &QualifiedName,
530    default_schema: Option<&Identifier>,
531    location: &SourceLocation,
532) -> Result<Constraint, ParseError> {
533    // Delegate to a public helper exposed by create_stmt to avoid duplicating
534    // FK extraction logic.
535    create_stmt::build_fk_for_alter(con, target_table, default_schema, location)
536}
537
538fn unsupported_alter(location: &SourceLocation) -> ParseError {
539    ParseError::Structural {
540        location: location.clone(),
541        message: "ALTER TABLE in source DDL is restricted to ADD CONSTRAINT FOREIGN KEY, \
542                 ALTER COLUMN SET STORAGE/COMPRESSION, SET (reloptions), and SET TABLESPACE; \
543                 pgevolve treats source SQL as declarative — express the desired schema \
544                 state via CREATE statements"
545            .into(),
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::ir::constraint::ConstraintKind;
553    use std::path::PathBuf;
554
555    fn loc() -> SourceLocation {
556        SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
557    }
558
559    fn build(sql: &str) -> Result<AlterTableOutput, ParseError> {
560        let parsed = pg_query::parse(sql).expect("parses");
561        let stmt = parsed
562            .protobuf
563            .stmts
564            .into_iter()
565            .next()
566            .and_then(|raw| raw.stmt)
567            .and_then(|n| n.node)
568            .expect("stmt");
569        let NodeEnum::AlterTableStmt(s) = stmt else {
570            panic!("not AlterTableStmt")
571        };
572        build_alter_table(&s, None, &loc())
573    }
574
575    #[test]
576    fn allowed_add_fk() {
577        let out = build(
578            "ALTER TABLE app.invoices ADD CONSTRAINT invoices_customer_fk \
579             FOREIGN KEY (customer_id) REFERENCES app.customers (id);",
580        )
581        .expect("builds");
582        assert_eq!(out.pending_fks.len(), 1);
583        let p = &out.pending_fks[0];
584        assert_eq!(p.target.to_string(), "app.invoices");
585        assert!(matches!(p.constraint.kind, ConstraintKind::ForeignKey(_)));
586    }
587
588    #[test]
589    fn alter_column_set_storage_external() {
590        let out =
591            build("ALTER TABLE app.t ALTER COLUMN doc SET STORAGE EXTERNAL;").expect("builds");
592        assert_eq!(out.pending_column_attrs.len(), 1);
593        let attr = &out.pending_column_attrs[0];
594        assert_eq!(attr.target.to_string(), "app.t");
595        assert_eq!(attr.column.as_str(), "doc");
596        assert!(matches!(
597            attr.kind,
598            PendingColumnAttrKind::Storage(StorageKind::External)
599        ));
600    }
601
602    #[test]
603    fn alter_column_set_storage_plain() {
604        let out = build("ALTER TABLE app.t ALTER COLUMN n SET STORAGE PLAIN;").expect("builds");
605        let attr = &out.pending_column_attrs[0];
606        assert!(matches!(
607            attr.kind,
608            PendingColumnAttrKind::Storage(StorageKind::Plain)
609        ));
610    }
611
612    #[test]
613    fn alter_column_set_storage_main() {
614        let out = build("ALTER TABLE app.t ALTER COLUMN n SET STORAGE MAIN;").expect("builds");
615        let attr = &out.pending_column_attrs[0];
616        assert!(matches!(
617            attr.kind,
618            PendingColumnAttrKind::Storage(StorageKind::Main)
619        ));
620    }
621
622    #[test]
623    fn alter_column_set_compression_lz4() {
624        let out = build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION lz4;").expect("builds");
625        assert_eq!(out.pending_column_attrs.len(), 1);
626        let attr = &out.pending_column_attrs[0];
627        assert!(matches!(
628            attr.kind,
629            PendingColumnAttrKind::Compression(Some(Compression::Lz4))
630        ));
631    }
632
633    #[test]
634    fn alter_column_set_compression_pglz() {
635        let out =
636            build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION pglz;").expect("builds");
637        let attr = &out.pending_column_attrs[0];
638        assert!(matches!(
639            attr.kind,
640            PendingColumnAttrKind::Compression(Some(Compression::Pglz))
641        ));
642    }
643
644    #[test]
645    fn alter_column_set_compression_default() {
646        let out =
647            build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION DEFAULT;").expect("builds");
648        let attr = &out.pending_column_attrs[0];
649        assert!(matches!(
650            attr.kind,
651            PendingColumnAttrKind::Compression(None)
652        ));
653    }
654
655    #[test]
656    fn rejects_drop_column() {
657        let err = build("ALTER TABLE app.users DROP COLUMN email;").unwrap_err();
658        match err {
659            ParseError::Structural { message, .. } => {
660                assert!(
661                    message.contains("declarative"),
662                    "expected declarative message, got: {message}"
663                );
664            }
665            other => panic!("expected Structural, got {other:?}"),
666        }
667    }
668
669    #[test]
670    fn rejects_add_column() {
671        let err = build("ALTER TABLE app.users ADD COLUMN email text;").unwrap_err();
672        match err {
673            ParseError::Structural { message, .. } => {
674                assert!(
675                    message.contains("declarative") || message.contains("FOREIGN KEY"),
676                    "got: {message}"
677                );
678            }
679            other => panic!("expected Structural, got {other:?}"),
680        }
681    }
682
683    /// Verify that `SET STORAGE BOGUS` always surfaces as an error, regardless
684    /// of whether `pg_query` catches it at parse time or our decoder catches it.
685    ///
686    /// The error path under test is `process_set_storage_cmd` line 176-181
687    /// (`unknown STORAGE attribute '…'`). If `pg_query` happens to accept the
688    /// keyword and pass it down, that arm is exercised. If `pg_query` rejects it
689    /// first, we confirm via a parse-level error — either way the contract holds.
690    #[test]
691    fn alter_column_set_storage_unknown_errors() {
692        let sql = "ALTER TABLE app.t ALTER COLUMN doc SET STORAGE BOGUS;";
693        // pg_query may reject this SQL outright (returning Err), or it may
694        // accept it and pass the unknown keyword to our decoder.
695        match pg_query::parse(sql) {
696            Err(_pg_err) => {
697                // pg_query rejected BOGUS before our decoder was reached.
698                // The contract is satisfied: malformed SQL fails at parse time.
699            }
700            Ok(parsed) => {
701                // pg_query accepted the keyword — our decoder must reject it.
702                let stmt = parsed
703                    .protobuf
704                    .stmts
705                    .into_iter()
706                    .next()
707                    .and_then(|raw| raw.stmt)
708                    .and_then(|n| n.node)
709                    .expect("stmt");
710                let NodeEnum::AlterTableStmt(s) = stmt else {
711                    panic!("expected AlterTableStmt");
712                };
713                let err = build_alter_table(&s, None, &loc())
714                    .expect_err("BOGUS storage keyword must be rejected by our decoder");
715                match err {
716                    ParseError::Structural { ref message, .. } => {
717                        assert!(
718                            message.contains("STORAGE"),
719                            "expected error to mention STORAGE, got: {message}"
720                        );
721                    }
722                    other => panic!("expected Structural error, got {other:?}"),
723                }
724            }
725        }
726    }
727
728    #[test]
729    fn rejects_add_check_via_alter() {
730        let err = build("ALTER TABLE app.t ADD CONSTRAINT c1 CHECK (n > 0);").unwrap_err();
731        assert!(matches!(err, ParseError::Structural { .. }));
732    }
733
734    /// `pg_query` encodes the new owner in `cmd.newowner` (a dedicated
735    /// [`pg_query::protobuf::RoleSpec`] field), not in `cmd.def`.  This test
736    /// guards that `process_change_owner_cmd` reads from the right field.
737    #[test]
738    fn alter_table_owner_to_role_name() {
739        let out = build("ALTER TABLE app.t OWNER TO app_owner;").expect("builds");
740        assert_eq!(out.pending_owners.len(), 1);
741        let po = &out.pending_owners[0];
742        assert_eq!(po.target.to_string(), "app.t");
743        assert_eq!(po.new_owner.as_str(), "app_owner");
744    }
745
746    // ── RLS toggle tests ──────────────────────────────────────────────────────
747
748    #[test]
749    fn enable_row_security_produces_toggle() {
750        let out = build("ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY;").expect("builds");
751        assert_eq!(out.pending_rls_toggles.len(), 1);
752        let t = &out.pending_rls_toggles[0];
753        assert_eq!(t.target.to_string(), "app.docs");
754        assert!(matches!(t.subtype, AlterTableType::AtEnableRowSecurity));
755    }
756
757    #[test]
758    fn disable_row_security_produces_toggle() {
759        let out = build("ALTER TABLE app.docs DISABLE 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::AtDisableRowSecurity));
763    }
764
765    #[test]
766    fn force_row_security_produces_toggle() {
767        let out = build("ALTER TABLE app.docs 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::AtForceRowSecurity));
771    }
772
773    #[test]
774    fn no_force_row_security_produces_toggle() {
775        let out = build("ALTER TABLE app.docs NO FORCE ROW LEVEL SECURITY;").expect("builds");
776        assert_eq!(out.pending_rls_toggles.len(), 1);
777        let t = &out.pending_rls_toggles[0];
778        assert!(matches!(t.subtype, AlterTableType::AtNoForceRowSecurity));
779    }
780
781    // ── SET / RESET reloption tests ───────────────────────────────────────────
782
783    #[test]
784    fn alter_table_set_reloption_fillfactor() {
785        let out = build("ALTER TABLE app.t SET (fillfactor = 80);").expect("builds");
786        assert_eq!(out.pending_rel_options.len(), 1);
787        let p = &out.pending_rel_options[0];
788        assert_eq!(p.target.to_string(), "app.t");
789        assert_eq!(p.options.fillfactor, Some(80));
790    }
791
792    #[test]
793    fn alter_table_set_reloption_autovacuum_enabled() {
794        let out = build("ALTER TABLE app.t SET (autovacuum_enabled = false);").expect("builds");
795        assert_eq!(out.pending_rel_options.len(), 1);
796        let p = &out.pending_rel_options[0];
797        assert_eq!(
798            p.options
799                .extra
800                .get("autovacuum_enabled")
801                .map(String::as_str),
802            Some("false")
803        );
804    }
805
806    #[test]
807    fn alter_table_set_reloption_multiple_options() {
808        let out = build("ALTER TABLE app.t SET (fillfactor = 70, parallel_workers = 2);")
809            .expect("builds");
810        let p = &out.pending_rel_options[0];
811        assert_eq!(p.options.fillfactor, Some(70));
812        assert_eq!(p.options.parallel_workers, Some(2));
813    }
814
815    #[test]
816    fn alter_table_reset_reloption_errors() {
817        let err = build("ALTER TABLE app.t RESET (fillfactor);").unwrap_err();
818        assert!(
819            matches!(err, ParseError::Structural { ref message, .. }
820                if message.contains("RESET") || message.contains("not supported")),
821            "unexpected error: {err:?}"
822        );
823    }
824
825    #[test]
826    fn alter_table_set_fillfactor_out_of_range_errors() {
827        let err = build("ALTER TABLE app.t SET (fillfactor = 5);").unwrap_err();
828        assert!(
829            matches!(err, ParseError::Structural { ref message, .. } if message.contains("out of range")),
830            "unexpected error: {err:?}"
831        );
832    }
833
834    // ── SET TABLESPACE tests ──────────────────────────────────────────────────
835
836    #[test]
837    fn alter_table_set_tablespace_produces_pending() {
838        let out = build("ALTER TABLE app.t SET TABLESPACE ts;").expect("builds");
839        assert_eq!(out.pending_tablespaces.len(), 1);
840        let p = &out.pending_tablespaces[0];
841        assert_eq!(p.target.to_string(), "app.t");
842        assert_eq!(p.tablespace.as_str(), "ts");
843    }
844}