safe_migrate/rules/
conflict.rs1use 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 = "Refactor the migration chain so each column is added only once with a consistent type, or consolidate into a single DDL statement.";
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::Table,
52 object_name: "unknown".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!(violations[0].reason.contains("Migration chain conflict"));
101 }
102
103 #[test]
104 fn test_conflict_rule_silent_on_applied() {
105 let rule = ConflictRule;
106 let result = MutationResult::Applied;
107 let violations = rule.evaluate(
108 &Mutation::Opaque(crate::analysis::mutations::OpaqueMutation::DynamicSql),
109 &result,
110 &crate::analysis::state::PreState {
111 relations: HashMap::new(),
112 functions: HashMap::new(),
113 roles: HashMap::new(),
114 publications: HashMap::new(),
115 subscriptions: HashMap::new(),
116 sequences: HashMap::new(),
117 types: HashMap::new(),
118 indexes: Vec::new(),
119 },
120 &crate::analysis::state::AnalysisState::new(crate::db::cache::DbCache::new()),
121 &Config::default(),
122 None,
123 );
124 assert!(violations.is_empty());
125 }
126}