persistant-migrate 0.9.4

A command-line tool over PersistAnt's migrations: apply, status and fresh-install check for a directory of .up.sql files, on PostgreSQL, SQLite, MySQL and MariaDB.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
//! The `persistant-migrate` binary, run for real against an SQLite file: what it prints and the exit
//! code each situation gets. The same code paths serve PostgreSQL and MySQL; those are covered by
//! `persistant-sqlx`'s own migration tests.

use persistant_migrate::{State, checksum, status};
use persistant_relational::Migration;
use std::path::Path;
use std::process::{Command, Output};

fn write(dir: &Path, name: &str, sql: &str) {
    std::fs::write(dir.join(name), sql).unwrap();
}

fn run(args: &[&str], db: &Path) -> Output {
    Command::new(env!("CARGO_BIN_EXE_persistant-migrate"))
        .args(args)
        .arg("--url")
        .arg(format!("sqlite:{}", db.display()))
        .env_remove("PERSISTANT_DATABASE_URL")
        .output()
        .unwrap()
}

fn stdout(o: &Output) -> String {
    String::from_utf8_lossy(&o.stdout).into_owned()
}

fn migrations(dir: &Path) -> std::path::PathBuf {
    let m = dir.join("migrations");
    std::fs::create_dir(&m).unwrap();
    write(
        &m,
        "1_things.up.sql",
        "CREATE TABLE things (id INTEGER PRIMARY KEY);",
    );
    write(&m, "1_things.down.sql", "DROP TABLE things;");
    write(&m, "2_more.up.sql", "ALTER TABLE things ADD COLUMN n TEXT;");
    m
}

#[test]
fn snapshot_scratch_live_check_and_stale() {
    use persistant_relational::catalog;
    let t = tempfile::tempdir().unwrap();
    let m = migrations(t.path());
    let db = t.path().join("s.db");
    let dir = m.to_str().unwrap();
    let file = t.path().join("sub").join("schema.json");
    let f = file.to_str().unwrap();

    // Scratch: the target database file is never created, and the output directory is.
    let snap = run(&["snapshot", "--scratch", "--dir", dir, "--out", f], &db);
    assert_eq!(snap.status.code(), Some(0), "{snap:?}");
    assert!(
        stdout(&snap).contains("1 tables, 2 migrations"),
        "{}",
        stdout(&snap)
    );
    let cat = catalog::parse(&std::fs::read_to_string(&file).unwrap()).unwrap();
    let things = cat
        .schema
        .tables()
        .find(|t| t.name == ["things"])
        .expect("the things table");
    let cols: Vec<_> = things.columns.keys().map(String::as_str).collect();
    assert_eq!(cols, ["id", "n"], "{cat:?}");
    assert_eq!(things.unique_keys, [vec!["id".to_string()]]);
    assert_eq!(things.columns["n"].nullable, Some(true));
    assert_eq!(things.columns["id"].nullable, Some(false));
    assert!(things.columns_complete && !things.keys_unreliable);
    assert_eq!(
        cat.meta
            .keys()
            .filter(|k| k.starts_with("migration."))
            .count(),
        2
    );
    assert!(!cat.schema.tables().any(|t| t.name == ["_sqlx_migrations"]));
    assert_eq!(
        run(
            &["snapshot", "--scratch", "--check", "--dir", dir, "--out", f],
            &db
        )
        .status
        .code(),
        Some(0)
    );

    // A changed migration makes the committed file stale (exit 8) and says which one.
    write(
        &m,
        "2_more.up.sql",
        "ALTER TABLE things ADD COLUMN other TEXT;",
    );
    let stale = run(
        &["snapshot", "--scratch", "--check", "--dir", dir, "--out", f],
        &db,
    );
    assert_eq!(stale.status.code(), Some(8), "{stale:?}");
    let err = String::from_utf8_lossy(&stale.stderr);
    assert!(err.contains("stale: migration 2 changed"), "{err}");
    write(&m, "2_more.up.sql", "ALTER TABLE things ADD COLUMN n TEXT;");

    // After `up`, the database's own catalog is the same snapshot; without `up` it is not.
    let before = run(&["snapshot", "--check", "--out", f], &db);
    assert_eq!(before.status.code(), Some(8), "{before:?}");
    assert_eq!(run(&["up", "--dir", dir], &db).status.code(), Some(0));
    let live = run(&["snapshot", "--check", "--out", f], &db);
    assert_eq!(live.status.code(), Some(0), "{live:?}");

    // A snapshot of a newer format, or no file at all, is a failed check.
    std::fs::write(&file, "{\"format\": 99}").unwrap();
    let newer = run(&["snapshot", "--check", "--out", f], &db);
    assert_eq!(newer.status.code(), Some(8));
    assert!(
        String::from_utf8_lossy(&newer.stderr).contains("newer than"),
        "{newer:?}"
    );
    // Reading the target needs no directory; scratch does.
    let usage = run(&["snapshot", "--scratch", "--out", f], &db);
    assert_eq!(usage.status.code(), Some(2), "{usage:?}");
}

