1use clap::{Parser, Subcommand};
39
40#[derive(Parser, Debug)]
42#[command(name = "migratio-runner")]
43#[command(about = "Database migration runner")]
44pub struct CliArgs {
45 #[command(subcommand)]
46 pub command: Commands,
47}
48
49#[derive(Subcommand, Debug)]
51pub enum Commands {
52 Status,
54 Upgrade {
56 #[arg(long)]
58 to: Option<u32>,
59 },
60 Downgrade {
62 #[arg(long)]
64 to: u32,
65 },
66 History,
68 Preview,
70 List,
72 Schema {
74 #[arg(short, long)]
76 output: Option<std::path::PathBuf>,
77 },
78}
79
80#[cfg(feature = "sqlite")]
81pub use sqlite::{run_sqlite, schema_sqlite};
82
83#[cfg(feature = "sqlite")]
84mod sqlite {
85 use super::{CliArgs, Commands};
86 use migratio::sqlite::SqliteMigrator;
87 use rusqlite::Connection;
88
89 pub fn run_sqlite(
103 migrator: SqliteMigrator,
104 database_url: &str,
105 args: CliArgs,
106 ) -> Result<(), Box<dyn std::error::Error>> {
107 let command = match args.command {
110 Commands::Schema { output } => return schema_sqlite(migrator, output),
111 command => command,
112 };
113
114 if !std::path::Path::new(database_url).exists() {
117 return Err(format!(
118 "Database file not found: {}\n\nTo create a new database, first create the file manually or use your application's initialization logic.",
119 database_url
120 ).into());
121 }
122
123 let mut conn = Connection::open(database_url)?;
124
125 match command {
126 Commands::Status => {
127 let version = migrator.get_current_version(&mut conn)?;
128 let pending = migrator.preview_upgrade(&mut conn)?;
129 println!("Current version: {}", version);
130 println!("Pending migrations: {}", pending.len());
131 for m in pending {
132 println!(" - {} (v{})", m.name(), m.version());
133 }
134 }
135 Commands::Upgrade { to } => {
136 let report = match to {
137 Some(target) => migrator.upgrade_to(&mut conn, target)?,
138 None => migrator.upgrade(&mut conn)?,
139 };
140 if report.migrations_run.is_empty() {
141 println!("No migrations to run.");
142 } else {
143 println!("Migrations run: {:?}", report.migrations_run);
144 }
145 }
146 Commands::Downgrade { to } => {
147 let report = migrator.downgrade(&mut conn, to)?;
148 if report.migrations_run.is_empty() {
149 println!("No migrations to roll back.");
150 } else {
151 println!("Migrations rolled back: {:?}", report.migrations_run);
152 }
153 }
154 Commands::History => {
155 let history = migrator.get_migration_history(&mut conn)?;
156 if history.is_empty() {
157 println!("No migrations have been applied yet.");
158 } else {
159 println!("Migration history:");
160 for entry in history {
161 println!(
162 " v{}: {} [{}] (applied {})",
163 entry.version, entry.name, entry.migration_type, entry.applied_at
164 );
165 }
166 }
167 }
168 Commands::Preview => {
169 let pending = migrator.preview_upgrade(&mut conn)?;
170 if pending.is_empty() {
171 println!("No pending migrations.");
172 } else {
173 println!("Pending migrations:");
174 for m in pending {
175 println!(" - {} (v{})", m.name(), m.version());
176 if let Some(desc) = m.description() {
177 println!(" {}", desc);
178 }
179 }
180 }
181 }
182 Commands::List => {
183 unreachable!("List command should be handled before run_sqlite");
186 }
187 Commands::Schema { .. } => {
188 unreachable!("Schema command is handled before run_sqlite reaches this match")
189 }
190 }
191
192 Ok(())
193 }
194
195 fn render_schema(conn: &Connection, migrations_applied: usize) -> Result<String, rusqlite::Error> {
197 let mut stmt = conn.prepare(
198 "SELECT sql FROM sqlite_master \
199 WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != '_migratio_version_' \
200 ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 WHEN 'trigger' THEN 3 ELSE 4 END, name",
201 )?;
202 let statements: Vec<String> = stmt
203 .query_map([], |row| row.get(0))?
204 .collect::<Result<Vec<_>, _>>()?;
205
206 let mut output = format!(
207 "-- Schema generated by migratio ({} migrations applied)\n\
208 -- Note: the _migratio_version_ bookkeeping table is excluded.\n\n",
209 migrations_applied
210 );
211 output.push_str(
212 &statements
213 .iter()
214 .map(|sql| format!("{};\n", sql))
215 .collect::<Vec<_>>()
216 .join("\n"),
217 );
218
219 Ok(output)
220 }
221
222 pub fn schema_sqlite(
224 migrator: SqliteMigrator,
225 output: Option<std::path::PathBuf>,
226 ) -> Result<(), Box<dyn std::error::Error>> {
227 let mut conn = Connection::open_in_memory()?;
228 let report = migrator.upgrade(&mut conn)?;
229 let schema = render_schema(&conn, report.migrations_run.len())?;
230 match output {
231 Some(path) => std::fs::write(path, schema)?,
232 None => println!("{}", schema),
233 }
234 Ok(())
235 }
236
237 #[cfg(test)]
238 mod tests {
239 use super::*;
240 use migratio::sql_migration;
241
242 #[test]
243 fn render_schema_single_migration() {
244 sql_migration!(CreateUsers, 1, "Create users",
245 up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
246 down: "DROP TABLE users"
247 );
248
249 let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
250 let mut conn = Connection::open_in_memory().unwrap();
251 let report = migrator.upgrade(&mut conn).unwrap();
252
253 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
254
255 assert!(schema.contains("CREATE TABLE users"));
256 assert!(schema.contains(';'));
257 }
258
259 #[test]
260 fn render_schema_multiple_migrations() {
261 sql_migration!(CreateUsers, 1, "Create users",
262 up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
263 down: "DROP TABLE users"
264 );
265 sql_migration!(CreatePosts, 2, "Create posts",
266 up: [
267 "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT)",
268 "CREATE INDEX idx_posts_user ON posts(user_id)"
269 ],
270 down: [
271 "DROP INDEX idx_posts_user",
272 "DROP TABLE posts"
273 ]
274 );
275
276 let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers), Box::new(CreatePosts)]);
277 let mut conn = Connection::open_in_memory().unwrap();
278 let report = migrator.upgrade(&mut conn).unwrap();
279
280 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
281
282 assert!(schema.contains("CREATE TABLE users"));
283 assert!(schema.contains("CREATE TABLE posts"));
284 assert!(schema.contains("CREATE INDEX idx_posts_user"));
285 }
286
287 #[test]
288 fn render_schema_orders_tables_then_index_then_view_then_trigger() {
289 sql_migration!(CreateAll, 1, "Create everything, scrambled",
290 up: [
291 "CREATE TABLE b (id INTEGER PRIMARY KEY)",
292 "CREATE TABLE a (id INTEGER PRIMARY KEY)",
293 "CREATE VIEW v AS SELECT * FROM a",
294 "CREATE INDEX idx_b ON b(id)",
295 "CREATE TRIGGER trg AFTER INSERT ON a BEGIN SELECT 1; END"
296 ],
297 down: [
298 "DROP TRIGGER trg",
299 "DROP INDEX idx_b",
300 "DROP VIEW v",
301 "DROP TABLE a",
302 "DROP TABLE b"
303 ]
304 );
305
306 let migrator = SqliteMigrator::new(vec![Box::new(CreateAll)]);
307 let mut conn = Connection::open_in_memory().unwrap();
308 let report = migrator.upgrade(&mut conn).unwrap();
309
310 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
311
312 let a = schema.find("CREATE TABLE a").unwrap();
313 let b = schema.find("CREATE TABLE b").unwrap();
314 let idx_b = schema.find("CREATE INDEX idx_b").unwrap();
315 let v = schema.find("CREATE VIEW v").unwrap();
316 let trg = schema.find("CREATE TRIGGER trg").unwrap();
317
318 assert!(a < b, "table a should come before table b");
319 assert!(b < idx_b, "tables should come before indexes");
320 assert!(idx_b < v, "indexes should come before views");
321 assert!(v < trg, "views should come before triggers");
322 }
323
324 #[test]
325 fn render_schema_excludes_migratio_bookkeeping_table() {
326 sql_migration!(CreateUsers, 1, "Create users",
327 up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
328 down: "DROP TABLE users"
329 );
330
331 let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
332 let mut conn = Connection::open_in_memory().unwrap();
333 let report = migrator.upgrade(&mut conn).unwrap();
334
335 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
336
337 assert!(!schema.contains("CREATE TABLE _migratio_version_"));
338 }
339
340 #[test]
341 fn render_schema_excludes_sqlite_internal_tables() {
342 sql_migration!(CreateUsers, 1, "Create users with autoincrement",
343 up: "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)",
344 down: "DROP TABLE users"
345 );
346
347 let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
348 let mut conn = Connection::open_in_memory().unwrap();
349 let report = migrator.upgrade(&mut conn).unwrap();
350
351 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
352
353 assert!(!schema.contains("sqlite_sequence"));
354 }
355
356 #[test]
357 fn render_schema_empty_migrator() {
358 let migrator = SqliteMigrator::new(vec![]);
359 let mut conn = Connection::open_in_memory().unwrap();
360 let report = migrator.upgrade(&mut conn).unwrap();
361
362 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
363
364 assert!(schema.contains("0 migrations applied"));
365 assert!(!schema.contains("CREATE TABLE"));
366 }
367
368 #[test]
369 fn render_schema_banner_reports_migration_count() {
370 sql_migration!(M1, 1, "one", up: "CREATE TABLE t1 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t1");
371 sql_migration!(M2, 2, "two", up: "CREATE TABLE t2 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t2");
372 sql_migration!(M3, 3, "three", up: "CREATE TABLE t3 (id INTEGER PRIMARY KEY)", down: "DROP TABLE t3");
373
374 let migrator =
375 SqliteMigrator::new(vec![Box::new(M1), Box::new(M2), Box::new(M3)]);
376 let mut conn = Connection::open_in_memory().unwrap();
377 let report = migrator.upgrade(&mut conn).unwrap();
378
379 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
380
381 assert!(schema.contains("3 migrations applied"));
382 }
383
384 #[test]
385 fn render_schema_banner_notes_bookkeeping_table_exclusion() {
386 let migrator = SqliteMigrator::new(vec![]);
387 let mut conn = Connection::open_in_memory().unwrap();
388 let report = migrator.upgrade(&mut conn).unwrap();
389
390 let schema = render_schema(&conn, report.migrations_run.len()).unwrap();
391
392 assert!(schema.contains("_migratio_version_"));
393 }
394
395 #[test]
396 fn schema_sqlite_writes_to_output_file() {
397 sql_migration!(CreateUsers, 1, "Create users",
398 up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
399 down: "DROP TABLE users"
400 );
401
402 let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
403 let path = std::env::temp_dir()
404 .join("migratio_cli_test_schema_sqlite_writes_to_output_file.sql");
405
406 let result = schema_sqlite(migrator, Some(path.clone()));
407
408 assert!(result.is_ok());
409 let contents = std::fs::read_to_string(&path).unwrap();
410 assert!(contents.contains("CREATE TABLE users"));
411
412 std::fs::remove_file(&path).ok();
413 }
414
415 #[test]
416 fn schema_sqlite_overwrites_existing_file_silently() {
417 sql_migration!(CreateUsers, 1, "Create users",
418 up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
419 down: "DROP TABLE users"
420 );
421
422 let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
423 let path = std::env::temp_dir()
424 .join("migratio_cli_test_schema_sqlite_overwrites_existing_file_silently.sql");
425 std::fs::write(&path, "old content").unwrap();
426
427 let result = schema_sqlite(migrator, Some(path.clone()));
428
429 assert!(result.is_ok());
430 let contents = std::fs::read_to_string(&path).unwrap();
431 assert!(!contents.contains("old content"));
432 assert!(contents.contains("CREATE TABLE users"));
433
434 std::fs::remove_file(&path).ok();
435 }
436
437 #[test]
438 fn schema_sqlite_prints_to_stdout_when_no_output_given() {
439 sql_migration!(CreateUsers, 1, "Create users",
440 up: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
441 down: "DROP TABLE users"
442 );
443
444 let migrator = SqliteMigrator::new(vec![Box::new(CreateUsers)]);
445
446 let result = schema_sqlite(migrator, None);
447
448 assert!(result.is_ok());
449 }
450 }
451}
452
453#[cfg(feature = "mysql")]
454pub use mysql_support::run_mysql;
455
456#[cfg(feature = "postgres")]
457pub use postgres_support::run_postgres;
458
459#[cfg(feature = "mysql")]
460mod mysql_support {
461 use super::{CliArgs, Commands};
462 use migratio::mysql::MysqlMigrator;
463 use mysql::{Conn, Opts};
464
465 pub fn run_mysql(
479 migrator: MysqlMigrator,
480 database_url: &str,
481 args: CliArgs,
482 ) -> Result<(), Box<dyn std::error::Error>> {
483 if let Commands::Schema { .. } = &args.command {
484 return Err("the `schema` command is only supported for sqlite databases".into());
485 }
486
487 let opts = Opts::from_url(database_url)?;
488 let mut conn = Conn::new(opts)?;
489
490 match args.command {
491 Commands::Status => {
492 let version = migrator.get_current_version(&mut conn)?;
493 let pending = migrator.preview_upgrade(&mut conn)?;
494 println!("Current version: {}", version);
495 println!("Pending migrations: {}", pending.len());
496 for m in pending {
497 println!(" - {} (v{})", m.name(), m.version());
498 }
499 }
500 Commands::Upgrade { to } => {
501 let report = match to {
502 Some(target) => migrator.upgrade_to(&mut conn, target)?,
503 None => migrator.upgrade(&mut conn)?,
504 };
505 if report.migrations_run.is_empty() {
506 println!("No migrations to run.");
507 } else {
508 println!("Migrations run: {:?}", report.migrations_run);
509 }
510 }
511 Commands::Downgrade { to } => {
512 let report = migrator.downgrade(&mut conn, to)?;
513 if report.migrations_run.is_empty() {
514 println!("No migrations to roll back.");
515 } else {
516 println!("Migrations rolled back: {:?}", report.migrations_run);
517 }
518 }
519 Commands::History => {
520 let history = migrator.get_migration_history(&mut conn)?;
521 if history.is_empty() {
522 println!("No migrations have been applied yet.");
523 } else {
524 println!("Migration history:");
525 for entry in history {
526 println!(
527 " v{}: {} [{}] (applied {})",
528 entry.version, entry.name, entry.migration_type, entry.applied_at
529 );
530 }
531 }
532 }
533 Commands::Preview => {
534 let pending = migrator.preview_upgrade(&mut conn)?;
535 if pending.is_empty() {
536 println!("No pending migrations.");
537 } else {
538 println!("Pending migrations:");
539 for m in pending {
540 println!(" - {} (v{})", m.name(), m.version());
541 if let Some(desc) = m.description() {
542 println!(" {}", desc);
543 }
544 }
545 }
546 }
547 Commands::List => {
548 unreachable!("List command should be handled before run_mysql");
551 }
552 Commands::Schema { .. } => {
553 unreachable!("Schema command is rejected before run_mysql reaches this match")
554 }
555 }
556
557 Ok(())
558 }
559}
560
561#[cfg(feature = "postgres")]
562mod postgres_support {
563 use super::{CliArgs, Commands};
564 use migratio::postgres::PostgresMigrator;
565 use postgres::{Client, NoTls};
566
567 pub fn run_postgres(
581 migrator: PostgresMigrator,
582 database_url: &str,
583 args: CliArgs,
584 ) -> Result<(), Box<dyn std::error::Error>> {
585 if let Commands::Schema { .. } = &args.command {
586 return Err("the `schema` command is only supported for sqlite databases".into());
587 }
588
589 let mut client = Client::connect(database_url, NoTls)?;
590
591 match args.command {
592 Commands::Status => {
593 let version = migrator.get_current_version(&mut client)?;
594 let pending = migrator.preview_upgrade(&mut client)?;
595 println!("Current version: {}", version);
596 println!("Pending migrations: {}", pending.len());
597 for m in pending {
598 println!(" - {} (v{})", m.name(), m.version());
599 }
600 }
601 Commands::Upgrade { to } => {
602 let report = match to {
603 Some(target) => migrator.upgrade_to(&mut client, target)?,
604 None => migrator.upgrade(&mut client)?,
605 };
606 if report.migrations_run.is_empty() {
607 println!("No migrations to run.");
608 } else {
609 println!("Migrations run: {:?}", report.migrations_run);
610 }
611 }
612 Commands::Downgrade { to } => {
613 let report = migrator.downgrade(&mut client, to)?;
614 if report.migrations_run.is_empty() {
615 println!("No migrations to roll back.");
616 } else {
617 println!("Migrations rolled back: {:?}", report.migrations_run);
618 }
619 }
620 Commands::History => {
621 let history = migrator.get_migration_history(&mut client)?;
622 if history.is_empty() {
623 println!("No migrations have been applied yet.");
624 } else {
625 println!("Migration history:");
626 for entry in history {
627 println!(
628 " v{}: {} [{}] (applied {})",
629 entry.version, entry.name, entry.migration_type, entry.applied_at
630 );
631 }
632 }
633 }
634 Commands::Preview => {
635 let pending = migrator.preview_upgrade(&mut client)?;
636 if pending.is_empty() {
637 println!("No pending migrations.");
638 } else {
639 println!("Pending migrations:");
640 for m in pending {
641 println!(" - {} (v{})", m.name(), m.version());
642 if let Some(desc) = m.description() {
643 println!(" {}", desc);
644 }
645 }
646 }
647 }
648 Commands::List => {
649 unreachable!("List command should be handled before run_postgres");
652 }
653 Commands::Schema { .. } => {
654 unreachable!("Schema command is rejected before run_postgres reaches this match")
655 }
656 }
657
658 Ok(())
659 }
660}