safe_migrate/rules/
indexes.rs1use crate::analysis::mutations::Mutation;
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::engine::config::Config;
5use crate::model::relation::Persistence;
6use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
7use crate::rules::Rule;
8
9pub struct ConcurrentIndexRule;
10
11impl Rule for ConcurrentIndexRule {
12 fn id(&self) -> &'static str {
13 "require-concurrent-index"
14 }
15 fn default_tier(&self) -> ViolationTier {
16 ViolationTier::Tier1
17 }
18 fn recipe(&self) -> &'static str {
19 "Index operations block writes (or both reads and writes) when executed synchronously. Add the CONCURRENTLY keyword."
20 }
21
22 fn evaluate(
23 &self,
24 mutation: &Mutation,
25 result: &MutationResult,
26 pre_state: &crate::analysis::state::PreState,
27 state: &AnalysisState,
28 config: &Config,
29 _cascade: Option<&CascadeResult>,
30 ) -> Vec<Violation> {
31 if *result == MutationResult::Skipped {
32 return vec![];
33 }
34
35 let mut violations = Vec::new();
36
37 match mutation {
38 Mutation::CreateIndex(create) if !create.concurrently => {
39 let (is_temp, is_stale, rows, tx_depth) =
40 match pre_state.relations.get(&create.table) {
41 Some(rel) => {
42 let stale =
43 rel.is_stale() && state.baseline_relations.contains(&create.table);
44 (
45 rel.persistence == Persistence::Temporary,
46 stale,
47 rel.estimated_rows.unwrap_or(config.default_rows),
48 rel.created_at_tx_depth,
49 )
50 }
51 None => (false, true, config.default_rows, 0),
52 };
53
54 if is_temp || (tx_depth > 0 && tx_depth <= state.local.transactions.len()) {
55 return violations;
56 }
57
58 if is_stale {
59 let key = format!("{}_stale_{}", self.id(), create.table);
60 violations.push(Violation { source_range: None,
61 rule_id: self.id(),
62 operation_kind: OperationKind::CreateIndex,
63 object_kind: ObjectKind::Index,
64 object_name: create.id.to_string(),
65 tier: ViolationTier::Tier2,
66 reason: format!("Table {} statistics are stale. Lock evaluations may be inaccurate.", create.table),
67 recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
68 dedup_key: Some(key),
69 sql: None,
70 });
71 }
72
73 let tier1_threshold = config.rule_tier1_threshold(self.id());
74 let tier2_threshold = config.rule_tier2_threshold(self.id());
75
76 let tier = if rows >= tier1_threshold {
77 ViolationTier::Tier1
78 } else if rows >= tier2_threshold {
79 ViolationTier::Tier2
80 } else {
81 ViolationTier::Tier3
82 };
83
84 let mut reason = format!("Synchronous index creation on {}", create.table);
85 if is_stale {
86 reason.push_str(" [WARNING: Based on offline/stale statistics]");
87 }
88
89 violations.push(Violation {
90 source_range: None,
91 rule_id: self.id(),
92 operation_kind: OperationKind::CreateIndex,
93 object_kind: ObjectKind::Index,
94 object_name: create.id.to_string(),
95 tier,
96 reason,
97 recipe: self.recipe(),
98 dedup_key: None,
99 sql: None,
100 });
101 }
102 Mutation::DropIndex(drop) if !drop.concurrently => {
103 let rule_id = "require-concurrent-drop-index";
104 let tier1_threshold = config.rule_tier1_threshold(rule_id);
105 let tier2_threshold = config.rule_tier2_threshold(rule_id);
106
107 if pre_state.relations.is_empty() {
111 let rows = config.default_rows;
112 let tier = if rows >= tier1_threshold {
113 ViolationTier::Tier1
114 } else if rows >= tier2_threshold {
115 ViolationTier::Tier2
116 } else {
117 ViolationTier::Tier3
118 };
119
120 violations.push(Violation {
121 source_range: None,
122 rule_id,
123 operation_kind: OperationKind::DropIndex,
124 object_kind: ObjectKind::Index,
125 object_name: drop.id.to_string(),
126 tier,
127 reason: format!("Synchronous index drop for {}", drop.id),
128 recipe: self.recipe(),
129 dedup_key: None,
130 sql: None,
131 });
132 } else {
133 let mut target_relations = Vec::new();
134 for idx in &pre_state.indexes {
135 if idx.index_id == drop.id
136 && let Some(rel) = pre_state.relations.get(&idx.relation_id)
137 {
138 target_relations.push(rel);
139 }
140 }
141
142 if target_relations.is_empty() {
143 let rows = config.default_rows;
144 let tier = if rows >= tier1_threshold {
145 ViolationTier::Tier1
146 } else if rows >= tier2_threshold {
147 ViolationTier::Tier2
148 } else {
149 ViolationTier::Tier3
150 };
151
152 violations.push(Violation {
153 source_range: None,
154 rule_id,
155 operation_kind: OperationKind::DropIndex,
156 object_kind: ObjectKind::Index,
157 object_name: drop.id.to_string(),
158 tier,
159 reason: format!("Synchronous index drop for {}", drop.id),
160 recipe: self.recipe(),
161 dedup_key: None,
162 sql: None,
163 });
164 } else {
165 for rel in target_relations {
166 if rel.persistence == Persistence::Temporary {
167 continue;
168 }
169
170 let rows = rel.estimated_rows.unwrap_or(config.default_rows);
171 let tier = if rows >= tier1_threshold {
172 ViolationTier::Tier1
173 } else if rows >= tier2_threshold {
174 ViolationTier::Tier2
175 } else {
176 ViolationTier::Tier3
177 };
178
179 let reason =
180 format!("Synchronous index drop for {} on {}", drop.id, rel.id);
181
182 violations.push(Violation {
183 source_range: None,
184 rule_id,
185 operation_kind: OperationKind::DropIndex,
186 object_kind: ObjectKind::Index,
187 object_name: drop.id.to_string(),
188 tier,
189 reason,
190 recipe: self.recipe(),
191 dedup_key: None,
192 sql: None,
193 });
194 }
195 }
196 }
197 }
198 _ => {}
199 }
200 violations
201 }
202}