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::ast::identifiers::ObjectId;
5use crate::engine::config::Config;
6use crate::model::relation::RelationState;
7use crate::report::violations::{Violation, ViolationTier};
8use crate::rules::Rule;
9use std::collections::HashMap;
10
11pub struct ConcurrentInsideTransactionRule;
12
13impl Rule for ConcurrentInsideTransactionRule {
14    fn id(&self) -> &'static str {
15        "concurrent-in-transaction"
16    }
17    fn default_tier(&self) -> ViolationTier {
18        ViolationTier::Tier1
19    }
20    fn recipe(&self) -> &'static str {
21        "PostgreSQL does not allow CREATE/DROP INDEX CONCURRENTLY inside a transaction block (BEGIN/COMMIT)."
22    }
23
24    fn evaluate(
25        &self,
26        mutation: &Mutation,
27        _result: &MutationResult,
28        _pre_relations: &HashMap<ObjectId, RelationState>,
29        state: &AnalysisState,
30        _config: &Config,
31        _cascade: Option<&CascadeResult>,
32    ) -> Vec<Violation> {
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 {
39                        rule_id: self.id(),
40                        title: format!("CREATE INDEX CONCURRENTLY on {} inside a transaction block", c.table),
41                        tier: self.default_tier(),
42                        recipe: "Move CONCURRENTLY index creation outside of explicit transaction blocks.",
43                        dedup_key: Some(format!("{}_{}", self.id(), c.id)),
44                    });
45                }
46                Mutation::DropIndex(d) if d.concurrently => {
47                    violations.push(Violation {
48                        rule_id: self.id(),
49                        title: format!(
50                            "DROP INDEX CONCURRENTLY on {} inside a transaction block",
51                            d.id
52                        ),
53                        tier: self.default_tier(),
54                        recipe: self.recipe(),
55                        dedup_key: None,
56                    });
57                }
58                _ => {}
59            }
60        }
61
62        violations
63    }
64}
65
66pub struct VacuumFullRule;
67
68impl Rule for VacuumFullRule {
69    fn id(&self) -> &'static str {
70        "vacuum-full"
71    }
72    fn default_tier(&self) -> ViolationTier {
73        ViolationTier::Tier1
74    }
75    fn recipe(&self) -> &'static str {
76        "VACUUM FULL rewrites the entire table and requires an ACCESS EXCLUSIVE lock. Run this manually outside of migration pipelines."
77    }
78
79    fn evaluate(
80        &self,
81        mutation: &Mutation,
82        _result: &MutationResult,
83        _pre_relations: &HashMap<ObjectId, RelationState>,
84        _state: &AnalysisState,
85        _config: &Config,
86        _cascade: Option<&CascadeResult>,
87    ) -> Vec<Violation> {
88        if let Mutation::Vacuum { is_full: true } = mutation {
89            return vec![Violation {
90                rule_id: self.id(),
91                title: "VACUUM FULL requires an ACCESS EXCLUSIVE lock".to_string(),
92                tier: self.default_tier(),
93                recipe: self.recipe(),
94                dedup_key: None,
95            }];
96        }
97        vec![]
98    }
99}