use pg_query::NodeEnum;
use pg_query::protobuf::{
AlterTableCmd, AlterTableStmt, AlterTableType, ConstrType, Constraint as PgConstraint,
ObjectType, RoleSpecType,
};
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::catalog::Catalog;
use crate::ir::column::{Compression, StorageKind};
use crate::ir::constraint::Constraint;
use crate::ir::reloptions::TableStorageOptions;
use crate::parse::builder::create_stmt;
use crate::parse::builder::shared;
use crate::parse::error::{ParseError, SourceLocation};
#[derive(Debug, Clone)]
pub struct PendingFk {
pub target: QualifiedName,
pub constraint: Constraint,
}
#[derive(Debug, Clone)]
pub struct PendingColumnAttr {
pub target: QualifiedName,
pub column: Identifier,
pub kind: PendingColumnAttrKind,
}
#[derive(Debug, Clone)]
pub enum PendingColumnAttrKind {
Storage(StorageKind),
Compression(Option<Compression>),
}
#[derive(Debug, Clone)]
pub struct PendingOwner {
pub target: QualifiedName,
pub new_owner: Identifier,
}
#[derive(Debug, Clone)]
pub struct PendingRlsToggle {
pub target: QualifiedName,
pub subtype: AlterTableType,
}
#[derive(Debug, Clone)]
pub struct PendingRelOptions {
pub target: QualifiedName,
pub options: TableStorageOptions,
}
#[derive(Debug, Default)]
pub struct AlterTableOutput {
pub pending_fks: Vec<PendingFk>,
pub pending_column_attrs: Vec<PendingColumnAttr>,
pub pending_owners: Vec<PendingOwner>,
pub pending_rls_toggles: Vec<PendingRlsToggle>,
pub pending_rel_options: Vec<PendingRelOptions>,
}
pub fn build_alter_table(
stmt: &AlterTableStmt,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<AlterTableOutput, ParseError> {
let relation = stmt
.relation
.as_ref()
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "ALTER TABLE missing relation".into(),
})?;
let target = shared::resolve_qname(relation, default_schema, location)?;
let mut out = AlterTableOutput::default();
for cmd_node in &stmt.cmds {
let Some(NodeEnum::AlterTableCmd(cmd)) = cmd_node.node.as_ref() else {
return Err(unsupported_alter(location));
};
process_cmd(cmd, &target, default_schema, location, &mut out)?;
}
Ok(out)
}
fn process_cmd(
cmd: &AlterTableCmd,
target: &QualifiedName,
default_schema: Option<&Identifier>,
location: &SourceLocation,
out: &mut AlterTableOutput,
) -> Result<(), ParseError> {
let subtype = AlterTableType::try_from(cmd.subtype).unwrap_or(AlterTableType::Undefined);
match subtype {
AlterTableType::AtAddConstraint => {
let pending = process_add_constraint_cmd(cmd, target, default_schema, location)?;
out.pending_fks.push(pending);
}
AlterTableType::AtSetStorage => {
let pending = process_set_storage_cmd(cmd, target, location)?;
out.pending_column_attrs.push(pending);
}
AlterTableType::AtSetCompression => {
let pending = process_set_compression_cmd(cmd, target, location)?;
out.pending_column_attrs.push(pending);
}
AlterTableType::AtChangeOwner => {
let pending = process_change_owner_cmd(cmd, target, location)?;
out.pending_owners.push(pending);
}
AlterTableType::AtEnableRowSecurity
| AlterTableType::AtDisableRowSecurity
| AlterTableType::AtForceRowSecurity
| AlterTableType::AtNoForceRowSecurity => {
out.pending_rls_toggles.push(PendingRlsToggle {
target: target.clone(),
subtype,
});
}
AlterTableType::AtSetRelOptions => {
let pending = process_set_rel_options_cmd(cmd, target, location)?;
out.pending_rel_options.push(pending);
}
AlterTableType::AtResetRelOptions | AlterTableType::AtReplaceRelOptions => {
return Err(ParseError::Structural {
location: location.clone(),
message: "ALTER TABLE ... RESET (...) in source is not supported — \
clear options out-of-band, then remove from source"
.into(),
});
}
_ => return Err(unsupported_alter(location)),
}
Ok(())
}
fn process_add_constraint_cmd(
cmd: &AlterTableCmd,
target: &QualifiedName,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<PendingFk, ParseError> {
let con = cmd
.def
.as_ref()
.and_then(|d| d.node.as_ref())
.and_then(|n| match n {
NodeEnum::Constraint(c) => Some(c.as_ref()),
_ => None,
})
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "ALTER TABLE ADD CONSTRAINT missing constraint definition".into(),
})?;
let kind = ConstrType::try_from(con.contype).unwrap_or(ConstrType::Undefined);
if !matches!(kind, ConstrType::ConstrForeign) {
return Err(ParseError::Structural {
location: location.clone(),
message: "ALTER TABLE may only ADD CONSTRAINT FOREIGN KEY in source DDL — \
other constraint kinds belong inline in the CREATE TABLE that \
declares them"
.into(),
});
}
let constraint = build_fk_constraint(con, target, default_schema, location)?;
Ok(PendingFk {
target: target.clone(),
constraint,
})
}
fn process_set_storage_cmd(
cmd: &AlterTableCmd,
target: &QualifiedName,
location: &SourceLocation,
) -> Result<PendingColumnAttr, ParseError> {
let column = shared::ident(&cmd.name, location)?;
let keyword = def_as_string(cmd, location)?;
let storage = match keyword.to_ascii_lowercase().as_str() {
"plain" => StorageKind::Plain,
"external" => StorageKind::External,
"extended" => StorageKind::Extended,
"main" => StorageKind::Main,
other => {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("unknown STORAGE attribute '{other}'"),
});
}
};
Ok(PendingColumnAttr {
target: target.clone(),
column,
kind: PendingColumnAttrKind::Storage(storage),
})
}
fn process_set_compression_cmd(
cmd: &AlterTableCmd,
target: &QualifiedName,
location: &SourceLocation,
) -> Result<PendingColumnAttr, ParseError> {
let column = shared::ident(&cmd.name, location)?;
let keyword = def_as_string(cmd, location)?;
let compression = match keyword.to_ascii_lowercase().as_str() {
"default" => None,
"pglz" => Some(Compression::Pglz),
"lz4" => Some(Compression::Lz4),
other => {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("unknown COMPRESSION codec '{other}'"),
});
}
};
Ok(PendingColumnAttr {
target: target.clone(),
column,
kind: PendingColumnAttrKind::Compression(compression),
})
}
fn process_change_owner_cmd(
cmd: &AlterTableCmd,
target: &QualifiedName,
location: &SourceLocation,
) -> Result<PendingOwner, ParseError> {
let rs = cmd
.newowner
.as_ref()
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "ALTER TABLE OWNER TO missing role specification".into(),
})?;
let roletype = RoleSpecType::try_from(rs.roletype).unwrap_or(RoleSpecType::Undefined);
if roletype == RoleSpecType::RolespecPublic {
return Err(ParseError::Structural {
location: location.clone(),
message: "ALTER TABLE OWNER TO PUBLIC is not valid — PUBLIC is not a role name".into(),
});
}
let new_owner = shared::ident(&rs.rolename, location)?;
Ok(PendingOwner {
target: target.clone(),
new_owner,
})
}
fn process_set_rel_options_cmd(
cmd: &AlterTableCmd,
target: &QualifiedName,
location: &SourceLocation,
) -> Result<PendingRelOptions, ParseError> {
let items = crate::parse::builder::reloptions::extract_def_list(cmd.def.as_deref(), location)?;
let options = crate::parse::builder::reloptions::decode_table_options(&items, location)?;
Ok(PendingRelOptions {
target: target.clone(),
options,
})
}
pub fn apply_pending_rel_options(
catalog: &mut Catalog,
pending: Vec<PendingRelOptions>,
location: &SourceLocation,
) -> Result<(), ParseError> {
for p in pending {
if let Some(table) = catalog.tables.iter_mut().find(|t| t.qname == p.target) {
merge_table_options(&mut table.storage, p.options);
continue;
}
if let Some(mv) = catalog
.materialized_views
.iter_mut()
.find(|m| m.qname == p.target)
{
merge_table_options(&mut mv.storage, p.options);
continue;
}
return Err(ParseError::Structural {
location: location.clone(),
message: format!(
"ALTER ... SET (...) referenced unknown relation {}",
p.target
),
});
}
Ok(())
}
fn merge_table_options(
dst: &mut crate::ir::reloptions::TableStorageOptions,
src: crate::ir::reloptions::TableStorageOptions,
) {
if src.fillfactor.is_some() {
dst.fillfactor = src.fillfactor;
}
if src.parallel_workers.is_some() {
dst.parallel_workers = src.parallel_workers;
}
if src.toast_tuple_target.is_some() {
dst.toast_tuple_target = src.toast_tuple_target;
}
if src.user_catalog_table.is_some() {
dst.user_catalog_table = src.user_catalog_table;
}
if src.vacuum_truncate.is_some() {
dst.vacuum_truncate = src.vacuum_truncate;
}
merge_autovacuum(&mut dst.autovacuum, &src.autovacuum);
for (k, v) in src.extra {
dst.extra.insert(k, v);
}
}
const fn merge_autovacuum(
dst: &mut crate::ir::reloptions::AutovacuumOptions,
src: &crate::ir::reloptions::AutovacuumOptions,
) {
if src.enabled.is_some() {
dst.enabled = src.enabled;
}
if src.vacuum_threshold.is_some() {
dst.vacuum_threshold = src.vacuum_threshold;
}
if src.vacuum_scale_factor.is_some() {
dst.vacuum_scale_factor = src.vacuum_scale_factor;
}
if src.vacuum_cost_delay.is_some() {
dst.vacuum_cost_delay = src.vacuum_cost_delay;
}
if src.vacuum_cost_limit.is_some() {
dst.vacuum_cost_limit = src.vacuum_cost_limit;
}
if src.analyze_threshold.is_some() {
dst.analyze_threshold = src.analyze_threshold;
}
if src.analyze_scale_factor.is_some() {
dst.analyze_scale_factor = src.analyze_scale_factor;
}
if src.freeze_max_age.is_some() {
dst.freeze_max_age = src.freeze_max_age;
}
if src.freeze_min_age.is_some() {
dst.freeze_min_age = src.freeze_min_age;
}
if src.freeze_table_age.is_some() {
dst.freeze_table_age = src.freeze_table_age;
}
if src.multixact_freeze_max_age.is_some() {
dst.multixact_freeze_max_age = src.multixact_freeze_max_age;
}
if src.multixact_freeze_min_age.is_some() {
dst.multixact_freeze_min_age = src.multixact_freeze_min_age;
}
if src.multixact_freeze_table_age.is_some() {
dst.multixact_freeze_table_age = src.multixact_freeze_table_age;
}
if src.vacuum_insert_threshold.is_some() {
dst.vacuum_insert_threshold = src.vacuum_insert_threshold;
}
if src.vacuum_insert_scale_factor.is_some() {
dst.vacuum_insert_scale_factor = src.vacuum_insert_scale_factor;
}
if src.log_min_duration.is_some() {
dst.log_min_duration = src.log_min_duration;
}
}
pub fn apply_pending_owners(
catalog: &mut Catalog,
pending: Vec<PendingOwner>,
location: &SourceLocation,
) -> Result<(), ParseError> {
for po in pending {
super::owner_stmt::set_owner_for_relation(
catalog,
&po.target,
ObjectType::ObjectTable, po.new_owner,
location,
)?;
}
Ok(())
}
pub fn apply_pending_rls_toggles(
catalog: &mut Catalog,
pending: Vec<PendingRlsToggle>,
location: &SourceLocation,
) -> Result<(), ParseError> {
for toggle in pending {
let table = catalog
.tables
.iter_mut()
.find(|t| t.qname == toggle.target)
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: format!(
"ALTER TABLE … ROW LEVEL SECURITY referenced unknown table {}",
toggle.target
),
})?;
match toggle.subtype {
AlterTableType::AtEnableRowSecurity => {
table.rls_enabled = true;
}
AlterTableType::AtDisableRowSecurity => {
table.rls_enabled = false;
}
AlterTableType::AtForceRowSecurity => {
table.rls_forced = true;
}
AlterTableType::AtNoForceRowSecurity => {
table.rls_forced = false;
}
_ => {
return Err(ParseError::Structural {
location: location.clone(),
message: "unexpected subtype in PendingRlsToggle".into(),
});
}
}
}
Ok(())
}
fn def_as_string(cmd: &AlterTableCmd, location: &SourceLocation) -> Result<String, ParseError> {
cmd.def
.as_ref()
.and_then(|d| d.node.as_ref())
.and_then(|n| match n {
NodeEnum::String(s) => Some(s.sval.clone()),
_ => None,
})
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "ALTER COLUMN SET STORAGE/COMPRESSION missing keyword node".into(),
})
}
fn build_fk_constraint(
con: &PgConstraint,
target_table: &QualifiedName,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<Constraint, ParseError> {
create_stmt::build_fk_for_alter(con, target_table, default_schema, location)
}
fn unsupported_alter(location: &SourceLocation) -> ParseError {
ParseError::Structural {
location: location.clone(),
message: "ALTER TABLE in source DDL is restricted to ADD CONSTRAINT FOREIGN KEY, \
ALTER COLUMN SET STORAGE/COMPRESSION, and SET (reloptions); \
pgevolve treats source SQL as declarative — express the desired schema \
state via CREATE statements"
.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::constraint::ConstraintKind;
use std::path::PathBuf;
fn loc() -> SourceLocation {
SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
}
fn build(sql: &str) -> Result<AlterTableOutput, ParseError> {
let parsed = pg_query::parse(sql).expect("parses");
let stmt = parsed
.protobuf
.stmts
.into_iter()
.next()
.and_then(|raw| raw.stmt)
.and_then(|n| n.node)
.expect("stmt");
let NodeEnum::AlterTableStmt(s) = stmt else {
panic!("not AlterTableStmt")
};
build_alter_table(&s, None, &loc())
}
#[test]
fn allowed_add_fk() {
let out = build(
"ALTER TABLE app.invoices ADD CONSTRAINT invoices_customer_fk \
FOREIGN KEY (customer_id) REFERENCES app.customers (id);",
)
.expect("builds");
assert_eq!(out.pending_fks.len(), 1);
let p = &out.pending_fks[0];
assert_eq!(p.target.to_string(), "app.invoices");
assert!(matches!(p.constraint.kind, ConstraintKind::ForeignKey(_)));
}
#[test]
fn alter_column_set_storage_external() {
let out =
build("ALTER TABLE app.t ALTER COLUMN doc SET STORAGE EXTERNAL;").expect("builds");
assert_eq!(out.pending_column_attrs.len(), 1);
let attr = &out.pending_column_attrs[0];
assert_eq!(attr.target.to_string(), "app.t");
assert_eq!(attr.column.as_str(), "doc");
assert!(matches!(
attr.kind,
PendingColumnAttrKind::Storage(StorageKind::External)
));
}
#[test]
fn alter_column_set_storage_plain() {
let out = build("ALTER TABLE app.t ALTER COLUMN n SET STORAGE PLAIN;").expect("builds");
let attr = &out.pending_column_attrs[0];
assert!(matches!(
attr.kind,
PendingColumnAttrKind::Storage(StorageKind::Plain)
));
}
#[test]
fn alter_column_set_storage_main() {
let out = build("ALTER TABLE app.t ALTER COLUMN n SET STORAGE MAIN;").expect("builds");
let attr = &out.pending_column_attrs[0];
assert!(matches!(
attr.kind,
PendingColumnAttrKind::Storage(StorageKind::Main)
));
}
#[test]
fn alter_column_set_compression_lz4() {
let out = build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION lz4;").expect("builds");
assert_eq!(out.pending_column_attrs.len(), 1);
let attr = &out.pending_column_attrs[0];
assert!(matches!(
attr.kind,
PendingColumnAttrKind::Compression(Some(Compression::Lz4))
));
}
#[test]
fn alter_column_set_compression_pglz() {
let out =
build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION pglz;").expect("builds");
let attr = &out.pending_column_attrs[0];
assert!(matches!(
attr.kind,
PendingColumnAttrKind::Compression(Some(Compression::Pglz))
));
}
#[test]
fn alter_column_set_compression_default() {
let out =
build("ALTER TABLE app.t ALTER COLUMN doc SET COMPRESSION DEFAULT;").expect("builds");
let attr = &out.pending_column_attrs[0];
assert!(matches!(
attr.kind,
PendingColumnAttrKind::Compression(None)
));
}
#[test]
fn rejects_drop_column() {
let err = build("ALTER TABLE app.users DROP COLUMN email;").unwrap_err();
match err {
ParseError::Structural { message, .. } => {
assert!(
message.contains("declarative"),
"expected declarative message, got: {message}"
);
}
other => panic!("expected Structural, got {other:?}"),
}
}
#[test]
fn rejects_add_column() {
let err = build("ALTER TABLE app.users ADD COLUMN email text;").unwrap_err();
match err {
ParseError::Structural { message, .. } => {
assert!(
message.contains("declarative") || message.contains("FOREIGN KEY"),
"got: {message}"
);
}
other => panic!("expected Structural, got {other:?}"),
}
}
#[test]
fn alter_column_set_storage_unknown_errors() {
let sql = "ALTER TABLE app.t ALTER COLUMN doc SET STORAGE BOGUS;";
match pg_query::parse(sql) {
Err(_pg_err) => {
}
Ok(parsed) => {
let stmt = parsed
.protobuf
.stmts
.into_iter()
.next()
.and_then(|raw| raw.stmt)
.and_then(|n| n.node)
.expect("stmt");
let NodeEnum::AlterTableStmt(s) = stmt else {
panic!("expected AlterTableStmt");
};
let err = build_alter_table(&s, None, &loc())
.expect_err("BOGUS storage keyword must be rejected by our decoder");
match err {
ParseError::Structural { ref message, .. } => {
assert!(
message.contains("STORAGE"),
"expected error to mention STORAGE, got: {message}"
);
}
other => panic!("expected Structural error, got {other:?}"),
}
}
}
}
#[test]
fn rejects_add_check_via_alter() {
let err = build("ALTER TABLE app.t ADD CONSTRAINT c1 CHECK (n > 0);").unwrap_err();
assert!(matches!(err, ParseError::Structural { .. }));
}
#[test]
fn alter_table_owner_to_role_name() {
let out = build("ALTER TABLE app.t OWNER TO app_owner;").expect("builds");
assert_eq!(out.pending_owners.len(), 1);
let po = &out.pending_owners[0];
assert_eq!(po.target.to_string(), "app.t");
assert_eq!(po.new_owner.as_str(), "app_owner");
}
#[test]
fn enable_row_security_produces_toggle() {
let out = build("ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY;").expect("builds");
assert_eq!(out.pending_rls_toggles.len(), 1);
let t = &out.pending_rls_toggles[0];
assert_eq!(t.target.to_string(), "app.docs");
assert!(matches!(t.subtype, AlterTableType::AtEnableRowSecurity));
}
#[test]
fn disable_row_security_produces_toggle() {
let out = build("ALTER TABLE app.docs DISABLE ROW LEVEL SECURITY;").expect("builds");
assert_eq!(out.pending_rls_toggles.len(), 1);
let t = &out.pending_rls_toggles[0];
assert!(matches!(t.subtype, AlterTableType::AtDisableRowSecurity));
}
#[test]
fn force_row_security_produces_toggle() {
let out = build("ALTER TABLE app.docs FORCE ROW LEVEL SECURITY;").expect("builds");
assert_eq!(out.pending_rls_toggles.len(), 1);
let t = &out.pending_rls_toggles[0];
assert!(matches!(t.subtype, AlterTableType::AtForceRowSecurity));
}
#[test]
fn no_force_row_security_produces_toggle() {
let out = build("ALTER TABLE app.docs NO FORCE ROW LEVEL SECURITY;").expect("builds");
assert_eq!(out.pending_rls_toggles.len(), 1);
let t = &out.pending_rls_toggles[0];
assert!(matches!(t.subtype, AlterTableType::AtNoForceRowSecurity));
}
#[test]
fn alter_table_set_reloption_fillfactor() {
let out = build("ALTER TABLE app.t SET (fillfactor = 80);").expect("builds");
assert_eq!(out.pending_rel_options.len(), 1);
let p = &out.pending_rel_options[0];
assert_eq!(p.target.to_string(), "app.t");
assert_eq!(p.options.fillfactor, Some(80));
}
#[test]
fn alter_table_set_reloption_autovacuum_enabled() {
let out = build("ALTER TABLE app.t SET (autovacuum_enabled = false);").expect("builds");
assert_eq!(out.pending_rel_options.len(), 1);
let p = &out.pending_rel_options[0];
assert_eq!(p.options.autovacuum.enabled, Some(false));
}
#[test]
fn alter_table_set_reloption_multiple_options() {
let out = build("ALTER TABLE app.t SET (fillfactor = 70, parallel_workers = 2);")
.expect("builds");
let p = &out.pending_rel_options[0];
assert_eq!(p.options.fillfactor, Some(70));
assert_eq!(p.options.parallel_workers, Some(2));
}
#[test]
fn alter_table_reset_reloption_errors() {
let err = build("ALTER TABLE app.t RESET (fillfactor);").unwrap_err();
assert!(
matches!(err, ParseError::Structural { ref message, .. }
if message.contains("RESET") || message.contains("not supported")),
"unexpected error: {err:?}"
);
}
#[test]
fn alter_table_set_fillfactor_out_of_range_errors() {
let err = build("ALTER TABLE app.t SET (fillfactor = 5);").unwrap_err();
assert!(
matches!(err, ParseError::Structural { ref message, .. } if message.contains("out of range")),
"unexpected error: {err:?}"
);
}
}