ormer 0.2.7

A minimalist ORM framework that supports SQLite, PostgreSQL, MySQL, and SqlServer
#![cfg(feature = "sqlite")]

use ormer::{Database, DbType, Migration, MigrationStep};

#[derive(Debug, ormer::Model)]
#[table = "ormer_migration_users"]
struct MigrationUserV1 {
    #[primary(auto)]
    id: i32,
    name: String,
}

#[derive(Debug, ormer::Model)]
#[table = "ormer_migration_users"]
struct MigrationUserV2 {
    #[primary(auto)]
    id: i32,
    name: String,
    display_name: String,
}

#[derive(Debug, ormer::Model)]
#[table = "ormer_migration_users"]
struct MigrationUserV3 {
    #[primary(auto)]
    id: i32,
    name: String,
    #[default("unknown")]
    #[unique]
    slug: String,
    #[index]
    tag: Option<String>,
}

#[derive(Debug, ormer::Model)]
#[table = "ormer_migration_type_values"]
struct MigrationIntegerValue {
    #[primary]
    id: i32,
    value: i32,
}

#[derive(Debug, ormer::Model)]
#[table = "ormer_migration_nullable_values"]
struct MigrationNonNullValue {
    #[primary]
    id: i32,
    value: String,
}

#[derive(Debug, ormer::Model)]
#[table = "ormer_migration_nullable_bad_values"]
struct MigrationNonNullBadValue {
    #[primary]
    id: i32,
    value: String,
}

struct CreateMigration;

impl Migration for CreateMigration {
    fn version(&self) -> u64 {
        1
    }

    fn name(&self) -> &str {
        "create_migration_users"
    }

    fn up(&self) -> Vec<MigrationStep> {
        vec![MigrationStep::Sql {
            sql: "CREATE TABLE migration_history_users (id INTEGER PRIMARY KEY)".to_string(),
        }]
    }
}

struct FailingMigration;

impl Migration for FailingMigration {
    fn version(&self) -> u64 {
        2
    }

    fn name(&self) -> &str {
        "failing_migration"
    }

    fn up(&self) -> Vec<MigrationStep> {
        vec![MigrationStep::Sql {
            sql: "CREATE TABLE migration_history_users (".to_string(),
        }]
    }
}

async fn database() -> ormer::Result<Database> {
    Ok(Database::connect(DbType::Sqlite, ":memory:").await?)
}

#[tokio::test]
async fn table_plan_creates_and_adds_columns() -> ormer::Result<()> {
    let db = database().await?;

    let initial = db.migrate_table::<MigrationUserV1>().plan().await?;
    assert_eq!(initial.table_name(), "ormer_migration_users");
    assert!(matches!(
        initial.steps().first(),
        Some(MigrationStep::CreateTable { .. })
    ));
    initial.to_sql()?;
    db.migrate_table::<MigrationUserV1>().execute().await?;
    db.validate_table::<MigrationUserV1>().await?;

    let additive = db.migrate_table::<MigrationUserV2>().plan().await?;
    assert!(additive.steps().iter().any(
        |step| matches!(step, MigrationStep::AddColumn { column, .. } if column == "display_name")

    ));
    db.migrate_table::<MigrationUserV2>().execute().await?;
    db.validate_table::<MigrationUserV2>().await?;
    assert!(
        db.migrate_table::<MigrationUserV2>()
            .plan()
            .await?
            .is_empty()
    );
    Ok(())
}

#[tokio::test]
async fn table_plan_rejects_implicit_not_null_addition_on_populated_sqlite_table()
-> ormer::Result<()> {
    let db = database().await?;
    db.migrate_table::<MigrationUserV1>().execute().await?;
    db.execute_sql("INSERT INTO ormer_migration_users (name) VALUES ('existing')")
        .await?;

    let error = db
        .migrate_table::<MigrationUserV2>()
        .plan()
        .await
        .expect_err("non-null column requires an explicit backfill");
    assert!(error.to_string().contains("explicit migration"));
    Ok(())
}

