safe-migrate 0.8.0

Check PostgreSQL migrations against a synchronized database baseline
Documentation
use crate::_internal::analysis::state::MutationResult;
use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
use crate::_internal::rules::{Rule, RuleContext};

pub struct ConflictRule;

impl ConflictRule {
    const ID: &'static str = "chain-conflict";
    const DEFAULT_TIER: ViolationTier = ViolationTier::Tier1;
    const RECIPE: &'static str = "Correct the migration so this statement can execute against the schema state produced by earlier statements. Use an idempotency guard only when a no-op is intended.";

    fn extract_conflict_reason(result: &MutationResult) -> Option<&str> {
        match result {
            MutationResult::Conflict { reason } => Some(reason.as_str()),
            _ => None,
        }
    }

    fn is_dedicated_transaction_conflict(reason: &str) -> bool {
        matches!(
            reason,
            "CREATE INDEX CONCURRENTLY cannot run inside a transaction"
                | "DROP INDEX CONCURRENTLY cannot run inside a transaction"
                | "REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run inside a transaction"
        )
    }
}

impl Rule for ConflictRule {
    fn id(&self) -> &'static str {
        Self::ID
    }

    fn default_tier(&self) -> ViolationTier {
        Self::DEFAULT_TIER
    }

    fn recipe(&self) -> &'static str {
        Self::RECIPE
    }

    fn evaluate(&self, context: &RuleContext<'_>) -> Vec<Violation> {
        match Self::extract_conflict_reason(context.result()) {
            Some(reason) if Self::is_dedicated_transaction_conflict(reason) => Vec::new(),
            Some(reason) => vec![Violation {
                source_range: None,
                rule_id: Self::ID,
                operation_kind: OperationKind::Conflict,
                object_kind: ObjectKind::Unknown,
                object_name: "<migration-state>".to_string(),
                tier: Self::DEFAULT_TIER,
                reason: format!("Migration chain conflict: {}", reason),
                recipe: Self::RECIPE,
                dedup_key: None,
                sql: None,
                fk_dependency_related: false,
            }],
            None => Vec::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::_internal::analysis::mutations::Mutation;
    use crate::_internal::analysis::state::MutationResult;
    use crate::_internal::engine::config::Config;
    use std::collections::{HashMap, HashSet};

    #[test]
    fn test_conflict_rule_emits_tier1_on_conflict() {
        let rule = ConflictRule;
        let result = MutationResult::Conflict {
            reason:
                "column 'x' already added with type int, this file adds it again with type text"
                    .to_string(),
        };
        let mutation =
            Mutation::Opaque(crate::_internal::analysis::mutations::OpaqueMutation::DynamicSql);
        let pre_state = crate::_internal::analysis::state::PreState {
            relations: HashMap::new(),
            functions: HashMap::new(),
            roles: HashMap::new(),
            publications: HashMap::new(),
            subscriptions: HashMap::new(),
            sequences: HashMap::new(),
            types: HashMap::new(),
            indexes: Vec::new(),
            baseline_foreign_keys: HashSet::new(),
        };
        let state = crate::_internal::analysis::state::AnalysisState::new(
            crate::_internal::db::cache::DbCache::new(),
        );
        let config = Config::default();
        let context = RuleContext::new(&mutation, &result, &pre_state, &state, &config, None);
        let violations = rule.evaluate(&context);
        assert_eq!(violations.len(), 1);
        assert_eq!(violations[0].rule_id, "chain-conflict");
        assert_eq!(violations[0].tier, ViolationTier::Tier1);
        assert_eq!(violations[0].object_kind, ObjectKind::Unknown);
        assert_eq!(violations[0].object_name, "<migration-state>");
        assert!(violations[0].reason.contains("Migration chain conflict"));
        assert!(violations[0].recipe.contains("schema state"));
        assert!(!violations[0].recipe.contains("each column"));
    }

    #[test]
    fn test_conflict_rule_silent_on_applied() {
        let rule = ConflictRule;
        let result = MutationResult::Applied;
        let mutation =
            Mutation::Opaque(crate::_internal::analysis::mutations::OpaqueMutation::DynamicSql);
        let pre_state = crate::_internal::analysis::state::PreState {
            relations: HashMap::new(),
            functions: HashMap::new(),
            roles: HashMap::new(),
            publications: HashMap::new(),
            subscriptions: HashMap::new(),
            sequences: HashMap::new(),
            types: HashMap::new(),
            indexes: Vec::new(),
            baseline_foreign_keys: HashSet::new(),
        };
        let state = crate::_internal::analysis::state::AnalysisState::new(
            crate::_internal::db::cache::DbCache::new(),
        );
        let config = Config::default();
        let context = RuleContext::new(&mutation, &result, &pre_state, &state, &config, None);
        let violations = rule.evaluate(&context);
        assert!(violations.is_empty());
    }
}