1use crate::analysis::mutations::{AlterTableActionMutation, Mutation};
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::engine::config::Config;
5use crate::model::relation::Persistence;
6use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
7use crate::rules::Rule;
8
9pub struct BlockingConstraintRule;
10
11impl Rule for BlockingConstraintRule {
12 fn id(&self) -> &'static str {
13 "blocking-constraint"
14 }
15 fn default_tier(&self) -> ViolationTier {
16 ViolationTier::Tier1
17 }
18 fn recipe(&self) -> &'static str {
19 "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."
20 }
21
22 fn evaluate(
23 &self,
24 mutation: &Mutation,
25 result: &MutationResult,
26 pre_state: &crate::analysis::state::PreState,
27 state: &AnalysisState,
28 config: &Config,
29 _cascade: Option<&CascadeResult>,
30 ) -> Vec<Violation> {
31 if *result == MutationResult::Skipped {
32 return vec![];
33 }
34
35 let mut violations = Vec::new();
36
37 if let Mutation::AlterTable(alter) = mutation {
38 let (is_temp, mut is_stale, child_rows) = match pre_state.relations.get(&alter.id) {
40 Some(rel) => {
41 let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id);
43 (
44 rel.persistence == Persistence::Temporary,
45 stale,
46 rel.estimated_rows.unwrap_or(config.default_rows),
47 )
48 }
49 None => (false, true, config.default_rows),
50 };
51
52 if is_temp {
54 return violations;
55 }
56
57 let max_locked_rows = match &alter.action {
59 AlterTableActionMutation::AddForeignKey { to_table, .. } => {
60 let parent_rows = match pre_state.relations.get(to_table) {
63 Some(parent_rel) => {
64 if parent_rel.is_stale() && state.baseline_relations.contains(to_table)
65 {
66 is_stale = true;
67 }
68 parent_rel.estimated_rows.unwrap_or(config.default_rows)
69 }
70 None => {
71 is_stale = true;
72 config.default_rows
73 }
74 };
75 std::cmp::max(child_rows, parent_rows)
76 }
77 _ => child_rows,
78 };
79
80 let tier1_threshold = config.rule_tier1_threshold(self.id());
82 let tier2_threshold = config.rule_tier2_threshold(self.id());
83
84 let is_partitioned =
86 if let AlterTableActionMutation::AddForeignKey { to_table, .. } = &alter.action {
87 let child_partitioned = pre_state
88 .relations
89 .get(&alter.id)
90 .is_some_and(|rel| rel.partition_type.is_some());
91 let parent_partitioned = pre_state
92 .relations
93 .get(to_table)
94 .is_some_and(|rel| rel.partition_type.is_some());
95 child_partitioned || parent_partitioned
96 } else {
97 false
98 };
99
100 let (adjusted_tier1, adjusted_tier2) = if is_partitioned {
102 (tier1_threshold / 2, tier2_threshold / 2)
103 } else {
104 (tier1_threshold, tier2_threshold)
105 };
106
107 let tier = if max_locked_rows >= adjusted_tier1 {
108 ViolationTier::Tier1
109 } else if max_locked_rows >= adjusted_tier2 {
110 ViolationTier::Tier2
111 } else {
112 ViolationTier::Tier3
113 };
114
115 if is_stale && tier != ViolationTier::Tier3 {
117 let key = format!("{}_stale_{}", self.id(), alter.id);
118 violations.push(Violation { source_range: None,
119 rule_id: self.id(),
120 operation_kind: OperationKind::AddConstraint,
121 object_kind: ObjectKind::Table,
122 object_name: alter.id.to_string(),
123 tier: ViolationTier::Tier2,
124 reason: "Table statistics are offline/stale. Lock evaluations may be inaccurate.".to_string(),
125 recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
126 dedup_key: Some(key),
127 sql: None,
128 });
129 }
130
131 if tier == ViolationTier::Tier3 {
133 return violations;
134 }
135
136 match &alter.action {
137 AlterTableActionMutation::AddCheckConstraint {
138 constraint_name,
139 not_valid: false,
140 } => {
141 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
142 let mut reason = format!(
143 "Synchronous CHECK constraint '{}' addition on {}",
144 name_str, alter.id
145 );
146 if is_stale {
147 reason.push_str(" [WARNING: Based on offline/stale statistics]");
148 }
149
150 violations.push(Violation {
151 source_range: None,
152 rule_id: self.id(),
153 operation_kind: OperationKind::AddConstraint,
154 object_kind: ObjectKind::Table,
155 object_name: alter.id.to_string(),
156 tier,
157 reason,
158 recipe: self.recipe(),
159 dedup_key: None,
160 sql: None,
161 });
162 }
163 AlterTableActionMutation::AddForeignKey {
164 constraint_name,
165 not_valid: false,
166 to_table,
167 ..
168 } => {
169 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
170
171 let mut reason = format!(
172 "Synchronous FOREIGN KEY constraint '{}' addition locks {} and {}",
173 name_str, alter.id, to_table
174 );
175 if is_partitioned {
176 reason.push_str(" [partitioned tables escalate lock severity]");
177 }
178 if is_stale {
179 reason.push_str(" [WARNING: Based on offline/stale statistics]");
180 }
181
182 violations.push(Violation {
183 source_range: None,
184 rule_id: self.id(),
185 operation_kind: OperationKind::AddConstraint,
186 object_kind: ObjectKind::Table,
187 object_name: alter.id.to_string(),
188 tier,
189 reason,
190 recipe: self.recipe(),
191 dedup_key: None,
192 sql: None,
193 });
194 }
195 AlterTableActionMutation::SetNotNull { column } => {
196 let has_fast_path = pre_state
197 .relations
198 .get(&alter.id)
199 .map(|r| {
200 r.get_column(column)
201 .map(|c| !c.is_nullable)
202 .unwrap_or(false)
203 })
204 .unwrap_or(false);
205
206 if !has_fast_path {
207 violations.push(Violation {
208 source_range: None,
209 rule_id: self.id(),
210 operation_kind: OperationKind::AddConstraint,
211 object_kind: ObjectKind::Table,
212 object_name: format!("{}.{}", alter.id, column),
213 tier,
214 reason: format!("Synchronous SET NOT NULL on {}.{}", alter.id, column),
215 recipe: "Add CHECK constraint NOT VALID, then VALIDATE separately.",
216 dedup_key: None,
217 sql: None,
218 });
219 }
220 }
221 AlterTableActionMutation::AddUniqueConstraint
222 | AlterTableActionMutation::AddPrimaryKeyConstraint => {
223 let mut reason =
224 format!("Adding a UNIQUE or PRIMARY KEY constraint to {}", alter.id);
225 if is_stale {
226 reason.push_str(" [WARNING: Based on offline/stale statistics]");
227 }
228
229 violations.push(Violation { source_range: None,
230 rule_id: "blocking-index-constraint",
231 operation_kind: OperationKind::AddConstraint,
232 object_kind: ObjectKind::Table,
233 object_name: alter.id.to_string(),
234 tier,
235 reason,
236 recipe: "Build a UNIQUE index CONCURRENTLY first, then add the constraint USING INDEX.",
237 dedup_key: None,
238 sql: None,
239 });
240 }
241 AlterTableActionMutation::SetStorage { column } => {
242 let mut reason = format!(
243 "Changing storage parameter for {}.{} causes a table rewrite",
244 alter.id, column
245 );
246 if is_stale {
247 reason.push_str(" [WARNING: Based on offline/stale statistics]");
248 }
249
250 violations.push(Violation { source_range: None,
251 rule_id: "table-rewrite-storage",
252 operation_kind: OperationKind::AlterColumnType,
253 object_kind: ObjectKind::Table,
254 object_name: format!("{}.{}", alter.id, column),
255 tier,
256 reason,
257 recipe: "Changing column storage requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
258 dedup_key: None,
259 sql: None,
260 });
261 }
262 AlterTableActionMutation::SetAccessMethod => {
263 let mut reason = format!(
264 "Changing access method for {} causes a table rewrite",
265 alter.id
266 );
267 if is_stale {
268 reason.push_str(" [WARNING: Based on offline/stale statistics]");
269 }
270
271 violations.push(Violation { source_range: None,
272 rule_id: "table-rewrite-access-method",
273 operation_kind: OperationKind::AlterColumnType,
274 object_kind: ObjectKind::Table,
275 object_name: alter.id.to_string(),
276 tier,
277 reason,
278 recipe: "Changing table access method requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
279 dedup_key: None,
280 sql: None,
281 });
282 }
283 _ => {}
284 }
285 }
286 violations
287 }
288}