pg-blast-radius 0.3.0

Workload-aware blast radius forecaster for PostgreSQL migrations
Documentation
use pg_query::protobuf::{self, ObjectType};

use crate::recipe;
use crate::types::*;

use super::RuleContext;

pub fn analyse_drop(
    drop_stmt: &protobuf::DropStmt,
    stmt_sql: &str,
    _ctx: &RuleContext,
) -> Vec<Finding> {
    let obj_type = ObjectType::try_from(drop_stmt.remove_type).unwrap_or(ObjectType::Undefined);

    match obj_type {
        ObjectType::ObjectIndex => analyse_drop_index(drop_stmt, stmt_sql),
        ObjectType::ObjectTable => analyse_drop_table(stmt_sql),
        _ => vec![],
    }
}

fn analyse_drop_index(drop_stmt: &protobuf::DropStmt, stmt_sql: &str) -> Vec<Finding> {
    let idx_name = extract_drop_object_name(&drop_stmt.objects);

    if drop_stmt.concurrent {
        vec![Finding {
            rule_id: "drop-index-concurrently".into(),
            risk_level: RiskLevel::Low,
            confidence: ConfidenceLedger::static_only(
                vec!["SHARE UPDATE EXCLUSIVE lock (non-blocking) for DROP INDEX CONCURRENTLY".into()],
            ),
            lock_mode: LockMode::ShareUpdateExclusive,
            rewrite: RewriteRisk::None,
            affected_table: None,
            summary: format!("DROP INDEX CONCURRENTLY \"{idx_name}\" (non-blocking)"),
            explanation: "SHARE UPDATE EXCLUSIVE lock does not block reads or writes. \
                Cannot run inside a transaction block."
                .into(),
            recipe: None,
            pg_version_note: None,
            statement_sql: stmt_sql.into(),
            duration_forecast: None,
        }]
    } else {
        vec![Finding {
            rule_id: "drop-index".into(),
            risk_level: RiskLevel::High,
            confidence: ConfidenceLedger::static_only(
                vec!["ACCESS EXCLUSIVE lock for DROP INDEX (blocks all queries)".into()],
            ),
            lock_mode: LockMode::AccessExclusive,
            rewrite: RewriteRisk::None,
            affected_table: None,
            summary: format!("DROP INDEX \"{idx_name}\" takes ACCESS EXCLUSIVE lock"),
            explanation: "ACCESS EXCLUSIVE lock blocks all reads and writes on the \
                table until the index is dropped."
                .into(),
            recipe: Some(recipe::drop_index_concurrently(&idx_name)),
            pg_version_note: None,
            statement_sql: stmt_sql.into(),
            duration_forecast: None,
        }]
    }
}

fn analyse_drop_table(stmt_sql: &str) -> Vec<Finding> {
    vec![Finding {
        rule_id: "drop-table".into(),
        risk_level: RiskLevel::High,
        confidence: ConfidenceLedger::static_only(
            vec!["ACCESS EXCLUSIVE lock for DROP TABLE (destructive)".into()],
        ),
        lock_mode: LockMode::AccessExclusive,
        rewrite: RewriteRisk::None,
        affected_table: None,
        summary: "DROP TABLE takes ACCESS EXCLUSIVE lock".into(),
        explanation: "ACCESS EXCLUSIVE lock blocks all queries. This is a destructive \
            operation that cannot be undone outside of a transaction."
            .into(),
        recipe: None,
        pg_version_note: None,
        statement_sql: stmt_sql.into(),
        duration_forecast: None,
    }]
}

fn extract_drop_object_name(objects: &[protobuf::Node]) -> String {
    objects
        .first()
        .and_then(|n| n.node.as_ref())
        .map(|n| match n {
            pg_query::protobuf::node::Node::List(list) => list
                .items
                .iter()
                .filter_map(super::extract_string_value)
                .collect::<Vec<_>>()
                .join("."),
            pg_query::protobuf::node::Node::String(s) => s.sval.clone(),
            _ => "unknown".into(),
        })
        .unwrap_or_else(|| "unknown".into())
}