1use 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 fk_dependency_related: false,
71 });
72 }
73
74 let tier1_threshold = config.rule_tier1_threshold(self.id());
75 let tier2_threshold = config.rule_tier2_threshold(self.id());
76
77 let tier = if rows >= tier1_threshold {
78 ViolationTier::Tier1
79 } else if rows >= tier2_threshold {
80 ViolationTier::Tier2
81 } else {
82 ViolationTier::Tier3
83 };
84
85 let mut reason = format!("Synchronous index creation on {}", create.table);
86 if is_stale {
87 reason.push_str(" [WARNING: Based on offline/stale statistics]");
88 }
89
90 violations.push(Violation {
91 source_range: None,
92 rule_id: self.id(),
93 operation_kind: OperationKind::CreateIndex,
94 object_kind: ObjectKind::Index,
95 object_name: create.id.to_string(),
96 tier,
97 reason,
98 recipe: self.recipe(),
99 dedup_key: None,
100 sql: None,
101 fk_dependency_related: false,
102 });
103 }
104 Mutation::DropIndex(drop) if !drop.concurrently => {
105 let rule_id = "require-concurrent-drop-index";
106 let tier1_threshold = config.rule_tier1_threshold(rule_id);
107 let tier2_threshold = config.rule_tier2_threshold(rule_id);
108
109 if pre_state.relations.is_empty() {
113 let rows = config.default_rows;
114 let tier = if rows >= tier1_threshold {
115 ViolationTier::Tier1
116 } else if rows >= tier2_threshold {
117 ViolationTier::Tier2
118 } else {
119 ViolationTier::Tier3
120 };
121
122 violations.push(Violation {
123 source_range: None,
124 rule_id,
125 operation_kind: OperationKind::DropIndex,
126 object_kind: ObjectKind::Index,
127 object_name: drop.id.to_string(),
128 tier,
129 reason: format!("Synchronous index drop for {}", drop.id),
130 recipe: self.recipe(),
131 dedup_key: None,
132 sql: None,
133 fk_dependency_related: false,
134 });
135 } else {
136 let mut target_relations = Vec::new();
137 for idx in &pre_state.indexes {
138 if idx.index_id == drop.id
139 && let Some(rel) = pre_state.relations.get(&idx.relation_id)
140 {
141 target_relations.push(rel);
142 }
143 }
144
145 if target_relations.is_empty() {
146 let rows = config.default_rows;
147 let tier = if rows >= tier1_threshold {
148 ViolationTier::Tier1
149 } else if rows >= tier2_threshold {
150 ViolationTier::Tier2
151 } else {
152 ViolationTier::Tier3
153 };
154
155 violations.push(Violation {
156 source_range: None,
157 rule_id,
158 operation_kind: OperationKind::DropIndex,
159 object_kind: ObjectKind::Index,
160 object_name: drop.id.to_string(),
161 tier,
162 reason: format!("Synchronous index drop for {}", drop.id),
163 recipe: self.recipe(),
164 dedup_key: None,
165 sql: None,
166 fk_dependency_related: false,
167 });
168 } else {
169 for rel in target_relations {
170 if rel.persistence == Persistence::Temporary {
171 continue;
172 }
173
174 let rows = rel.estimated_rows.unwrap_or(config.default_rows);
175 let tier = if rows >= tier1_threshold {
176 ViolationTier::Tier1
177 } else if rows >= tier2_threshold {
178 ViolationTier::Tier2
179 } else {
180 ViolationTier::Tier3
181 };
182
183 let reason =
184 format!("Synchronous index drop for {} on {}", drop.id, rel.id);
185
186 violations.push(Violation {
187 source_range: None,
188 rule_id,
189 operation_kind: OperationKind::DropIndex,
190 object_kind: ObjectKind::Index,
191 object_name: drop.id.to_string(),
192 tier,
193 reason,
194 recipe: self.recipe(),
195 dedup_key: None,
196 sql: None,
197 fk_dependency_related: false,
198 });
199 }
200 }
201 }
202 }
203 _ => {}
204 }
205 violations
206 }
207}