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