#[tokio::test]
async fn table_plan_uses_defaults_and_adds_indexes_for_new_columns() -> ormer::Result<()> {
    let db = database().await?;
    db.migrate_table::<MigrationUserV1>().execute().await?;
    db.execute_sql("INSERT INTO ormer_migration_users (name) VALUES ('existing')")
        .await?;

    let plan = db.migrate_table::<MigrationUserV3>().plan().await?;
    assert!(
        plan.steps()
            .iter()
            .any(|step| { matches!(step, MigrationStep::CreateIndex { unique: true, .. }) })
    );
    assert!(plan.steps().iter().any(|step| {
        matches!(
            step,
            MigrationStep::CreateIndex { unique: false, columns, .. }
                if columns == &vec!["tag".to_string()]
        )
    }));

    db.migrate_table::<MigrationUserV3>().execute().await?;
    let rows = db
        .select_sql::<(String, String)>(
            "SELECT name, slug FROM ormer_migration_users ORDER BY name",
        )
        .collect::<Vec<_>>()
        .await?;
    assert_eq!(rows, vec![("existing".to_string(), "unknown".to_string())]);
    let error = db
        .execute_sql(
            "INSERT INTO ormer_migration_users (name, slug) \
             VALUES ('second', 'unknown')",
        )
        .await
        .expect_err("new unique index must be enforced");
    assert!(!error.to_string().is_empty());
    Ok(())
}

#[tokio::test]
async fn sqlite_migrates_text_to_integer_and_preserves_data() -> ormer::Result<()> {
    let db = database().await?;
    db.execute_sql(
        "CREATE TABLE ormer_migration_type_values (id INTEGER PRIMARY KEY, value TEXT NOT NULL)",
    )
    .await?;
    db.execute_sql(
        "INSERT INTO ormer_migration_type_values (id, value) \
         VALUES (1, '0'), (2, '7'), (3, '-12')",
    )
    .await?;

    let plan = db.migrate_table::<MigrationIntegerValue>().plan().await?;
    assert!(!plan.is_empty());
    assert!(plan.warnings().is_empty());

    db.migrate_table::<MigrationIntegerValue>()
        .execute()
        .await?;
    db.validate_table::<MigrationIntegerValue>().await?;
    let values = db
        .select_sql::<(i32, i32)>("SELECT id, value FROM ormer_migration_type_values ORDER BY id")
        .collect::<Vec<_>>()
        .await?;
    assert_eq!(values, vec![(1, 0), (2, 7), (3, -12)]);
    assert!(
        db.migrate_table::<MigrationIntegerValue>()
            .plan()
            .await?
            .is_empty()
    );
    Ok(())
}

#[tokio::test]
async fn sqlite_migrates_nullable_to_not_null_and_enforces_constraint() -> ormer::Result<()> {
    let db = database().await?;
    db.execute_sql(
        "CREATE TABLE ormer_migration_nullable_values (id INTEGER PRIMARY KEY, value TEXT)",
    )
    .await?;
    db.execute_sql("INSERT INTO ormer_migration_nullable_values (id, value) VALUES (1, 'alice')")
        .await?;

    db.migrate_table::<MigrationNonNullValue>()
        .execute()
        .await?;
    db.validate_table::<MigrationNonNullValue>().await?;

    let column_info = db
        .select_sql::<(String, i64)>(
            "SELECT name, \"notnull\" FROM pragma_table_info('ormer_migration_nullable_values') \
             WHERE name = 'value'",
        )
        .collect::<Vec<_>>()
        .await?;
    assert_eq!(column_info, vec![("value".to_string(), 1)]);
    let error = db
        .execute_sql("INSERT INTO ormer_migration_nullable_values (id, value) VALUES (2, NULL)")
        .await
        .expect_err("migrated NOT NULL column must reject NULL");
    assert!(!error.to_string().is_empty());
    Ok(())
}

