safe_migrate/rules/
destructive.rs1use 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 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 (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}