use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "migratio-runner")]
#[command(about = "Database migration runner")]
pub struct CliArgs {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Status,
Upgrade {
#[arg(long)]
to: Option<u32>,
},
Downgrade {
#[arg(long)]
to: u32,
},
History,
Preview,
List,
Schema {
#[arg(short, long)]
output: Option<std::path::PathBuf>,
},
}
#[cfg(feature = "sqlite")]
pub use sqlite::{run_sqlite, schema_sqlite};
#[cfg(feature = "sqlite")]
mod sqlite {
use super::{CliArgs, Commands};
use migratio::sqlite::SqliteMigrator;
use rusqlite::Connection;
pub fn run_sqlite(
migrator: SqliteMigrator,
database_url: &str,
args: CliArgs,
) -> Result<(), Box<dyn std::error::Error>> {
let command = match args.command {
Commands::Schema { output } => return schema_sqlite(migrator, output),
command => command,
};
if !std::path::Path::new(database_url).exists() {
return Err(format!(
"Database file not found: {}\n\nTo create a new database, first create the file manually or use your application's initialization logic.",
database_url
).into());
}
let mut conn = Connection::open(database_url)?;
match command {
Commands::Status => {
let version = migrator.get_current_version(&mut conn)?;
let pending = migrator.preview_upgrade(&mut conn)?;
println!("Current version: {}", version);
println!("Pending migrations: {}", pending.len());
for m in pending {
println!(" - {} (v{})", m.name(), m.version());
}
}
Commands::Upgrade { to } => {
let report = match to {
Some(target) => migrator.upgrade_to(&mut conn, target)?,
None => migrator.upgrade(&mut conn)?,
};
if report.migrations_run.is_empty() {
println!("No migrations to run.");
} else {
println!("Migrations run: {:?}", report.migrations_run);
}
}
Commands::Downgrade { to } => {
let report = migrator.downgrade(&mut conn, to)?;
if report.migrations_run.is_empty() {
println!("No migrations to roll back.");
} else {
println!("Migrations rolled back: {:?}", report.migrations_run);
}
}
Commands::History => {
let history = migrator.get_migration_history(&mut conn)?;
if history.is_empty() {
println!("No migrations have been applied yet.");
} else {
println!("Migration history:");
for entry in history {
println!(
" v{}: {} [{}] (applied {})",
entry.version, entry.name, entry.migration_type, entry.applied_at
);
}
}
}
Commands::Preview => {
let pending = migrator.preview_upgrade(&mut conn)?;
if pending.is_empty() {
println!("No pending migrations.");
} else {
println!("Pending migrations:");
for m in pending {
println!(" - {} (v{})", m.name(), m.version());
if let Some(desc) = m.description() {
println!(" {}", desc);
}
}
}
}
Commands::List => {
unreachable!("List command should be handled before run_sqlite");
}
Commands::Schema { .. } => {
unreachable!("Schema command is handled before run_sqlite reaches this match")
}
}
Ok(())
}
fn render_schema(conn: &Connection, migrations_applied: usize) -> Result<String, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT sql FROM sqlite_master \
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != '_migratio_version_' \
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 WHEN 'trigger' THEN 3 ELSE 4 END, name",
)?;
let statements: Vec<String> = stmt
.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
let mut output = format!(
"-- Schema generated by migratio ({} migrations applied)\n\
-- Note: the _migratio_version_ bookkeeping table is excluded.\n\n",
migrations_applied
);
output.push_str(
&statements
.iter()
.map(|sql| format!("{};\n", sql))
.collect::<Vec<_>>()
.join("\n"),
);
Ok(output)
}
pub fn schema_sqlite(
migrator: SqliteMigrator,
output: Option<std::path::PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut conn = Connection::open_in_memory()?;
let report = migrator.upgrade(&mut conn)?;
let schema = render_schema(&conn, report.migrations_run.len())?;
match output {
Some(path) => std::fs::write(path, schema)?,
None => println!("{}", schema),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use migratio::sql_migration;
#[test]
fn render_schema_single_migration() {
sql_migration!(CreateUsers, 1, "Create users",
up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
down: "DROP TABLE users"
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
assert!(schema.contains("CREATE TABLE users"));
assert!(schema.contains(';'));
}
#[test]
fn render_schema_multiple_migrations() {
sql_migration!(CreateUsers, 1, "Create users",
up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
down: "DROP TABLE users"
);
sql_migration!(CreatePosts, 2, "Create posts",
up: [
"CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT)",
"CREATE INDEX idx_posts_user ON posts(user_id)"
],
down: [
"DROP INDEX idx_posts_user",
"DROP TABLE posts"
]
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers), Box::new(CreatePosts)]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
assert!(schema.contains("CREATE TABLE users"));
assert!(schema.contains("CREATE TABLE posts"));
assert!(schema.contains("CREATE INDEX idx_posts_user"));
}
#[test]
fn render_schema_orders_tables_then_index_then_view_then_trigger() {
sql_migration!(CreateAll, 1, "Create everything, scrambled",
up: [
"CREATE TABLE b (id INTEGER PRIMARY KEY)",
"CREATE TABLE a (id INTEGER PRIMARY KEY)",
"CREATE VIEW v AS SELECT * FROM a",
"CREATE INDEX idx_b ON b(id)",
"CREATE TRIGGER trg AFTER INSERT ON a BEGIN SELECT 1; END"
],
down: [
"DROP TRIGGER trg",
"DROP INDEX idx_b",
"DROP VIEW v",
"DROP TABLE a",
"DROP TABLE b"
]
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateAll)]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
let a = schema.find("CREATE TABLE a").unwrap();
let b = schema.find("CREATE TABLE b").unwrap();
let idx_b = schema.find("CREATE INDEX idx_b").unwrap();
let v = schema.find("CREATE VIEW v").unwrap();
let trg = schema.find("CREATE TRIGGER trg").unwrap();
assert!(a < b, "table a should come before table b");
assert!(b < idx_b, "tables should come before indexes");
assert!(idx_b < v, "indexes should come before views");
assert!(v < trg, "views should come before triggers");
}
#[test]
fn render_schema_excludes_migratio_bookkeeping_table() {
sql_migration!(CreateUsers, 1, "Create users",
up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
down: "DROP TABLE users"
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
assert!(!schema.contains("CREATE TABLE _migratio_version_"));
}
#[test]
fn render_schema_excludes_sqlite_internal_tables() {
sql_migration!(CreateUsers, 1, "Create users with autoincrement",
up: "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)",
down: "DROP TABLE users"
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
assert!(!schema.contains("sqlite_sequence"));
}
#[test]
fn render_schema_empty_migrator() {
let migrator = SqliteMigrator::new(vec![]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
assert!(schema.contains("0 migrations applied"));
assert!(!schema.contains("CREATE TABLE"));
}
#[test]
fn render_schema_banner_reports_migration_count() {
sql_migration!(M1, 1, "one", up: "CREATE TABLE t1 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t1");
sql_migration!(M2, 2, "two", up: "CREATE TABLE t2 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t2");
sql_migration!(M3, 3, "three", up: "CREATE TABLE t3 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t3");
let migrator =
SqliteMigrator::new(vec![Box::new(M1), Box::new(M2), Box::new(M3)]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
assert!(schema.contains("3 migrations applied"));
}
#[test]
fn render_schema_banner_notes_bookkeeping_table_exclusion() {
let migrator = SqliteMigrator::new(vec![]);
let mut conn = Connection::open_in_memory().unwrap();
let report = migrator.upgrade(&mut conn).unwrap();
let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
assert!(schema.contains("_migratio_version_"));
}
#[test]
fn schema_sqlite_writes_to_output_file() {
sql_migration!(CreateUsers, 1, "Create users",
up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
down: "DROP TABLE users"
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
let path = std::env::temp_dir()
.join("migratio_cli_test_schema_sqlite_writes_to_output_file.sql");
let result = schema_sqlite(migrator, Some(path.clone()));
assert!(result.is_ok());
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("CREATE TABLE users"));
std::fs::remove_file(&path).ok();
}
#[test]
fn schema_sqlite_overwrites_existing_file_silently() {
sql_migration!(CreateUsers, 1, "Create users",
up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
down: "DROP TABLE users"
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
let path = std::env::temp_dir()
.join("migratio_cli_test_schema_sqlite_overwrites_existing_file_silently.sql");
std::fs::write(&path, "old content").unwrap();
let result = schema_sqlite(migrator, Some(path.clone()));
assert!(result.is_ok());
let contents = std::fs::read_to_string(&path).unwrap();
assert!(!contents.contains("old content"));
assert!(contents.contains("CREATE TABLE users"));
std::fs::remove_file(&path).ok();
}
#[test]
fn schema_sqlite_prints_to_stdout_when_no_output_given() {
sql_migration!(CreateUsers, 1, "Create users",
up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
down: "DROP TABLE users"
);
let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
let result = schema_sqlite(migrator, None);
assert!(result.is_ok());
}
}
}
#[cfg(feature = "mysql")]
pub use mysql_support::run_mysql;
#[cfg(feature = "postgres")]
pub use postgres_support::run_postgres;
#[cfg(feature = "mysql")]
mod mysql_support {
use super::{CliArgs, Commands};
use migratio::mysql::MysqlMigrator;
use mysql::{Conn, Opts};
pub fn run_mysql(
migrator: MysqlMigrator,
database_url: &str,
args: CliArgs,
) -> Result<(), Box<dyn std::error::Error>> {
if let Commands::Schema { .. } = &args.command {
return Err("the `schema` command is only supported for sqlite databases".into());
}
let opts = Opts::from_url(database_url)?;
let mut conn = Conn::new(opts)?;
match args.command {
Commands::Status => {
let version = migrator.get_current_version(&mut conn)?;
let pending = migrator.preview_upgrade(&mut conn)?;
println!("Current version: {}", version);
println!("Pending migrations: {}", pending.len());
for m in pending {
println!(" - {} (v{})", m.name(), m.version());
}
}
Commands::Upgrade { to } => {
let report = match to {
Some(target) => migrator.upgrade_to(&mut conn, target)?,
None => migrator.upgrade(&mut conn)?,
};
if report.migrations_run.is_empty() {
println!("No migrations to run.");
} else {
println!("Migrations run: {:?}", report.migrations_run);
}
}
Commands::Downgrade { to } => {
let report = migrator.downgrade(&mut conn, to)?;
if report.migrations_run.is_empty() {
println!("No migrations to roll back.");
} else {
println!("Migrations rolled back: {:?}", report.migrations_run);
}
}
Commands::History => {
let history = migrator.get_migration_history(&mut conn)?;
if history.is_empty() {
println!("No migrations have been applied yet.");
} else {
println!("Migration history:");
for entry in history {
println!(
" v{}: {} [{}] (applied {})",
entry.version, entry.name, entry.migration_type, entry.applied_at
);
}
}
}
Commands::Preview => {
let pending = migrator.preview_upgrade(&mut conn)?;
if pending.is_empty() {
println!("No pending migrations.");
} else {
println!("Pending migrations:");
for m in pending {
println!(" - {} (v{})", m.name(), m.version());
if let Some(desc) = m.description() {
println!(" {}", desc);
}
}
}
}
Commands::List => {
unreachable!("List command should be handled before run_mysql");
}
Commands::Schema { .. } => {
unreachable!("Schema command is rejected before run_mysql reaches this match")
}
}
Ok(())
}
}
#[cfg(feature = "postgres")]
mod postgres_support {
use super::{CliArgs, Commands};
use migratio::postgres::PostgresMigrator;
use postgres::{Client, NoTls};
pub fn run_postgres(
migrator: PostgresMigrator,
database_url: &str,
args: CliArgs,
) -> Result<(), Box<dyn std::error::Error>> {
if let Commands::Schema { .. } = &args.command {
return Err("the `schema` command is only supported for sqlite databases".into());
}
let mut client = Client::connect(database_url, NoTls)?;
match args.command {
Commands::Status => {
let version = migrator.get_current_version(&mut client)?;
let pending = migrator.preview_upgrade(&mut client)?;
println!("Current version: {}", version);
println!("Pending migrations: {}", pending.len());
for m in pending {
println!(" - {} (v{})", m.name(), m.version());
}
}
Commands::Upgrade { to } => {
let report = match to {
Some(target) => migrator.upgrade_to(&mut client, target)?,
None => migrator.upgrade(&mut client)?,
};
if report.migrations_run.is_empty() {
println!("No migrations to run.");
} else {
println!("Migrations run: {:?}", report.migrations_run);
}
}
Commands::Downgrade { to } => {
let report = migrator.downgrade(&mut client, to)?;
if report.migrations_run.is_empty() {
println!("No migrations to roll back.");
} else {
println!("Migrations rolled back: {:?}", report.migrations_run);
}
}
Commands::History => {
let history = migrator.get_migration_history(&mut client)?;
if history.is_empty() {
println!("No migrations have been applied yet.");
} else {
println!("Migration history:");
for entry in history {
println!(
" v{}: {} [{}] (applied {})",
entry.version, entry.name, entry.migration_type, entry.applied_at
);
}
}
}
Commands::Preview => {
let pending = migrator.preview_upgrade(&mut client)?;
if pending.is_empty() {
println!("No pending migrations.");
} else {
println!("Pending migrations:");
for m in pending {
println!(" - {} (v{})", m.name(), m.version());
if let Some(desc) = m.description() {
println!(" {}", desc);
}
}
}
}
Commands::List => {
unreachable!("List command should be handled before run_postgres");
}
Commands::Schema { .. } => {
unreachable!("Schema command is rejected before run_postgres reaches this match")
}
}
Ok(())
}
}