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::ast::identifiers::ObjectId;
5use crate::engine::config::Config;
6use crate::model::relation::{Persistence, RelationState};
7use crate::report::violations::{Violation, ViolationTier};
8use crate::rules::Rule;
9use std::collections::HashMap;
10
11pub struct BlockingConstraintRule;
12
13impl Rule for BlockingConstraintRule {
14    fn id(&self) -> &'static str {
15        "blocking-constraint"
16    }
17    fn default_tier(&self) -> ViolationTier {
18        ViolationTier::Tier1
19    }
20    fn recipe(&self) -> &'static str {
21        "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."
22    }
23
24    fn evaluate(
25        &self,
26        mutation: &Mutation,
27        result: &MutationResult,
28        pre_relations: &HashMap<ObjectId, RelationState>,
29        state: &AnalysisState,
30        config: &Config,
31        _cascade: Option<&CascadeResult>,
32    ) -> Vec<Violation> {
33        if *result == MutationResult::Skipped {
34            return vec![];
35        }
36
37        let mut violations = Vec::new();
38
39        if let Mutation::AlterTable(alter) = mutation {
40            // Get child table properties
41            let (is_temp, mut is_stale, child_rows) = match pre_relations.get(&alter.id) {
42                Some(rel) => {
43                    // BUG FIX: Only mark as stale if it actually existed in the baseline database!
44                    let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id);
45                    (
46                        rel.persistence == Persistence::Temporary,
47                        stale,
48                        rel.estimated_rows.unwrap_or(config.default_rows),
49                    )
50                }
51                None => (false, true, config.default_rows),
52            };
53
54            // If the table being altered is a temp table, schema locks don't block other sessions.
55            if is_temp {
56                return violations;
57            }
58
59            // Evaluate max locked rows based on the specific action
60            let max_locked_rows = match &alter.action {
61                AlterTableActionMutation::AddForeignKey { to_table, .. } => {
62                    // BUG FIX: Foreign keys lock BOTH the child and the parent table.
63                    // We must escalate the lock tier if the parent table is massive, even if the child is empty.
64                    let parent_rows = match pre_relations.get(to_table) {
65                        Some(parent_rel) => {
66                            if parent_rel.is_stale() && state.baseline_relations.contains(to_table)
67                            {
68                                is_stale = true;
69                            }
70                            parent_rel.estimated_rows.unwrap_or(config.default_rows)
71                        }
72                        None => {
73                            is_stale = true;
74                            config.default_rows
75                        }
76                    };
77                    std::cmp::max(child_rows, parent_rows)
78                }
79                _ => child_rows,
80            };
81
82            // FIX: Evaluate the violation tier using granular RuleConfig overrides
83            let tier1_threshold = config.rule_tier1_threshold(self.id());
84            let tier2_threshold = config.rule_tier2_threshold(self.id());
85
86            let tier = if max_locked_rows >= tier1_threshold {
87                ViolationTier::Tier1
88            } else if max_locked_rows >= tier2_threshold {
89                ViolationTier::Tier2
90            } else {
91                ViolationTier::Tier3
92            };
93
94            // Emit staleness warning if required (and if it's not going to be completely silent)
95            if is_stale && tier != ViolationTier::Tier3 {
96                let key = format!("{}_stale_{}", self.id(), alter.id);
97                violations.push(Violation {
98                    rule_id: self.id(),
99                    title: "Table statistics are offline/stale. Lock evaluations may be inaccurate.".to_string(),
100                    tier: ViolationTier::Tier2,
101                    recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
102                    dedup_key: Some(key),
103                });
104            }
105
106            // Short-circuit if the locked tables are small enough to be safe
107            if tier == ViolationTier::Tier3 {
108                return violations;
109            }
110
111            match &alter.action {
112                AlterTableActionMutation::AddCheckConstraint {
113                    constraint_name,
114                    not_valid: false,
115                } => {
116                    let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
117                    let mut title = format!(
118                        "Synchronous CHECK constraint '{}' addition on {}",
119                        name_str, alter.id
120                    );
121                    if is_stale {
122                        title.push_str(" [WARNING: Based on offline/stale statistics]");
123                    }
124
125                    violations.push(Violation {
126                        rule_id: self.id(),
127                        title,
128                        tier,
129                        recipe: self.recipe(),
130                        dedup_key: None,
131                    });
132                }
133                AlterTableActionMutation::AddForeignKey {
134                    constraint_name,
135                    not_valid: false,
136                    to_table,
137                    ..
138                } => {
139                    let name_str = constraint_name.as_deref().unwrap_or("<unnamed>");
140
141                    // Update title to explicitly mention the parent table that caused the escalation
142                    let mut title = format!(
143                        "Synchronous FOREIGN KEY constraint '{}' addition locks {} and {}",
144                        name_str, alter.id, to_table
145                    );
146                    if is_stale {
147                        title.push_str(" [WARNING: Based on offline/stale statistics]");
148                    }
149
150                    violations.push(Violation {
151                        rule_id: self.id(),
152                        title,
153                        tier,
154                        recipe: self.recipe(),
155                        dedup_key: None,
156                    });
157                }
158                AlterTableActionMutation::SetNotNull { column } => {
159                    let has_fast_path = pre_relations
160                        .get(&alter.id)
161                        .map(|r| {
162                            r.get_column(column)
163                                .map(|c| !c.is_nullable)
164                                .unwrap_or(false)
165                        })
166                        .unwrap_or(false);
167
168                    if !has_fast_path {
169                        violations.push(Violation {
170                            rule_id: self.id(),
171                            title: format!("Synchronous SET NOT NULL on {}.{}", alter.id, column),
172                            tier,
173                            recipe: "Add CHECK constraint NOT VALID, then VALIDATE separately.",
174                            dedup_key: None,
175                        });
176                    }
177                }
178                AlterTableActionMutation::AddUniqueConstraint
179                | AlterTableActionMutation::AddPrimaryKeyConstraint => {
180                    let mut title =
181                        format!("Adding a UNIQUE or PRIMARY KEY constraint to {}", alter.id);
182                    if is_stale {
183                        title.push_str(" [WARNING: Based on offline/stale statistics]");
184                    }
185
186                    violations.push(Violation {
187                        rule_id: "blocking-index-constraint", // Maps to a different recipe/rule functionally
188                        title,
189                        tier,
190                        recipe: "Build a UNIQUE index CONCURRENTLY first, then add the constraint USING INDEX.",
191                        dedup_key: None,
192                    });
193                }
194                AlterTableActionMutation::SetStorage { column } => {
195                    let mut title = format!(
196                        "Changing storage parameter for {}.{} causes a table rewrite",
197                        alter.id, column
198                    );
199                    if is_stale {
200                        title.push_str(" [WARNING: Based on offline/stale statistics]");
201                    }
202
203                    violations.push(Violation {
204                        rule_id: "table-rewrite-storage",
205                        title,
206                        tier,
207                        recipe: "Changing column storage requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
208                        dedup_key: None,
209                    });
210                }
211                AlterTableActionMutation::SetAccessMethod => {
212                    let mut title = format!(
213                        "Changing access method for {} causes a table rewrite",
214                        alter.id
215                    );
216                    if is_stale {
217                        title.push_str(" [WARNING: Based on offline/stale statistics]");
218                    }
219
220                    violations.push(Violation {
221                        rule_id: "table-rewrite-access-method",
222                        title,
223                        tier,
224                        recipe: "Changing table access method requires an ACCESS EXCLUSIVE lock. Execute during a planned maintenance window.",
225                        dedup_key: None,
226                    });
227                }
228                _ => {}
229            }
230        }
231        violations
232    }
233}