safe_migrate/rules/
conflict.rs1use 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}