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            // Evaluate max locked rows based on the specific action
58            let max_locked_rows = match &alter.action {
59                AlterTableActionMutation::AddForeignKey { to_table, .. } => {
60                    // BUG FIX: Foreign keys lock BOTH the child and the parent table.
61                    // We must escalate the lock tier if the parent table is massive, even if the child is empty.
62                    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            // FIX: Evaluate the violation tier using granular RuleConfig overrides
81            let tier1_threshold = config.rule_tier1_threshold(self.id());
82            let tier2_threshold = config.rule_tier2_threshold(self.id());
83
84            // Check if either table is partitioned (partitioned tables have higher lock costs)
85            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            // Partitioned tables escalate lock severity: use 50% of threshold (floor at 1)
101            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            // Emit staleness warning if required (and if it's not going to be completely silent)
119            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            // Short-circuit if the locked tables are small enough to be safe
136            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}