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 action_is_relevant = matches!(
58 &alter.action,
59 AlterTableActionMutation::AddCheckConstraint {
60 not_valid: false,
61 ..
62 } | AlterTableActionMutation::AddForeignKey {
63 not_valid: false,
64 ..
65 } | AlterTableActionMutation::SetNotNull { .. }
66 | AlterTableActionMutation::AddUniqueConstraint {
67 using_index: None,
68 ..
69 }
70 | AlterTableActionMutation::AddPrimaryKeyConstraint {
71 using_index: None,
72 ..
73 }
74 | AlterTableActionMutation::AddExcludeConstraint { .. }
75 | AlterTableActionMutation::SetStorage { .. }
76 | AlterTableActionMutation::SetAccessMethod
77 );
78 if !action_is_relevant {
79 return violations;
80 }
81
82 let max_locked_rows = match &alter.action {
84 AlterTableActionMutation::AddForeignKey { to_table, .. } => {
85 let parent_rows = match pre_state.relations.get(to_table) {
88 Some(parent_rel) => {
89 if parent_rel.is_stale() && state.baseline_relations.contains(to_table)
90 {
91 is_stale = true;
92 }
93 parent_rel.estimated_rows.unwrap_or(config.default_rows)
94 }
95 None => {
96 is_stale = true;
97 config.default_rows
98 }
99 };
100 std::cmp::max(child_rows, parent_rows)
101 }
102 _ => child_rows,
103 };
104
105 let tier1_threshold = config.rule_tier1_threshold(self.id());
107 let tier2_threshold = config.rule_tier2_threshold(self.id());
108
109 let is_partitioned =
111 if let AlterTableActionMutation::AddForeignKey { to_table, .. } = &alter.action {
112 let child_partitioned = pre_state
113 .relations
114 .get(&alter.id)
115 .is_some_and(|rel| rel.partition_type.is_some());
116 let parent_partitioned = pre_state
117 .relations
118 .get(to_table)
119 .is_some_and(|rel| rel.partition_type.is_some());
120 child_partitioned || parent_partitioned
121 } else {
122 false
123 };
124
125 let (adjusted_tier1, adjusted_tier2) = if is_partitioned {
127 (
128 std::cmp::max(1, tier1_threshold / 2),
129 std::cmp::max(1, tier2_threshold / 2),
130 )
131 } else {
132 (tier1_threshold, tier2_threshold)
133 };
134
135 let tier = if max_locked_rows >= adjusted_tier1 {
136 ViolationTier::Tier1
137 } else if max_locked_rows >= adjusted_tier2 {
138 ViolationTier::Tier2
139 } else {
140 ViolationTier::Tier3
141 };
142
143 if is_stale && tier != ViolationTier::Tier3 {
145 let key = format!("{}_stale_{}", self.id(), alter.id);
146 violations.push(Violation { source_range: None,
147 rule_id: self.id(),
148 operation_kind: OperationKind::AddConstraint,
149 object_kind: ObjectKind::Table,
150 object_name: alter.id.to_string(),
151 tier: ViolationTier::Tier2,
152 reason: "Table statistics are offline/stale. Lock evaluations may be inaccurate.".to_string(),
153 recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
154 dedup_key: Some(key),
155 sql: None,
156 fk_dependency_related: false,
157 });
158 }
159
160 if tier == ViolationTier::Tier3 {
162 return violations;
163 }
164
165 match &alter.action {
166 AlterTableActionMutation::AddCheckConstraint {
167 constraint_name,
168 not_valid: false,
169 } => {
170 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
171 let mut reason = format!(
172 "Synchronous CHECK constraint '{}' addition on {}",
173 name_str, alter.id
174 );
175 if is_stale {
176 reason.push_str(" [WARNING: Based on offline/stale statistics]");
177 }
178
179 violations.push(Violation {
180 source_range: None,
181 rule_id: self.id(),
182 operation_kind: OperationKind::AddConstraint,
183 object_kind: ObjectKind::Table,
184 object_name: alter.id.to_string(),
185 tier,
186 reason,
187 recipe: self.recipe(),
188 dedup_key: None,
189 sql: None,
190 fk_dependency_related: false,
191 });
192 }
193 AlterTableActionMutation::AddForeignKey {
194 constraint_name,
195 not_valid: false,
196 to_table,
197 ..
198 } => {
199 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
200
201 let mut reason = format!(
202 "Synchronous FOREIGN KEY constraint '{}' addition locks {} and {}",
203 name_str, alter.id, to_table
204 );
205 if is_partitioned {
206 reason.push_str(" [partitioned tables escalate lock severity]");
207 }
208 if is_stale {
209 reason.push_str(" [WARNING: Based on offline/stale statistics]");
210 }
211
212 violations.push(Violation {
213 source_range: None,
214 rule_id: self.id(),
215 operation_kind: OperationKind::AddConstraint,
216 object_kind: ObjectKind::Table,
217 object_name: alter.id.to_string(),
218 tier,
219 reason,
220 recipe: self.recipe(),
221 dedup_key: None,
222 sql: None,
223 fk_dependency_related: false,
224 });
225 }
226 AlterTableActionMutation::SetNotNull { column } => {
227 let has_fast_path = pre_state
228 .relations
229 .get(&alter.id)
230 .map(|r| {
231 r.get_column(column)
232 .map(|c| !c.is_nullable)
233 .unwrap_or(false)
234 })
235 .unwrap_or(false);
236
237 if !has_fast_path {
238 violations.push(Violation {
239 source_range: None,
240 rule_id: self.id(),
241 operation_kind: OperationKind::AddConstraint,
242 object_kind: ObjectKind::Table,
243 object_name: format!("{}.{}", alter.id, column),
244 tier,
245 reason: format!("Synchronous SET NOT NULL on {}.{}", alter.id, column),
246 recipe: "Add CHECK constraint NOT VALID, then VALIDATE separately.",
247 dedup_key: None,
248 sql: None,
249 fk_dependency_related: false,
250 });
251 }
252 }
253 AlterTableActionMutation::AddUniqueConstraint {
254 using_index: None, ..
255 }
256 | AlterTableActionMutation::AddPrimaryKeyConstraint {
257 using_index: None, ..
258 }
259 | AlterTableActionMutation::AddExcludeConstraint { .. } => {
260 let mut reason = format!(
261 "Building an index for a UNIQUE, PRIMARY KEY, or EXCLUDE constraint on {}",
262 alter.id
263 );
264 if is_stale {
265 reason.push_str(" [WARNING: Based on offline/stale statistics]");
266 }
267
268 violations.push(Violation { source_range: None,
269 rule_id: "blocking-index-constraint",
270 operation_kind: OperationKind::AddConstraint,
271 object_kind: ObjectKind::Table,
272 object_name: alter.id.to_string(),
273 tier,
274 reason,
275 recipe: "Build a UNIQUE index CONCURRENTLY first, then add the constraint USING INDEX.",
276 dedup_key: None,
277 sql: None,
278 fk_dependency_related: false,
279 });
280 }
281 AlterTableActionMutation::SetStorage { column } => {
282 let mut reason = format!(
283 "Changing storage parameter for {}.{} causes a table rewrite",
284 alter.id, column
285 );
286 if is_stale {
287 reason.push_str(" [WARNING: Based on offline/stale statistics]");
288 }
289
290 violations.push(Violation { source_range: None,
291 rule_id: "table-rewrite-storage",
292 operation_kind: OperationKind::AlterColumnType,
293 object_kind: ObjectKind::Table,
294 object_name: format!("{}.{}", alter.id, column),
295 tier,
296 reason,
297 recipe: "Changing column storage requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
298 dedup_key: None,
299 sql: None,
300 fk_dependency_related: false,
301 });
302 }
303 AlterTableActionMutation::SetAccessMethod => {
304 let mut reason = format!(
305 "Changing access method for {} causes a table rewrite",
306 alter.id
307 );
308 if is_stale {
309 reason.push_str(" [WARNING: Based on offline/stale statistics]");
310 }
311
312 violations.push(Violation { source_range: None,
313 rule_id: "table-rewrite-access-method",
314 operation_kind: OperationKind::AlterColumnType,
315 object_kind: ObjectKind::Table,
316 object_name: alter.id.to_string(),
317 tier,
318 reason,
319 recipe: "Changing table access method requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
320 dedup_key: None,
321 sql: None,
322 fk_dependency_related: false,
323 });
324 }
325 _ => {}
326 }
327 }
328 violations
329 }
330}