Skip to main content

safe_migrate/rules/
transactions.rs

1use crate::analysis::mutations::{AlterTypeActionMutation, Mutation};
2use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
3use crate::engine::config::Config;
4use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
5use crate::rules::Rule;
6
7pub struct ConcurrentInsideTransactionRule;
8
9impl Rule for ConcurrentInsideTransactionRule {
10    fn id(&self) -> &'static str {
11        "concurrent-in-transaction"
12    }
13    fn default_tier(&self) -> ViolationTier {
14        ViolationTier::Tier1
15    }
16    fn recipe(&self) -> &'static str {
17        "PostgreSQL does not allow CREATE/DROP INDEX CONCURRENTLY inside a transaction block (BEGIN/COMMIT)."
18    }
19
20    fn evaluate(
21        &self,
22        mutation: &Mutation,
23        _result: &MutationResult,
24        _pre_state: &crate::analysis::state::PreState,
25        state: &AnalysisState,
26        _config: &Config,
27        _cascade: Option<&CascadeResult>,
28    ) -> Vec<Violation> {
29        let mut violations = Vec::new();
30
31        if !state.local.transactions.is_empty() {
32            match mutation {
33                Mutation::CreateIndex(c) if c.concurrently => {
34                    violations.push(Violation { source_range: None,
35                        rule_id: self.id(),
36                        operation_kind: OperationKind::CreateIndex,
37                        object_kind: ObjectKind::Index,
38                        object_name: c.id.to_string(),
39                        tier: self.default_tier(),
40                        reason: format!("CREATE INDEX CONCURRENTLY on {} inside a transaction block", c.table),
41                        recipe: "Move CONCURRENTLY index creation outside of explicit transaction blocks.",
42                        dedup_key: Some(format!("{}_{}", self.id(), c.id)),
43                                    sql: None,
44                                    fk_dependency_related: false,
45                    });
46                }
47                Mutation::DropIndex(d) if d.concurrently => {
48                    violations.push(Violation {
49                        source_range: None,
50                        rule_id: self.id(),
51                        operation_kind: OperationKind::DropIndex,
52                        object_kind: ObjectKind::Index,
53                        object_name: d.id.to_string(),
54                        tier: self.default_tier(),
55                        reason: format!(
56                            "DROP INDEX CONCURRENTLY on {} inside a transaction block",
57                            d.id
58                        ),
59                        recipe: self.recipe(),
60                        dedup_key: None,
61                        sql: None,
62                        fk_dependency_related: false,
63                    });
64                }
65                _ => {}
66            }
67        }
68
69        violations
70    }
71}
72
73pub struct AlterTypeAddValueRule;
74
75impl Rule for AlterTypeAddValueRule {
76    fn id(&self) -> &'static str {
77        "alter-type-add-value-txn"
78    }
79    fn default_tier(&self) -> ViolationTier {
80        ViolationTier::Tier2
81    }
82    fn recipe(&self) -> &'static str {
83        "Commit before later statements use the new enum value, or put the dependent work in a later migration."
84    }
85
86    fn evaluate(
87        &self,
88        mutation: &Mutation,
89        _result: &MutationResult,
90        _pre_state: &crate::analysis::state::PreState,
91        state: &AnalysisState,
92        _config: &Config,
93        _cascade: Option<&CascadeResult>,
94    ) -> Vec<Violation> {
95        if !state.local.transactions.is_empty()
96            && let Mutation::AlterType(alter) = mutation
97            && matches!(alter.action, AlterTypeActionMutation::AddValue { .. })
98        {
99            return vec![Violation {
100                source_range: None,
101                rule_id: self.id(),
102                operation_kind: OperationKind::AlterType,
103                object_kind: ObjectKind::Type,
104                object_name: alter.id.to_string(),
105                tier: self.default_tier(),
106                reason: format!(
107                    "ALTER TYPE {} ADD VALUE is inside a transaction; PostgreSQL does not allow the new value to be used until commit",
108                    alter.id
109                ),
110                recipe: self.recipe(),
111                dedup_key: None,
112                sql: None,
113                fk_dependency_related: false,
114            }];
115        }
116        vec![]
117    }
118}
119
120pub struct VacuumFullRule;
121
122impl Rule for VacuumFullRule {
123    fn id(&self) -> &'static str {
124        "vacuum-full"
125    }
126    fn default_tier(&self) -> ViolationTier {
127        ViolationTier::Tier1
128    }
129    fn recipe(&self) -> &'static str {
130        "VACUUM FULL rewrites the entire table and requires an ACCESS EXCLUSIVE lock. Run this manually outside of migration pipelines."
131    }
132
133    fn evaluate(
134        &self,
135        mutation: &Mutation,
136        _result: &MutationResult,
137        _pre_state: &crate::analysis::state::PreState,
138        _state: &AnalysisState,
139        _config: &Config,
140        _cascade: Option<&CascadeResult>,
141    ) -> Vec<Violation> {
142        if let Mutation::Vacuum {
143            is_full: true,
144            table_id,
145        } = mutation
146        {
147            let object_name = table_id
148                .as_ref()
149                .map(|id| id.to_string())
150                .unwrap_or_else(|| "<all tables>".to_string());
151            return vec![Violation {
152                source_range: None,
153                rule_id: self.id(),
154                operation_kind: OperationKind::VacuumFull,
155                object_kind: ObjectKind::Table,
156                object_name,
157                tier: self.default_tier(),
158                reason: "VACUUM FULL requires an ACCESS EXCLUSIVE lock".to_string(),
159                recipe: self.recipe(),
160                dedup_key: None,
161                sql: None,
162                fk_dependency_related: false,
163            }];
164        }
165        vec![]
166    }
167}