use std::path::Path;
use anyhow::Result;
use vcs_runner::Cmd;
use super::{run_step, test_db_name, DbEngine, ProvisionCtx, Provisioner, Setup};
pub struct Prisma;
impl Provisioner for Prisma {
fn name(&self) -> &'static str {
"prisma"
}
fn detect(&self, ws_dir: &Path) -> bool {
ws_dir.join("prisma/schema.prisma").is_file()
}
fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup> {
let schema = std::fs::read_to_string(ctx.ws_dir.join("prisma/schema.prisma"))?;
let engine = match datasource_provider(&schema).as_deref() {
Some("postgresql") => DbEngine::Postgres,
Some("mysql") => DbEngine::Mysql,
Some("sqlite") | None => return Ok(Setup::default()), Some(other) => {
eprintln!("Prisma provider `{other}` not supported yet; skipping DB setup");
return Ok(Setup::default());
}
};
let env_var = url_env_var(&schema).unwrap_or_else(|| "DATABASE_URL".to_string());
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);
let has_migrations = ctx.ws_dir.join("prisma/migrations").is_dir();
let mut apply = if has_migrations {
Cmd::new("npx").args(["prisma", "migrate", "deploy"])
} else {
Cmd::new("npx").args(["prisma", "db", "push", "--skip-generate", "--accept-data-loss"])
};
apply = apply.env(&env_var, &url).in_dir(ctx.ws_dir);
for (k, v) in ctx.mise_vars {
apply = apply.env(k, v);
}
let applied = run_step(apply, "prisma schema apply");
let mut generate = Cmd::new("npx").args(["prisma", "generate"]).env(&env_var, &url).in_dir(ctx.ws_dir);
for (k, v) in ctx.mise_vars {
generate = generate.env(k, v);
}
let generated = run_step(generate, "prisma generate");
Ok(Setup {
resources: vec![resource],
env: vec![(env_var, url)],
failed_steps: applied.into_iter().chain(generated).collect(),
..Setup::default()
})
}
}
pub(crate) fn datasource_provider(schema: &str) -> Option<String> {
const DB_PROVIDERS: &[&str] = &["postgresql", "mysql", "sqlite", "sqlserver", "cockroachdb", "mongodb"];
for line in schema.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("provider") {
let value = rest.trim_start_matches(['=', ' ']).trim().trim_matches('"');
if DB_PROVIDERS.contains(&value) {
return Some(value.to_string());
}
}
}
None
}
pub(crate) fn url_env_var(schema: &str) -> Option<String> {
let after = schema.split("env(").nth(1)?;
let inner = after.split(')').next()?;
Some(inner.trim().trim_matches('"').to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_by_schema_prisma() {
let tmp = tempfile::tempdir().unwrap();
assert!(!Prisma.detect(tmp.path()));
std::fs::create_dir(tmp.path().join("prisma")).unwrap();
std::fs::write(tmp.path().join("prisma/schema.prisma"), "datasource db {}\n").unwrap();
assert!(Prisma.detect(tmp.path()));
}
#[test]
fn parses_provider_and_env_var_ignoring_the_generator() {
let schema = r#"
generator client { provider = "prisma-client-js" }
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
"#;
assert_eq!(datasource_provider(schema).as_deref(), Some("postgresql"));
assert_eq!(url_env_var(schema).as_deref(), Some("DATABASE_URL"));
}
#[test]
fn sqlite_datasource_is_a_noop() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("prisma")).unwrap();
std::fs::write(
tmp.path().join("prisma/schema.prisma"),
"datasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n",
)
.unwrap();
let mise = std::collections::HashMap::new();
let ctx = ProvisionCtx {
project_dir: tmp.path(),
project_name: "p",
ws_id: "ws-x",
ws_dir: tmp.path(),
mise_vars: &mise,
};
let setup = Prisma.setup(&ctx).unwrap();
assert!(setup.resources.is_empty() && setup.env.is_empty(), "sqlite -> no-op");
}
#[test]
fn prisma_cycle_applies_migrations_to_isolated_db() {
use crate::provision::Resource;
use std::collections::HashMap;
use std::process::Command;
let name = test_db_name("prismafix", "ws-cycle");
Resource::PostgresDb { name: name.clone() }.teardown();
if DbEngine::Postgres.create(&name).is_err() {
return; }
Resource::PostgresDb { name: name.clone() }.teardown();
let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/prisma");
if !Path::new(fixture).join("node_modules/.bin/prisma").exists() {
return; }
let tmp = tempfile::tempdir().unwrap();
let ws_dir = tmp.path().join("prismafix-ws-cycle");
assert!(Command::new("cp").args(["-R", fixture, ws_dir.to_str().unwrap()]).status().unwrap().success());
let mise = HashMap::new();
let ctx = ProvisionCtx {
project_dir: &ws_dir,
project_name: "prismafix",
ws_id: "ws-cycle",
ws_dir: &ws_dir,
mise_vars: &mise,
};
let setup = Prisma.setup(&ctx).unwrap();
assert_eq!(setup.resources, vec![Resource::PostgresDb { name: name.clone() }]);
let out =
Command::new("psql").args(["-tAc", "select to_regclass('public.\"Widget\"')", &name]).output().unwrap();
let table = String::from_utf8_lossy(&out.stdout).trim().to_string();
Resource::PostgresDb { name: name.clone() }.teardown();
assert!(table.contains("Widget"), "migrate deploy should have created the Widget table, got {table:?}");
}
#[test]
fn prisma_cycle_applies_migrations_to_isolated_mysql_db() {
use crate::provision::Resource;
use std::collections::HashMap;
use std::process::Command;
let name = test_db_name("prismamysql", "ws-cycle");
Resource::MysqlDb { name: name.clone() }.teardown();
if DbEngine::Mysql.create(&name).is_err() {
return; }
Resource::MysqlDb { name: name.clone() }.teardown();
let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/prisma");
if !Path::new(fixture).join("node_modules/.bin/prisma").exists() {
return; }
let tmp = tempfile::tempdir().unwrap();
let ws_dir = tmp.path().join("prismamysql-ws-cycle");
assert!(Command::new("cp").args(["-R", fixture, ws_dir.to_str().unwrap()]).status().unwrap().success());
std::fs::write(
ws_dir.join("prisma/schema.prisma"),
"generator client {\n provider = \"prisma-client-js\"\n}\n\
datasource db {\n provider = \"mysql\"\n url = env(\"DATABASE_URL\")\n}\n\
model Widget {\n id Int @id @default(autoincrement())\n name String?\n}\n",
)
.unwrap();
std::fs::write(ws_dir.join("prisma/migrations/migration_lock.toml"), "provider = \"mysql\"\n").unwrap();
std::fs::write(
ws_dir.join("prisma/migrations/0001_init/migration.sql"),
"CREATE TABLE `Widget` (\n `id` INTEGER NOT NULL AUTO_INCREMENT,\n `name` VARCHAR(191) NULL,\n PRIMARY KEY (`id`)\n) DEFAULT CHARACTER SET utf8mb4;\n",
)
.unwrap();
let mise = HashMap::new();
let ctx = ProvisionCtx {
project_dir: &ws_dir,
project_name: "prismamysql",
ws_id: "ws-cycle",
ws_dir: &ws_dir,
mise_vars: &mise,
};
let setup = Prisma.setup(&ctx).unwrap();
assert_eq!(setup.resources, vec![Resource::MysqlDb { name: name.clone() }]);
assert!(
setup.env.iter().any(|(k, v)| k == "DATABASE_URL" && v.starts_with("mysql://") && v.contains(&name)),
"expected a mysql:// URL for {name}, got {:?}",
setup.env,
);
let exists = mysql_has_table(&name, "Widget");
Resource::MysqlDb { name: name.clone() }.teardown();
assert!(exists, "migrate deploy should have created the Widget table in mysql");
}
fn mysql_has_table(db: &str, table: &str) -> bool {
let user =
std::env::var("MYSQL_USER").ok().or_else(|| std::env::var("USER").ok()).unwrap_or_else(|| "root".into());
let query = format!(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='{db}' AND table_name='{table}'"
);
std::process::Command::new("mysql")
.args(["-u", &user, "-N", "-e", &query])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "1")
.unwrap_or(false)
}
proptest::proptest! {
#[test]
fn the_datasource_provider_and_url_var_read_back(
provider in proptest::sample::select(vec!["postgresql", "mysql", "sqlite", "sqlserver", "cockroachdb", "mongodb"]),
spacing in " {0,6}= ?",
env_var in "[A-Z][A-Z0-9_]{0,15}",
) {
let schema = format!(
"generator client {{\n provider = \"prisma-client-js\"\n}}\n\ndatasource db {{\n provider{spacing}\"{provider}\"\n url = env(\"{env_var}\")\n}}\n"
);
proptest::prop_assert_eq!(datasource_provider(&schema), Some(provider.to_string()));
proptest::prop_assert_eq!(url_env_var(&schema), Some(env_var));
}
}
}