use std::collections::BTreeSet;
use pg_query::protobuf::{AlterTableType, ConstrType, ObjectType, RangeVar, RawStmt};
use pg_query::NodeEnum;
use crate::{Finding, Severity};
const RELPERSISTENCE_TEMP: &str = "t";
pub(crate) fn qualified_key(schema: &str, relname: &str) -> String {
if schema.is_empty() || schema == "public" {
relname.to_string()
} else {
format!("{schema}.{relname}")
}
}
pub(crate) fn rangevar_key(rv: &RangeVar) -> String {
qualified_key(&rv.schemaname, &rv.relname)
}
pub(crate) fn lintable_create_relation(node: &NodeEnum) -> Option<&RangeVar> {
let NodeEnum::CreateStmt(c) = node else {
return None;
};
if c.partbound.is_some() {
return None;
}
let rv = c.relation.as_ref()?;
if rv.relpersistence == RELPERSISTENCE_TEMP {
return None;
}
Some(rv)
}
fn created_table_key(node: &NodeEnum) -> Option<String> {
match node {
NodeEnum::CreateStmt(c) => c.relation.as_ref().map(rangevar_key),
_ => None,
}
}
fn populated_table_key(node: &NodeEnum) -> Option<String> {
match node {
NodeEnum::InsertStmt(i) => i.relation.as_ref().map(rangevar_key),
NodeEnum::MergeStmt(m) => m.relation.as_ref().map(rangevar_key),
NodeEnum::CopyStmt(c) if c.is_from => c.relation.as_ref().map(rangevar_key),
_ => None,
}
}
fn attach_partition_child(node: &NodeEnum) -> Option<String> {
let NodeEnum::AlterTableStmt(a) = node else {
return None;
};
a.cmds.iter().find_map(|n| {
let NodeEnum::AlterTableCmd(cmd) = n.node.as_ref()? else {
return None;
};
if AlterTableType::try_from(cmd.subtype) != Ok(AlterTableType::AtAttachPartition) {
return None;
}
match cmd.def.as_ref()?.node.as_ref()? {
NodeEnum::PartitionCmd(pc) => pc.name.as_ref().map(rangevar_key),
_ => None,
}
})
}
fn added_check_constraint(node: &NodeEnum) -> Option<(String, bool)> {
let NodeEnum::AlterTableStmt(a) = node else {
return None;
};
let key = a.relation.as_ref().map(rangevar_key)?;
a.cmds.iter().find_map(|n| {
let NodeEnum::AlterTableCmd(cmd) = n.node.as_ref()? else {
return None;
};
if AlterTableType::try_from(cmd.subtype) != Ok(AlterTableType::AtAddConstraint) {
return None;
}
let NodeEnum::Constraint(con) = cmd.def.as_ref()?.node.as_ref()? else {
return None;
};
if ConstrType::try_from(con.contype) != Ok(ConstrType::ConstrCheck) {
return None;
}
Some((key.clone(), !con.skip_validation))
})
}
fn validated_constraint_table(node: &NodeEnum) -> Option<String> {
let NodeEnum::AlterTableStmt(a) = node else {
return None;
};
let key = a.relation.as_ref().map(rangevar_key)?;
a.cmds.iter().find_map(|n| {
let NodeEnum::AlterTableCmd(cmd) = n.node.as_ref()? else {
return None;
};
(AlterTableType::try_from(cmd.subtype) == Ok(AlterTableType::AtValidateConstraint))
.then(|| key.clone())
})
}
pub(crate) fn attach_escalation_indices(stmts: &[RawStmt]) -> BTreeSet<usize> {
let mut created: BTreeSet<String> = BTreeSet::new();
let mut check_not_valid: BTreeSet<String> = BTreeSet::new();
let mut check_prepared: BTreeSet<String> = BTreeSet::new();
let mut escalate: BTreeSet<usize> = BTreeSet::new();
for (i, raw) in stmts.iter().enumerate() {
let Some(node) = raw.stmt.as_ref().and_then(|b| b.node.as_ref()) else {
continue;
};
if let Some(child) = attach_partition_child(node) {
if !created.contains(&child) && !check_prepared.contains(&child) {
escalate.insert(i);
}
}
if let Some(key) = created_table_key(node) {
created.insert(key);
}
if let Some((key, immediately_valid)) = added_check_constraint(node) {
if immediately_valid {
check_prepared.insert(key);
} else {
check_not_valid.insert(key);
}
}
if let Some(key) = validated_constraint_table(node) {
if check_not_valid.contains(&key) {
check_prepared.insert(key);
}
}
}
escalate
}
pub(crate) fn escalate_pre_existing_attach(stmts: &[RawStmt], findings: &mut [Finding]) {
let escalate = attach_escalation_indices(stmts);
if escalate.is_empty() {
return;
}
for f in findings.iter_mut() {
if f.rule_id == "attach-partition" && escalate.contains(&f.statement_index) {
f.severity = Severity::Error;
f.message.push_str(
" The child table is not created in this migration, so it may already be \
receiving traffic; the validation scan blocks it under ACCESS EXCLUSIVE for the \
scan's duration.",
);
}
}
}
fn drop_table_key(node: &NodeEnum) -> Option<String> {
let NodeEnum::DropStmt(d) = node else {
return None;
};
if ObjectType::try_from(d.remove_type) != Ok(ObjectType::ObjectTable) || d.objects.len() != 1 {
return None;
}
let NodeEnum::List(list) = d.objects[0].node.as_ref()? else {
return None;
};
let parts: Vec<&str> = list
.items
.iter()
.filter_map(|n| match n.node.as_ref() {
Some(NodeEnum::String(s)) => Some(s.sval.as_str()),
_ => None,
})
.collect();
match parts.as_slice() {
[relname] => Some(qualified_key("", relname)),
[schema, relname] => Some(qualified_key(schema, relname)),
_ => None,
}
}
fn truncate_table_key(node: &NodeEnum) -> Option<String> {
let NodeEnum::TruncateStmt(t) = node else {
return None;
};
if t.relations.len() != 1 {
return None;
}
match t.relations[0].node.as_ref()? {
NodeEnum::RangeVar(rv) => Some(rangevar_key(rv)),
_ => None,
}
}
fn target_table_key(node: &NodeEnum) -> Option<String> {
if let Some(child) = attach_partition_child(node) {
return Some(child);
}
match node {
NodeEnum::AlterTableStmt(a) => a.relation.as_ref().map(rangevar_key),
NodeEnum::IndexStmt(i) => i.relation.as_ref().map(rangevar_key),
NodeEnum::CreateTrigStmt(t) => t.relation.as_ref().map(rangevar_key),
NodeEnum::RenameStmt(r) => r.relation.as_ref().map(rangevar_key),
NodeEnum::DropStmt(_) => drop_table_key(node),
NodeEnum::TruncateStmt(_) => truncate_table_key(node),
_ => None,
}
}
pub(crate) fn drop_new_table_findings(
stmts: &[RawStmt],
findings: Vec<Finding>,
) -> (Vec<Finding>, BTreeSet<usize>) {
let mut empty: BTreeSet<String> = BTreeSet::new();
let mut dropped: BTreeSet<usize> = BTreeSet::new();
for (i, raw) in stmts.iter().enumerate() {
let Some(node) = raw.stmt.as_ref().and_then(|b| b.node.as_ref()) else {
continue;
};
if let Some(key) = target_table_key(node) {
if empty.contains(&key) {
dropped.insert(i);
}
}
if let Some(key) = created_table_key(node) {
empty.insert(key);
} else if let Some(key) = populated_table_key(node) {
empty.remove(&key);
}
}
if dropped.is_empty() {
return (findings, dropped);
}
let kept = findings
.into_iter()
.filter(|f| !dropped.contains(&f.statement_index))
.collect();
(kept, dropped)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qualified_key_normalizes_public_to_bare() {
assert_eq!(qualified_key("", "t"), "t");
assert_eq!(qualified_key("public", "t"), "t");
assert_eq!(qualified_key("app", "t"), "app.t");
}
fn first_node(sql: &str) -> NodeEnum {
pg_query::parse(sql).unwrap().protobuf.stmts[0]
.stmt
.as_ref()
.unwrap()
.node
.as_ref()
.unwrap()
.clone()
}
fn stmts_of(sql: &str) -> Vec<RawStmt> {
pg_query::parse(sql).unwrap().protobuf.stmts
}
#[test]
fn added_check_constraint_classifies_validity() {
assert_eq!(
added_check_constraint(&first_node("ALTER TABLE t ADD CONSTRAINT c CHECK (a > 0)")),
Some(("t".to_string(), true)) );
assert_eq!(
added_check_constraint(&first_node(
"ALTER TABLE t ADD CONSTRAINT c CHECK (a > 0) NOT VALID"
)),
Some(("t".to_string(), false)) );
assert_eq!(
added_check_constraint(&first_node(
"ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (x) REFERENCES p (id)"
)),
None
);
assert_eq!(
added_check_constraint(&first_node("ALTER TABLE t ADD COLUMN x int")),
None
);
}
#[test]
fn validated_constraint_table_matches_validate() {
assert_eq!(
validated_constraint_table(&first_node("ALTER TABLE t VALIDATE CONSTRAINT c"))
.as_deref(),
Some("t")
);
assert_eq!(
validated_constraint_table(&first_node("ALTER TABLE t ADD COLUMN x int")),
None
);
}
#[test]
fn escalates_attach_of_pre_existing_child_without_check() {
let idx = attach_escalation_indices(&stmts_of(
"ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (100)",
));
assert!(idx.contains(&0));
}
#[test]
fn does_not_escalate_same_migration_created_child() {
let idx = attach_escalation_indices(&stmts_of(
"CREATE TABLE child (id int); \
ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (100)",
));
assert!(idx.is_empty());
}
#[test]
fn does_not_escalate_when_not_valid_check_then_validated() {
let idx = attach_escalation_indices(&stmts_of(
"ALTER TABLE child ADD CONSTRAINT cc CHECK (id >= 0 AND id < 100) NOT VALID; \
ALTER TABLE child VALIDATE CONSTRAINT cc; \
ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (100)",
));
assert!(idx.is_empty());
}
#[test]
fn does_not_escalate_when_plain_check_present() {
let idx = attach_escalation_indices(&stmts_of(
"ALTER TABLE child ADD CONSTRAINT cc CHECK (id >= 0 AND id < 100); \
ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (100)",
));
assert!(idx.is_empty());
}
#[test]
fn escalates_when_not_valid_check_never_validated() {
let idx = attach_escalation_indices(&stmts_of(
"ALTER TABLE child ADD CONSTRAINT cc CHECK (id >= 0 AND id < 100) NOT VALID; \
ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (100)",
));
assert!(idx.contains(&1));
}
#[test]
fn key_extraction() {
assert_eq!(
created_table_key(&first_node("CREATE TABLE foo (id int)")).as_deref(),
Some("foo")
);
assert_eq!(
created_table_key(&first_node("CREATE TABLE s.foo (id int)")).as_deref(),
Some("s.foo")
);
assert_eq!(
created_table_key(&first_node("CREATE TABLE foo AS SELECT 1")),
None
);
assert_eq!(
target_table_key(&first_node("ALTER TABLE foo ADD COLUMN c int")).as_deref(),
Some("foo")
);
assert_eq!(
target_table_key(&first_node("CREATE INDEX i ON foo (x)")).as_deref(),
Some("foo")
);
assert_eq!(
target_table_key(&first_node(
"CREATE TRIGGER trg AFTER INSERT ON foo FOR EACH ROW EXECUTE FUNCTION f()"
))
.as_deref(),
Some("foo")
);
assert_eq!(
target_table_key(&first_node(
"ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (0) TO (100)"
))
.as_deref(),
Some("child")
);
assert_eq!(
populated_table_key(&first_node("INSERT INTO foo VALUES (1)")).as_deref(),
Some("foo")
);
assert_eq!(
populated_table_key(&first_node("COPY foo FROM '/tmp/x'")).as_deref(),
Some("foo")
);
assert_eq!(
populated_table_key(&first_node("COPY foo TO '/tmp/x'")),
None
);
assert_eq!(
populated_table_key(&first_node(
"MERGE INTO foo USING src ON foo.id = src.id WHEN NOT MATCHED THEN INSERT VALUES (src.id)"
))
.as_deref(),
Some("foo")
);
}
}