sqlpage 0.46.2

Build data user interfaces entirely in SQL. A web server that takes .sql files and formats the query result using pre-made configurable professional-looking components.
use super::Database;
use super::error_highlighting::display_db_error;
use crate::MIGRATIONS_DIR;
use anyhow;
use anyhow::Context;
use sqlx::migrate::MigrateError;
use sqlx::migrate::Migration;
use sqlx::migrate::Migrator;

pub async fn apply(config: &crate::app_config::AppConfig, db: &Database) -> anyhow::Result<()> {
    let migrations_dir = config.configuration_directory.join(MIGRATIONS_DIR);
    if !migrations_dir.exists() {
        log::info!(
            "Not applying database migrations because '{}' does not exist",
            migrations_dir.display()
        );
        return Ok(());
    }
    log::debug!("Applying migrations from '{}'", migrations_dir.display());
    let migrator = Migrator::new(migrations_dir.clone())
        .await
        .with_context(|| migration_err("preparing the database migration"))?;
    if migrator.migrations.is_empty() {
        log::debug!(
            "No migration found in {}. \
        You can specify database operations to apply when the server first starts by creating files \
        in {MIGRATIONS_DIR}/<VERSION>_<DESCRIPTION>.sql \
        where <VERSION> is a number and <DESCRIPTION> is a short string.",
            migrations_dir.display()
        );
        return Ok(());
    }
    log::info!("Found {} migrations:", migrator.migrations.len());
    for m in migrator.iter() {
        log::info!("\t{}", DisplayMigration(m));
    }
    migrator.run(&db.connection).await.map_err(|err| {
        match err {
            MigrateError::Execute(n, source) => {
                let migration = failing_migration(&migrator.migrations, n)
                    .expect("sqlx reports the version of a migration it just ran");
                let source_file = migrations_dir.join(format!(
                    "{:04}_{}{}",
                    n,
                    migration.description.replace(' ', "_"),
                    migration.migration_type.suffix()
                ));
                display_db_error(&source_file, &migration.sql, source).context(format!(
                    "Failed to apply {} migration {}",
                    db,
                    DisplayMigration(migration)
                ))
            }
            source => anyhow::Error::new(source),
        }
        .context(format!(
            "Failed to apply database migrations from {MIGRATIONS_DIR:?}"
        ))
    })?;
    Ok(())
}

fn failing_migration(migrations: &[Migration], version: i64) -> Option<&Migration> {
    migrations
        .iter()
        .find(|m| m.version == version && !m.migration_type.is_down_migration())
}

struct DisplayMigration<'a>(&'a Migration);

impl std::fmt::Display for DisplayMigration<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let Migration {
            version,
            migration_type,
            description,
            ..
        } = &self.0;
        write!(f, "[{version:04}]")?;
        if migration_type != &sqlx::migrate::MigrationType::Simple {
            write!(f, " ({migration_type:?})")?;
        }
        write!(f, " {description}")?;
        Ok(())
    }
}

fn migration_err(operation: &'static str) -> String {
    format!(
        "An error occurred while {operation}.
        The path '{MIGRATIONS_DIR}' has to point to a directory, which contains valid SQL files
        with names using the format '<VERSION>_<DESCRIPTION>.sql',
        where <VERSION> is a positive number, and <DESCRIPTION> is a string.
        The current state of migrations will be stored in a table called _sqlx_migrations."
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use sqlx::migrate::MigrationType;
    use tempfile::TempDir;

    fn migration(version: i64, description: &str, migration_type: MigrationType) -> Migration {
        Migration::new(
            version,
            description.to_owned().into(),
            migration_type,
            String::new().into(),
        )
    }

    async fn apply_error(files: &[(&str, &str)]) -> String {
        let dir = TempDir::new().unwrap();
        let migrations_dir = dir.path().join(MIGRATIONS_DIR);
        std::fs::create_dir(&migrations_dir).unwrap();
        for (name, sql) in files {
            std::fs::write(migrations_dir.join(name), sql).unwrap();
        }
        let mut config = crate::app_config::tests::test_config();
        config.database_url = "sqlite::memory:".to_owned();
        config.configuration_directory = dir.path().to_owned();
        let db = Database::init(&config).await.unwrap();
        let error = apply(&config, &db)
            .await
            .expect_err("the migration must fail");
        format!("{error:#}")
    }

    #[actix_web::test]
    async fn only_the_failing_migration_is_named() {
        let error = apply_error(&[
            ("0001_ok.sql", "CREATE TABLE t(x);"),
            ("0002_bad_thing.sql", "SELECT * FROM does_not_exist;"),
        ])
        .await;
        assert!(error.contains("[0002] bad thing"), "{error}");
        assert!(error.contains("0002_bad_thing.sql"), "{error}");
        assert!(error.contains("does_not_exist"), "{error}");
        assert!(!error.contains("[0001] ok"), "{error}");
    }

    #[actix_web::test]
    async fn a_reversible_migration_reports_the_half_that_ran() {
        let error = apply_error(&[
            ("0001_add_new_users.up.sql", "SELECT * FROM does_not_exist;"),
            ("0001_add_new_users.down.sql", "SELECT 'the down half';"),
        ])
        .await;
        assert!(
            error.contains("[0001] (ReversibleUp) add new users"),
            "{error}"
        );
        assert!(error.contains("0001_add_new_users.up.sql"), "{error}");
        assert!(!error.contains("the down half"), "{error}");
    }

    #[test]
    fn the_down_half_is_never_the_failing_migration() {
        let up = migration(1, "x", MigrationType::ReversibleUp);
        let down = migration(1, "x", MigrationType::ReversibleDown);
        for pair in [[up.clone(), down.clone()], [down.clone(), up.clone()]] {
            assert_eq!(
                failing_migration(&pair, 1).map(|m| m.migration_type),
                Some(MigrationType::ReversibleUp)
            );
        }
        assert!(failing_migration(&[down], 1).is_none());
    }

    #[test]
    fn display_migration_shows_version_description_and_reversibility() {
        assert_eq!(
            DisplayMigration(&migration(7, "add users", MigrationType::Simple)).to_string(),
            "[0007] add users"
        );
        assert_eq!(
            DisplayMigration(&migration(7, "add users", MigrationType::ReversibleUp)).to_string(),
            "[0007] (ReversibleUp) add users"
        );
    }
}