safe-migrate 0.3.0

Lint PostgreSQL migrations against live database statistics to prevent blocking locks
Documentation
// FILE: src/rules/indexes.rs
use crate::analysis::mutations::Mutation;
use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
use crate::ast::identifiers::ObjectId;
use crate::engine::config::Config;
use crate::model::relation::{Persistence, RelationState};
use crate::report::violations::{Violation, ViolationTier};
use crate::rules::Rule;
use std::collections::HashMap;

pub struct ConcurrentIndexRule;

impl Rule for ConcurrentIndexRule {
    fn id(&self) -> &'static str {
        "require-concurrent-index"
    }
    fn default_tier(&self) -> ViolationTier {
        ViolationTier::Tier1
    }
    fn recipe(&self) -> &'static str {
        "Index operations block writes (or both reads and writes) when executed synchronously. Add the CONCURRENTLY keyword."
    }

    fn evaluate(
        &self,
        mutation: &Mutation,
        result: &MutationResult,
        pre_relations: &HashMap<ObjectId, RelationState>,
        state: &AnalysisState,
        config: &Config,
        _cascade: Option<&CascadeResult>,
    ) -> Vec<Violation> {
        if *result == MutationResult::Skipped {
            return vec![];
        }

        let mut violations = Vec::new();

        match mutation {
            Mutation::CreateIndex(create) if !create.concurrently => {
                let (is_temp, is_stale, rows, tx_depth) = match pre_relations.get(&create.table) {
                    Some(rel) => {
                        let stale =
                            rel.is_stale() && state.baseline_relations.contains(&create.table);
                        (
                            rel.persistence == Persistence::Temporary,
                            stale,
                            rel.estimated_rows.unwrap_or(config.default_rows),
                            rel.created_at_tx_depth,
                        )
                    }
                    None => (false, true, config.default_rows, 0),
                };

                if is_temp || tx_depth == state.local.transactions.len() {
                    return violations;
                }

                if is_stale {
                    let key = format!("{}_stale_{}", self.id(), create.table);
                    violations.push(Violation {
                        rule_id: self.id(),
                        title: format!("Table {} statistics are stale. Lock evaluations may be inaccurate.", create.table),
                        tier: ViolationTier::Tier2,
                        recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
                        dedup_key: Some(key),
                    });
                }

                let tier1_threshold = config.rule_tier1_threshold(self.id());
                let tier2_threshold = config.rule_tier2_threshold(self.id());

                let tier = if rows >= tier1_threshold {
                    ViolationTier::Tier1
                } else if rows >= tier2_threshold {
                    ViolationTier::Tier2
                } else {
                    ViolationTier::Tier3
                };

                if tier != ViolationTier::Tier3 {
                    let mut title = format!("Synchronous index creation on {}", create.table);
                    if is_stale {
                        title.push_str(" [WARNING: Based on offline/stale statistics]");
                    }

                    violations.push(Violation {
                        rule_id: self.id(),
                        title,
                        tier,
                        recipe: self.recipe(),
                        dedup_key: None,
                    });
                }
            }
            Mutation::DropIndex(drop) if !drop.concurrently => {
                let rule_id = "require-concurrent-drop-index";
                let tier1_threshold = config.rule_tier1_threshold(rule_id);
                let tier2_threshold = config.rule_tier2_threshold(rule_id);

                if pre_relations.is_empty() {
                    let rows = config.default_rows;
                    let tier = if rows >= tier1_threshold {
                        ViolationTier::Tier1
                    } else if rows >= tier2_threshold {
                        ViolationTier::Tier2
                    } else {
                        ViolationTier::Tier3
                    };

                    if tier != ViolationTier::Tier3 {
                        violations.push(Violation {
                            rule_id,
                            title: format!("Synchronous index drop for {}", drop.id),
                            tier,
                            recipe: self.recipe(),
                            dedup_key: None,
                        });
                    }
                } else {
                    for rel in pre_relations.values() {
                        if rel.persistence == Persistence::Temporary {
                            continue;
                        }

                        let is_stale = rel.is_stale() && state.baseline_relations.contains(&rel.id);

                        if is_stale {
                            let key = format!("{}_stale_{}", rule_id, rel.id);
                            violations.push(Violation {
                                rule_id,
                                title: format!("Table {} statistics are stale. Lock evaluations may be inaccurate.", rel.id),
                                tier: ViolationTier::Tier2,
                                recipe: "Run ANALYZE to ensure accurate row estimates before structural changes.",
                                dedup_key: Some(key),
                            });
                        }

                        let rows = rel.estimated_rows.unwrap_or(config.default_rows);
                        let tier = if rows >= tier1_threshold {
                            ViolationTier::Tier1
                        } else if rows >= tier2_threshold {
                            ViolationTier::Tier2
                        } else {
                            ViolationTier::Tier3
                        };

                        if tier != ViolationTier::Tier3 {
                            let mut title =
                                format!("Synchronous index drop for {} on {}", drop.id, rel.id);
                            if is_stale {
                                title.push_str(" [WARNING: Based on offline/stale statistics]");
                            }

                            violations.push(Violation {
                                rule_id,
                                title,
                                tier,
                                recipe: self.recipe(),
                                dedup_key: None,
                            });
                        }
                    }
                }
            }
            _ => {}
        }
        violations
    }
}