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::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}