Skip to main content

safe_migrate/rules/
constraints.rs

1// FILE: src/rules/constraints.rs
2use 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            // Get child table properties
39            let (is_temp, mut is_stale, child_rows) = match pre_state.relations.get(&alter.id) {
40                Some(rel) => {
41                    // BUG FIX: Only mark as stale if it actually existed in the baseline database!
42                    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 the table being altered is a temp table, schema locks don't block other sessions.
53            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            // Evaluate max locked rows based on the specific action
83            let max_locked_rows = match &alter.action {
84                AlterTableActionMutation::AddForeignKey { to_table, .. } => {
85                    // BUG FIX: Foreign keys lock BOTH the child and the parent table.
86                    // We must escalate the lock tier if the parent table is massive, even if the child is empty.
87                    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            // FIX: Evaluate the violation tier using granular RuleConfig overrides
106            let tier1_threshold = config.rule_tier1_threshold(self.id());
107            let tier2_threshold = config.rule_tier2_threshold(self.id());
108
109            // Check if either table is partitioned (partitioned tables have higher lock costs)
110            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            // Partitioned tables escalate lock severity: use 50% of threshold (floor at 1)
126            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            // Emit staleness warning if required (and if it's not going to be completely silent)
144            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            // Short-circuit if the locked tables are small enough to be safe
161            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}