Skip to main content

safe_migrate/rules/
transactions.rs

1// FILE: src/rules/transactions.rs
2use crate::analysis::mutations::Mutation;
3use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
4use crate::engine::config::Config;
5use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
6use crate::rules::Rule;
7
8pub struct ConcurrentInsideTransactionRule;
9
10impl Rule for ConcurrentInsideTransactionRule {
11    fn id(&self) -> &'static str {
12        "concurrent-in-transaction"
13    }
14    fn default_tier(&self) -> ViolationTier {
15        ViolationTier::Tier1
16    }
17    fn recipe(&self) -> &'static str {
18        "PostgreSQL does not allow CREATE/DROP INDEX CONCURRENTLY inside a transaction block (BEGIN/COMMIT)."
19    }
20
21    fn evaluate(
22        &self,
23        mutation: &Mutation,
24        result: &MutationResult,
25        _pre_state: &crate::analysis::state::PreState,
26        state: &AnalysisState,
27        _config: &Config,
28        _cascade: Option<&CascadeResult>,
29    ) -> Vec<Violation> {
30        if *result == MutationResult::Skipped {
31            return vec![];
32        }
33        let mut violations = Vec::new();
34
35        if !state.local.transactions.is_empty() {
36            match mutation {
37                Mutation::CreateIndex(c) if c.concurrently => {
38                    violations.push(Violation { source_range: None,
39                        rule_id: self.id(),
40                        operation_kind: OperationKind::CreateIndex,
41                        object_kind: ObjectKind::Index,
42                        object_name: c.id.to_string(),
43                        tier: self.default_tier(),
44                        reason: format!("CREATE INDEX CONCURRENTLY on {} inside a transaction block", c.table),
45                        recipe: "Move CONCURRENTLY index creation outside of explicit transaction blocks.",
46                        dedup_key: Some(format!("{}_{}", self.id(), c.id)),
47                                    sql: None,
48                    });
49                }
50                Mutation::DropIndex(d) if d.concurrently => {
51                    violations.push(Violation {
52                        source_range: None,
53                        rule_id: self.id(),
54                        operation_kind: OperationKind::DropIndex,
55                        object_kind: ObjectKind::Index,
56                        object_name: d.id.to_string(),
57                        tier: self.default_tier(),
58                        reason: format!(
59                            "DROP INDEX CONCURRENTLY on {} inside a transaction block",
60                            d.id
61                        ),
62                        recipe: self.recipe(),
63                        dedup_key: None,
64                        sql: None,
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::Tier1
83    }
84    fn recipe(&self) -> &'static str {
85        "ALTER TYPE ... ADD VALUE cannot be executed inside a transaction block in PostgreSQL."
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        {
100            return vec![Violation {
101                source_range: None,
102                rule_id: self.id(),
103                operation_kind: OperationKind::AlterType,
104                object_kind: ObjectKind::Type,
105                object_name: alter.id.to_string(),
106                tier: self.default_tier(),
107                reason: format!("ALTER TYPE {} ADD VALUE inside transaction", alter.id),
108                recipe: self.recipe(),
109                dedup_key: None,
110                sql: None,
111            }];
112        }
113        vec![]
114    }
115}
116
117pub struct VacuumFullRule;
118
119impl Rule for VacuumFullRule {
120    fn id(&self) -> &'static str {
121        "vacuum-full"
122    }
123    fn default_tier(&self) -> ViolationTier {
124        ViolationTier::Tier1
125    }
126    fn recipe(&self) -> &'static str {
127        "VACUUM FULL rewrites the entire table and requires an ACCESS EXCLUSIVE lock. Run this manually outside of migration pipelines."
128    }
129
130    fn evaluate(
131        &self,
132        mutation: &Mutation,
133        _result: &MutationResult,
134        _pre_state: &crate::analysis::state::PreState,
135        _state: &AnalysisState,
136        _config: &Config,
137        _cascade: Option<&CascadeResult>,
138    ) -> Vec<Violation> {
139        if let Mutation::Vacuum {
140            is_full: true,
141            table_id,
142        } = mutation
143        {
144            let object_name = table_id
145                .as_ref()
146                .map(|id| id.to_string())
147                .unwrap_or_else(|| "<all tables>".to_string());
148            return vec![Violation {
149                source_range: None,
150                rule_id: self.id(),
151                operation_kind: OperationKind::VacuumFull,
152                object_kind: ObjectKind::Table,
153                object_name,
154                tier: self.default_tier(),
155                reason: "VACUUM FULL requires an ACCESS EXCLUSIVE lock".to_string(),
156                recipe: self.recipe(),
157                dedup_key: None,
158                sql: None,
159            }];
160        }
161        vec![]
162    }
163}