Skip to main content

safe_migrate/rules/
destructive.rs

1// FILE: src/rules/destructive.rs
2use crate::analysis::mutations::{AlterTableActionMutation, Mutation};
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::ast::identifiers::ObjectId;
5use crate::engine::config::Config;
6use crate::model::relation::RelationState;
7use crate::report::violations::{Violation, ViolationTier};
8use crate::rules::Rule;
9use std::collections::HashMap;
10
11pub struct CascadingDropRule;
12
13impl Rule for CascadingDropRule {
14    fn id(&self) -> &'static str {
15        "destructive-cascade"
16    }
17    fn default_tier(&self) -> ViolationTier {
18        ViolationTier::Tier1
19    }
20    fn recipe(&self) -> &'static str {
21        "Avoid CASCADE on DROP TABLE in production. Handle dependencies explicitly."
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_closure: Option<&CascadeResult>,
32    ) -> Vec<Violation> {
33        if *result == MutationResult::Skipped {
34            return vec![];
35        }
36
37        let mut violations = Vec::new();
38
39        if let Mutation::DropTable(drop) = mutation
40            && drop.cascade
41            && let Some(closure) = cascade_closure
42        {
43            let mut affects_baseline = false;
44
45            for rel_id in &closure.dropped_relations {
46                if rel_id != &drop.id && state.baseline_relations.contains(rel_id) {
47                    affects_baseline = true;
48                    break;
49                }
50            }
51
52            if !affects_baseline {
53                for constraint in &closure.dropped_constraints {
54                    if state.baseline_foreign_keys.contains(constraint) {
55                        affects_baseline = true;
56                        break;
57                    }
58                }
59            }
60
61            if affects_baseline {
62                violations.push(Violation {
63                    rule_id: self.id(),
64                    title: format!("DROP TABLE {} CASCADE silently destroys pre-existing database dependencies", drop.id),
65                    tier: self.default_tier(),
66                    recipe: self.recipe(),
67                    dedup_key: None,
68                });
69            }
70        }
71        violations
72    }
73}
74
75pub struct SizeAwareAddColumnRule;
76
77impl Rule for SizeAwareAddColumnRule {
78    fn id(&self) -> &'static str {
79        "size-aware-add-column"
80    }
81    fn default_tier(&self) -> ViolationTier {
82        ViolationTier::Tier1
83    }
84    fn recipe(&self) -> &'static str {
85        "Adding a column with a default requires a table rewrite. For PG11+, constant defaults are safe. For volatiles or <PG11, use a multi-step backfill."
86    }
87
88    fn evaluate(
89        &self,
90        mutation: &Mutation,
91        result: &MutationResult,
92        pre_relations: &HashMap<ObjectId, RelationState>,
93        state: &AnalysisState,
94        config: &Config,
95        _cascade_closure: Option<&CascadeResult>,
96    ) -> Vec<Violation> {
97        if *result == MutationResult::Skipped {
98            return vec![];
99        }
100
101        let mut violations = Vec::new();
102        let pg_version = state.pg_version_num.unwrap_or(config.assume_pg_version);
103
104        if let Mutation::AlterTable(alter) = mutation
105            && let AlterTableActionMutation::AddColumn {
106                default: Some(def), ..
107            } = &alter.action
108        {
109            let is_volatile = def.is_volatile();
110            let requires_rewrite = is_volatile || pg_version < 110000;
111
112            if requires_rewrite {
113                let (has_wide_columns, is_stale, rows) = match pre_relations.get(&alter.id) {
114                    Some(rel) => {
115                        let wide = rel.columns.iter().any(|c| {
116                            c.avg_width.unwrap_or(0) >= config.toast_width_threshold_bytes
117                        });
118                        // BUG FIX: Only mark as stale if it actually existed in the baseline database!
119                        // Tables created in this migration script are 0-rows fresh, not stale.
120                        let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id);
121                        (
122                            wide,
123                            stale,
124                            rel.estimated_rows.unwrap_or(config.default_rows),
125                        )
126                    }
127                    None => {
128                        // Table is completely unknown (not in cache, not in migration). We are guessing. Mark as stale.
129                        (false, true, config.default_rows)
130                    }
131                };
132
133                if is_stale {
134                    let key = format!("{}_stale_{}", self.id(), alter.id);
135                    violations.push(Violation {
136                        rule_id: self.id(),
137                        title: format!("Table {} statistics are stale. Lock evaluations may be inaccurate.", alter.id),
138                        tier: ViolationTier::Tier2,
139                        recipe: "Run ANALYZE to ensure accurate TOAST width and row estimates before structural changes.",
140                        dedup_key: Some(key),
141                    });
142                }
143
144                let tier1_threshold = config.rule_tier1_threshold(self.id());
145                let mut tier = if rows >= tier1_threshold {
146                    ViolationTier::Tier1
147                } else {
148                    ViolationTier::Tier2
149                };
150
151                if has_wide_columns && tier == ViolationTier::Tier2 {
152                    tier = ViolationTier::Tier1;
153                }
154
155                let mut title = if is_volatile {
156                    format!(
157                        "Adding column with volatile DEFAULT to {} triggers a table rewrite",
158                        alter.id
159                    )
160                } else {
161                    format!(
162                        "Adding column with DEFAULT to {} triggers a table rewrite on Postgres < 11",
163                        alter.id
164                    )
165                };
166
167                if has_wide_columns && tier == ViolationTier::Tier1 {
168                    title.push_str(" (Escalated due to wide TOAST columns)");
169                }
170                if is_stale {
171                    title.push_str(" [WARNING: Based on unknown offline statistics]");
172                }
173
174                violations.push(Violation {
175                    rule_id: self.id(),
176                    title,
177                    tier,
178                    recipe: self.recipe(),
179                    dedup_key: None,
180                });
181            }
182        }
183        violations
184    }
185}
186
187pub struct TypeChangeRewriteRule;
188
189impl TypeChangeRewriteRule {
190    fn is_type_change_safe(old_type: &str, new_type: &str, pg_version: u32) -> bool {
191        let old = old_type.to_lowercase();
192        let new = new_type.to_lowercase();
193        if old == new {
194            return true;
195        }
196
197        let old_base = old.split('(').next().unwrap_or(&old).trim();
198        let new_base = new.split('(').next().unwrap_or(&new).trim();
199
200        if (old_base == "varchar" || old_base == "character varying")
201            && (new_base == "varchar" || new_base == "character varying" || new_base == "text")
202            && (new == "text" || new == "varchar" || new == "character varying")
203        {
204            return true;
205        }
206
207        if pg_version >= 120000
208            && (old_base == "numeric" || old_base == "decimal")
209            && (new_base == "numeric" || new_base == "decimal")
210            && !new.contains('(')
211        {
212            return true;
213        }
214
215        false
216    }
217}
218
219impl Rule for TypeChangeRewriteRule {
220    fn id(&self) -> &'static str {
221        "type-change-rewrite"
222    }
223    fn default_tier(&self) -> ViolationTier {
224        ViolationTier::Tier1
225    }
226    fn recipe(&self) -> &'static str {
227        "Changing this column type requires an ACCESS EXCLUSIVE table rewrite. Add a new column, backfill, and swap."
228    }
229
230    fn evaluate(
231        &self,
232        mutation: &Mutation,
233        result: &MutationResult,
234        pre_relations: &HashMap<ObjectId, RelationState>,
235        state: &AnalysisState,
236        config: &Config,
237        _cascade_closure: Option<&CascadeResult>,
238    ) -> Vec<Violation> {
239        if *result == MutationResult::Skipped {
240            return vec![];
241        }
242
243        let mut violations = Vec::new();
244
245        if let Mutation::AlterTable(alter) = mutation
246            && let AlterTableActionMutation::SetType {
247                column,
248                ty,
249                has_using: _,
250            } = &alter.action
251        {
252            let pg_version = state.pg_version_num.unwrap_or(config.assume_pg_version);
253
254            let (is_safe, rows, old_type_str) = match pre_relations.get(&alter.id) {
255                Some(rel) => {
256                    let old_ty = rel
257                        .columns
258                        .iter()
259                        .find(|c| c.name == *column)
260                        .and_then(|col| col.data_type.as_ref());
261
262                    let safe = old_ty
263                        .map(|o| Self::is_type_change_safe(o, ty, pg_version))
264                        .unwrap_or(false);
265                    (
266                        safe,
267                        rel.estimated_rows.unwrap_or(config.default_rows),
268                        old_ty.cloned().unwrap_or_else(|| "unknown".to_string()),
269                    )
270                }
271                None => (false, config.default_rows, "unknown".to_string()),
272            };
273
274            if !is_safe {
275                let tier1_threshold = config.rule_tier1_threshold(self.id());
276
277                let tier = if rows >= tier1_threshold {
278                    ViolationTier::Tier1
279                } else {
280                    ViolationTier::Tier2
281                };
282
283                violations.push(Violation {
284                    rule_id: self.id(),
285                    title: format!(
286                        "Changing column {}.{} type from {} to {} causes a table rewrite",
287                        alter.id, column, old_type_str, ty
288                    ),
289                    tier,
290                    recipe: self.recipe(),
291                    dedup_key: None,
292                });
293            }
294        }
295        violations
296    }
297}