safe-migrate 0.8.0

Check PostgreSQL migrations against a synchronized database baseline
Documentation
use crate::_internal::analysis::mutations::Mutation;
use crate::_internal::analysis::state::MutationResult;
use crate::_internal::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
use crate::_internal::rules::{
    FUNCTION_CAPABILITIES, FUNCTION_DEPENDENCY_CAPABILITIES, Rule, RuleCapability, RuleContext,
};

pub struct FunctionVolatilityRule;

impl Rule for FunctionVolatilityRule {
    fn id(&self) -> &'static str {
        "function-volatility-change"
    }
    fn default_tier(&self) -> ViolationTier {
        ViolationTier::Tier2
    }
    fn recipe(&self) -> &'static str {
        "Changing a function's volatility (e.g., IMMUTABLE -> VOLATILE) can invalidate existing indexes or change query plan stability."
    }

    fn required_capabilities(&self) -> &'static [RuleCapability] {
        FUNCTION_CAPABILITIES
    }

    fn evaluate(&self, context: &RuleContext<'_>) -> Vec<Violation> {
        let mut violations = Vec::new();

        if let Mutation::AlterFunction(alter) = context.mutation()
            && let Some(old_func) = context.pre_state().functions.get(&alter.id)
            && let crate::_internal::analysis::facts::AlterFunctionAction::OptionsChange(new_opts) =
                &alter.action
            && let Some(nv) = new_opts.iter().find_map(|opt| {
                if let crate::_internal::analysis::facts::FuncOptionFact::Volatility(v) = opt {
                    match v {
                        crate::_internal::analysis::facts::VolatilityKind::Volatile => {
                            Some(crate::_internal::model::function::Volatility::Volatile)
                        }
                        crate::_internal::analysis::facts::VolatilityKind::Stable => {
                            Some(crate::_internal::model::function::Volatility::Stable)
                        }
                        crate::_internal::analysis::facts::VolatilityKind::Immutable => {
                            Some(crate::_internal::model::function::Volatility::Immutable)
                        }
                    }
                } else {
                    None
                }
            })
        {
            let ov = old_func.volatility.clone();
            if ov != nv {
                violations.push(Violation {
                    source_range: None,
                    rule_id: self.id(),
                    operation_kind: OperationKind::AlterFunction,
                    object_kind: ObjectKind::Function,
                    object_name: alter.id.to_string(),
                    tier: self.default_tier(),
                    reason: format!(
                        "Function {} volatility changed from {:?} to {:?}",
                        alter.id, ov, nv
                    ),
                    recipe: self.recipe(),
                    dedup_key: None,
                    sql: None,
                    fk_dependency_related: false,
                });
            }
        }

        violations
    }
}

pub struct BrokenComputeRule;

impl Rule for BrokenComputeRule {
    fn id(&self) -> &'static str {
        "broken-compute"
    }
    fn default_tier(&self) -> ViolationTier {
        ViolationTier::Tier1
    }
    fn recipe(&self) -> &'static str {
        "Drop or replace the dependent triggers first. Use CASCADE only after reviewing every dependent object."
    }

    fn required_capabilities(&self) -> &'static [RuleCapability] {
        FUNCTION_DEPENDENCY_CAPABILITIES
    }

    fn evaluate(&self, context: &RuleContext<'_>) -> Vec<Violation> {
        if !matches!(context.result(), MutationResult::Conflict { .. }) {
            return vec![];
        }
        if let Mutation::DropFunction(drop) = context.mutation()
            && !drop.cascade
        {
            for sig in &drop.signatures {
                let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(","));
                let schema = context.state().resolve_function_schema(&sig.name, &sig_str);
                let function_id =
                    crate::_internal::ast::identifiers::ObjectId::new(schema, sig_str);

                let affected = context
                    .state()
                    .local
                    .graph
                    .triggers_for_function(&function_id);

                if !affected.is_empty() {
                    let triggers_info: Vec<String> = affected
                        .iter()
                        .map(|t| format!("trigger {} on table {}", t.dependent, t.referenced))
                        .collect();

                    return vec![Violation {
                        source_range: None,
                        rule_id: self.id(),
                        operation_kind: OperationKind::DropFunction,
                        object_kind: ObjectKind::Function,
                        object_name: function_id.to_string(),
                        tier: self.default_tier(),
                        reason: format!(
                            "PostgreSQL rejects this function drop because it is used by {}",
                            triggers_info.join(", ")
                        ),
                        recipe: self.recipe(),
                        dedup_key: None,
                        sql: None,
                        fk_dependency_related: false,
                    }];
                }
            }
        }
        vec![]
    }
}