Skip to main content

safe_migrate/rules/
conflict.rs

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