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`] 解析迁移目录,
18//! 对齐 sz-orm 的迁移文件命名约定(`<version>_<name>_up.sql` / `<version>_<name>_down.sql`)。
19//!
20//! ### 离线模式(默认)
21//!
22//! 不连接数据库,仅解析并列出迁移文件。`migrate` 命令输出"将执行的 SQL",
23//! `migrate:status` 输出迁移列表(状态统一显示 `Pending*`,因离线无法确定执行历史)。
24//!
25//! ### 在线模式(未来扩展)
26//!
27//! 通过 `Migrator::migrate()` 执行真实迁移,需要注入 `MigrationContext::connection`。
28//! 当前 CLI 不直接依赖具体数据库驱动(如 `sz-orm-sqlx`),保持包体积精简。
29//! 用户可基于本模块的解析结果,自行调用 `Migrator` API 执行迁移。
30
31use std::path::{Path, PathBuf};
32
33use clap::Args;
34use sz_orm_core::migration::{FileMigrationResolver, Migration, MigrationResolver};
35use sz_orm_core::DbType;
36
37use crate::error::CliError;
38
39/// `migrate` 命令参数
40///
41/// 对齐 PHP `php think migrate` / `php think migrate:rollback`。
42#[derive(Args, Debug)]
43pub struct MigrateArgs {
44    /// 回滚最后一批迁移(对齐 PHP `migrate:rollback`)
45    #[arg(long)]
46    pub rollback: bool,
47
48    /// 迁移目录(默认 `migrations`)
49    #[arg(short = 'p', long, default_value = "migrations")]
50    pub path: String,
51
52    /// 数据库类型(默认 `postgres`,对齐 sz-orm `DbType`)
53    ///
54    /// 影响迁移解析的方言处理。支持值:
55    /// `mysql` / `postgres` / `sqlite` / `oracle` / `mssql` /
56    /// `oceanbase` / `dameng` / `kingbase` 等(详见 `DbType::from_str`)。
57    #[arg(long, default_value = "postgres")]
58    pub db_type: String,
59
60    /// 打印每个迁移的 SQL 内容(dry-run 模式,便于审查)
61    #[arg(long)]
62    pub show_sql: bool,
63}
64
65/// 执行 migrate 命令
66///
67/// - 无 `--rollback`:列出所有待迁移,可选打印 SQL(对齐 `php think migrate`)
68/// - 有 `--rollback`:列出最后一个迁移作为回滚目标(对齐 `php think migrate:rollback`)
69///
70/// # 离线模式说明
71///
72/// 当前为离线模式:仅解析迁移目录并打印待执行内容,不连接数据库。
73/// 真正执行迁移需要在线模式(未来扩展,通过 `Migrator::migrate` 注入连接)。
74pub fn execute_migrate(args: &MigrateArgs) -> Result<(), CliError> {
75    let path = PathBuf::from(&args.path);
76
77    if !path.exists() {
78        return Err(CliError::Migration(format!(
79            "Migration directory not found: {}",
80            path.display()
81        )));
82    }
83
84    let db_type = DbType::from_str(&args.db_type)
85        .ok_or_else(|| CliError::Migration(format!("Unknown database type: {}", args.db_type)))?;
86
87    let migrations = resolve_migrations(&path, db_type)?;
88
89    if migrations.is_empty() {
90        println!("No migrations found in: {}", path.display());
91        return Ok(());
92    }
93
94    if args.rollback {
95        println!("Rolling back last batch in: {}", path.display());
96        // 离线模式:回滚目标为列表中最后一个迁移
97        if let Some(last) = migrations.last() {
98            println!("  Would rollback: {} ({})", last.version, last.name);
99            if args.show_sql {
100                print_sql_block("SQL DOWN", &last.sql_down);
101            }
102        }
103        println!("Note: Actual rollback requires database connection (offline mode).");
104    } else {
105        println!("Running migrations in: {}", path.display());
106        for m in &migrations {
107            println!("  Would apply: {} ({})", m.version, m.name);
108            if args.show_sql {
109                print_sql_block("SQL UP", &m.sql_up);
110            }
111        }
112        println!(
113            "Total: {} migration(s). Note: Actual execution requires database connection (offline mode).",
114            migrations.len()
115        );
116    }
117
118    Ok(())
119}
120
121/// 执行 migrate:status 命令(兼容入口,使用默认 `postgres` 方言)
122///
123/// 对齐 PHP `php think migrate:status`,输出表格格式的迁移状态。
124///
125/// 等价于 [`execute_status_with`] 传入 `db_type="postgres"`、`show_sql=false`。
126pub fn execute_status(path: &str) -> Result<(), CliError> {
127    execute_status_with(path, "postgres", false)
128}
129
130/// 执行 migrate:status 命令(完整参数)
131///
132/// # 参数
133///
134/// - `path`:迁移目录
135/// - `db_type_str`:数据库类型字符串(由 `DbType::from_str` 解析)
136/// - `show_sql`:是否打印每个迁移的 SQL 内容
137pub fn execute_status_with(path: &str, db_type_str: &str, show_sql: bool) -> Result<(), CliError> {
138    let path_buf = PathBuf::from(path);
139
140    if !path_buf.exists() {
141        return Err(CliError::Migration(format!(
142            "Migration directory not found: {}",
143            path_buf.display()
144        )));
145    }
146
147    let db_type = DbType::from_str(db_type_str)
148        .ok_or_else(|| CliError::Migration(format!("Unknown database type: {}", db_type_str)))?;
149
150    let migrations = resolve_migrations(&path_buf, db_type)?;
151
152    if migrations.is_empty() {
153        println!("No migrations found in: {}", path_buf.display());
154        return Ok(());
155    }
156
157    // 表格输出(对齐 PHP migrate:status 格式)
158    println!(
159        "{:<15} {:<30} {:<20}",
160        "Version", "Migration Name", "Status"
161    );
162    println!("{}", "-".repeat(65));
163
164    for m in &migrations {
165        // 离线模式:无法确定是否已执行,统一显示 "Pending*"
166        println!("{:<15} {:<30} {:<20}", m.version, m.name, "Pending*");
167        if show_sql {
168            print_sql_block("SQL UP", &m.sql_up);
169            print_sql_block("SQL DOWN", &m.sql_down);
170        }
171    }
172
173    println!();
174    println!("* Status cannot be determined without database connection (offline mode).");
175
176    Ok(())
177}
178
179/// 解析迁移目录,返回排序后的迁移列表
180///
181/// 整合 [`FileMigrationResolver`],对齐 sz-orm 的迁移文件命名约定。
182///
183/// # 错误
184///
185/// - [`CliError::Migration`]:目录读取失败或迁移文件解析失败
186fn resolve_migrations(path: &Path, db_type: DbType) -> Result<Vec<Migration>, CliError> {
187    let resolver = FileMigrationResolver::new(path.to_path_buf());
188    resolver
189        .resolve(db_type)
190        .map_err(|e| CliError::Migration(format!("Failed to resolve migrations: {}", e)))
191}
192
193/// 打印 SQL 代码块(带标题分隔符)
194///
195/// 格式:
196/// ```text
197///   --- <title> ---
198///   <sql content>
199///   ----------------
200/// ```
201fn print_sql_block(title: &str, sql: &str) {
202    if sql.is_empty() {
203        return;
204    }
205    println!("  --- {} ---", title);
206    for line in sql.lines() {
207        println!("  {}", line);
208    }
209    println!("  {}", "-".repeat(title.len() + 8));
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::fs;
216    use std::io::Write;
217
218    /// 创建测试用迁移文件(`<version>_<name>_up.sql` + `<version>_<name>_down.sql`)
219    fn create_test_migration(dir: &Path, version: &str, name: &str) {
220        let up_name = format!("{}_{}_up.sql", version, name);
221        let down_name = format!("{}_{}_down.sql", version, name);
222
223        let up_path = dir.join(up_name);
224        let down_path = dir.join(down_name);
225
226        let mut up_file = fs::File::create(&up_path).unwrap();
227        writeln!(up_file, "-- {} up", name).unwrap();
228
229        let mut down_file = fs::File::create(&down_path).unwrap();
230        writeln!(down_file, "-- {} down", name).unwrap();
231    }
232
233    #[test]
234    fn test_resolve_migrations_empty() {
235        let temp = tempfile::tempdir().unwrap();
236        let path = temp.path().to_path_buf();
237        let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
238        assert!(result.is_empty());
239    }
240
241    #[test]
242    fn test_resolve_migrations_with_files() {
243        let temp = tempfile::tempdir().unwrap();
244        let path = temp.path().to_path_buf();
245
246        create_test_migration(&path, "001", "create_users");
247        create_test_migration(&path, "002", "add_index");
248
249        let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
250        assert_eq!(result.len(), 2);
251        assert_eq!(result[0].version, "001");
252        assert_eq!(result[0].name, "create_users");
253        assert_eq!(result[1].version, "002");
254        assert_eq!(result[1].name, "add_index");
255    }
256
257    #[test]
258    fn test_resolve_migrations_returns_sql_content() {
259        // 验证整合 sz-orm 后能正确读取 SQL 内容(不再是空 stub)
260        let temp = tempfile::tempdir().unwrap();
261        let path = temp.path().to_path_buf();
262
263        let up_path = path.join("001_init_up.sql");
264        let down_path = path.join("001_init_down.sql");
265        fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
266        fs::write(&down_path, "DROP TABLE users;").unwrap();
267
268        let result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
269        assert_eq!(result.len(), 1);
270        assert!(result[0].sql_up.contains("CREATE TABLE users"));
271        assert!(result[0].sql_down.contains("DROP TABLE users"));
272    }
273
274    #[test]
275    fn test_resolve_migrations_supports_multiple_db_types() {
276        // 验证 DbType 参数能正确传入(当前 FileMigrationResolver 不区分方言,但 API 兼容)
277        let temp = tempfile::tempdir().unwrap();
278        let path = temp.path().to_path_buf();
279        create_test_migration(&path, "001", "init");
280
281        let mysql_result = resolve_migrations(&path, DbType::MySQL).unwrap();
282        let pg_result = resolve_migrations(&path, DbType::PostgreSQL).unwrap();
283
284        assert_eq!(mysql_result.len(), 1);
285        assert_eq!(pg_result.len(), 1);
286    }
287
288    #[test]
289    fn test_execute_status_nonexistent_dir() {
290        let result = execute_status("/nonexistent/path/migrations");
291        assert!(matches!(result, Err(CliError::Migration(_))));
292    }
293
294    #[test]
295    fn test_execute_status_empty_dir() {
296        let temp = tempfile::tempdir().unwrap();
297        let path = temp.path().to_str().unwrap();
298        let result = execute_status(path);
299        assert!(result.is_ok());
300    }
301
302    #[test]
303    fn test_execute_status_with_migrations() {
304        let temp = tempfile::tempdir().unwrap();
305        let path = temp.path().to_path_buf();
306        create_test_migration(&path, "001", "create_users");
307
308        let path_str = temp.path().to_str().unwrap();
309        let result = execute_status(path_str);
310        assert!(result.is_ok());
311    }
312
313    #[test]
314    fn test_execute_status_with_invalid_db_type() {
315        let temp = tempfile::tempdir().unwrap();
316        let path = temp.path().to_str().unwrap();
317        let result = execute_status_with(path, "invalid_db_type", false);
318        assert!(matches!(result, Err(CliError::Migration(_))));
319    }
320
321    #[test]
322    fn test_execute_status_with_show_sql() {
323        let temp = tempfile::tempdir().unwrap();
324        let path = temp.path().to_path_buf();
325
326        let up_path = path.join("001_init_up.sql");
327        let down_path = path.join("001_init_down.sql");
328        fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
329        fs::write(&down_path, "DROP TABLE users;").unwrap();
330
331        let path_str = temp.path().to_str().unwrap();
332        let result = execute_status_with(path_str, "postgres", true);
333        assert!(result.is_ok());
334    }
335
336    #[test]
337    fn test_execute_migrate_nonexistent_dir() {
338        let args = MigrateArgs {
339            rollback: false,
340            path: "/nonexistent/migrations".to_string(),
341            db_type: "postgres".to_string(),
342            show_sql: false,
343        };
344        let result = execute_migrate(&args);
345        assert!(matches!(result, Err(CliError::Migration(_))));
346    }
347
348    #[test]
349    fn test_execute_migrate_empty_dir() {
350        let temp = tempfile::tempdir().unwrap();
351        let args = MigrateArgs {
352            rollback: false,
353            path: temp.path().to_str().unwrap().to_string(),
354            db_type: "postgres".to_string(),
355            show_sql: false,
356        };
357        let result = execute_migrate(&args);
358        assert!(result.is_ok());
359    }
360
361    #[test]
362    fn test_execute_migrate_with_files() {
363        let temp = tempfile::tempdir().unwrap();
364        let path = temp.path().to_path_buf();
365        create_test_migration(&path, "001", "create_users");
366
367        let args = MigrateArgs {
368            rollback: false,
369            path: temp.path().to_str().unwrap().to_string(),
370            db_type: "postgres".to_string(),
371            show_sql: false,
372        };
373        let result = execute_migrate(&args);
374        assert!(result.is_ok());
375    }
376
377    #[test]
378    fn test_execute_migrate_with_show_sql() {
379        let temp = tempfile::tempdir().unwrap();
380        let path = temp.path().to_path_buf();
381
382        let up_path = path.join("001_init_up.sql");
383        let down_path = path.join("001_init_down.sql");
384        fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
385        fs::write(&down_path, "DROP TABLE users;").unwrap();
386
387        let args = MigrateArgs {
388            rollback: false,
389            path: temp.path().to_str().unwrap().to_string(),
390            db_type: "postgres".to_string(),
391            show_sql: true,
392        };
393        let result = execute_migrate(&args);
394        assert!(result.is_ok());
395    }
396
397    #[test]
398    fn test_execute_migrate_with_invalid_db_type() {
399        let temp = tempfile::tempdir().unwrap();
400        let args = MigrateArgs {
401            rollback: false,
402            path: temp.path().to_str().unwrap().to_string(),
403            db_type: "invalid_db_type".to_string(),
404            show_sql: false,
405        };
406        let result = execute_migrate(&args);
407        assert!(matches!(result, Err(CliError::Migration(_))));
408    }
409
410    #[test]
411    fn test_execute_migrate_rollback() {
412        let temp = tempfile::tempdir().unwrap();
413        let path = temp.path().to_path_buf();
414        create_test_migration(&path, "001", "create_users");
415        create_test_migration(&path, "002", "add_index");
416
417        let args = MigrateArgs {
418            rollback: true,
419            path: temp.path().to_str().unwrap().to_string(),
420            db_type: "postgres".to_string(),
421            show_sql: false,
422        };
423        let result = execute_migrate(&args);
424        assert!(result.is_ok());
425    }
426
427    #[test]
428    fn test_execute_migrate_rollback_with_show_sql() {
429        let temp = tempfile::tempdir().unwrap();
430        let path = temp.path().to_path_buf();
431
432        let up_path = path.join("001_init_up.sql");
433        let down_path = path.join("001_init_down.sql");
434        fs::write(&up_path, "CREATE TABLE users (id INT);").unwrap();
435        fs::write(&down_path, "DROP TABLE users;").unwrap();
436
437        let args = MigrateArgs {
438            rollback: true,
439            path: temp.path().to_str().unwrap().to_string(),
440            db_type: "postgres".to_string(),
441            show_sql: true,
442        };
443        let result = execute_migrate(&args);
444        assert!(result.is_ok());
445    }
446
447    #[test]
448    fn test_print_sql_block_empty_sql() {
449        // 空 SQL 不应输出任何内容(不 panic)
450        print_sql_block("SQL UP", "");
451    }
452
453    #[test]
454    fn test_print_sql_block_with_content() {
455        // 非空 SQL 应正常输出(不 panic)
456        print_sql_block("SQL UP", "CREATE TABLE users (id INT);");
457    }
458}