toolu-orm-cli 0.1.1

Migration CLI (generate, migrate, status) for toolu-orm
Documentation
use toolu_orm_connection::{Database, DbConnection, LibsqlConnection};
use toolu_orm_core::dialect::Dialect;
use toolu_orm_core::journal::{compute_hash, Journal};
use toolu_orm_core::row::FromRow;
use toolu_orm_core::value::Value;

type TestResult = Result<(), Box<dyn std::error::Error>>;

async fn create_test_connection() -> Result<LibsqlConnection, Box<dyn std::error::Error>> {
  let db = Database::init_local(":memory:").await?;
  Ok(db.connect()?)
}

#[tokio::test]
async fn test_migrate_applies_pending_migrations_legacy() -> TestResult {
  let dir = tempfile::tempdir()?;
  let migrations_dir = dir.path().join("migrations");
  std::fs::create_dir_all(&migrations_dir)?;

  let sql = "CREATE TABLE \"conversations\" (\n    \"id\" TEXT PRIMARY KEY\n);";
  std::fs::write(migrations_dir.join("0001_initial.sql"), sql)?;

  let conn = create_test_connection().await?;

  let count = toolu_orm_cli::migrate::run_migrate(
    &conn,
    migrations_dir.to_str().ok_or("invalid path")?,
    Dialect::Sqlite,
  )
  .await?;
  assert_eq!(count, 1);

  let rows = conn
    .query_map::<ScalarI64>("SELECT count(*) AS v FROM conversations", vec![])
    .await?;
  assert_eq!(
    rows.first().ok_or("expected scalar row")?.v,
    0,
    "conversations table should exist and be empty"
  );

  let mig = conn
    .query_map::<MigrationNameRow>("SELECT name FROM _migrations", vec![])
    .await?;
  assert_eq!(mig.len(), 1);
  assert_eq!(
    mig.first().ok_or("expected migration row")?.name,
    "0001_initial.sql"
  );
  Ok(())
}

#[tokio::test]
async fn test_migrate_skips_already_applied() -> TestResult {
  let dir = tempfile::tempdir()?;
  let migrations_dir = dir.path().join("migrations");
  std::fs::create_dir_all(&migrations_dir)?;

  let sql = "CREATE TABLE \"conversations\" (\n    \"id\" TEXT PRIMARY KEY\n);";
  std::fs::write(migrations_dir.join("0001_initial.sql"), sql)?;

  let conn = create_test_connection().await?;

  let count = toolu_orm_cli::migrate::run_migrate(
    &conn,
    migrations_dir.to_str().ok_or("invalid path")?,
    Dialect::Sqlite,
  )
  .await?;
  assert_eq!(count, 1);

  let count = toolu_orm_cli::migrate::run_migrate(
    &conn,
    migrations_dir.to_str().ok_or("invalid path")?,
    Dialect::Sqlite,
  )
  .await?;
  assert_eq!(count, 0);
  Ok(())
}

#[tokio::test]
async fn test_migrate_with_journal_and_breakpoints() -> TestResult {
  let dir = tempfile::tempdir()?;
  let migrations_dir = dir.path().join("migrations");
  std::fs::create_dir_all(&migrations_dir)?;

  let sql = "CREATE TABLE a (id TEXT);\n--> statement-breakpoint\nCREATE TABLE b (id TEXT);";
  std::fs::write(migrations_dir.join("0001_init.sql"), sql)?;

  let mut journal = Journal::empty();
  journal.add_entry("0001_init.sql", &compute_hash(sql));
  let journal_path = migrations_dir.join("_journal.json");
  journal.write_to_path(journal_path.to_str().ok_or("invalid path")?)?;

  let conn = create_test_connection().await?;

  let count = toolu_orm_cli::migrate::run_migrate(
    &conn,
    migrations_dir.to_str().ok_or("invalid path")?,
    Dialect::Sqlite,
  )
  .await?;
  assert_eq!(count, 1);

  let a = conn
    .query_map::<ScalarI64>("SELECT count(*) AS v FROM a", vec![])
    .await?;
  assert_eq!(a.first().ok_or("table a should exist")?.v, 0);
  let b = conn
    .query_map::<ScalarI64>("SELECT count(*) AS v FROM b", vec![])
    .await?;
  assert_eq!(b.first().ok_or("table b should exist")?.v, 0);
  Ok(())
}

#[tokio::test]
async fn test_migrate_hash_mismatch_errors() -> TestResult {
  let dir = tempfile::tempdir()?;
  let migrations_dir = dir.path().join("migrations");
  std::fs::create_dir_all(&migrations_dir)?;

  std::fs::write(
    migrations_dir.join("0001_init.sql"),
    "CREATE TABLE a (id TEXT);",
  )?;

  let mut journal = Journal::empty();
  journal.add_entry("0001_init.sql", "sha256:wrong_hash");
  let journal_path = migrations_dir.join("_journal.json");
  journal.write_to_path(journal_path.to_str().ok_or("invalid path")?)?;

  let conn = create_test_connection().await?;

  let result = toolu_orm_cli::migrate::run_migrate(
    &conn,
    migrations_dir.to_str().ok_or("invalid path")?,
    Dialect::Sqlite,
  )
  .await;
  let err = result.err().ok_or("expected migration to fail")?;
  assert!(err.to_string().contains("modified"));
  Ok(())
}

#[tokio::test]
async fn ensure_migrations_table_creates_correct_schema_for_dialect() -> TestResult {
  let conn = create_test_connection().await?;

  toolu_orm_cli::migrate::ensure_migrations_table(&conn, Dialect::Sqlite).await?;

  conn
    .execute_sql(
      "INSERT INTO _migrations (name, hash) VALUES (?1, ?2)",
      vec![
        Value::Text("test.sql".to_owned()),
        Value::Text("sha256:abc".to_owned()),
      ],
    )
    .await?;

  let m = conn
    .query_map::<MigrationNameRow>("SELECT name FROM _migrations", vec![])
    .await?;
  assert_eq!(m.len(), 1);
  Ok(())
}

struct ScalarI64 {
  v: i64,
}

impl FromRow for ScalarI64 {
  const REQUIRED_COLUMNS: &'static [&'static str] = &["v"];

  fn from_pg_row(_: &tokio_postgres::Row) -> Result<Self, toolu_orm_core::error::DbCoreError> {
    Err(toolu_orm_core::error::DbCoreError::RowMapping(
      "ScalarI64 is only used in libsql migrate tests".into(),
    ))
  }

  fn from_libsql_row(row: &libsql::Row) -> Result<Self, toolu_orm_core::error::DbCoreError> {
    let v: i64 = row
      .get(0)
      .map_err(|e| toolu_orm_core::error::DbCoreError::RowMapping(e.to_string()))?;
    Ok(Self { v })
  }
}

struct MigrationNameRow {
  name: String,
}

impl FromRow for MigrationNameRow {
  const REQUIRED_COLUMNS: &'static [&'static str] = &["name"];

  fn from_pg_row(_: &tokio_postgres::Row) -> Result<Self, toolu_orm_core::error::DbCoreError> {
    Err(toolu_orm_core::error::DbCoreError::RowMapping(
      "MigrationNameRow: pg stub".into(),
    ))
  }

  fn from_libsql_row(row: &libsql::Row) -> Result<Self, toolu_orm_core::error::DbCoreError> {
    let name: String = row
      .get(0)
      .map_err(|e| toolu_orm_core::error::DbCoreError::RowMapping(e.to_string()))?;
    Ok(Self { name })
  }
}