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                    for id in &d.ids {
49                        violations.push(Violation {
50                            source_range: None,
51                            rule_id: self.id(),
52                            operation_kind: OperationKind::DropIndex,
53                            object_kind: ObjectKind::Index,
54                            object_name: id.to_string(),
55                            tier: self.default_tier(),
56                            reason: format!(
57                                "DROP INDEX CONCURRENTLY on {} inside a transaction block",
58                                id
59                            ),
60                            recipe: self.recipe(),
61                            dedup_key: None,
62                            sql: None,
63                            fk_dependency_related: false,
64                        });
65                    }
66                }
67                _ => {}
68            }
69        }
70
71        violations
72    }
73}
74
75pub struct AlterTypeAddValueRule;
76
77impl Rule for AlterTypeAddValueRule {
78    fn id(&self) -> &'static str {
79        "alter-type-add-value-txn"
80    }
81    fn default_tier(&self) -> ViolationTier {
82        ViolationTier::Tier2
83    }
84    fn recipe(&self) -> &'static str {
85        "Commit before later statements use the new enum value, or put the dependent work in a later migration."
86    }
87
88    fn evaluate(
89        &self,
90        mutation: &Mutation,
91        _result: &MutationResult,
92        _pre_state: &crate::analysis::state::PreState,
93        state: &AnalysisState,
94        _config: &Config,
95        _cascade: Option<&CascadeResult>,
96    ) -> Vec<Violation> {
97        if !state.local.transactions.is_empty()
98            && let Mutation::AlterType(alter) = mutation
99            && matches!(alter.action, AlterTypeActionMutation::AddValue { .. })
100        {
101            return vec![Violation {
102                source_range: None,
103                rule_id: self.id(),
104                operation_kind: OperationKind::AlterType,
105                object_kind: ObjectKind::Type,
106                object_name: alter.id.to_string(),
107                tier: self.default_tier(),
108                reason: format!(
109                    "ALTER TYPE {} ADD VALUE is inside a transaction; PostgreSQL does not allow the new value to be used until commit",
110                    alter.id
111                ),
112                recipe: self.recipe(),
113                dedup_key: None,
114                sql: None,
115                fk_dependency_related: false,
116            }];
117        }
118        vec![]
119    }
120}
121
122pub struct VacuumFullRule;
123
124impl Rule for VacuumFullRule {
125    fn id(&self) -> &'static str {
126        "vacuum-full"
127    }
128    fn default_tier(&self) -> ViolationTier {
129        ViolationTier::Tier1
130    }
131    fn recipe(&self) -> &'static str {
132        "VACUUM FULL rewrites the entire table and requires an ACCESS EXCLUSIVE lock. Run this manually outside of migration pipelines."
133    }
134
135    fn evaluate(
136        &self,
137        mutation: &Mutation,
138        _result: &MutationResult,
139        _pre_state: &crate::analysis::state::PreState,
140        _state: &AnalysisState,
141        _config: &Config,
142        _cascade: Option<&CascadeResult>,
143    ) -> Vec<Violation> {
144        if let Mutation::Vacuum {
145            is_full: true,
146            table_id,
147        } = mutation
148        {
149            let object_name = table_id
150                .as_ref()
151                .map(|id| id.to_string())
152                .unwrap_or_else(|| "<all tables>".to_string());
153            return vec![Violation {
154                source_range: None,
155                rule_id: self.id(),
156                operation_kind: OperationKind::VacuumFull,
157                object_kind: ObjectKind::Table,
158                object_name,
159                tier: self.default_tier(),
160                reason: "VACUUM FULL requires an ACCESS EXCLUSIVE lock".to_string(),
161                recipe: self.recipe(),
162                dedup_key: None,
163                sql: None,
164                fk_dependency_related: false,
165            }];
166        }
167        vec![]
168    }
169}