safe-migrate 0.8.0

Check PostgreSQL migrations against a synchronized database baseline
Documentation
use crate::_internal::analysis::mutations::{AlterTypeActionMutation, Mutation};
use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
use crate::_internal::rules::{Rule, RuleCapability, RuleContext, TRANSACTION_CAPABILITIES};

pub struct ConcurrentInsideTransactionRule;

impl Rule for ConcurrentInsideTransactionRule {
    fn id(&self) -> &'static str {
        "concurrent-in-transaction"
    }
    fn default_tier(&self) -> ViolationTier {
        ViolationTier::Tier1
    }
    fn recipe(&self) -> &'static str {
        "PostgreSQL does not allow concurrent index creation/drop or materialized-view refresh inside a transaction block (BEGIN/COMMIT)."
    }

    fn required_capabilities(&self) -> &'static [RuleCapability] {
        TRANSACTION_CAPABILITIES
    }

    fn evaluate(&self, context: &RuleContext<'_>) -> Vec<Violation> {
        let mut violations = Vec::new();

        if context.state().in_transaction() {
            match context.mutation() {
                Mutation::CreateIndex(c) if c.concurrently => {
                    violations.push(Violation { source_range: None,
                        rule_id: self.id(),
                        operation_kind: OperationKind::CreateIndex,
                        object_kind: ObjectKind::Index,
                        object_name: c.id.to_string(),
                        tier: self.default_tier(),
                        reason: format!("CREATE INDEX CONCURRENTLY on {} inside a transaction block", c.table),
                        recipe: "Move CONCURRENTLY index creation outside of explicit transaction blocks.",
                        dedup_key: Some(format!("{}_{}", self.id(), c.id)),
                                    sql: None,
                                    fk_dependency_related: false,
                    });
                }
                Mutation::DropIndex(d) if d.concurrently => {
                    for id in &d.ids {
                        violations.push(Violation {
                            source_range: None,
                            rule_id: self.id(),
                            operation_kind: OperationKind::DropIndex,
                            object_kind: ObjectKind::Index,
                            object_name: id.to_string(),
                            tier: self.default_tier(),
                            reason: format!(
                                "DROP INDEX CONCURRENTLY on {} inside a transaction block",
                                id
                            ),
                            recipe: self.recipe(),
                            dedup_key: None,
                            sql: None,
                            fk_dependency_related: false,
                        });
                    }
                }
                Mutation::RefreshMaterializedView(refresh) if refresh.concurrently => {
                    violations.push(Violation {
                        source_range: None,
                        rule_id: self.id(),
                        operation_kind: OperationKind::RefreshMaterializedView,
                        object_kind: ObjectKind::MaterializedView,
                        object_name: refresh.id.to_string(),
                        tier: self.default_tier(),
                        reason: format!(
                            "REFRESH MATERIALIZED VIEW CONCURRENTLY on {} inside a transaction block",
                            refresh.id
                        ),
                        recipe: self.recipe(),
                        dedup_key: Some(format!("{}_{}", self.id(), refresh.id)),
                        sql: None,
                        fk_dependency_related: false,
                    });
                }
                _ => {}
            }
        }

        violations
    }
}

pub struct AlterTypeAddValueRule;

impl Rule for AlterTypeAddValueRule {
    fn id(&self) -> &'static str {
        "alter-type-add-value-txn"
    }
    fn default_tier(&self) -> ViolationTier {
        ViolationTier::Tier2
    }
    fn recipe(&self) -> &'static str {
        "Commit before later statements use the new enum value, or put the dependent work in a later migration."
    }

    fn required_capabilities(&self) -> &'static [RuleCapability] {
        TRANSACTION_CAPABILITIES
    }

    fn evaluate(&self, context: &RuleContext<'_>) -> Vec<Violation> {
        if context.state().in_transaction()
            && let Mutation::AlterType(alter) = context.mutation()
            && matches!(alter.action, AlterTypeActionMutation::AddValue { .. })
        {
            return vec![Violation {
                source_range: None,
                rule_id: self.id(),
                operation_kind: OperationKind::AlterType,
                object_kind: ObjectKind::Type,
                object_name: alter.id.to_string(),
                tier: self.default_tier(),
                reason: format!(
                    "ALTER TYPE {} ADD VALUE is inside a transaction; PostgreSQL does not allow the new value to be used until commit",
                    alter.id
                ),
                recipe: self.recipe(),
                dedup_key: None,
                sql: None,
                fk_dependency_related: false,
            }];
        }
        vec![]
    }
}

pub struct VacuumFullRule;

impl Rule for VacuumFullRule {
    fn id(&self) -> &'static str {
        "vacuum-full"
    }
    fn default_tier(&self) -> ViolationTier {
        ViolationTier::Tier1
    }
    fn recipe(&self) -> &'static str {
        "VACUUM FULL rewrites the entire table and requires an ACCESS EXCLUSIVE lock. Run this manually outside of migration pipelines."
    }

    fn required_capabilities(&self) -> &'static [RuleCapability] {
        &[]
    }

    fn evaluate(&self, context: &RuleContext<'_>) -> Vec<Violation> {
        if let Mutation::Vacuum {
            is_full: true,
            table_id,
        } = context.mutation()
        {
            let object_name = table_id
                .as_ref()
                .map(|id| id.to_string())
                .unwrap_or_else(|| "<all tables>".to_string());
            return vec![Violation {
                source_range: None,
                rule_id: self.id(),
                operation_kind: OperationKind::VacuumFull,
                object_kind: ObjectKind::Table,
                object_name,
                tier: self.default_tier(),
                reason: "VACUUM FULL requires an ACCESS EXCLUSIVE lock".to_string(),
                recipe: self.recipe(),
                dedup_key: None,
                sql: None,
                fk_dependency_related: false,
            }];
        }
        vec![]
    }
}