safe_migrate/rules/
transactions.rs1use 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}