Skip to main content

safe_migrate/rules/
indexes.rs

1// FILE: src/rules/indexes.rs
2use 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                // BUG-010: Do not check or push stale statistics warning for DROP INDEX.
110                // We only perform size evaluation for the drop index violation tier.
111
112                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.dependent == drop.id
139                            && let Some(rel) = pre_state.relations.get(&idx.referenced)
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}