1use std::path::{Path, PathBuf};
36
37use clap::Args;
38use sz_rust_core::orm::migration::{FileMigrationResolver, Migration, MigrationResolver};
39use sz_rust_core::orm::{Connection, DbType};
40
41use crate::error::CliError;
42
43#[derive(Args, Debug)]
47pub struct MigrateArgs {
48 #[arg(long)]
50 pub rollback: bool,
51
52 #[arg(short = 'p', long, default_value = "migrations")]
54 pub path: String,
55
56 #[arg(long, default_value = "postgres")]
62 pub db_type: String,
63
64 #[arg(long)]
66 pub show_sql: bool,
67
68 #[arg(long)]
76 pub url: Option<String>,
77}
78
79pub async fn execute_migrate(args: &MigrateArgs) -> Result<(), CliError> {
89 let path = PathBuf::from(&args.path);
90
91 if !path.exists() {
92 return Err(CliError::Migration(format!(
93 "Migration directory not found: {}",
94 path.display()
95 )));
96 }
97
98 let db_type = DbType::from_str(&args.db_type)
99 .ok_or_else(|| CliError::Migration(format!("Unknown database type: {}", args.db_type)))?;
100
101 let migrations = resolve_migrations(&path, db_type)?;
102
103 if migrations.is_empty() {
104 println!("No migrations found in: {}", path.display());
105 return Ok(());
106 }
107
108 match &args.url {
109 None => execute_migrate_offline(args, &migrations),
110 Some(url) => execute_migrate_online(args, &migrations, url, db_type).await,
111 }
112}
113
114fn execute_migrate_offline(args: &MigrateArgs, migrations: &[Migration]) -> Result<(), CliError> {
116 if args.rollback {
117 println!("Rolling back last batch in: {}", args.path);
118 if let Some(last) = migrations.last() {
119 println!(" Would rollback: {} ({})", last.version, last.name);
120 if args.show_sql {
121 println!("{}", print_sql_block("SQL DOWN", &last.sql_down));
122 }
123 }
124 println!("Note: Actual rollback requires database connection (offline mode).");
125 } else {
126 println!("Running migrations in: {}", args.path);
127 for m in migrations {
128 println!(" Would apply: {} ({})", m.version, m.name);
129 if args.show_sql {
130 println!("{}", print_sql_block("SQL UP", &m.sql_up));
131 }
132 }
133 println!(
134 "Total: {} migration(s). Note: Actual execution requires database connection (offline mode).",
135 migrations.len()
136 );
137 }
138 Ok(())
139}
140
141async fn execute_migrate_online(
143 args: &MigrateArgs,
144 migrations: &[Migration],
145 url: &str,
146 db_type: DbType,
147) -> Result<(), CliError> {
148 let mut conn = create_connection(url, db_type).await?;
149
150 if args.rollback {
151 let last = migrations
153 .last()
154 .ok_or_else(|| CliError::Migration("No migrations to rollback".to_string()))?;
155 println!("Rolling back: {} ({})", last.version, last.name);
156 if args.show_sql {
157 println!("{}", print_sql_block("SQL DOWN", &last.sql_down));
158 }
159 if !last.sql_down.is_empty() {
160 let sql = prepare_sql_for_db(&last.sql_down, db_type);
161 conn.execute(&sql)
162 .await
163 .map_err(|e| CliError::Migration(format!("Rollback failed: {}", e)))?;
164 }
165 delete_migration_record(&mut conn, &last.version, db_type).await?;
167 println!("Rollback completed: {} ({})", last.version, last.name);
168 } else {
169 ensure_migrations_table(&mut conn, db_type).await?;
171
172 let applied = fetch_applied_versions(&mut conn, db_type).await?;
174
175 let pending: Vec<&Migration> = migrations
177 .iter()
178 .filter(|m| !applied.contains(&m.version))
179 .collect();
180
181 if pending.is_empty() {
182 println!("No pending migrations. Database is up to date.");
183 return Ok(());
184 }
185
186 println!("Running {} pending migration(s):", pending.len());
187
188 let mut applied_count = 0;
189 for m in &pending {
190 if args.show_sql {
191 println!("{}", print_sql_block("SQL UP", &m.sql_up));
192 }
193 let sql = prepare_sql_for_db(&m.sql_up, db_type);
194
195 conn.execute(&sql).await.map_err(|e| {
196 CliError::Migration(format!("Migration {} failed: {}", m.version, e))
197 })?;
198 insert_migration_record(&mut conn, &m.version, &m.name, db_type).await?;
199 println!(" Applied: {}", m.version);
200 applied_count += 1;
201 }
202 println!("Migration completed: {} applied.", applied_count);
203 }
204
205 Ok(())
206}
207
208pub async fn execute_status(path: &str) -> Result<(), CliError> {
214 execute_status_full(path, "postgres", false, None).await
215}
216
217pub async fn execute_status_with(
225 path: &str,
226 db_type_str: &str,
227 show_sql: bool,
228) -> Result<(), CliError> {
229 execute_status_full(path, db_type_str, show_sql, None).await
230}
231
232pub async fn execute_status_full(
234 path: &str,
235 db_type_str: &str,
236 show_sql: bool,
237 url: Option<&str>,
238) -> Result<(), CliError> {
239 let path_buf = PathBuf::from(path);
240
241 if !path_buf.exists() {
242 return Err(CliError::Migration(format!(
243 "Migration directory not found: {}",
244 path_buf.display()
245 )));
246 }
247
248 let db_type = DbType::from_str(db_type_str)
249 .ok_or_else(|| CliError::Migration(format!("Unknown database type: {}", db_type_str)))?;
250
251 let migrations = resolve_migrations(&path_buf, db_type)?;
252
253 if migrations.is_empty() {
254 println!("No migrations found in: {}", path_buf.display());
255 return Ok(());
256 }
257
258 let applied_versions = if let Some(url) = url {
260 let mut conn = create_connection(url, db_type).await?;
261 ensure_migrations_table(&mut conn, db_type).await?;
262 fetch_applied_versions(&mut conn, db_type).await?
263 } else {
264 std::collections::HashSet::new()
265 };
266
267 println!(
269 "{:<15} {:<30} {:<20}",
270 "Version", "Migration Name", "Status"
271 );
272 println!("{}", "-".repeat(65));
273
274 for m in &migrations {
275 let status = if applied_versions.contains(&m.version) {
276 "Applied"
277 } else if url.is_some() {
278 "Pending"
279 } else {
280 "Pending*"
281 };
282 println!("{:<15} {:<30} {:<20}", m.version, m.name, status);
283 if show_sql {
284 println!("{}", print_sql_block("SQL UP", &m.sql_up));
285 println!("{}", print_sql_block("SQL DOWN", &m.sql_down));
286 }
287 }
288
289 println!();
290 if url.is_some() {
291 let applied = migrations
292 .iter()
293 .filter(|m| applied_versions.contains(&m.version))
294 .count();
295 println!(
296 "Total: {} migration(s), {} applied, {} pending.",
297 migrations.len(),
298 applied,
299 migrations.len() - applied
300 );
301 } else {
302 println!("* Status cannot be determined without database connection (offline mode).");
303 }
304
305 Ok(())
306}
307
308async fn create_connection(url: &str, db_type: DbType) -> Result<Box<dyn Connection>, CliError> {
317 use std::sync::Arc;
318 use sz_orm_sqlx::any_driver::AnyPool;
319
320 if db_type == DbType::SqlServer {
321 use sz_orm_mssql::{MssqlConnectionFactory, MssqlPoolHandle};
322 use sz_rust_core::orm::ConnectionFactory;
323
324 let rest = url
325 .strip_prefix("mssql://")
326 .or_else(|| url.strip_prefix("sqlserver://"))
327 .ok_or_else(|| CliError::Migration("Invalid MSSQL DSN".to_string()))?;
328 let (userinfo, hostinfo) = rest
329 .split_once('@')
330 .ok_or_else(|| CliError::Migration("MSSQL DSN missing @".to_string()))?;
331 let (username, password) = userinfo
332 .split_once(':')
333 .ok_or_else(|| CliError::Migration("MSSQL DSN missing password".to_string()))?;
334 let (host_port, database) = hostinfo
335 .split_once('/')
336 .ok_or_else(|| CliError::Migration("MSSQL DSN missing database".to_string()))?;
337 let (host, port) = host_port.split_once(':').unwrap_or((host_port, "1433"));
338 let ado = format!(
339 "Server={host},{port};Database={database};User Id={username};Password={password};\
340 Encrypt=false;TrustServerCertificate=true;"
341 );
342 let pool = MssqlPoolHandle::connect(&ado)
343 .await
344 .map_err(|e| CliError::Migration(format!("MSSQL connect failed: {e}")))?;
345 let factory = MssqlConnectionFactory::new(Arc::new(pool));
346 let conn = factory
347 .create()
348 .await
349 .map_err(|e| CliError::Migration(format!("MSSQL acquire failed: {e}")))?;
350 return Ok(conn);
351 }
352
353 if db_type == DbType::Oracle {
354 use sz_orm_oracle::{OracleConnectionFactory, OraclePoolHandle};
355 use sz_rust_core::orm::ConnectionFactory;
356
357 let rest = url
358 .strip_prefix("oracle://")
359 .ok_or_else(|| CliError::Migration("Invalid Oracle DSN".to_string()))?;
360 let (userinfo, hostinfo) = rest
361 .split_once('@')
362 .ok_or_else(|| CliError::Migration("Oracle DSN missing @".to_string()))?;
363 let (username, password) = userinfo
364 .split_once(':')
365 .ok_or_else(|| CliError::Migration("Oracle DSN missing password".to_string()))?;
366 let pool = OraclePoolHandle::connect(username, password, hostinfo)
367 .map_err(|e| CliError::Migration(format!("Oracle connect failed: {e}")))?;
368 let pool_arc = Arc::new(pool);
369 let factory = OracleConnectionFactory::new(pool_arc.clone());
370 std::mem::forget(pool_arc);
371 let conn = factory
372 .create()
373 .await
374 .map_err(|e| CliError::Migration(format!("Oracle acquire failed: {e}")))?;
375 return Ok(conn);
376 }
377
378 let pool = AnyPool::connect(url)
379 .await
380 .map_err(|e| CliError::Migration(format!("{:?} connect failed: {}", db_type, e)))?;
381 let conn = pool
382 .create()
383 .await
384 .map_err(|e| CliError::Migration(format!("{:?} acquire failed: {}", db_type, e)))?;
385 Ok(Box::new(conn))
386}
387
388async fn ensure_migrations_table(
390 conn: &mut Box<dyn Connection>,
391 db_type: DbType,
392) -> Result<(), CliError> {
393 let sql: &str = match db_type {
394 DbType::PostgreSQL | DbType::Sqlite => {
395 "CREATE TABLE IF NOT EXISTS __migrations (
396 version VARCHAR(255) PRIMARY KEY,
397 name VARCHAR(255) NOT NULL,
398 batch INTEGER NOT NULL,
399 executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
400 )"
401 }
402 DbType::MySQL => {
403 "CREATE TABLE IF NOT EXISTS __migrations (
404 version VARCHAR(255) PRIMARY KEY,
405 name VARCHAR(255) NOT NULL,
406 batch INT NOT NULL,
407 executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
408 )"
409 }
410 DbType::Oracle => {
411 "CREATE TABLE \"__migrations\" (\
412 version VARCHAR2(255) PRIMARY KEY,\
413 name VARCHAR2(255) NOT NULL,\
414 batch NUMBER(10) NOT NULL,\
415 executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\
416 )"
417 }
418 DbType::SqlServer => {
419 "CREATE TABLE __migrations (\
420 version NVARCHAR(255) PRIMARY KEY,\
421 name NVARCHAR(255) NOT NULL,\
422 batch INT NOT NULL,\
423 executed_at DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP\
424 )"
425 }
426 _ => {
427 return Err(CliError::Migration(format!(
428 "Cannot ensure __migrations table for db_type {:?}",
429 db_type
430 )))
431 }
432 };
433 let result = conn.execute(sql).await;
434 match result {
435 Ok(_) => Ok(()),
436 Err(e) if db_type == DbType::SqlServer => {
437 let err_msg = format!("{}", e);
438 if err_msg.contains("2714") || err_msg.contains("already exists") {
439 Ok(())
440 } else {
441 Err(CliError::Migration(format!(
442 "Failed to create __migrations table: {}",
443 e
444 )))
445 }
446 }
447 Err(e) if db_type == DbType::Oracle => {
448 let err_msg = format!("{}", e);
449 if err_msg.contains("ORA-00955") || err_msg.contains("already exists") {
450 Ok(())
451 } else {
452 Err(CliError::Migration(format!(
453 "Failed to create __migrations table: {}",
454 e
455 )))
456 }
457 }
458 Err(e) => Err(CliError::Migration(format!(
459 "Failed to create __migrations table: {}",
460 e
461 ))),
462 }
463}
464
465fn migrations_table_name(db_type: DbType) -> &'static str {
467 match db_type {
468 DbType::Oracle => "\"__migrations\"",
469 _ => "__migrations",
470 }
471}
472
473fn prepare_sql_for_db(sql: &str, db_type: DbType) -> String {
475 if db_type == DbType::Oracle {
476 sql.trim_end().trim_end_matches(';').to_string()
477 } else {
478 sql.to_string()
479 }
480}
481
482async fn fetch_applied_versions(
487 conn: &mut Box<dyn Connection>,
488 db_type: DbType,
489) -> Result<std::collections::HashSet<String>, CliError> {
490 let table = migrations_table_name(db_type);
491 let sql = format!("SELECT version FROM {}", table);
492 let rows = conn
493 .query(&sql)
494 .await
495 .map_err(|e| CliError::Migration(format!("Failed to query __migrations: {}", e)))?;
496
497 let mut versions = std::collections::HashSet::new();
498 for row in &rows {
499 use sz_rust_core::orm::Value;
500 let val = row.get("version").or_else(|| row.get("VERSION"));
501 if let Some(val) = val {
502 match val {
503 Value::String(s) => versions.insert(s.clone()),
504 Value::I64(i) => versions.insert(i.to_string()),
505 Value::I32(i) => versions.insert(i.to_string()),
506 _ => false,
507 };
508 }
509 }
510 Ok(versions)
511}
512
513async fn insert_migration_record(
519 conn: &mut Box<dyn Connection>,
520 version: &str,
521 name: &str,
522 db_type: DbType,
523) -> Result<(), CliError> {
524 if !matches!(
525 db_type,
526 DbType::PostgreSQL | DbType::Sqlite | DbType::MySQL | DbType::Oracle | DbType::SqlServer
527 ) {
528 return Ok(());
529 }
530 use sz_rust_core::orm::Value;
531 let table = migrations_table_name(db_type);
532 let sql = format!(
533 "INSERT INTO {} (version, name, batch) VALUES (?, ?, 1)",
534 table
535 );
536 conn.execute_with_params(
537 &sql,
538 &[
539 Value::String(version.to_string()),
540 Value::String(name.to_string()),
541 ],
542 )
543 .await
544 .map_err(|e| CliError::Migration(format!("Failed to insert migration record: {}", e)))?;
545 conn.commit()
546 .await
547 .map_err(|e| CliError::Migration(format!("Failed to commit: {}", e)))?;
548 Ok(())
549}
550
551async fn delete_migration_record(
552 conn: &mut Box<dyn Connection>,
553 version: &str,
554 db_type: DbType,
555) -> Result<(), CliError> {
556 if !matches!(
557 db_type,
558 DbType::PostgreSQL | DbType::Sqlite | DbType::MySQL | DbType::Oracle | DbType::SqlServer
559 ) {
560 return Ok(());
561 }
562 use sz_rust_core::orm::Value;
563 let table = migrations_table_name(db_type);
564 let sql = format!("DELETE FROM {} WHERE version = ?", table);
565 conn.execute_with_params(&sql, &[Value::String(version.to_string())])
566 .await
567 .map_err(|e| CliError::Migration(format!("Failed to delete migration record: {}", e)))?;
568 conn.commit()
569 .await
570 .map_err(|e| CliError::Migration(format!("Failed to commit: {}", e)))?;
571 Ok(())
572}
573
574fn resolve_migrations(path: &Path, db_type: DbType) -> Result<Vec<Migration>, CliError> {
582 let resolver = FileMigrationResolver::new(path.to_path_buf());
583 resolver
584 .resolve(db_type)
585 .map_err(|e| CliError::Migration(format!("Failed to resolve migrations: {}", e)))
586}
587
588fn print_sql_block(title: &str, sql: &str) -> String {
598 if sql.is_empty() {
599 return String::new();
600 }
601 let mut out = format!(" --- {} ---\n", title);
602 for line in sql.lines() {
603 out.push_str(&format!(" {}\n", line));
604 }
605 out.push_str(&format!(" {}\n", "-".repeat(title.len() + 8)));
606 out
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use std::fs;
613 use std::io::Write;
614
615 fn create_test_migration(dir: &Path, version: &str, name: &str) {
617 let up_name = format!("{}_{}_up.sql", version, name);
618 let down_name = format!("{}_{}_down.sql", version, name);
619
620 let up_path = dir.join(up_name);
621 let down_path = dir.join(down_name);
622
623 let mut up_file = fs::File::create(&up_path).unwrap();
624 writeln!(up_file, "-- {} up", name).unwrap();
625
626 let mut down_file = fs::File::create(&down_path).unwrap();
627 writeln!(down_file, "-- {} down", name).unwrap();
628 }
629
630 #[test]
631 fn test_resolve_migrations_empty() {
632 let temp = tempfile::tempdir().unwrap();
633 let path = temp.path().to_path_buf();
634 let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
635 assert!(result.is_empty());
636 }
637
638 #[test]
639 fn test_resolve_migrations_with_files() {
640 let temp = tempfile::tempdir().unwrap();
641 let path = temp.path().to_path_buf();
642
643 create_test_migration(&path, "001", "create_users");
644 create_test_migration(&path, "002", "add_index");
645
646 let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
647 assert_eq!(result.len(), 2);
648 assert_eq!(result[0].version, "001");
649 assert_eq!(result[0].name, "create_users");
650 assert_eq!(result[1].version, "002");
651 assert_eq!(result[1].name, "add_index");
652 }
653
654 #[test]
655 fn test_resolve_migrations_returns_sql_content() {
656 let temp = tempfile::tempdir().unwrap();
657 let path = temp.path().to_path_buf();
658
659 let up_path = path.join("001_init_up.sql");
660 let down_path = path.join("001_init_down.sql");
661 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
662 fs::write(&down_path, "DROP TABLE users;").unwrap();
663
664 let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
665 assert_eq!(result.len(), 1);
666 assert!(result[0].sql_up.contains("CREATE TABLE users"));
667 assert!(result[0].sql_down.contains("DROP TABLE users"));
668 }
669
670 #[test]
671 fn test_resolve_migrations_supports_multiple_db_types() {
672 let temp = tempfile::tempdir().unwrap();
673 let path = temp.path().to_path_buf();
674 create_test_migration(&path, "001", "init");
675
676 let mysql_result = resolve_migrations(&path, DbType::MySQL).unwrap();
677 let pg_result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
678
679 assert_eq!(mysql_result.len(), 1);
680 assert_eq!(pg_result.len(), 1);
681 }
682
683 #[tokio::test]
684 async fn test_execute_status_nonexistent_dir() {
685 let result = execute_status("/nonexistent/path/migrations").await;
686 assert!(matches!(result, Err(CliError::Migration(_))));
687 }
688
689 #[tokio::test]
690 async fn test_execute_status_empty_dir() {
691 let temp = tempfile::tempdir().unwrap();
692 let path = temp.path().to_str().unwrap();
693 let result = execute_status(path).await;
694 assert!(result.is_ok());
695 }
696
697 #[tokio::test]
698 async fn test_execute_status_with_migrations() {
699 let temp = tempfile::tempdir().unwrap();
700 let path = temp.path().to_path_buf();
701 create_test_migration(&path, "001", "create_users");
702
703 let path_str = temp.path().to_str().unwrap();
704 let result = execute_status(path_str).await;
705 assert!(result.is_ok());
706 }
707
708 #[tokio::test]
709 async fn test_execute_status_with_invalid_db_type() {
710 let temp = tempfile::tempdir().unwrap();
711 let path = temp.path().to_str().unwrap();
712 let result = execute_status_with(path, "invalid_db_type", false).await;
713 assert!(matches!(result, Err(CliError::Migration(_))));
714 }
715
716 #[tokio::test]
717 async fn test_execute_status_with_show_sql() {
718 let temp = tempfile::tempdir().unwrap();
719 let path = temp.path().to_path_buf();
720
721 let up_path = path.join("001_init_up.sql");
722 let down_path = path.join("001_init_down.sql");
723 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
724 fs::write(&down_path, "DROP TABLE users;").unwrap();
725
726 let path_str = temp.path().to_str().unwrap();
727 let result = execute_status_with(path_str, "postgres", true).await;
728 assert!(result.is_ok());
729 }
730
731 #[tokio::test]
732 async fn test_execute_migrate_nonexistent_dir() {
733 let args = MigrateArgs {
734 rollback: false,
735 path: "/nonexistent/migrations".to_string(),
736 db_type: "postgres".to_string(),
737 show_sql: false,
738 url: None,
739 };
740 let result = execute_migrate(&args).await;
741 assert!(matches!(result, Err(CliError::Migration(_))));
742 }
743
744 #[tokio::test]
745 async fn test_execute_migrate_empty_dir() {
746 let temp = tempfile::tempdir().unwrap();
747 let args = MigrateArgs {
748 rollback: false,
749 path: temp.path().to_str().unwrap().to_string(),
750 db_type: "postgres".to_string(),
751 show_sql: false,
752 url: None,
753 };
754 let result = execute_migrate(&args).await;
755 assert!(result.is_ok());
756 }
757
758 #[tokio::test]
759 async fn test_execute_migrate_with_files_offline() {
760 let temp = tempfile::tempdir().unwrap();
761 let path = temp.path().to_path_buf();
762 create_test_migration(&path, "001", "create_users");
763
764 let args = MigrateArgs {
765 rollback: false,
766 path: temp.path().to_str().unwrap().to_string(),
767 db_type: "postgres".to_string(),
768 show_sql: false,
769 url: None,
770 };
771 let result = execute_migrate(&args).await;
772 assert!(result.is_ok());
773 }
774
775 #[tokio::test]
776 async fn test_execute_migrate_with_show_sql_offline() {
777 let temp = tempfile::tempdir().unwrap();
778 let path = temp.path().to_path_buf();
779
780 let up_path = path.join("001_init_up.sql");
781 let down_path = path.join("001_init_down.sql");
782 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
783 fs::write(&down_path, "DROP TABLE users;").unwrap();
784
785 let args = MigrateArgs {
786 rollback: false,
787 path: temp.path().to_str().unwrap().to_string(),
788 db_type: "postgres".to_string(),
789 show_sql: true,
790 url: None,
791 };
792 let result = execute_migrate(&args).await;
793 assert!(result.is_ok());
794 }
795
796 #[tokio::test]
797 async fn test_execute_migrate_with_invalid_db_type() {
798 let temp = tempfile::tempdir().unwrap();
799 let args = MigrateArgs {
800 rollback: false,
801 path: temp.path().to_str().unwrap().to_string(),
802 db_type: "invalid_db_type".to_string(),
803 show_sql: false,
804 url: None,
805 };
806 let result = execute_migrate(&args).await;
807 assert!(matches!(result, Err(CliError::Migration(_))));
808 }
809
810 #[tokio::test]
811 async fn test_execute_migrate_rollback_offline() {
812 let temp = tempfile::tempdir().unwrap();
813 let path = temp.path().to_path_buf();
814 create_test_migration(&path, "001", "create_users");
815 create_test_migration(&path, "002", "add_index");
816
817 let args = MigrateArgs {
818 rollback: true,
819 path: temp.path().to_str().unwrap().to_string(),
820 db_type: "postgres".to_string(),
821 show_sql: false,
822 url: None,
823 };
824 let result = execute_migrate(&args).await;
825 assert!(result.is_ok());
826 }
827
828 #[tokio::test]
829 async fn test_execute_migrate_rollback_with_show_sql_offline() {
830 let temp = tempfile::tempdir().unwrap();
831 let path = temp.path().to_path_buf();
832
833 let up_path = path.join("001_init_up.sql");
834 let down_path = path.join("001_init_down.sql");
835 fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
836 fs::write(&down_path, "DROP TABLE users;").unwrap();
837
838 let args = MigrateArgs {
839 rollback: true,
840 path: temp.path().to_str().unwrap().to_string(),
841 db_type: "postgres".to_string(),
842 show_sql: true,
843 url: None,
844 };
845 let result = execute_migrate(&args).await;
846 assert!(result.is_ok());
847 }
848
849 #[test]
850 fn test_print_sql_block_empty_sql() {
851 let out = print_sql_block("SQL UP", "");
853 assert!(out.is_empty(), "空 SQL 不应产生输出,实际: {:?}", out);
854 }
855
856 #[test]
857 fn test_print_sql_block_with_content() {
858 let out = print_sql_block("SQL UP", "CREATE TABLE users (id INT);");
859 assert!(out.contains("--- SQL UP ---"), "应包含标题, 实际: {out}");
860 assert!(
861 out.contains("CREATE TABLE users (id INT);"),
862 "应包含 SQL 内容, 实际: {out}"
863 );
864 assert!(
865 out.contains(&"-".repeat("SQL UP".len() + 8)),
866 "应以分隔线结尾, 实际: {:?}",
867 out
868 );
869 }
870
871 #[tokio::test]
872 async fn test_execute_status_full_offline_no_url() {
873 let temp = tempfile::tempdir().unwrap();
874 let path = temp.path().to_path_buf();
875 create_test_migration(&path, "001", "init");
876
877 let path_str = temp.path().to_str().unwrap();
878 let result = execute_status_full(path_str, "postgres", false, None).await;
879 assert!(result.is_ok());
880 }
881
882 #[tokio::test]
883 async fn test_execute_status_full_offline_with_show_sql() {
884 let temp = tempfile::tempdir().unwrap();
885 let path = temp.path().to_path_buf();
886 let up_path = path.join("001_init_up.sql");
887 let down_path = path.join("001_init_down.sql");
888 fs::write(&up_path, "CREATE TABLE t (id INT);").unwrap();
889 fs::write(&down_path, "DROP TABLE t;").unwrap();
890
891 let path_str = temp.path().to_str().unwrap();
892 let result = execute_status_full(path_str, "postgres", true, None).await;
893 assert!(result.is_ok());
894 }
895
896 #[tokio::test]
897 async fn test_execute_status_full_invalid_db_type() {
898 let temp = tempfile::tempdir().unwrap();
899 let path_str = temp.path().to_str().unwrap();
900 let result = execute_status_full(path_str, "invalid_db", false, None).await;
901 assert!(matches!(result, Err(CliError::Migration(_))));
902 }
903
904 #[tokio::test]
905 async fn test_execute_migrate_online_with_invalid_url_returns_error() {
906 let temp = tempfile::tempdir().unwrap();
907 let path = temp.path().to_path_buf();
908 create_test_migration(&path, "001", "init");
909
910 let args = MigrateArgs {
911 rollback: false,
912 path: temp.path().to_str().unwrap().to_string(),
913 db_type: "postgres".to_string(),
914 show_sql: false,
915 url: Some("postgres://invalid:invalid@127.0.0.1:1/invalid".to_string()),
916 };
917 let result = execute_migrate(&args).await;
918 assert!(result.is_err());
920 }
921
922 #[tokio::test]
923 async fn test_create_connection_oracle_dsn_attempts_connect() {
924 let result = create_connection(
925 "oracle://invalid:invalid@127.0.0.1:1/invalid",
926 DbType::Oracle,
927 )
928 .await;
929 match result {
930 Err(e) => {
931 let err = format!("{}", e);
932 assert!(
933 !err.contains("not supported"),
934 "Oracle 应尝试连接而非拒绝: {err}"
935 );
936 }
937 Ok(_) => panic!("Oracle 连接应失败"),
938 }
939 }
940
941 #[tokio::test]
942 async fn test_create_connection_mssql_dsn_attempts_connect() {
943 let result = create_connection(
944 "mssql://invalid:invalid@127.0.0.1:1/invalid",
945 DbType::SqlServer,
946 )
947 .await;
948 match result {
949 Err(e) => {
950 let err = format!("{}", e);
951 assert!(
952 !err.contains("not supported"),
953 "MSSQL 应尝试连接而非拒绝: {err}"
954 );
955 }
956 Ok(_) => panic!("MSSQL 连接应失败"),
957 }
958 }
959
960 #[test]
961 fn test_migrations_table_name_postgres() {
962 assert_eq!(migrations_table_name(DbType::PostgreSQL), "__migrations");
963 }
964
965 #[test]
966 fn test_migrations_table_name_mysql() {
967 assert_eq!(migrations_table_name(DbType::MySQL), "__migrations");
968 }
969
970 #[test]
971 fn test_migrations_table_name_sqlite() {
972 assert_eq!(migrations_table_name(DbType::Sqlite), "__migrations");
973 }
974
975 #[test]
976 fn test_migrations_table_name_oracle() {
977 assert_eq!(migrations_table_name(DbType::Oracle), "\"__migrations\"");
978 }
979
980 #[test]
981 fn test_migrations_table_name_mssql() {
982 assert_eq!(migrations_table_name(DbType::SqlServer), "__migrations");
983 }
984
985 #[test]
986 fn test_prepare_sql_for_db_postgres() {
987 let sql = "CREATE TABLE users (id INT);";
988 assert_eq!(prepare_sql_for_db(sql, DbType::PostgreSQL), sql);
989 }
990
991 #[test]
992 fn test_prepare_sql_for_db_oracle_strips_semicolon() {
993 let sql = "CREATE TABLE users (id INT);";
994 assert_eq!(
995 prepare_sql_for_db(sql, DbType::Oracle),
996 "CREATE TABLE users (id INT)"
997 );
998 }
999
1000 #[test]
1001 fn test_prepare_sql_for_db_oracle_no_semicolon() {
1002 let sql = "CREATE TABLE users (id INT)";
1003 assert_eq!(prepare_sql_for_db(sql, DbType::Oracle), sql);
1004 }
1005
1006 #[test]
1007 fn test_prepare_sql_for_db_oracle_trailing_whitespace() {
1008 let sql = "CREATE TABLE users (id INT); \n";
1009 assert_eq!(
1010 prepare_sql_for_db(sql, DbType::Oracle),
1011 "CREATE TABLE users (id INT)"
1012 );
1013 }
1014
1015 #[test]
1016 fn test_prepare_sql_for_db_mysql_no_change() {
1017 let sql = "CREATE TABLE users (id INT);";
1018 assert_eq!(prepare_sql_for_db(sql, DbType::MySQL), sql);
1019 }
1020}