drizzle-cli 0.1.6

Command-line interface for drizzle-rs migrations
Documentation
#![cfg(feature = "rusqlite")]

use assert_cmd::cargo::cargo_bin_cmd;
use std::fs;
use std::path::Path;
use tempfile::tempdir;

fn write_config(root: &Path, db_path: &Path, migrations_dir: &Path, table: Option<&str>) {
    let migrations_block = table
        .map(|table| format!("\n[migrations]\ntable = \"{table}\"\n"))
        .unwrap_or_default();

    fs::write(
        root.join("drizzle.config.toml"),
        format!(
            r#"
dialect = "sqlite"
schema = '{schema_path}'
out = '{out_dir}'
{migrations_block}
[dbCredentials]
url = '{db_url}'
"#,
            schema_path = root.join("schema.rs").to_string_lossy(),
            out_dir = migrations_dir.to_string_lossy(),
            db_url = db_path.to_string_lossy(),
            migrations_block = migrations_block,
        ),
    )
    .expect("write config");

    fs::write(root.join("schema.rs"), "// test schema\n").expect("write schema");
}

fn write_migration(migrations_dir: &Path, tag: &str, sql: &str) {
    let dir = migrations_dir.join(tag);
    fs::create_dir_all(&dir).expect("create migration dir");
    fs::write(dir.join("migration.sql"), sql).expect("write migration sql");
}

fn tracking_columns(conn: &rusqlite::Connection, table: &str) -> Vec<String> {
    let pragma = format!("SELECT name FROM pragma_table_info('{table}') ORDER BY cid");
    let mut stmt = conn.prepare(&pragma).expect("prepare pragma_table_info");
    stmt.query_map([], |row| row.get::<_, String>(0))
        .expect("query pragma_table_info")
        .collect::<Result<Vec<_>, _>>()
        .expect("collect pragma columns")
}

#[test]
fn migrate_upgrades_legacy_tracking_table_and_applies_pending_migrations() {
    let dir = tempdir().expect("tempdir");
    let root = dir.path();
    let db_path = root.join("dev.db");
    let migrations_dir = root.join("migrations");
    write_config(root, &db_path, &migrations_dir, None);

    write_migration(
        &migrations_dir,
        "20230331141203_initial",
        "CREATE TABLE already_applied_table (id INTEGER PRIMARY KEY);\n",
    );
    write_migration(
        &migrations_dir,
        "20230331141204_pending",
        "CREATE TABLE pending_after_upgrade (id INTEGER PRIMARY KEY);\n",
    );

    let migrations = drizzle_migrations::MigrationDir::new(&migrations_dir)
        .discover()
        .expect("discover migrations");
    let first = &migrations[0];
    let second = &migrations[1];

    let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
    conn.execute(
        "CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)",
        [],
    )
    .expect("create legacy tracking table");
    conn.execute(
        "CREATE TABLE already_applied_table (id INTEGER PRIMARY KEY)",
        [],
    )
    .expect("create applied table");
    conn.execute(
        "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?1, ?2)",
        rusqlite::params![first.hash(), first.created_at()],
    )
    .expect("insert legacy applied row");
    drop(conn);

    cargo_bin_cmd!("drizzle")
        .current_dir(root)
        .args(["migrate"])
        .assert()
        .success();

    let conn = rusqlite::Connection::open(&db_path).expect("reopen sqlite");
    assert_eq!(
        tracking_columns(&conn, "__drizzle_migrations"),
        vec!["id", "hash", "created_at", "name", "applied_at"]
    );

    let rows: Vec<(String, i64, String, Option<String>)> = {
        let mut stmt = conn
            .prepare(
                "SELECT hash, created_at, name, applied_at FROM __drizzle_migrations ORDER BY id ASC",
            )
            .expect("prepare metadata query");
        stmt.query_map([], |row| {
            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
        })
        .expect("query metadata rows")
        .collect::<Result<Vec<_>, _>>()
        .expect("collect metadata rows")
    };

    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0].0, first.hash());
    assert_eq!(rows[0].1, first.created_at());
    assert_eq!(rows[0].2, first.name());
    assert_eq!(
        rows[0].3, None,
        "backfilled legacy rows keep NULL applied_at"
    );
    assert_eq!(rows[1].0, second.hash());
    assert_eq!(rows[1].1, second.created_at());
    assert_eq!(rows[1].2, second.name());
    assert!(rows[1].3.is_some(), "newly applied rows set applied_at");

    let pending_exists: i64 = conn
        .query_row(
            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='pending_after_upgrade'",
            [],
            |row| row.get(0),
        )
        .expect("query pending table");
    assert_eq!(pending_exists, 1);
}

#[test]
fn migrate_upgrades_legacy_custom_tracking_table() {
    let dir = tempdir().expect("tempdir");
    let root = dir.path();
    let db_path = root.join("dev.db");
    let migrations_dir = root.join("migrations");
    write_config(root, &db_path, &migrations_dir, Some("custom_migrations"));

    write_migration(
        &migrations_dir,
        "20230331141203_initial",
        "CREATE TABLE custom_already_applied (id INTEGER PRIMARY KEY);\n",
    );

    let migrations = drizzle_migrations::MigrationDir::new(&migrations_dir)
        .discover()
        .expect("discover migrations");
    let first = &migrations[0];

    let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
    conn.execute(
        "CREATE TABLE custom_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)",
        [],
    )
    .expect("create custom legacy tracking table");
    conn.execute(
        "CREATE TABLE custom_already_applied (id INTEGER PRIMARY KEY)",
        [],
    )
    .expect("create applied table");
    conn.execute(
        "INSERT INTO custom_migrations (hash, created_at) VALUES (?1, ?2)",
        rusqlite::params![first.hash(), first.created_at()],
    )
    .expect("insert custom legacy applied row");
    drop(conn);

    cargo_bin_cmd!("drizzle")
        .current_dir(root)
        .args(["migrate", "--verify"])
        .assert()
        .success();

    let conn = rusqlite::Connection::open(&db_path).expect("reopen sqlite");
    assert_eq!(
        tracking_columns(&conn, "custom_migrations"),
        vec!["id", "hash", "created_at", "name", "applied_at"]
    );

    let (name, applied_at): (String, Option<String>) = conn
        .query_row(
            "SELECT name, applied_at FROM custom_migrations LIMIT 1",
            [],
            |row| Ok((row.get(0)?, row.get(1)?)),
        )
        .expect("query upgraded custom metadata row");
    assert_eq!(name, first.name());
    assert_eq!(applied_at, None);
}