#[tokio::test]
async fn sqlite_invalid_type_value_rolls_back() -> ormer::Result<()> {
    let db = database().await?;
    db.execute_sql(
        "CREATE TABLE ormer_migration_type_values (id INTEGER PRIMARY KEY, value TEXT NOT NULL)",
    )
    .await?;
    db.execute_sql(
        "INSERT INTO ormer_migration_type_values (id, value) \
         VALUES (1, '12'), (2, 'not-an-integer')",
    )
    .await?;

    let error = db
        .migrate_table::<MigrationIntegerValue>()
        .execute()
        .await
        .expect_err("invalid integer text must abort migration");
    assert!(!error.to_string().is_empty());

    let rows = db
        .select_sql::<(i32, String)>(
            "SELECT id, value FROM ormer_migration_type_values ORDER BY id",
        )
        .collect::<Vec<_>>()
        .await?;
    assert_eq!(
        rows,
        vec![(1, "12".to_string()), (2, "not-an-integer".to_string())]
    );
    let column_info = db
        .select_sql::<(String, String)>(
            "SELECT name, type FROM pragma_table_info('ormer_migration_type_values') \
             WHERE name = 'value'",
        )
        .collect::<Vec<_>>()
        .await?;
    assert_eq!(column_info, vec![("value".to_string(), "TEXT".to_string())]);
    Ok(())
}

#[tokio::test]
async fn sqlite_not_null_migration_with_existing_null_rolls_back() -> ormer::Result<()> {
    let db = database().await?;
    db.execute_sql(
        "CREATE TABLE ormer_migration_nullable_bad_values (id INTEGER PRIMARY KEY, value TEXT)",
    )
    .await?;
    db.execute_sql("INSERT INTO ormer_migration_nullable_bad_values (id, value) VALUES (1, NULL)")
        .await?;

    let error = db
        .migrate_table::<MigrationNonNullBadValue>()
        .execute()
        .await
        .expect_err("existing NULL must abort NOT NULL migration");
    assert!(!error.to_string().is_empty());

    let null_count = db
        .select_sql::<i64>(
            "SELECT COUNT(*) FROM ormer_migration_nullable_bad_values WHERE value IS NULL",
        )
        .collect::<Vec<_>>()
        .await?;
    assert_eq!(null_count, vec![1]);
    let column_info = db
        .select_sql::<(String, i64)>(
            "SELECT name, \"notnull\" \
             FROM pragma_table_info('ormer_migration_nullable_bad_values') \
             WHERE name = 'value'",
        )
        .collect::<Vec<_>>()
        .await?;
    assert_eq!(column_info, vec![("value".to_string(), 0)]);
    Ok(())
}

#[tokio::test]
async fn versioned_migrations_track_pending_and_rollback() -> ormer::Result<()> {
    let db = database().await?;
    let create = CreateMigration;
    let failing = FailingMigration;
    let migrations: [&dyn Migration; 2] = [&create, &failing];

    let pending = db.pending_migrations(&migrations).await?;
    assert_eq!(
        pending
            .iter()
            .map(|migration| migration.version)
            .collect::<Vec<_>>(),
        vec![1, 2]
    );

    let error = db
        .apply_migrations(&migrations)
        .await
        .expect_err("migration should fail");
    assert!(!error.to_string().is_empty());
    assert!(db.validate_table::<MigrationHistoryUser>().await.is_err());
    assert_eq!(db.pending_migrations(&migrations).await?.len(), 2);

    let create_only: [&dyn Migration; 1] = [&create];
    assert_eq!(db.apply_migrations(&create_only).await?, 1);
    let history = db.migration_history().await?;
    assert_eq!(history.len(), 1);
    assert_eq!(history[0].version, 1);
    assert_eq!(history[0].name, "create_migration_users");
    assert_ne!(history[0].checksum, 0);
    assert_eq!(db.apply_migrations(&create_only).await?, 0);
    Ok(())
}

#[derive(Debug, ormer::Model)]
#[table = "migration_history_users"]
struct MigrationHistoryUser {
    #[primary]
    id: i32,
}