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                    });
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                // BUG-010: Do not check or push stale statistics warning for DROP INDEX.
108                // We only perform size evaluation for the drop index violation tier.
109
110                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}