use std::path::Path;
use anyhow::Result;
use vcs_runner::Cmd;
use super::{run_step, test_db_name, venv_python, DbEngine, ProvisionCtx, Provisioner, Setup};
pub struct Alembic;
impl Provisioner for Alembic {
fn name(&self) -> &'static str {
"alembic"
}
fn detect(&self, ws_dir: &Path) -> bool {
ws_dir.join("alembic.ini").is_file()
}
fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup> {
let ini = std::fs::read_to_string(ctx.ws_dir.join("alembic.ini")).unwrap_or_default();
if configured_url(&ini).is_some_and(|u| u.starts_with("sqlite")) {
return Ok(Setup::default()); }
let engine = DbEngine::Postgres;
let db = test_db_name(ctx.project_name, ctx.ws_id);
eprintln!("Creating test database {db}...");
if let Err(e) = engine.create(&db) {
return Ok(Setup::database_not_created(&db, &e));
}
let resource = engine.resource(&db);
let url = engine.url(&db);
eprintln!("Applying migrations (alembic upgrade head)...");
let mut cmd = Cmd::new(venv_python(ctx.ws_dir))
.args(["-m", "alembic", "upgrade", "head"])
.env("DATABASE_URL", &url)
.in_dir(ctx.ws_dir);
for (k, v) in ctx.mise_vars {
cmd = cmd.env(k, v);
}
let failed_steps = run_step(cmd, "alembic upgrade head").into_iter().collect();
Ok(Setup::database_url(vec![resource], url, failed_steps))
}
}
pub(crate) fn configured_url(ini: &str) -> Option<String> {
ini.lines().find_map(|l| {
let v = l.trim().strip_prefix("sqlalchemy.url")?.trim_start_matches(['=', ' ']).trim();
(!v.is_empty()).then(|| v.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_by_alembic_ini() {
let tmp = tempfile::tempdir().unwrap();
assert!(!Alembic.detect(tmp.path()));
std::fs::write(tmp.path().join("alembic.ini"), "[alembic]\n").unwrap();
assert!(Alembic.detect(tmp.path()));
}
#[test]
fn configured_url_reads_non_blank() {
assert_eq!(configured_url("[alembic]\nsqlalchemy.url =\n"), None);
assert_eq!(configured_url("sqlalchemy.url = sqlite:///x.db").as_deref(), Some("sqlite:///x.db"));
}
#[test]
fn alembic_cycle_applies_migrations_to_isolated_db() {
use crate::provision::Resource;
use std::collections::HashMap;
use std::process::Command;
let name = test_db_name("alembicfix", "ws-cycle");
Resource::PostgresDb { name: name.clone() }.teardown();
if DbEngine::Postgres.create(&name).is_err() {
return;
}
Resource::PostgresDb { name: name.clone() }.teardown();
let fixture = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/alembic"));
if !fixture.join(".venv/bin/python").exists() {
return; }
let mise = HashMap::new();
let ctx = ProvisionCtx {
project_dir: fixture,
project_name: "alembicfix",
ws_id: "ws-cycle",
ws_dir: fixture,
mise_vars: &mise,
};
let setup = Alembic.setup(&ctx).unwrap();
assert_eq!(setup.resources, vec![Resource::PostgresDb { name: name.clone() }]);
let out = Command::new("psql").args(["-tAc", "select to_regclass('public.widgets')", &name]).output().unwrap();
let table = String::from_utf8_lossy(&out.stdout).trim().to_string();
Resource::PostgresDb { name: name.clone() }.teardown();
assert_eq!(table, "widgets", "alembic upgrade should have created the widgets table");
}
proptest::proptest! {
#[test]
fn configured_url_is_the_first_non_blank_sqlalchemy_url(
blank_first in proptest::bool::ANY,
spacing in " ?= ?",
url in "[a-z+]{1,10}: ) {
let blank = if blank_first { "sqlalchemy.url =\n" } else { "" };
let ini = format!("[alembic]\nscript_location = migrations\n{blank}sqlalchemy.url{spacing}{url}\n");
proptest::prop_assert_eq!(configured_url(&ini), Some(url));
}
}
}