#[test]
fn up_then_status_then_a_changed_migration_is_exit_3() {
    let t = tempfile::tempdir().unwrap();
    let m = migrations(t.path());
    let db = t.path().join("a.db");
    let dir = m.to_str().unwrap();

    let before = run(&["status", "--dir", dir], &db);
    assert_eq!(before.status.code(), Some(0), "{before:?}");
    assert_eq!(stdout(&before).matches("pending").count(), 2);

    let up = run(&["up", "--dir", dir], &db);
    assert_eq!(up.status.code(), Some(0), "{up:?}");
    assert!(stdout(&up).contains("applied [1, 2]"), "{}", stdout(&up));

    let after = run(&["status", "--dir", dir], &db);
    assert_eq!(after.status.code(), Some(0));
    assert_eq!(stdout(&after).matches("applied").count(), 2);

    // The down file was read, never run: the table is still there, so a second `up` is a no-op.
    let again = run(&["up", "--dir", dir], &db);
    assert!(stdout(&again).contains("already applied [1, 2]"));

    write(
        &m,
        "2_more.up.sql",
        "ALTER TABLE things ADD COLUMN other TEXT;",
    );
    let changed = run(&["status", "--dir", dir], &db);
    assert_eq!(changed.status.code(), Some(3), "{changed:?}");
    assert!(stdout(&changed).contains("CHANGED"));
    let refused = run(&["up", "--dir", dir], &db);
    assert_eq!(refused.status.code(), Some(3), "{refused:?}");
}

#[test]
fn a_database_newer_than_the_directory_is_exit_4() {
    let t = tempfile::tempdir().unwrap();
    let m = migrations(t.path());
    let db = t.path().join("b.db");
    assert_eq!(
        run(&["up", "--dir", m.to_str().unwrap()], &db)
            .status
            .code(),
        Some(0)
    );
    std::fs::remove_file(m.join("2_more.up.sql")).unwrap();
    let o = run(&["status", "--dir", m.to_str().unwrap()], &db);
    assert_eq!(o.status.code(), Some(4), "{o:?}");
    assert!(stdout(&o).contains("UNKNOWN"));
}

#[test]
fn a_down_file_with_no_up_is_refused() {
    let t = tempfile::tempdir().unwrap();
    let m = migrations(t.path());
    write(&m, "3_orphan.down.sql", "SELECT 1;");
    let o = run(
        &["up", "--dir", m.to_str().unwrap()],
        &t.path().join("c.db"),
    );
    assert_eq!(o.status.code(), Some(7), "{o:?}");
    assert!(String::from_utf8_lossy(&o.stderr).contains("3 has a .down.sql but no .up.sql"));
}

