Skip to main content

safe_migrate/rules/
conflict.rs

1use crate::analysis::mutations::Mutation;
2use crate::analysis::state::MutationResult;
3use crate::engine::config::Config;
4use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
5use crate::rules::Rule;
6
7pub struct ConflictRule;
8
9impl ConflictRule {
10    const ID: &'static str = "chain-conflict";
11    const DEFAULT_TIER: ViolationTier = ViolationTier::Tier1;
12    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.";
13
14    fn extract_conflict_reason(result: &MutationResult) -> Option<&str> {
15        match result {
16            MutationResult::Conflict { reason } => Some(reason.as_str()),
17            _ => None,
18        }
19    }
20}
21
22impl Rule for ConflictRule {
23    fn id(&self) -> &'static str {
24        Self::ID
25    }
26
27    fn default_tier(&self) -> ViolationTier {
28        Self::DEFAULT_TIER
29    }
30
31    fn recipe(&self) -> &'static str {
32        Self::RECIPE
33    }
34
35    fn evaluate(
36        &self,
37        _mutation: &Mutation,
38        result: &MutationResult,
39        _pre_state: &crate::analysis::state::PreState,
40        _state: &crate::analysis::state::AnalysisState,
41        _config: &Config,
42        _cascade_closure: Option<&crate::analysis::state::CascadeResult>,
43    ) -> Vec<Violation> {
44        match Self::extract_conflict_reason(result) {
45            Some(reason) => vec![Violation {
46                source_range: None,
47                rule_id: Self::ID,
48                operation_kind: OperationKind::Conflict,
49                object_kind: ObjectKind::Unknown,
50                object_name: "<migration-state>".to_string(),
51                tier: Self::DEFAULT_TIER,
52                reason: format!("Migration chain conflict: {}", reason),
53                recipe: Self::RECIPE,
54                dedup_key: None,
55                sql: None,
56                fk_dependency_related: false,
57            }],
58            None => Vec::new(),
59        }
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::analysis::mutations::Mutation;
67    use crate::analysis::state::MutationResult;
68    use std::collections::HashMap;
69
70    #[test]
71    fn test_conflict_rule_emits_tier1_on_conflict() {
72        let rule = ConflictRule;
73        let result = MutationResult::Conflict {
74            reason:
75                "column 'x' already added with type int, this file adds it again with type text"
76                    .to_string(),
77        };
78        let violations = rule.evaluate(
79            &Mutation::Opaque(crate::analysis::mutations::OpaqueMutation::DynamicSql),
80            &result,
81            &crate::analysis::state::PreState {
82                relations: HashMap::new(),
83                functions: HashMap::new(),
84                roles: HashMap::new(),
85                publications: HashMap::new(),
86                subscriptions: HashMap::new(),
87                sequences: HashMap::new(),
88                types: HashMap::new(),
89                indexes: Vec::new(),
90            },
91            &crate::analysis::state::AnalysisState::new(crate::db::cache::DbCache::new()),
92            &Config::default(),
93            None,
94        );
95        assert_eq!(violations.len(), 1);
96        assert_eq!(violations[0].rule_id, "chain-conflict");
97        assert_eq!(violations[0].tier, ViolationTier::Tier1);
98        assert_eq!(violations[0].object_kind, ObjectKind::Unknown);
99        assert_eq!(violations[0].object_name, "<migration-state>");
100        assert!(violations[0].reason.contains("Migration chain conflict"));
101        assert!(violations[0].recipe.contains("schema state"));
102        assert!(!violations[0].recipe.contains("each column"));
103    }
104
105    #[test]
106    fn test_conflict_rule_silent_on_applied() {
107        let rule = ConflictRule;
108        let result = MutationResult::Applied;
109        let violations = rule.evaluate(
110            &Mutation::Opaque(crate::analysis::mutations::OpaqueMutation::DynamicSql),
111            &result,
112            &crate::analysis::state::PreState {
113                relations: HashMap::new(),
114                functions: HashMap::new(),
115                roles: HashMap::new(),
116                publications: HashMap::new(),
117                subscriptions: HashMap::new(),
118                sequences: HashMap::new(),
119                types: HashMap::new(),
120                indexes: Vec::new(),
121            },
122            &crate::analysis::state::AnalysisState::new(crate::db::cache::DbCache::new()),
123            &Config::default(),
124            None,
125        );
126        assert!(violations.is_empty());
127    }
128}