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