Skip to main content

safe_migrate/rules/
functions.rs

1use crate::analysis::mutations::Mutation;
2use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult, PreState};
3use crate::engine::config::Config;
4use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
5use crate::rules::Rule;
6
7pub struct FunctionVolatilityRule;
8
9impl Rule for FunctionVolatilityRule {
10    fn id(&self) -> &'static str {
11        "function-volatility-change"
12    }
13    fn default_tier(&self) -> ViolationTier {
14        ViolationTier::Tier2
15    }
16    fn recipe(&self) -> &'static str {
17        "Changing a function's volatility (e.g., IMMUTABLE -> VOLATILE) can invalidate existing indexes or change query plan stability."
18    }
19
20    fn evaluate(
21        &self,
22        mutation: &Mutation,
23        _result: &MutationResult,
24        pre_state: &PreState,
25        _state: &AnalysisState,
26        _config: &Config,
27        _cascade: Option<&CascadeResult>,
28    ) -> Vec<Violation> {
29        let mut violations = Vec::new();
30
31        if let Mutation::AlterFunction(alter) = mutation
32            && let Some(old_func) = pre_state.functions.get(&alter.id)
33            && let crate::analysis::facts::AlterFunctionAction::OptionsChange(new_opts) =
34                &alter.action
35            && let Some(nv) = new_opts.iter().find_map(|opt| {
36                if let crate::analysis::facts::FuncOptionFact::Volatility(v) = opt {
37                    match v {
38                        crate::analysis::facts::VolatilityKind::Volatile => {
39                            Some(crate::model::function::Volatility::Volatile)
40                        }
41                        crate::analysis::facts::VolatilityKind::Stable => {
42                            Some(crate::model::function::Volatility::Stable)
43                        }
44                        crate::analysis::facts::VolatilityKind::Immutable => {
45                            Some(crate::model::function::Volatility::Immutable)
46                        }
47                    }
48                } else {
49                    None
50                }
51            })
52        {
53            let ov = old_func.volatility.clone();
54            if ov != nv {
55                violations.push(Violation {
56                    source_range: None,
57                    rule_id: self.id(),
58                    operation_kind: OperationKind::AlterFunction,
59                    object_kind: ObjectKind::Function,
60                    object_name: alter.id.to_string(),
61                    tier: self.default_tier(),
62                    reason: format!(
63                        "Function {} volatility changed from {:?} to {:?}",
64                        alter.id, ov, nv
65                    ),
66                    recipe: self.recipe(),
67                    dedup_key: None,
68                    sql: None,
69                    fk_dependency_related: false,
70                });
71            }
72        }
73
74        violations
75    }
76}
77
78pub struct BrokenComputeRule;
79
80impl Rule for BrokenComputeRule {
81    fn id(&self) -> &'static str {
82        "broken-compute"
83    }
84    fn default_tier(&self) -> ViolationTier {
85        ViolationTier::Tier1
86    }
87    fn recipe(&self) -> &'static str {
88        "Drop or replace the dependent triggers first. Use CASCADE only after reviewing every dependent object."
89    }
90
91    fn evaluate(
92        &self,
93        mutation: &Mutation,
94        result: &MutationResult,
95        _pre_state: &PreState,
96        state: &AnalysisState,
97        _config: &Config,
98        _cascade_closure: Option<&CascadeResult>,
99    ) -> Vec<Violation> {
100        if !matches!(result, MutationResult::Conflict { .. }) {
101            return vec![];
102        }
103        if let Mutation::DropFunction(drop) = mutation
104            && !drop.cascade
105        {
106            for sig in &drop.signatures {
107                let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(","));
108                let schema = state.resolve_function_schema(&sig.name, &sig_str);
109                let function_id = crate::ast::identifiers::ObjectId::new(schema, sig_str);
110
111                let affected = state.local.graph.triggers_for_function(&function_id);
112
113                if !affected.is_empty() {
114                    let triggers_info: Vec<String> = affected
115                        .iter()
116                        .map(|t| format!("trigger {} on table {}", t.dependent, t.referenced))
117                        .collect();
118
119                    return vec![Violation {
120                        source_range: None,
121                        rule_id: self.id(),
122                        operation_kind: OperationKind::DropFunction,
123                        object_kind: ObjectKind::Function,
124                        object_name: function_id.to_string(),
125                        tier: self.default_tier(),
126                        reason: format!(
127                            "PostgreSQL rejects this function drop because it is used by {}",
128                            triggers_info.join(", ")
129                        ),
130                        recipe: self.recipe(),
131                        dedup_key: None,
132                        sql: None,
133                        fk_dependency_related: false,
134                    }];
135                }
136            }
137        }
138        vec![]
139    }
140}