arcature-data 2026.2.0

Arcature high-level data layer: explicit-ownership model/query ergonomics over SeaORM/SQLx, N+1 detection, and migration lint.
Documentation
//! The migration lint classifier: a deterministic, database-free scan of
//! [`SqlStatement`] text for real PostgreSQL migration risks (PROGRAM.md
//! AP2.1-6).
//!
//! Classification is **deterministic and side-effect-free** — it reads no
//! environment variables, connects to no database, and panics on no input.
//! Each statement is upper-cased and scanned for keyword sequences that name
//! real PostgreSQL deployment hazards. The categories and severities are the
//! ones named in PROGRAM.md AP2.1-6: destructive drops, unsafe `NOT NULL`,
//! type narrowing, dangerous renames, and blocking indexes.
//!
//! # What this does NOT do
//!
//! It does not assume a reverse-migration exists, does not execute the SQL,
//! does not compare schemas, and does not require a shadow database. Surfacing
//! a destructive change is the deliverable; the operator decides whether to
//! apply. The shadow-DB `diff`/`drift`/`verify` paths (PROGRAM.md AP2.1-6) are
//! deferred to a later wave and would feed *rendered* SQL into this same
//! classifier.

use super::finding::{Finding, LintCategory, LintReport, LintSeverity};
use super::statement::SqlStatement;

/// Classify a set of [`SqlStatement`]s and return a [`LintReport`].
///
/// Deterministic; never panics. See the module docs for what is and is not
/// detected.
#[must_use]
pub fn classify_statements(statements: &[SqlStatement]) -> LintReport {
    let mut findings = Vec::new();
    for (index, statement) in statements.iter().enumerate() {
        let upper = statement.sql.to_uppercase();
        classify_one(&upper, index, &mut findings);
    }
    LintReport {
        findings,
        statements: statements.len(),
    }
}

/// Classify a single script string (split into statements first).
#[must_use]
pub fn classify(script: &str) -> LintReport {
    let statements = SqlStatement::split(script);
    classify_statements(&statements)
}

fn classify_one(upper: &str, index: usize, findings: &mut Vec<Finding>) {
    // Destructive drops. DROP TABLE / DROP COLUMN are Critical (row data loss);
    // other DROP forms are Warning (structural, reversible with care).
    if contains_word(upper, "DROP TABLE") || contains_word(upper, "DROP COLUMN") {
        findings.push(Finding {
            severity: LintSeverity::Critical,
            category: LintCategory::DestructiveDrop,
            message: "DROP removes data irreversibly".to_owned(),
            fix: "back up the table/column, verify no dependencies, and apply \
                  outside a rolling upgrade"
                .to_owned(),
            statement: index,
        });
    } else if contains_word(upper, "DROP INDEX")
        || contains_word(upper, "DROP SCHEMA")
        || contains_word(upper, "DROP CONSTRAINT")
        || contains_word(upper, "DROP VIEW")
        || contains_word(upper, "DROP TYPE")
        || contains_word(upper, "DROP MATERIALIZED")
    {
        findings.push(Finding {
            severity: LintSeverity::Warning,
            category: LintCategory::DestructiveDrop,
            message: "DROP removes a schema object; verify no code depends on it".to_owned(),
            fix: "confirm no queries/code reference the dropped object before \
                  applying"
                .to_owned(),
            statement: index,
        });
    }

    // ADD COLUMN ... NOT NULL without a DEFAULT — fails on existing rows and
    // breaks rolling upgrades (old app instances still insert without it).
    if (contains_word(upper, "ADD COLUMN") || contains_word(upper, "ADD"))
        && contains_word(upper, "NOT NULL")
        && !contains_word(upper, "DEFAULT")
    {
        findings.push(Finding {
            severity: LintSeverity::Critical,
            category: LintCategory::AddNotNullNoDefault,
            message: "ADD COLUMN with NOT NULL and no DEFAULT fails on existing \
                      rows and breaks rolling upgrades"
                .to_owned(),
            fix: "add a DEFAULT (or make the column nullable, backfill, then \
                  SET NOT NULL in a later migration)"
                .to_owned(),
            statement: index,
        });
    }

    // SET NOT NULL on an existing column — table rewrite, fails on existing
    // NULLs. (ADD ... NOT NULL is handled above; this catches the standalone
    // ALTER COLUMN ... SET NOT NULL form.)
    if contains_word(upper, "SET NOT NULL") && !contains_word(upper, "ADD") {
        findings.push(Finding {
            severity: LintSeverity::Warning,
            category: LintCategory::UnsafeNotNull,
            message: "SET NOT NULL rewrites the table and fails on existing NULL \
                      values"
                .to_owned(),
            fix: "backfill NULLs to a real value first, then SET NOT NULL (or \
                  add a CHECK constraint NOT VALID, validate, then SET NOT NULL)"
                .to_owned(),
            statement: index,
        });
    }

    // ALTER COLUMN ... TYPE — may narrow the value domain or rewrite the table.
    if contains_word(upper, "ALTER COLUMN") && contains_word(upper, "TYPE") {
        findings.push(Finding {
            severity: LintSeverity::Warning,
            category: LintCategory::TypeNarrowing,
            message: "ALTER COLUMN TYPE may narrow the value domain or rewrite \
                      the table"
                .to_owned(),
            fix: "review the new type for value loss; prefer a multi-step \
                  widen (add column, copy, drop old) for risky changes"
                .to_owned(),
            statement: index,
        });
    }

    // RENAME — breaks code referencing the old name; rolling-upgrade hazard.
    if contains_word(upper, "RENAME TO") || contains_word(upper, "RENAME COLUMN") {
        findings.push(Finding {
            severity: LintSeverity::Warning,
            category: LintCategory::DangerousRename,
            message: "RENAME breaks code referencing the old name and complicates \
                      rolling upgrades"
                .to_owned(),
            fix: "deploy code that tolerates both names first, or rename during \
                  a coordinated downtime"
                .to_owned(),
            statement: index,
        });
    }

    // CREATE INDEX without CONCURRENTLY — blocks writes during the build.
    if (contains_word(upper, "CREATE INDEX") || contains_word(upper, "CREATE UNIQUE INDEX"))
        && !contains_word(upper, "CONCURRENTLY")
    {
        findings.push(Finding {
            severity: LintSeverity::Warning,
            category: LintCategory::BlockingIndex,
            message: "CREATE INDEX without CONCURRENTLY blocks writes for the \
                      duration of the build"
                .to_owned(),
            fix: "use CREATE INDEX CONCURRENTLY (cannot run inside a transaction \
                  block — apply as a standalone migration)"
                .to_owned(),
            statement: index,
        });
    }
}

