Skip to main content

safe_migrate/rules/
indexes.rs

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                // DROP INDEX classification does not emit a stale-statistics finding.
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                        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}