#[test]
fn verify_fresh_leaves_the_target_database_alone_and_catches_a_broken_set() {
    let t = tempfile::tempdir().unwrap();
    let m = migrations(t.path());
    let db = t.path().join("d.db");
    let ok = run(&["verify-fresh", "--dir", m.to_str().unwrap()], &db);
    assert_eq!(ok.status.code(), Some(0), "{ok:?}");
    // Nothing was applied to the real target.
    let st = run(&["status", "--dir", m.to_str().unwrap()], &db);
    assert_eq!(stdout(&st).matches("pending").count(), 2);

    write(
        &m,
        "2_more.up.sql",
        "ALTER TABLE no_such_table ADD COLUMN n TEXT;",
    );
    let bad = run(&["verify-fresh", "--dir", m.to_str().unwrap()], &db);
    assert_eq!(bad.status.code(), Some(6), "{bad:?}");
}

#[test]
fn usage_errors_are_exit_2() {
    let o = Command::new(env!("CARGO_BIN_EXE_persistant-migrate"))
        .arg("up")
        .env_remove("PERSISTANT_DATABASE_URL")
        .output()
        .unwrap();
    assert_eq!(o.status.code(), Some(2));
}

#[test]
fn status_compares_text_checksums_and_names_unknown_versions() {
    let a = Migration::new(1, "a", "SELECT 1");
    let b = Migration::new(2, "b", "SELECT 2");
    let ledger = vec![
        (1, checksum(&a)),
        (2, checksum(&Migration::new(2, "b", "SELECT 3"))),
        (9, vec![0]),
    ];
    let states: Vec<State> = status(&[a, b], &ledger)
        .into_iter()
        .map(|l| l.state)
        .collect();
    assert_eq!(states, [State::Applied, State::Changed, State::Unknown]);
    let none = status(&[Migration::new(1, "a", "SELECT 1")], &[]);
    assert_eq!(none[0].state, State::Pending);
}

fn run_url(args: &[&str], url: &str) -> Output {
    Command::new(env!("CARGO_BIN_EXE_persistant-migrate"))
        .args(args)
        .arg("--url")
        .arg(url)
        .env_remove("PERSISTANT_DATABASE_URL")
        .output()
        .unwrap()
}

#[test]
fn a_remote_target_is_refused_and_named_without_its_password() {
    let t = tempfile::tempdir().unwrap();
    let m = migrations(t.path());
    let dir = m.to_str().unwrap();
    // 192.0.2.1 is TEST-NET-1: never routable, and the guard must refuse before any connection.
    let o = run_url(
        &["up", "--dir", dir],
        "postgres://app:s3cretpw@192.0.2.1:5432/prod",
    );
    assert_eq!(o.status.code(), Some(9), "{o:?}");
    let err = String::from_utf8_lossy(&o.stderr);
    assert!(
        err.contains("up on postgres://app@192.0.2.1:5432/prod"),
        "{err}"
    );
    assert!(!err.contains("s3cretpw"), "{err}");
    assert!(err.contains("--allow-remote"), "{err}");
    // Read-only commands are guarded too, and a host= override cannot smuggle a remote host in.
    let o = run_url(
        &["status", "--dir", dir],
        "postgres://app@localhost/prod?host=192.0.2.1",
    );
    assert_eq!(o.status.code(), Some(9), "{o:?}");
    // Positive control: with the flag the guard lets it through (and the connection then fails, 7).
    let o = run_url(
        &["status", "--dir", dir, "--allow-remote"],
        "mysql://app@192.0.2.1:1/prod",
    );
    assert_eq!(o.status.code(), Some(7), "{o:?}");
}

#[test]
fn a_local_target_is_printed_and_allowed() {
    let t = tempfile::tempdir().unwrap();
    let m = migrations(t.path());
    let db = t.path().join("g.db");
    let o = run(&["status", "--dir", m.to_str().unwrap()], &db);
    assert_eq!(o.status.code(), Some(0), "{o:?}");
    assert!(String::from_utf8_lossy(&o.stderr).contains("status on sqlite:"));
    for url in [
        "postgres://u:p@localhost/d",
        "mysql://u@127.0.0.1:3306/d",
        "postgres://u@[::1]/d",
        "postgres:///d?host=/var/run/postgresql",
    ] {
        assert!(persistant_migrate::describe_target(url).local, "{url}");
    }
    assert!(!persistant_migrate::describe_target("nonsense").local);
}