pgsafe 0.8.6

Static safety linter for PostgreSQL DDL migrations — catches unsafe schema changes before they lock or break production.
Documentation
use pgsafe::{lint_sql, LintOptions};

fn fires(sql: &str, rule_id: &str) -> bool {
    lint_sql(sql, &LintOptions::default())
        .unwrap()
        .iter()
        .any(|f| f.rule_id == rule_id)
}

#[test]
fn public_qualified_correlates_with_bare_table() {
    // `public` is the default schema, so `public.t` and `t` are the same table: the new-table
    // exemption correlates across spellings.
    // both spelling directions correlate (the production-dominant case is a bare CREATE + a later
    // tooling-qualified op).
    assert!(!fires(
        "CREATE TABLE public.t (id int); DROP TABLE t;",
        "drop-table"
    ));
    assert!(!fires(
        "CREATE TABLE t (id int); DROP TABLE public.t;",
        "drop-table"
    ));
    assert!(!fires(
        "CREATE TABLE t (id int); TRUNCATE public.t;",
        "truncate"
    ));
    assert!(!fires(
        "CREATE TABLE public.t (id int); TRUNCATE t;",
        "truncate"
    ));
    // a covering index clears fk-without-covering-index regardless of which side is qualified.
    assert!(!fires(
        "CREATE TABLE public.child (pid int REFERENCES parent); CREATE INDEX ON child (pid);",
        "fk-without-covering-index"
    ));
    assert!(!fires(
        "CREATE TABLE child (pid int REFERENCES parent); CREATE INDEX ON public.child (pid);",
        "fk-without-covering-index"
    ));
}

#[test]
fn non_public_schema_stays_distinct() {
    // `app.t` is not the same table as bare `t`, so neither the exemption nor a covering index on the
    // wrong schema applies.
    assert!(fires(
        "CREATE TABLE app.t (id int); DROP TABLE t;",
        "drop-table"
    ));
    assert!(fires(
        "CREATE TABLE child (pid int REFERENCES parent); CREATE INDEX ON app.child (pid);",
        "fk-without-covering-index"
    ));
}

#[test]
fn merge_populated_new_table_still_fires() {
    assert!(fires(
        "CREATE TABLE foo (id int); \
         MERGE INTO foo USING src ON foo.id = src.id WHEN NOT MATCHED THEN INSERT VALUES (src.id); \
         ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
}

#[test]
fn directive_on_dropped_new_table_op_is_not_unused() {
    // A redundant inline directive on a new-table-safe op must not flip the gate red.
    let fs = lint_sql(
        "CREATE TABLE foo (id int);\n\
         -- pgsafe:ignore add-unique-constraint  belt and suspenders\n\
         ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id);",
        &LintOptions::default(),
    )
    .unwrap();
    assert!(
        !fs.iter().any(|f| f.rule_id == "suppression-unused"),
        "directive on a dropped new-table op must not be reported unused"
    );
    assert!(fs.is_empty(), "the run should be clean");
}

#[test]
fn empty_new_table_operations_are_dropped() {
    assert!(!fires(
        "CREATE TABLE foo (id int); ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
    assert!(!fires(
        "CREATE TABLE foo (id int); ALTER TABLE foo ADD COLUMN c uuid DEFAULT gen_random_uuid();",
        "add-column-volatile-default"
    ));
    assert!(!fires(
        "CREATE TABLE foo (id int); CREATE INDEX i ON foo (id);",
        "add-index-non-concurrent"
    ));
    // An inline CHECK on ADD COLUMN against a same-migration empty table has nothing to scan.
    assert!(!fires(
        "CREATE TABLE foo (id int); ALTER TABLE foo ADD COLUMN x int CHECK (x > 0);",
        "add-check-without-not-valid"
    ));
    assert!(!fires(
        "CREATE TABLE foo (id int); ALTER TABLE foo ADD COLUMN x int DEFAULT 5 CHECK (x > 0);",
        "add-check-without-not-valid"
    ));
}

#[test]
fn drop_truncate_rename_on_new_empty_table_are_dropped() {
    // A table created empty in the same migration has no data and no traffic, so dropping, truncating,
    // or renaming it is safe — the findings must be dropped.
    assert!(!fires(
        "CREATE TABLE foo (id int); DROP TABLE foo;",
        "drop-table"
    ));
    assert!(!fires(
        "CREATE TABLE foo (id int); TRUNCATE foo;",
        "truncate"
    ));
    assert!(!fires(
        "CREATE TABLE foo (id int); ALTER TABLE foo RENAME TO bar;",
        "rename"
    ));
    assert!(!fires(
        "CREATE TABLE foo (id int, a int); ALTER TABLE foo RENAME COLUMN a TO b;",
        "rename"
    ));
}

#[test]
fn drop_truncate_rename_on_existing_or_populated_table_still_fire() {
    // No CREATE in the migration → the table is pre-existing → not exempt.
    assert!(fires("DROP TABLE foo;", "drop-table"));
    assert!(fires("TRUNCATE foo;", "truncate"));
    assert!(fires("ALTER TABLE foo RENAME TO bar;", "rename"));
    // Populated in the same migration → no longer empty → still fires.
    assert!(fires(
        "CREATE TABLE foo (id int); INSERT INTO foo VALUES (1); DROP TABLE foo;",
        "drop-table"
    ));
    assert!(fires(
        "CREATE TABLE foo (id int); INSERT INTO foo VALUES (1); TRUNCATE foo;",
        "truncate"
    ));
    // Multi-table DROP/TRUNCATE is conservatively never exempted (the key extractor returns None),
    // so it still fires even when EVERY named table was created empty in this same migration.
    assert!(fires(
        "CREATE TABLE foo (id int); CREATE TABLE bar (id int); DROP TABLE foo, bar;",
        "drop-table"
    ));
    assert!(fires(
        "CREATE TABLE foo (id int); CREATE TABLE bar (id int); TRUNCATE foo, bar;",
        "truncate"
    ));
}

#[test]
fn populated_new_table_still_fires() {
    // INSERT populates → flagged
    assert!(fires(
        "CREATE TABLE foo (id int); INSERT INTO foo VALUES (1); ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
    // COPY ... FROM populates → flagged
    assert!(fires(
        "CREATE TABLE foo (id int); COPY foo FROM '/tmp/data.csv'; ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
    // CREATE TABLE AS is born populated → flagged
    assert!(fires(
        "CREATE TABLE foo AS SELECT 1 AS id; ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
}

#[test]
fn not_a_new_table_still_fires() {
    // bar not created in this input
    assert!(fires(
        "ALTER TABLE bar ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
    // different table than the one created
    assert!(fires(
        "CREATE TABLE foo (id int); ALTER TABLE other ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
    // schema-qualified mismatch — conservative, still fires
    assert!(fires(
        "CREATE TABLE s.foo (id int); ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id);",
        "add-unique-constraint"
    ));
}

#[test]
fn alter_before_create_still_fires() {
    assert!(fires(
        "ALTER TABLE foo ADD CONSTRAINT u UNIQUE (id); CREATE TABLE foo (id int);",
        "add-unique-constraint"
    ));
}