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