Skip to main content

sz_rust_cli/cmd/
migrate.rs

1//! `migrate` / `migrate:status` 命令 — 整合 `sz-orm-core::migration`
2//!
3//! ## PHP 对齐
4//!
5//! PHP `migrate:status` 输出表格:
6//! ```text
7//! +---------+------------------+---------------------+
8//! | Version | Migration Name   | Run Time            |
9//! +---------+------------------+---------------------+
10//! | 001     | create_users     | 2024-01-01 00:00:00 |
11//! | 002     | add_index        | Pending             |
12//! +---------+------------------+---------------------+
13//! ```
14//!
15//! ## 整合说明
16//!
17//! 本模块使用 `sz_orm_core::migration::FileMigrationResolver`(sz-orm-core,经 sz-rust-orm-facade 透传) 解析迁移目录,
18//! 对齐 sz-orm 的迁移文件命名约定(`<version>_<name>_up.sql` / `<version>_<name>_down.sql`)。
19//!
20//! ### 离线模式(默认)
21//!
22//! 不连接数据库,仅解析并列出迁移文件。`migrate` 命令输出"将执行的 SQL",
23//! `migrate:status` 输出迁移列表(状态统一显示 `Pending*`,因离线无法确定执行历史)。
24//!
25//! ### 在线模式(提供 `--url` 时启用)
26//!
27//! 通过 `Migrator::migrate()` 执行真实迁移,需要注入 `MigrationContext::connection`。
28//! CLI 通过 `sz-orm-sqlx` 建立 PostgreSQL/MySQL/SQLite 连接池,包装为
29//! `Box<dyn Connection>` 注入 `MigrationContext`。
30//! `migrate:status` 在线模式下从 `__migrations` 表查询已应用版本,显示真实状态。
31
32use 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/// `migrate` 命令参数
43///
44/// 对齐 PHP `php think migrate` / `php think migrate:rollback`。
45#[derive(Args, Debug)]
46pub struct MigrateArgs {
47    /// 回滚最后一批迁移(对齐 PHP `migrate:rollback`)
48    #[arg(long)]
49    pub rollback: bool,
50
51    /// 迁移目录(默认 `migrations`)
52    #[arg(short = 'p', long, default_value = "migrations")]
53    pub path: String,
54
55    /// 数据库类型(默认 `postgres`,对齐 sz-orm `DbType`)
56    ///
57    /// 影响迁移解析的方言处理。支持值:
58    /// `mysql` / `postgres` / `sqlite` / `oracle` / `mssql` /
59    /// `oceanbase` / `dameng` / `kingbase` 等(详见 `DbType::from_str`)。
60    #[arg(long, default_value = "postgres")]
61    pub db_type: String,
62
63    /// 打印每个迁移的 SQL 内容(dry-run 模式,便于审查)
64    #[arg(long)]
65    pub show_sql: bool,
66
67    /// 数据库连接 URL(启用在线模式)
68    ///
69    /// 提供时连接数据库执行真实迁移;省略时为离线模式(仅列出待执行的 SQL)。
70    /// 格式示例:
71    /// - PostgreSQL: `postgres://user:pass@host:5432/dbname`
72    /// - MySQL: `mysql://user:pass@host:3306/dbname`
73    /// - SQLite: `sqlite://path/to/database.db`
74    #[arg(long)]
75    pub url: Option<String>,
76}
77
78/// 执行 migrate 命令
79///
80/// - 无 `--rollback`:执行所有待迁移(对齐 `php think migrate`)
81/// - 有 `--rollback`:回滚最后一批迁移(对齐 `php think migrate:rollback`)
82///
83/// # 模式
84///
85/// - **离线模式**(默认,未提供 `--url`):仅解析迁移目录并打印待执行内容
86/// - **在线模式**(提供 `--url`):连接数据库执行真实迁移
87pub 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
113/// 离线模式执行 migrate(仅打印,不连库)
114fn 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
140/// 在线模式执行 migrate(连接数据库真实执行)
141fn execute_migrate_online(
142    args: &MigrateArgs,
143    migrations: &[Migration],
144    url: &str,
145    db_type: DbType,
146) -> Result<(), CliError> {
147    // 阻塞执行异步逻辑
148    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            // 回滚最后一个迁移
158            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            // 从 __migrations 表删除记录
171            delete_migration_record(&mut conn, &last.version, db_type).await?;
172            println!("Rollback completed: {} ({})", last.version, last.name);
173        } else {
174            // 确保 __migrations 表存在
175            ensure_migrations_table(&mut conn, db_type).await?;
176
177            // 查询已应用版本
178            let applied = fetch_applied_versions(&mut conn, db_type).await?;
179
180            // 过滤出待执行的迁移
181            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            // 构建 Migrator 并执行
194            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                // Migration 未实现 Clone,按字段重建实例
200                let rebuilt = Migration::new(&m.version, &m.name, &m.sql_up, &m.sql_down);
201                if applied.contains(&m.version) {
202                    // 标记为已执行(batch>0),避免 Migrator 重复执行
203                    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
224/// 执行 migrate:status 命令(兼容入口,使用默认 `postgres` 方言)
225///
226/// 对齐 PHP `php think migrate:status`,输出表格格式的迁移状态。
227///
228/// 等价于 [`execute_status_with`] 传入 `db_type="postgres"`、`show_sql=false`、`url=None`。
229pub fn execute_status(path: &str) -> Result<(), CliError> {
230    execute_status_full(path, "postgres", false, None)
231}
232
233/// 执行 migrate:status 命令(完整参数)
234///
235/// # 参数
236///
237/// - `path`:迁移目录
238/// - `db_type_str`:数据库类型字符串(由 `DbType::from_str` 解析)
239/// - `show_sql`:是否打印每个迁移的 SQL 内容
240pub 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
244/// 执行 migrate:status 命令(完整参数,含在线模式)
245pub 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    // 在线模式:查询数据库已应用版本
271    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    // 表格输出(对齐 PHP migrate:status 格式)
286    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
326/// 创建数据库连接(按 DbType 选择驱动)
327async 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
372/// 确保 __migrations 表存在
373async 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
407/// 查询已应用的迁移版本
408///
409/// 执行 `SELECT version FROM __migrations` 查询已应用的迁移版本集合。
410/// 用于 `migrate:status` 在线模式区分已应用/未应用迁移。
411async fn fetch_applied_versions(
412    conn: &mut Box<dyn Connection>,
413    _db_type: DbType,
414) -> Result<std::collections::HashSet<String>, CliError> {
415    // 查询 __migrations 表中所有已记录的迁移版本
416    // QueryRows = Vec<HashMap<String, Value>>
417    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        // version 列为字符串类型,尝试从 HashMap 提取
425        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
438/// 删除 __migrations 表中的迁移记录
439///
440/// 参数化绑定防 SQL 注入(铁律 §1):`version` 虽源自迁移文件名而非用户输入,
441/// 仍统一走 `execute_with_params` 参数化路径,杜绝任何拼接风险。
442async 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 = ?", &params)
452        .await
453        .map_err(|e| CliError::Migration(format!("Failed to delete migration record: {}", e)))?;
454    Ok(())
455}
456
457/// 解析迁移目录,返回排序后的迁移列表
458///
459/// 整合 [`FileMigrationResolver`],对齐 sz-orm 的迁移文件命名约定。
460///
461/// # 错误
462///
463/// - [`CliError::Migration`]:目录读取失败或迁移文件解析失败
464fn 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
471/// 打印 SQL 代码块(带标题分隔符)
472///
473/// 格式:
474/// ```text
475///   --- <title> ---
476///   <sql content>
477///   ----------------
478/// ```
479fn 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    /// 创建测试用迁移文件(`<version>_<name>_up.sql` + `<version>_<name>_down.sql`)
497    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        // 连接失败应返回错误(不 panic)
788        assert!(result.is_err());
789    }
790}