/// Word-boundary-aware substring test on an upper-cased SQL string.
///
/// `needle` must be upper-case. Returns `true` when `needle` appears as a
/// run of SQL tokens (surrounded by non-alphanumeric boundaries), so that
/// e.g. searching for `DROP TABLE` does not match `DROPDOWN TABLE`.
fn contains_word(haystack: &str, needle: &str) -> bool {
    let Some(start) = haystack.find(needle) else {
        return false;
    };
    let end = start + needle.len();
    let before_ok = start == 0 || !is_ident_char(haystack.as_bytes()[start - 1]);
    let after_ok = end >= haystack.len() || !is_ident_char(haystack.as_bytes()[end]);
    before_ok && after_ok
}

fn is_ident_char(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'"'
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clean_statement_has_no_findings() {
        let report = classify("CREATE TABLE t (id int);");
        assert!(report.is_clean());
        assert_eq!(report.statements, 1);
    }

    #[test]
    fn flags_drop_table_as_critical() {
        let report = classify("DROP TABLE users;");
        assert!(report.has_critical());
        assert_eq!(report.findings[0].category, LintCategory::DestructiveDrop);
        assert_eq!(report.findings[0].severity, LintSeverity::Critical);
    }

    #[test]
    fn flags_drop_index_as_warning() {
        let report = classify("DROP INDEX idx;");
        assert!(!report.has_critical());
        assert_eq!(report.count_at(LintSeverity::Warning), 1);
    }

    #[test]
    fn flags_add_not_null_without_default_as_critical() {
        let report = classify("ALTER TABLE t ADD COLUMN c int NOT NULL;");
        assert!(report.has_critical());
        assert_eq!(
            report.findings[0].category,
            LintCategory::AddNotNullNoDefault
        );
    }

    #[test]
    fn add_not_null_with_default_is_clean() {
        let report = classify("ALTER TABLE t ADD COLUMN c int NOT NULL DEFAULT 0;");
        assert!(
            report.is_clean(),
            "NOT NULL with a DEFAULT is safe; report was {report:?}"
        );
    }

    #[test]
    fn flags_set_not_null_as_warning() {
        let report = classify("ALTER TABLE t ALTER COLUMN c SET NOT NULL;");
        assert_eq!(report.count_at(LintSeverity::Warning), 1);
        assert_eq!(report.findings[0].category, LintCategory::UnsafeNotNull);
    }

    #[test]
    fn flags_create_index_without_concurrently() {
        let report = classify("CREATE INDEX idx ON t (c);");
        assert_eq!(report.count_at(LintSeverity::Warning), 1);
        assert_eq!(report.findings[0].category, LintCategory::BlockingIndex);
    }

    #[test]
    fn create_index_concurrently_is_clean() {
        let report = classify("CREATE INDEX CONCURRENTLY idx ON t (c);");
        assert!(report.is_clean());
    }

    #[test]
    fn flags_rename() {
        let report = classify("ALTER TABLE t RENAME TO t2;");
        assert_eq!(report.count_at(LintSeverity::Warning), 1);
        assert_eq!(report.findings[0].category, LintCategory::DangerousRename);
    }

    #[test]
    fn flags_alter_column_type() {
        let report = classify("ALTER TABLE t ALTER COLUMN c TYPE bigint;");
        assert_eq!(report.count_at(LintSeverity::Warning), 1);
        assert_eq!(report.findings[0].category, LintCategory::TypeNarrowing);
    }

    #[test]
    fn word_boundary_prevents_false_positive() {
        // `DROPDOWN` must not match `DROP`.
        let report = classify("CREATE TABLE dropdown (id int);");
        assert!(report.is_clean());
    }

    #[test]
    fn multiple_findings_across_statements() {
        let report = classify("DROP TABLE a; CREATE INDEX i ON b (c);");
        assert_eq!(report.statements, 2);
        assert!(report.has_critical());
        assert_eq!(report.count_at(LintSeverity::Warning), 1);
    }

    #[test]
    fn hostile_input_does_not_panic() {
        // Garbage / unterminated / nested quotes — must not panic.
        let _ = classify("'''''''\"\"\"\"DROP '");
        let _ = classify("/* /* nested */ DROP TABLE");
        let _ = classify(";");
    }
}