safe_migrate/rules/
constraints.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::{Persistence, RelationState};
7use crate::report::violations::{Violation, ViolationTier};
8use crate::rules::Rule;
9use std::collections::HashMap;
10
11pub struct BlockingConstraintRule;
12
13impl Rule for BlockingConstraintRule {
14 fn id(&self) -> &'static str {
15 "blocking-constraint"
16 }
17 fn default_tier(&self) -> ViolationTier {
18 ViolationTier::Tier1
19 }
20 fn recipe(&self) -> &'static str {
21 "Adding a valid CHECK or FOREIGN KEY constraint takes an ACCESS EXCLUSIVE lock and scans the table. Add it as NOT VALID first, then VALIDATE it in a separate transaction."
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 if let Mutation::AlterTable(alter) = mutation {
40 let (is_temp, mut is_stale, child_rows) = match pre_relations.get(&alter.id) {
42 Some(rel) => {
43 let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id);
45 (
46 rel.persistence == Persistence::Temporary,
47 stale,
48 rel.estimated_rows.unwrap_or(config.default_rows),
49 )
50 }
51 None => (false, true, config.default_rows),
52 };
53
54 if is_temp {
56 return violations;
57 }
58
59 let max_locked_rows = match &alter.action {
61 AlterTableActionMutation::AddForeignKey { to_table, .. } => {
62 let parent_rows = match pre_relations.get(to_table) {
65 Some(parent_rel) => {
66 if parent_rel.is_stale() && state.baseline_relations.contains(to_table)
67 {
68 is_stale = true;
69 }
70 parent_rel.estimated_rows.unwrap_or(config.default_rows)
71 }
72 None => {
73 is_stale = true;
74 config.default_rows
75 }
76 };
77 std::cmp::max(child_rows, parent_rows)
78 }
79 _ => child_rows,
80 };
81
82 let tier1_threshold = config.rule_tier1_threshold(self.id());
84 let tier2_threshold = config.rule_tier2_threshold(self.id());
85
86 let tier = if max_locked_rows >= tier1_threshold {
87 ViolationTier::Tier1
88 } else if max_locked_rows >= tier2_threshold {
89 ViolationTier::Tier2
90 } else {
91 ViolationTier::Tier3
92 };
93
94 if is_stale && tier != ViolationTier::Tier3 {
96 let key = format!("{}_stale_{}", self.id(), alter.id);
97 violations.push(Violation {
98 rule_id: self.id(),
99 title: "Table statistics are offline/stale. Lock evaluations may be inaccurate.".to_string(),
100 tier: ViolationTier::Tier2,
101 recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
102 dedup_key: Some(key),
103 });
104 }
105
106 if tier == ViolationTier::Tier3 {
108 return violations;
109 }
110
111 match &alter.action {
112 AlterTableActionMutation::AddCheckConstraint {
113 constraint_name,
114 not_valid: false,
115 } => {
116 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
117 let mut title = format!(
118 "Synchronous CHECK constraint '{}' addition on {}",
119 name_str, alter.id
120 );
121 if is_stale {
122 title.push_str(" [WARNING: Based on offline/stale statistics]");
123 }
124
125 violations.push(Violation {
126 rule_id: self.id(),
127 title,
128 tier,
129 recipe: self.recipe(),
130 dedup_key: None,
131 });
132 }
133 AlterTableActionMutation::AddForeignKey {
134 constraint_name,
135 not_valid: false,
136 to_table,
137 ..
138 } => {
139 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
140
141 let mut title = format!(
143 "Synchronous FOREIGN KEY constraint '{}' addition locks {} and {}",
144 name_str, alter.id, to_table
145 );
146 if is_stale {
147 title.push_str(" [WARNING: Based on offline/stale statistics]");
148 }
149
150 violations.push(Violation {
151 rule_id: self.id(),
152 title,
153 tier,
154 recipe: self.recipe(),
155 dedup_key: None,
156 });
157 }
158 AlterTableActionMutation::SetNotNull { column } => {
159 let has_fast_path = pre_relations
160 .get(&alter.id)
161 .map(|r| {
162 r.get_column(column)
163 .map(|c| !c.is_nullable)
164 .unwrap_or(false)
165 })
166 .unwrap_or(false);
167
168 if !has_fast_path {
169 violations.push(Violation {
170 rule_id: self.id(),
171 title: format!("Synchronous SET NOT NULL on {}.{}", alter.id, column),
172 tier,
173 recipe: "Add CHECK constraint NOT VALID, then VALIDATE separately.",
174 dedup_key: None,
175 });
176 }
177 }
178 AlterTableActionMutation::AddUniqueConstraint
179 | AlterTableActionMutation::AddPrimaryKeyConstraint => {
180 let mut title =
181 format!("Adding a UNIQUE or PRIMARY KEY constraint to {}", alter.id);
182 if is_stale {
183 title.push_str(" [WARNING: Based on offline/stale statistics]");
184 }
185
186 violations.push(Violation {
187 rule_id: "blocking-index-constraint", title,
189 tier,
190 recipe: "Build a UNIQUE index CONCURRENTLY first, then add the constraint USING INDEX.",
191 dedup_key: None,
192 });
193 }
194 AlterTableActionMutation::SetStorage { column } => {
195 let mut title = format!(
196 "Changing storage parameter for {}.{} causes a table rewrite",
197 alter.id, column
198 );
199 if is_stale {
200 title.push_str(" [WARNING: Based on offline/stale statistics]");
201 }
202
203 violations.push(Violation {
204 rule_id: "table-rewrite-storage",
205 title,
206 tier,
207 recipe: "Changing column storage requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
208 dedup_key: None,
209 });
210 }
211 AlterTableActionMutation::SetAccessMethod => {
212 let mut title = format!(
213 "Changing access method for {} causes a table rewrite",
214 alter.id
215 );
216 if is_stale {
217 title.push_str(" [WARNING: Based on offline/stale statistics]");
218 }
219
220 violations.push(Violation {
221 rule_id: "table-rewrite-access-method",
222 title,
223 tier,
224 recipe: "Changing table access method requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
225 dedup_key: None,
226 });
227 }
228 _ => {}
229 }
230 }
231 violations
232 }
233}