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