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