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 (
103 std::cmp::max(1, tier1_threshold / 2),
104 std::cmp::max(1, tier2_threshold / 2),
105 )
106 } else {
107 (tier1_threshold, tier2_threshold)
108 };
109
110 let tier = if max_locked_rows >= adjusted_tier1 {
111 ViolationTier::Tier1
112 } else if max_locked_rows >= adjusted_tier2 {
113 ViolationTier::Tier2
114 } else {
115 ViolationTier::Tier3
116 };
117
118 if is_stale && tier != ViolationTier::Tier3 {
120 let key = format!("{}_stale_{}", self.id(), alter.id);
121 violations.push(Violation { source_range: None,
122 rule_id: self.id(),
123 operation_kind: OperationKind::AddConstraint,
124 object_kind: ObjectKind::Table,
125 object_name: alter.id.to_string(),
126 tier: ViolationTier::Tier2,
127 reason: "Table statistics are offline/stale. Lock evaluations may be inaccurate.".to_string(),
128 recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
129 dedup_key: Some(key),
130 sql: None,
131 fk_dependency_related: false,
132 });
133 }
134
135 if tier == ViolationTier::Tier3 {
137 return violations;
138 }
139
140 match &alter.action {
141 AlterTableActionMutation::AddCheckConstraint {
142 constraint_name,
143 not_valid: false,
144 } => {
145 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
146 let mut reason = format!(
147 "Synchronous CHECK constraint '{}' addition on {}",
148 name_str, alter.id
149 );
150 if is_stale {
151 reason.push_str(" [WARNING: Based on offline/stale statistics]");
152 }
153
154 violations.push(Violation {
155 source_range: None,
156 rule_id: self.id(),
157 operation_kind: OperationKind::AddConstraint,
158 object_kind: ObjectKind::Table,
159 object_name: alter.id.to_string(),
160 tier,
161 reason,
162 recipe: self.recipe(),
163 dedup_key: None,
164 sql: None,
165 fk_dependency_related: false,
166 });
167 }
168 AlterTableActionMutation::AddForeignKey {
169 constraint_name,
170 not_valid: false,
171 to_table,
172 ..
173 } => {
174 let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
175
176 let mut reason = format!(
177 "Synchronous FOREIGN KEY constraint '{}' addition locks {} and {}",
178 name_str, alter.id, to_table
179 );
180 if is_partitioned {
181 reason.push_str(" [partitioned tables escalate lock severity]");
182 }
183 if is_stale {
184 reason.push_str(" [WARNING: Based on offline/stale statistics]");
185 }
186
187 violations.push(Violation {
188 source_range: None,
189 rule_id: self.id(),
190 operation_kind: OperationKind::AddConstraint,
191 object_kind: ObjectKind::Table,
192 object_name: alter.id.to_string(),
193 tier,
194 reason,
195 recipe: self.recipe(),
196 dedup_key: None,
197 sql: None,
198 fk_dependency_related: false,
199 });
200 }
201 AlterTableActionMutation::SetNotNull { column } => {
202 let has_fast_path = pre_state
203 .relations
204 .get(&alter.id)
205 .map(|r| {
206 r.get_column(column)
207 .map(|c| !c.is_nullable)
208 .unwrap_or(false)
209 })
210 .unwrap_or(false);
211
212 if !has_fast_path {
213 violations.push(Violation {
214 source_range: None,
215 rule_id: self.id(),
216 operation_kind: OperationKind::AddConstraint,
217 object_kind: ObjectKind::Table,
218 object_name: format!("{}.{}", alter.id, column),
219 tier,
220 reason: format!("Synchronous SET NOT NULL on {}.{}", alter.id, column),
221 recipe: "Add CHECK constraint NOT VALID, then VALIDATE separately.",
222 dedup_key: None,
223 sql: None,
224 fk_dependency_related: false,
225 });
226 }
227 }
228 AlterTableActionMutation::AddUniqueConstraint
229 | AlterTableActionMutation::AddPrimaryKeyConstraint => {
230 let mut reason =
231 format!("Adding a UNIQUE or PRIMARY KEY constraint to {}", alter.id);
232 if is_stale {
233 reason.push_str(" [WARNING: Based on offline/stale statistics]");
234 }
235
236 violations.push(Violation { source_range: None,
237 rule_id: "blocking-index-constraint",
238 operation_kind: OperationKind::AddConstraint,
239 object_kind: ObjectKind::Table,
240 object_name: alter.id.to_string(),
241 tier,
242 reason,
243 recipe: "Build a UNIQUE index CONCURRENTLY first, then add the constraint USING INDEX.",
244 dedup_key: None,
245 sql: None,
246 fk_dependency_related: false,
247 });
248 }
249 AlterTableActionMutation::SetStorage { column } => {
250 let mut reason = format!(
251 "Changing storage parameter for {}.{} causes a table rewrite",
252 alter.id, column
253 );
254 if is_stale {
255 reason.push_str(" [WARNING: Based on offline/stale statistics]");
256 }
257
258 violations.push(Violation { source_range: None,
259 rule_id: "table-rewrite-storage",
260 operation_kind: OperationKind::AlterColumnType,
261 object_kind: ObjectKind::Table,
262 object_name: format!("{}.{}", alter.id, column),
263 tier,
264 reason,
265 recipe: "Changing column storage requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
266 dedup_key: None,
267 sql: None,
268 fk_dependency_related: false,
269 });
270 }
271 AlterTableActionMutation::SetAccessMethod => {
272 let mut reason = format!(
273 "Changing access method for {} causes a table rewrite",
274 alter.id
275 );
276 if is_stale {
277 reason.push_str(" [WARNING: Based on offline/stale statistics]");
278 }
279
280 violations.push(Violation { source_range: None,
281 rule_id: "table-rewrite-access-method",
282 operation_kind: OperationKind::AlterColumnType,
283 object_kind: ObjectKind::Table,
284 object_name: alter.id.to_string(),
285 tier,
286 reason,
287 recipe: "Changing table access method requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
288 dedup_key: None,
289 sql: None,
290 fk_dependency_related: false,
291 });
292 }
293 _ => {}
294 }
295 }
296 violations
297 }
298}