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 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}