1use std::path::{Path, PathBuf};
33
34use clap::Args;
35use sz_rust_core::orm::migration::{
36 FileMigrationResolver, Migration, MigrationContext, MigrationResolver, Migrator,
37};
38use sz_rust_core::orm::{Connection, ConnectionFactory, DbType};
39
40use crate::error::CliError;
41
42#[derive(Args, Debug)]
46pub struct MigrateArgs {
47 #[arg(long)]
49 pub rollback: bool,
50
51 #[arg(short = 'p', long, default_value = "migrations")]
53 pub path: String,
54
55 #[arg(long, default_value = "postgres")]
61 pub db_type: String,
62
63 #[arg(long)]
65 pub show_sql: bool,
66
67 #[arg(long)]
75 pub url: Option<String>,
76}
77
78pub fn execute_migrate(args: &MigrateArgs) -> Result<(), CliError> {
88 let path = PathBuf::from(&args.path);
89
90 if !path.exists() {
91 return Err(CliError::Migration(format!(
92 "Migration directory not found: {}",
93 path.display()
94 )));
95 }
96
97 let db_type = DbType::from_str(&args.db_type)
98 .ok_or_else(|| CliError::Migration(format!("Unknown database type: {}", args.db_type)))?;
99
100 let migrations = resolve_migrations(&path, db_type)?;
101
102 if migrations.is_empty() {
103 println!("No migrations found in: {}", path.display());
104 return Ok(());
105 }
106
107 match &args.url {
108 None => execute_migrate_offline(args, &migrations),
109 Some(url) => execute_migrate_online(args, &migrations, url, db_type),
110 }
111}
112
113fn execute_migrate_offline(args: &MigrateArgs, migrations: &[Migration]) -> Result<(), CliError> {
115 if args.rollback {
116 println!("Rolling back last batch in: {}", args.path);
117 if let Some(last) = migrations.last() {
118 println!(" Would rollback: {} ({})", last.version, last.name);
119 if args.show_sql {
120 print_sql_block("SQL DOWN", &last.sql_down);
121 }
122 }
123 println!("Note: Actual rollback requires database connection (offline mode).");
124 } else {
125 println!("Running migrations in: {}", args.path);
126 for m in migrations {
127 println!(" Would apply: {} ({})", m.version, m.name);
128 if args.show_sql {
129 print_sql_block("SQL UP", &m.sql_up);
130 }
131 }
132 println!(
133 "Total: {} migration(s). Note: Actual execution requires database connection (offline mode).",
134 migrations.len()
135 );
136 }
137 Ok(())
138}
139
140fn execute_migrate_online(
142 args: &MigrateArgs,
143 migrations: &[Migration],
144 url: &str,
145 db_type: DbType,
146) -> Result<(), CliError> {
147 let rt = tokio::runtime::Builder::new_current_thread()
149 .enable_all()
150 .build()
151 .map_err(|e| CliError::Migration(format!("Failed to create tokio runtime: {}", e)))?;
152
153 rt.block_on(async move {
154 let mut conn = create_connection(url, db_type).await?;
155
156 if args.rollback {
157 let last = migrations
159 .last()
160 .ok_or_else(|| CliError::Migration("No migrations to rollback".to_string()))?;
161 println!("Rolling back: {} ({})", last.version, last.name);
162 if args.show_sql {
163 print_sql_block("SQL DOWN", &last.sql_down);
164 }
165 if !last.sql_down.is_empty() {
166 conn.execute(&last.sql_down)
167 .await
168 .map_err(|e| CliError::Migration(format!("Rollback failed: {}", e)))?;
169 }
170 delete_migration_record(&mut conn, &last.version, db_type).await?;
172 println!("Rollback completed: {} ({})", last.version, last.name);
173 } else {
174 ensure_migrations_table(&mut conn, db_type).await?;
176
177 let applied = fetch_applied_versions(&mut conn, db_type).await?;
179
180 let pending: Vec<&Migration> = migrations
182 .iter()
183 .filter(|m| !applied.contains(&m.version))
184 .collect();
185
186 if pending.is_empty() {
187 println!("No pending migrations. Database is up to date.");
188 return Ok(());
189 }
190
191 println!("Running {} pending migration(s):", pending.len());
192
193 let mut context = MigrationContext::default().with_db_type(db_type);
195 context.connection = Some(conn);
196
197 let mut migrator = Migrator::new(context);
198 for m in migrations {
199 let rebuilt = Migration::new(&m.version, &m.name, &m.sql_up, &m.sql_down);
201 if applied.contains(&m.version) {
202 migrator = migrator.add_migration(rebuilt.with_batch(1));
204 } else {
205 migrator = migrator.add_migration(rebuilt);
206 }
207 }
208
209 let applied_versions = migrator
210 .migrate()
211 .await
212 .map_err(|e| CliError::Migration(format!("Migration failed: {}", e)))?;
213
214 for v in &applied_versions {
215 println!(" Applied: {}", v);
216 }
217 println!("Migration completed: {} applied.", applied_versions.len());
218 }
219
220 Ok::<(), CliError>(())
221 })
222}
223
224pub fn execute_status(path: &str) -> Result<(), CliError> {
230 execute_status_full(path, "postgres", false, None)
231}
232
233pub fn execute_status_with(path: &str, db_type_str: &str, show_sql: bool) -> Result<(), CliError> {
241 execute_status_full(path, db_type_str, show_sql, None)
242}
243
244pub fn execute_status_full(
246 path: &str,
247 db_type_str: &str,
248 show_sql: bool,
249 url: Option<&str>,
250) -> Result<(), CliError> {
251 let path_buf = PathBuf::from(path);
252
253 if !path_buf.exists() {
254 return Err(CliError::Migration(format!(
255 "Migration directory not found: {}",
256 path_buf.display()
257 )));
258 }
259
260 let db_type = DbType::from_str(db_type_str)
261 .ok_or_else(|| CliError::Migration(format!("Unknown database type: {}", db_type_str)))?;
262
263 let migrations = resolve_migrations(&path_buf, db_type)?;
264
265 if migrations.is_empty() {
266 println!("No migrations found in: {}", path_buf.display());
267 return Ok(());
268 }
269
270 let applied_versions = if let Some(url) = url {
272 let rt = tokio::runtime::Builder::new_current_thread()
273 .enable_all()
274 .build()
275 .map_err(|e| CliError::Migration(format!("Failed to create tokio runtime: {}", e)))?;
276 rt.block_on(async move {
277 let mut conn = create_connection(url, db_type).await?;
278 ensure_migrations_table(&mut conn, db_type).await?;
279 fetch_applied_versions(&mut conn, db_type).await
280 })?
281 } else {
282 std::collections::HashSet::new()
283 };
284
285 println!(
287 "{:<15} {:<30} {:<20}",
288 "Version", "Migration Name", "Status"
289 );
290 println!("{}", "-".repeat(65));
291
292 for m in &migrations {
293 let status = if applied_versions.contains(&m.version) {
294 "Applied"
295 } else if url.is_some() {
296 "Pending"
297 } else {
298 "Pending*"
299 };
300 println!("{:<15} {:<30} {:<20}", m.version, m.name, status);
301 if show_sql {
302 print_sql_block("SQL UP", &m.sql_up);
303 print_sql_block("SQL DOWN", &m.sql_down);
304 }
305 }
306
307 println!();
308 if url.is_some() {
309 let applied = migrations
310 .iter()
311 .filter(|m| applied_versions.contains(&m.version))
312 .count();
313 println!(
314 "Total: {} migration(s), {} applied, {} pending.",
315 migrations.len(),
316 applied,
317 migrations.len() - applied
318 );
319 } else {
320 println!("* Status cannot be determined without database connection (offline mode).");
321 }
322
323 Ok(())
324}
325
326async fn create_connection(url: &str, db_type: DbType) -> Result<Box<dyn Connection>, CliError> {
328 use std::sync::Arc;
329 use sz_orm_sqlx::{
330 MySqlPoolHandle, PgPoolHandle, SqlitePoolHandle, SqlxMySqlConnectionFactory,
331 SqlxPgConnectionFactory, SqlxSqliteConnectionFactory,
332 };
333
334 match db_type {
335 DbType::PostgreSQL => {
336 let pool = PgPoolHandle::connect(url).await.map_err(|e| {
337 CliError::Migration(format!("PostgreSQL connect failed: {}", e))
338 })?;
339 let factory = SqlxPgConnectionFactory::new(Arc::new(pool));
340 let conn = factory.create().await.map_err(|e| {
341 CliError::Migration(format!("PostgreSQL acquire failed: {}", e))
342 })?;
343 Ok(conn)
344 }
345 DbType::MySQL => {
346 let pool = MySqlPoolHandle::connect(url).await.map_err(|e| {
347 CliError::Migration(format!("MySQL connect failed: {}", e))
348 })?;
349 let factory = SqlxMySqlConnectionFactory::new(Arc::new(pool));
350 let conn = factory.create().await.map_err(|e| {
351 CliError::Migration(format!("MySQL acquire failed: {}", e))
352 })?;
353 Ok(conn)
354 }
355 DbType::Sqlite => {
356 let pool = SqlitePoolHandle::connect(url).await.map_err(|e| {
357 CliError::Migration(format!("SQLite connect failed: {}", e))
358 })?;
359 let factory = SqlxSqliteConnectionFactory::new(Arc::new(pool));
360 let conn = factory.create().await.map_err(|e| {
361 CliError::Migration(format!("SQLite acquire failed: {}", e))
362 })?;
363 Ok(conn)
364 }
365 _ => Err(CliError::Migration(format!(
366 "Online migration not supported for db_type {:?}. Supported: PostgreSQL, MySQL, SQLite.",
367 db_type
368 ))),
369 }
370}
371
372async fn ensure_migrations_table(
374 conn: &mut Box<dyn Connection>,
375 db_type: DbType,
376) -> Result<(), CliError> {
377 let sql = match db_type {
378 DbType::PostgreSQL | DbType::Sqlite => {
379 "CREATE TABLE IF NOT EXISTS __migrations (
380 version VARCHAR(255) PRIMARY KEY,
381 name VARCHAR(255) NOT NULL,
382 batch INTEGER NOT NULL,
383 run_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
384 )"
385 }
386 DbType::MySQL => {
387 "CREATE TABLE IF NOT EXISTS __migrations (
388 version VARCHAR(255) PRIMARY KEY,
389 name VARCHAR(255) NOT NULL,
390 batch INT NOT NULL,
391 run_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
392 )"
393 }
394 _ => {
395 return Err(CliError::Migration(format!(
396 "Cannot ensure __migrations table for db_type {:?}",
397 db_type
398 )))
399 }
400 };
401 conn.execute(sql)
402 .await
403 .map_err(|e| CliError::Migration(format!("Failed to create __migrations table: {}", e)))?;
404 Ok(())
405}
406
407async fn fetch_applied_versions(
412 conn: &mut Box<dyn Connection>,
413 _db_type: DbType,
414) -> Result<std::collections::HashSet<String>, CliError> {
415 let rows = conn
418 .query("SELECT version FROM __migrations")
419 .await
420 .map_err(|e| CliError::Migration(format!("Failed to query __migrations: {}", e)))?;
421
422 let mut versions = std::collections::HashSet::new();
423 for row in &rows {
424 if let Some(val) = row.get("version") {
426 use sz_rust_core::orm::Value;
427 match val {
428 Value::String(s) => versions.insert(s.clone()),
429 Value::I64(i) => versions.insert(i.to_string()),
430 Value::I32(i) => versions.insert(i.to_string()),
431 _ => false,
432 };
433 }
434 }
435 Ok(versions)
436}
437
438async fn delete_migration_record(
443 conn: &mut Box<dyn Connection>,
444 version: &str,
445 db_type: DbType,
446) -> Result<(), CliError> {
447 if !matches!(db_type, DbType::PostgreSQL | DbType::Sqlite | DbType::MySQL) {
448 return Ok(());
449 }
450 let params = [sz_rust_core::orm::Value::String(version.to_string())];
451 conn.execute_with_params("DELETE FROM __migrations WHERE version = ?", ¶ms)
452 .await
453 .map_err(|e| CliError::Migration(format!("Failed to delete migration record: {}", e)))?;
454 Ok(())
455}
456
457fn resolve_migrations(path: &Path, db_type: DbType) -> Result<Vec<Migration>, CliError> {
465 let resolver = FileMigrationResolver::new(path.to_path_buf());
466 resolver
467 .resolve(db_type)
468 .map_err(|e| CliError::Migration(format!("Failed to resolve migrations: {}", e)))
469}
470
471fn print_sql_block(title: &str, sql: &str) {
480 if sql.is_empty() {
481 return;
482 }
483 println!(" --- {} ---", title);
484 for line in sql.lines() {
485 println!(" {}", line);
486 }
487 println!(" {}", "-".repeat(title.len() + 8));
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use std::fs;
494 use std::io::Write;
495
496 fn create_test_migration(dir: &Path, version: &str, name: &str) {
498 let up_name = format!("{}_{}_up.sql", version, name);
499 let down_name = format!("{}_{}_down.sql", version, name);
500
501 let up_path = dir.join(up_name);
502 let down_path = dir.join(down_name);
503
504 let mut up_file = fs::File::create(&up_path).unwrap();
505 writeln!(up_file, "-- {} up", name).unwrap();
506
507 let mut down_file = fs::File::create(&down_path).unwrap();
508 writeln!(down_file, "-- {} down", name).unwrap();
509 }
510
511 #[test]
512 fn test_resolve_migrations_empty() {
513 let temp = tempfile::tempdir().unwrap();
514 let path = temp.path().to_path_buf();
515 let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
516 assert!(result.is_empty());
517 }
518
519 #[test]
520 fn test_resolve_migrations_with_files() {
521 let temp = tempfile::tempdir().unwrap();
522 let path = temp.path().to_path_buf();
523
524 create_test_migration(&path, "001", "create_users");
525 create_test_migration(&path, "002", "add_index");
526
527 let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
528 assert_eq!(result.len(), 2);
529 assert_eq!(result[0].version, "001");
530 assert_eq!(result[0].name, "create_users");
531 assert_eq!(result[1].version, "002");
532 assert_eq!(result[1].name, "add_index");
533 }
534
535 #[test]
536 fn test_resolve_migrations_returns_sql_content() {
537 let temp = tempfile::tempdir().unwrap();
538 let path = temp.path().to_path_buf();
539
540 let up_path = path.join("001_init_up.sql");
541 let down_path = path.join("001_init_down.sql");
542 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
543 fs::write(&down_path, "DROP TABLE users;").unwrap();
544
545 let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
546 assert_eq!(result.len(), 1);
547 assert!(result[0].sql_up.contains("CREATE TABLE users"));
548 assert!(result[0].sql_down.contains("DROP TABLE users"));
549 }
550
551 #[test]
552 fn test_resolve_migrations_supports_multiple_db_types() {
553 let temp = tempfile::tempdir().unwrap();
554 let path = temp.path().to_path_buf();
555 create_test_migration(&path, "001", "init");
556
557 let mysql_result = resolve_migrations(&path, DbType::MySQL).unwrap();
558 let pg_result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
559
560 assert_eq!(mysql_result.len(), 1);
561 assert_eq!(pg_result.len(), 1);
562 }
563
564 #[test]
565 fn test_execute_status_nonexistent_dir() {
566 let result = execute_status("/nonexistent/path/migrations");
567 assert!(matches!(result, Err(CliError::Migration(_))));
568 }
569
570 #[test]
571 fn test_execute_status_empty_dir() {
572 let temp = tempfile::tempdir().unwrap();
573 let path = temp.path().to_str().unwrap();
574 let result = execute_status(path);
575 assert!(result.is_ok());
576 }
577
578 #[test]
579 fn test_execute_status_with_migrations() {
580 let temp = tempfile::tempdir().unwrap();
581 let path = temp.path().to_path_buf();
582 create_test_migration(&path, "001", "create_users");
583
584 let path_str = temp.path().to_str().unwrap();
585 let result = execute_status(path_str);
586 assert!(result.is_ok());
587 }
588
589 #[test]
590 fn test_execute_status_with_invalid_db_type() {
591 let temp = tempfile::tempdir().unwrap();
592 let path = temp.path().to_str().unwrap();
593 let result = execute_status_with(path, "invalid_db_type", false);
594 assert!(matches!(result, Err(CliError::Migration(_))));
595 }
596
597 #[test]
598 fn test_execute_status_with_show_sql() {
599 let temp = tempfile::tempdir().unwrap();
600 let path = temp.path().to_path_buf();
601
602 let up_path = path.join("001_init_up.sql");
603 let down_path = path.join("001_init_down.sql");
604 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
605 fs::write(&down_path, "DROP TABLE users;").unwrap();
606
607 let path_str = temp.path().to_str().unwrap();
608 let result = execute_status_with(path_str, "postgres", true);
609 assert!(result.is_ok());
610 }
611
612 #[test]
613 fn test_execute_migrate_nonexistent_dir() {
614 let args = MigrateArgs {
615 rollback: false,
616 path: "/nonexistent/migrations".to_string(),
617 db_type: "postgres".to_string(),
618 show_sql: false,
619 url: None,
620 };
621 let result = execute_migrate(&args);
622 assert!(matches!(result, Err(CliError::Migration(_))));
623 }
624
625 #[test]
626 fn test_execute_migrate_empty_dir() {
627 let temp = tempfile::tempdir().unwrap();
628 let args = MigrateArgs {
629 rollback: false,
630 path: temp.path().to_str().unwrap().to_string(),
631 db_type: "postgres".to_string(),
632 show_sql: false,
633 url: None,
634 };
635 let result = execute_migrate(&args);
636 assert!(result.is_ok());
637 }
638
639 #[test]
640 fn test_execute_migrate_with_files_offline() {
641 let temp = tempfile::tempdir().unwrap();
642 let path = temp.path().to_path_buf();
643 create_test_migration(&path, "001", "create_users");
644
645 let args = MigrateArgs {
646 rollback: false,
647 path: temp.path().to_str().unwrap().to_string(),
648 db_type: "postgres".to_string(),
649 show_sql: false,
650 url: None,
651 };
652 let result = execute_migrate(&args);
653 assert!(result.is_ok());
654 }
655
656 #[test]
657 fn test_execute_migrate_with_show_sql_offline() {
658 let temp = tempfile::tempdir().unwrap();
659 let path = temp.path().to_path_buf();
660
661 let up_path = path.join("001_init_up.sql");
662 let down_path = path.join("001_init_down.sql");
663 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
664 fs::write(&down_path, "DROP TABLE users;").unwrap();
665
666 let args = MigrateArgs {
667 rollback: false,
668 path: temp.path().to_str().unwrap().to_string(),
669 db_type: "postgres".to_string(),
670 show_sql: true,
671 url: None,
672 };
673 let result = execute_migrate(&args);
674 assert!(result.is_ok());
675 }
676
677 #[test]
678 fn test_execute_migrate_with_invalid_db_type() {
679 let temp = tempfile::tempdir().unwrap();
680 let args = MigrateArgs {
681 rollback: false,
682 path: temp.path().to_str().unwrap().to_string(),
683 db_type: "invalid_db_type".to_string(),
684 show_sql: false,
685 url: None,
686 };
687 let result = execute_migrate(&args);
688 assert!(matches!(result, Err(CliError::Migration(_))));
689 }
690
691 #[test]
692 fn test_execute_migrate_rollback_offline() {
693 let temp = tempfile::tempdir().unwrap();
694 let path = temp.path().to_path_buf();
695 create_test_migration(&path, "001", "create_users");
696 create_test_migration(&path, "002", "add_index");
697
698 let args = MigrateArgs {
699 rollback: true,
700 path: temp.path().to_str().unwrap().to_string(),
701 db_type: "postgres".to_string(),
702 show_sql: false,
703 url: None,
704 };
705 let result = execute_migrate(&args);
706 assert!(result.is_ok());
707 }
708
709 #[test]
710 fn test_execute_migrate_rollback_with_show_sql_offline() {
711 let temp = tempfile::tempdir().unwrap();
712 let path = temp.path().to_path_buf();
713
714 let up_path = path.join("001_init_up.sql");
715 let down_path = path.join("001_init_down.sql");
716 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
717 fs::write(&down_path, "DROP TABLE users;").unwrap();
718
719 let args = MigrateArgs {
720 rollback: true,
721 path: temp.path().to_str().unwrap().to_string(),
722 db_type: "postgres".to_string(),
723 show_sql: true,
724 url: None,
725 };
726 let result = execute_migrate(&args);
727 assert!(result.is_ok());
728 }
729
730 #[test]
731 fn test_print_sql_block_empty_sql() {
732 print_sql_block("SQL UP", "");
733 }
734
735 #[test]
736 fn test_print_sql_block_with_content() {
737 print_sql_block("SQL UP", "CREATE TABLE users (id INT);");
738 }
739
740 #[test]
741 fn test_execute_status_full_offline_no_url() {
742 let temp = tempfile::tempdir().unwrap();
743 let path = temp.path().to_path_buf();
744 create_test_migration(&path, "001", "init");
745
746 let path_str = temp.path().to_str().unwrap();
747 let result = execute_status_full(path_str, "postgres", false, None);
748 assert!(result.is_ok());
749 }
750
751 #[test]
752 fn test_execute_status_full_offline_with_show_sql() {
753 let temp = tempfile::tempdir().unwrap();
754 let path = temp.path().to_path_buf();
755 let up_path = path.join("001_init_up.sql");
756 let down_path = path.join("001_init_down.sql");
757 fs::write(&up_path, "CREATE TABLE t (id INT);").unwrap();
758 fs::write(&down_path, "DROP TABLE t;").unwrap();
759
760 let path_str = temp.path().to_str().unwrap();
761 let result = execute_status_full(path_str, "postgres", true, None);
762 assert!(result.is_ok());
763 }
764
765 #[test]
766 fn test_execute_status_full_invalid_db_type() {
767 let temp = tempfile::tempdir().unwrap();
768 let path_str = temp.path().to_str().unwrap();
769 let result = execute_status_full(path_str, "invalid_db", false, None);
770 assert!(matches!(result, Err(CliError::Migration(_))));
771 }
772
773 #[test]
774 fn test_execute_migrate_online_with_invalid_url_returns_error() {
775 let temp = tempfile::tempdir().unwrap();
776 let path = temp.path().to_path_buf();
777 create_test_migration(&path, "001", "init");
778
779 let args = MigrateArgs {
780 rollback: false,
781 path: temp.path().to_str().unwrap().to_string(),
782 db_type: "postgres".to_string(),
783 show_sql: false,
784 url: Some("postgres://invalid:invalid@127.0.0.1:1/invalid".to_string()),
785 };
786 let result = execute_migrate(&args);
787 assert!(result.is_err());
789 }
790}