Skip to main content

sz_rust_core/
migration_history.rs

1//! 迁移历史表(Migration History Table)
2//!
3//! 持久化记录已执行的迁移版本,避免重复执行。对齐 PHP `think migrate` 的
4//! `migrations` 表设计:
5//!
6//! ```sql
7//! CREATE TABLE migrations (
8//!     id          BIGINT PRIMARY KEY AUTO_INCREMENT,
9//!     version     VARCHAR(255) NOT NULL,         -- 迁移版本号(如 "001")
10//!     name        VARCHAR(255) NOT NULL,         -- 迁移名称
11//!     batch       INT          NOT NULL,         -- 批次号
12//!     executed_at TIMESTAMP    NOT NULL          -- 执行时间
13//! );
14//! ```
15//!
16//! ## 多方言支持
17//!
18//! - MySQL / MariaDB / OceanBase / TiDB / PolarDB:`AUTO_INCREMENT` + `TIMESTAMP DEFAULT CURRENT_TIMESTAMP`
19//! - PostgreSQL / KingbaseES / GaussDB:`BIGSERIAL` + `TIMESTAMP DEFAULT NOW()`
20//! - SQLite:`INTEGER PRIMARY KEY AUTOINCREMENT` + `DATETIME DEFAULT CURRENT_TIMESTAMP`
21//! - Oracle / Dameng:`NUMBER GENERATED BY DEFAULT AS IDENTITY` + `TIMESTAMP DEFAULT SYSTIMESTAMP`
22//! - SQL Server / Sybase:`BIGINT IDENTITY(1,1)` + `DATETIME DEFAULT GETDATE()`
23//!
24//! ## 安全约束
25//!
26//! - 所有标识符(表名)经 `validate_identifier` 校验,防止 SQL 注入
27//! - 占位符按方言生成(`?` vs `$1`),由调用方通过参数绑定执行
28//! - 本模块只生成 SQL,不执行 SQL(执行由 sz-rust-cli 或上层应用负责)
29
30use std::fmt;
31
32/// 数据库类型枚举(迁移历史表用方言识别)
33///
34/// 复刻 sz-orm 的 `DbType` 子集,避免 sz-rust-core 强依赖 sz-orm-core。
35/// 仅包含迁移历史表 DDL 关心的主流关系型数据库。
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37pub enum HistoryDbType {
38    /// MySQL / MariaDB / OceanBase / TiDB / PolarDB(AUTO_INCREMENT 方言家族)
39    MySQL,
40    /// PostgreSQL / KingbaseES / GaussDB(BIGSERIAL 方言家族)
41    #[default]
42    PostgreSQL,
43    /// SQLite(AUTOINCREMENT 方言)
44    SQLite,
45    /// Oracle / Dameng(GENERATED BY DEFAULT AS IDENTITY 方言家族)
46    Oracle,
47    /// SQL Server / Sybase(IDENTITY(1,1) 方言家族)
48    SqlServer,
49}
50
51impl HistoryDbType {
52    /// 从字符串解析数据库类型
53    ///
54    /// 支持的别名(大小写不敏感):
55    /// - `mysql` / `mariadb` / `oceanbase` / `tidb` / `polardb` → MySQL
56    /// - `postgres` / `postgresql` / `kingbase` / `gaussdb` / `clickhouse` → PostgreSQL
57    /// - `sqlite` → SQLite
58    /// - `oracle` / `dameng` → Oracle
59    /// - `mssql` / `sqlserver` / `sybase` → SqlServer
60    ///
61    /// 注:命名为 `parse_db_type` 而非 `from_str`,避免与 `std::str::FromStr` trait 冲突。
62    pub fn parse_db_type(s: &str) -> Option<Self> {
63        match s.to_lowercase().as_str() {
64            "mysql" | "mariadb" | "oceanbase" | "tidb" | "polardb" => Some(Self::MySQL),
65            "postgres" | "postgresql" | "kingbase" | "gaussdb" | "clickhouse" => {
66                Some(Self::PostgreSQL)
67            }
68            "sqlite" => Some(Self::SQLite),
69            "oracle" | "dameng" => Some(Self::Oracle),
70            "mssql" | "sqlserver" | "sybase" => Some(Self::SqlServer),
71            _ => None,
72        }
73    }
74
75    /// 返回占位符(`?` 或 `$1`)
76    fn placeholder(&self, index: usize) -> String {
77        match self {
78            Self::PostgreSQL => format!("${}", index),
79            _ => "?".to_string(),
80        }
81    }
82}
83
84impl fmt::Display for HistoryDbType {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::MySQL => write!(f, "mysql"),
88            Self::PostgreSQL => write!(f, "postgres"),
89            Self::SQLite => write!(f, "sqlite"),
90            Self::Oracle => write!(f, "oracle"),
91            Self::SqlServer => write!(f, "mssql"),
92        }
93    }
94}
95
96/// 校验 SQL 标识符(表名/列名),防止 SQL 注入
97///
98/// 仅允许:字母、数字、下划线,长度 1-64,且不以数字开头。
99/// 多表名点号分隔(如 `public.migrations`)按点号拆分后逐段校验。
100fn validate_identifier(name: &str, label: &str) -> Result<(), String> {
101    if name.is_empty() {
102        return Err(format!("{} cannot be empty", label));
103    }
104    if name.len() > 64 {
105        return Err(format!("{} too long (max 64 chars): {}", label, name));
106    }
107    // 支持模式名.表名(如 public.migrations)
108    for segment in name.split('.') {
109        if segment.is_empty() {
110            return Err(format!("{} has empty segment: {}", label, name));
111        }
112        let chars: Vec<char> = segment.chars().collect();
113        if chars[0].is_ascii_digit() {
114            return Err(format!("{} cannot start with digit: {}", label, name));
115        }
116        for c in chars {
117            if !c.is_ascii_alphanumeric() && c != '_' {
118                return Err(format!("{} contains invalid char '{}': {}", label, c, name));
119            }
120        }
121    }
122    Ok(())
123}
124
125/// 迁移历史表仓库
126///
127/// 封装历史表 DDL 与 CRUD SQL 生成,支持多数据库方言。
128/// 仅生成 SQL 字符串,不执行 SQL(执行由调用方负责)。
129#[derive(Debug, Clone, Default)]
130pub struct MigrationHistory;
131
132impl MigrationHistory {
133    /// 生成迁移历史表 DDL(`CREATE TABLE IF NOT EXISTS`)
134    ///
135    /// # 参数
136    ///
137    /// - `table_name`:历史表名(默认 `__migrations`)
138    /// - `db_type`:目标数据库类型,决定字段类型与自增策略
139    ///
140    /// # 错误
141    ///
142    /// 返回 `Result<String, String>`,校验失败时返回 `Err(error_msg)`。
143    pub fn create_table_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
144        validate_identifier(table_name, "migration history table name")?;
145
146        let ddl = match db_type {
147            // MySQL 方言家族:AUTO_INCREMENT + TIMESTAMP DEFAULT CURRENT_TIMESTAMP
148            HistoryDbType::MySQL => format!(
149                "CREATE TABLE IF NOT EXISTS {} (\n\
150                 \x20   id BIGINT NOT NULL AUTO_INCREMENT,\n\
151                 \x20   version VARCHAR(255) NOT NULL,\n\
152                 \x20   name VARCHAR(255) NOT NULL,\n\
153                 \x20   batch INT NOT NULL,\n\
154                 \x20   executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\
155                 \x20   PRIMARY KEY (id),\n\
156                 \x20   UNIQUE KEY uk_version (version)\n\
157                 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
158                table_name
159            ),
160            // PostgreSQL 方言家族:BIGSERIAL + TIMESTAMP DEFAULT NOW()
161            HistoryDbType::PostgreSQL => format!(
162                "CREATE TABLE IF NOT EXISTS {} (\n\
163                 \x20   id BIGSERIAL PRIMARY KEY,\n\
164                 \x20   version VARCHAR(255) NOT NULL UNIQUE,\n\
165                 \x20   name VARCHAR(255) NOT NULL,\n\
166                 \x20   batch INT NOT NULL,\n\
167                 \x20   executed_at TIMESTAMP NOT NULL DEFAULT NOW()\n\
168                 )",
169                table_name
170            ),
171            // SQLite:INTEGER PRIMARY KEY AUTOINCREMENT
172            HistoryDbType::SQLite => format!(
173                "CREATE TABLE IF NOT EXISTS {} (\n\
174                 \x20   id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
175                 \x20   version VARCHAR(255) NOT NULL UNIQUE,\n\
176                 \x20   name VARCHAR(255) NOT NULL,\n\
177                 \x20   batch INT NOT NULL,\n\
178                 \x20   executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n\
179                 )",
180                table_name
181            ),
182            // Oracle 方言家族:GENERATED BY DEFAULT AS IDENTITY
183            HistoryDbType::Oracle => format!(
184                "CREATE TABLE {} (\n\
185                 \x20   id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\
186                 \x20   version VARCHAR2(255) NOT NULL UNIQUE,\n\
187                 \x20   name VARCHAR2(255) NOT NULL,\n\
188                 \x20   batch NUMBER(10) NOT NULL,\n\
189                 \x20   executed_at TIMESTAMP NOT NULL DEFAULT SYSTIMESTAMP\n\
190                 )",
191                table_name
192            ),
193            // SQL Server 方言家族:IDENTITY(1,1)
194            HistoryDbType::SqlServer => format!(
195                "CREATE TABLE {} (\n\
196                 \x20   id BIGINT IDENTITY(1,1) PRIMARY KEY,\n\
197                 \x20   version NVARCHAR(255) NOT NULL UNIQUE,\n\
198                 \x20   name NVARCHAR(255) NOT NULL,\n\
199                 \x20   batch INT NOT NULL,\n\
200                 \x20   executed_at DATETIME NOT NULL DEFAULT GETDATE()\n\
201                 )",
202                table_name
203            ),
204        };
205        Ok(ddl)
206    }
207
208    /// 生成插入历史记录的 SQL(使用占位符 `?` 或 `$1`)
209    ///
210    /// 占位符顺序:`version`, `name`, `batch`
211    ///
212    /// # 参数
213    ///
214    /// - `table_name`:历史表名
215    /// - `db_type`:目标数据库类型,决定占位符风格
216    pub fn insert_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
217        validate_identifier(table_name, "migration history table name")?;
218
219        let p1 = db_type.placeholder(1);
220        let p2 = db_type.placeholder(2);
221        let p3 = db_type.placeholder(3);
222        Ok(format!(
223            "INSERT INTO {} (version, name, batch) VALUES ({}, {}, {})",
224            table_name, p1, p2, p3
225        ))
226    }
227
228    /// 生成删除历史记录的 SQL(按版本号删除)
229    ///
230    /// # 参数
231    ///
232    /// - `table_name`:历史表名
233    /// - `db_type`:目标数据库类型,决定占位符风格
234    pub fn delete_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
235        validate_identifier(table_name, "migration history table name")?;
236        let p1 = db_type.placeholder(1);
237        Ok(format!("DELETE FROM {} WHERE version = {}", table_name, p1))
238    }
239
240    /// 生成查询所有已应用迁移的 SQL(按版本号升序)
241    ///
242    /// 返回字段:`version`, `name`, `batch`, `executed_at`
243    pub fn list_sql(table_name: &str) -> Result<String, String> {
244        validate_identifier(table_name, "migration history table name")?;
245        Ok(format!(
246            "SELECT version, name, batch, executed_at FROM {} ORDER BY version ASC",
247            table_name
248        ))
249    }
250
251    /// 生成查询最大批次号的 SQL
252    ///
253    /// 返回字段:`max_batch`(无记录时为 NULL,调用方应处理为 0)
254    pub fn max_batch_sql(table_name: &str) -> Result<String, String> {
255        validate_identifier(table_name, "migration history table name")?;
256        Ok(format!(
257            "SELECT COALESCE(MAX(batch), 0) AS max_batch FROM {}",
258            table_name
259        ))
260    }
261
262    /// 生成查询指定版本是否已记录的 SQL
263    ///
264    /// 返回字段:`cnt`(0 表示未记录,1 表示已记录)
265    pub fn exists_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
266        validate_identifier(table_name, "migration history table name")?;
267        let p1 = db_type.placeholder(1);
268        Ok(format!(
269            "SELECT COUNT(*) AS cnt FROM {} WHERE version = {}",
270            table_name, p1
271        ))
272    }
273}
274
275/// 历史记录条目(对应一行迁移执行记录)
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct MigrationHistoryRecord {
278    /// 迁移版本号
279    pub version: String,
280    /// 迁移名称
281    pub name: String,
282    /// 批次号
283    pub batch: i32,
284    /// 执行时间(ISO 8601 字符串)
285    pub executed_at: String,
286}
287
288impl MigrationHistoryRecord {
289    /// 创建新的历史记录条目
290    pub fn new(version: impl Into<String>, name: impl Into<String>, batch: i32) -> Self {
291        Self {
292            version: version.into(),
293            name: name.into(),
294            batch,
295            executed_at: String::new(),
296        }
297    }
298
299    /// 设置执行时间
300    pub fn with_executed_at(mut self, executed_at: impl Into<String>) -> Self {
301        self.executed_at = executed_at.into();
302        self
303    }
304}
305
306/// 迁移历史表配置
307#[derive(Debug, Clone)]
308pub struct MigrationHistoryConfig {
309    /// 历史表名(默认 `__migrations`)
310    pub table_name: String,
311    /// 数据库类型
312    pub db_type: HistoryDbType,
313}
314
315impl Default for MigrationHistoryConfig {
316    fn default() -> Self {
317        Self {
318            table_name: "__migrations".to_string(),
319            db_type: HistoryDbType::default(),
320        }
321    }
322}
323
324impl MigrationHistoryConfig {
325    /// 创建 MySQL 配置
326    pub fn mysql() -> Self {
327        Self {
328            table_name: "__migrations".to_string(),
329            db_type: HistoryDbType::MySQL,
330        }
331    }
332
333    /// 创建 PostgreSQL 配置
334    pub fn postgres() -> Self {
335        Self {
336            table_name: "__migrations".to_string(),
337            db_type: HistoryDbType::PostgreSQL,
338        }
339    }
340
341    /// 创建 SQLite 配置
342    pub fn sqlite() -> Self {
343        Self {
344            table_name: "__migrations".to_string(),
345            db_type: HistoryDbType::SQLite,
346        }
347    }
348
349    /// 设置自定义表名
350    pub fn with_table_name(mut self, name: impl Into<String>) -> Self {
351        self.table_name = name.into();
352        self
353    }
354}
355
356// ============================================================================
357// 单元测试
358// ============================================================================
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    // ========================================================================
365    // HistoryDbType
366    // ========================================================================
367
368    #[test]
369    fn test_db_type_from_str_mysql_family() {
370        assert_eq!(
371            HistoryDbType::parse_db_type("mysql"),
372            Some(HistoryDbType::MySQL)
373        );
374        assert_eq!(
375            HistoryDbType::parse_db_type("MySQL"),
376            Some(HistoryDbType::MySQL)
377        );
378        assert_eq!(
379            HistoryDbType::parse_db_type("mariadb"),
380            Some(HistoryDbType::MySQL)
381        );
382        assert_eq!(
383            HistoryDbType::parse_db_type("oceanbase"),
384            Some(HistoryDbType::MySQL)
385        );
386        assert_eq!(
387            HistoryDbType::parse_db_type("tidb"),
388            Some(HistoryDbType::MySQL)
389        );
390        assert_eq!(
391            HistoryDbType::parse_db_type("polardb"),
392            Some(HistoryDbType::MySQL)
393        );
394    }
395
396    #[test]
397    fn test_db_type_from_str_pg_family() {
398        assert_eq!(
399            HistoryDbType::parse_db_type("postgres"),
400            Some(HistoryDbType::PostgreSQL)
401        );
402        assert_eq!(
403            HistoryDbType::parse_db_type("postgresql"),
404            Some(HistoryDbType::PostgreSQL)
405        );
406        assert_eq!(
407            HistoryDbType::parse_db_type("kingbase"),
408            Some(HistoryDbType::PostgreSQL)
409        );
410        assert_eq!(
411            HistoryDbType::parse_db_type("gaussdb"),
412            Some(HistoryDbType::PostgreSQL)
413        );
414    }
415
416    #[test]
417    fn test_db_type_from_str_others() {
418        assert_eq!(
419            HistoryDbType::parse_db_type("sqlite"),
420            Some(HistoryDbType::SQLite)
421        );
422        assert_eq!(
423            HistoryDbType::parse_db_type("oracle"),
424            Some(HistoryDbType::Oracle)
425        );
426        assert_eq!(
427            HistoryDbType::parse_db_type("dameng"),
428            Some(HistoryDbType::Oracle)
429        );
430        assert_eq!(
431            HistoryDbType::parse_db_type("mssql"),
432            Some(HistoryDbType::SqlServer)
433        );
434        assert_eq!(
435            HistoryDbType::parse_db_type("sqlserver"),
436            Some(HistoryDbType::SqlServer)
437        );
438        assert_eq!(
439            HistoryDbType::parse_db_type("sybase"),
440            Some(HistoryDbType::SqlServer)
441        );
442    }
443
444    #[test]
445    fn test_db_type_from_str_unknown_returns_none() {
446        assert_eq!(HistoryDbType::parse_db_type("redis"), None);
447        assert_eq!(HistoryDbType::parse_db_type("mongodb"), None);
448        assert_eq!(HistoryDbType::parse_db_type(""), None);
449    }
450
451    #[test]
452    fn test_db_type_display() {
453        assert_eq!(format!("{}", HistoryDbType::MySQL), "mysql");
454        assert_eq!(format!("{}", HistoryDbType::PostgreSQL), "postgres");
455        assert_eq!(format!("{}", HistoryDbType::SQLite), "sqlite");
456        assert_eq!(format!("{}", HistoryDbType::Oracle), "oracle");
457        assert_eq!(format!("{}", HistoryDbType::SqlServer), "mssql");
458    }
459
460    #[test]
461    fn test_db_type_default_is_postgres() {
462        assert_eq!(HistoryDbType::default(), HistoryDbType::PostgreSQL);
463    }
464
465    // ========================================================================
466    // validate_identifier
467    // ========================================================================
468
469    #[test]
470    fn test_validate_identifier_valid() {
471        assert!(validate_identifier("__migrations", "table").is_ok());
472        assert!(validate_identifier("migrations", "table").is_ok());
473        assert!(validate_identifier("public.migrations", "table").is_ok());
474        assert!(validate_identifier("_t123", "table").is_ok());
475    }
476
477    #[test]
478    fn test_validate_identifier_rejects_empty() {
479        assert!(validate_identifier("", "table").is_err());
480    }
481
482    #[test]
483    fn test_validate_identifier_rejects_too_long() {
484        let long = "a".repeat(65);
485        assert!(validate_identifier(&long, "table").is_err());
486    }
487
488    #[test]
489    fn test_validate_identifier_rejects_digit_start() {
490        assert!(validate_identifier("1table", "table").is_err());
491    }
492
493    #[test]
494    fn test_validate_identifier_rejects_special_chars() {
495        assert!(validate_identifier("table;", "table").is_err());
496        assert!(validate_identifier("table'", "table").is_err());
497        assert!(validate_identifier("table--", "table").is_err());
498        assert!(validate_identifier("ta ble", "table").is_err());
499        assert!(validate_identifier("table; DROP TABLE users", "table").is_err());
500    }
501
502    #[test]
503    fn test_validate_identifier_rejects_empty_segment() {
504        assert!(validate_identifier("public..migrations", "table").is_err());
505        assert!(validate_identifier(".migrations", "table").is_err());
506        assert!(validate_identifier("migrations.", "table").is_err());
507    }
508
509    // ========================================================================
510    // MigrationHistory::create_table_sql
511    // ========================================================================
512
513    #[test]
514    fn test_create_table_sql_mysql() {
515        let sql = MigrationHistory::create_table_sql("__migrations", HistoryDbType::MySQL).unwrap();
516        assert!(sql.contains("CREATE TABLE IF NOT EXISTS __migrations"));
517        assert!(sql.contains("AUTO_INCREMENT"));
518        assert!(sql.contains("UNIQUE KEY uk_version"));
519        assert!(sql.contains("CURRENT_TIMESTAMP"));
520    }
521
522    #[test]
523    fn test_create_table_sql_postgres() {
524        let sql =
525            MigrationHistory::create_table_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
526        assert!(sql.contains("BIGSERIAL"));
527        assert!(sql.contains("DEFAULT NOW()"));
528        assert!(sql.contains("UNIQUE"));
529    }
530
531    #[test]
532    fn test_create_table_sql_sqlite() {
533        let sql =
534            MigrationHistory::create_table_sql("__migrations", HistoryDbType::SQLite).unwrap();
535        assert!(sql.contains("AUTOINCREMENT"));
536        assert!(sql.contains("CURRENT_TIMESTAMP"));
537    }
538
539    #[test]
540    fn test_create_table_sql_oracle() {
541        let sql =
542            MigrationHistory::create_table_sql("__migrations", HistoryDbType::Oracle).unwrap();
543        assert!(sql.contains("GENERATED BY DEFAULT AS IDENTITY"));
544        assert!(sql.contains("SYSTIMESTAMP"));
545        assert!(sql.contains("VARCHAR2"));
546        // Oracle 不支持 IF NOT EXISTS
547        assert!(!sql.contains("IF NOT EXISTS"));
548    }
549
550    #[test]
551    fn test_create_table_sql_mssql() {
552        let sql =
553            MigrationHistory::create_table_sql("__migrations", HistoryDbType::SqlServer).unwrap();
554        assert!(sql.contains("IDENTITY(1,1)"));
555        assert!(sql.contains("GETDATE()"));
556        assert!(sql.contains("NVARCHAR"));
557    }
558
559    #[test]
560    fn test_create_table_sql_supports_schema_qualified_name() {
561        let sql =
562            MigrationHistory::create_table_sql("public.migrations", HistoryDbType::PostgreSQL)
563                .unwrap();
564        assert!(sql.contains("public.migrations"));
565    }
566
567    #[test]
568    fn test_create_table_sql_rejects_injection() {
569        let result =
570            MigrationHistory::create_table_sql("m; DROP TABLE users", HistoryDbType::MySQL);
571        assert!(result.is_err());
572    }
573
574    // ========================================================================
575    // MigrationHistory::insert_sql
576    // ========================================================================
577
578    #[test]
579    fn test_insert_sql_mysql_uses_question_mark() {
580        let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::MySQL).unwrap();
581        assert!(sql.contains("INSERT INTO __migrations"));
582        assert!(sql.contains("(?, ?, ?)"));
583    }
584
585    #[test]
586    fn test_insert_sql_postgres_uses_dollar() {
587        let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
588        assert!(sql.contains("($1, $2, $3)"));
589    }
590
591    #[test]
592    fn test_insert_sql_sqlite_uses_question_mark() {
593        let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::SQLite).unwrap();
594        assert!(sql.contains("(?, ?, ?)"));
595    }
596
597    #[test]
598    fn test_insert_sql_oracle_uses_question_mark() {
599        let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::Oracle).unwrap();
600        assert!(sql.contains("(?, ?, ?)"));
601    }
602
603    #[test]
604    fn test_insert_sql_rejects_injection() {
605        let result = MigrationHistory::insert_sql("m; DROP TABLE users", HistoryDbType::MySQL);
606        assert!(result.is_err());
607    }
608
609    // ========================================================================
610    // MigrationHistory::delete_sql
611    // ========================================================================
612
613    #[test]
614    fn test_delete_sql_mysql() {
615        let sql = MigrationHistory::delete_sql("__migrations", HistoryDbType::MySQL).unwrap();
616        assert!(sql.contains("DELETE FROM __migrations WHERE version = ?"));
617    }
618
619    #[test]
620    fn test_delete_sql_postgres() {
621        let sql = MigrationHistory::delete_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
622        assert!(sql.contains("WHERE version = $1"));
623    }
624
625    #[test]
626    fn test_delete_sql_rejects_injection() {
627        let result = MigrationHistory::delete_sql("m; DROP TABLE users", HistoryDbType::MySQL);
628        assert!(result.is_err());
629    }
630
631    // ========================================================================
632    // MigrationHistory::list_sql / max_batch_sql / exists_sql
633    // ========================================================================
634
635    #[test]
636    fn test_list_sql() {
637        let sql = MigrationHistory::list_sql("__migrations").unwrap();
638        assert!(sql.contains("SELECT version, name, batch, executed_at"));
639        assert!(sql.contains("FROM __migrations"));
640        assert!(sql.contains("ORDER BY version ASC"));
641    }
642
643    #[test]
644    fn test_max_batch_sql() {
645        let sql = MigrationHistory::max_batch_sql("__migrations").unwrap();
646        assert!(sql.contains("COALESCE(MAX(batch), 0)"));
647        assert!(sql.contains("AS max_batch"));
648    }
649
650    #[test]
651    fn test_exists_sql_mysql() {
652        let sql = MigrationHistory::exists_sql("__migrations", HistoryDbType::MySQL).unwrap();
653        assert!(sql.contains("SELECT COUNT(*) AS cnt"));
654        assert!(sql.contains("WHERE version = ?"));
655    }
656
657    #[test]
658    fn test_exists_sql_postgres() {
659        let sql = MigrationHistory::exists_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
660        assert!(sql.contains("WHERE version = $1"));
661    }
662
663    #[test]
664    fn test_list_sql_rejects_injection() {
665        let result = MigrationHistory::list_sql("m; DROP TABLE users");
666        assert!(result.is_err());
667    }
668
669    // ========================================================================
670    // MigrationHistoryRecord
671    // ========================================================================
672
673    #[test]
674    fn test_history_record_new() {
675        let record = MigrationHistoryRecord::new("001", "create_users", 1);
676        assert_eq!(record.version, "001");
677        assert_eq!(record.name, "create_users");
678        assert_eq!(record.batch, 1);
679        assert!(record.executed_at.is_empty());
680    }
681
682    #[test]
683    fn test_history_record_with_executed_at() {
684        let record =
685            MigrationHistoryRecord::new("001", "init", 1).with_executed_at("2026-07-25T10:00:00Z");
686        assert_eq!(record.executed_at, "2026-07-25T10:00:00Z");
687    }
688
689    #[test]
690    fn test_history_record_equality() {
691        let r1 = MigrationHistoryRecord::new("001", "init", 1);
692        let r2 = MigrationHistoryRecord::new("001", "init", 1);
693        assert_eq!(r1, r2);
694    }
695
696    // ========================================================================
697    // MigrationHistoryConfig
698    // ========================================================================
699
700    #[test]
701    fn test_config_default() {
702        let config = MigrationHistoryConfig::default();
703        assert_eq!(config.table_name, "__migrations");
704        assert_eq!(config.db_type, HistoryDbType::PostgreSQL);
705    }
706
707    #[test]
708    fn test_config_mysql() {
709        let config = MigrationHistoryConfig::mysql();
710        assert_eq!(config.db_type, HistoryDbType::MySQL);
711    }
712
713    #[test]
714    fn test_config_postgres() {
715        let config = MigrationHistoryConfig::postgres();
716        assert_eq!(config.db_type, HistoryDbType::PostgreSQL);
717    }
718
719    #[test]
720    fn test_config_sqlite() {
721        let config = MigrationHistoryConfig::sqlite();
722        assert_eq!(config.db_type, HistoryDbType::SQLite);
723    }
724
725    #[test]
726    fn test_config_with_table_name() {
727        let config = MigrationHistoryConfig::default().with_table_name("app_migrations");
728        assert_eq!(config.table_name, "app_migrations");
729    }
730
731    // ========================================================================
732    // 端到端:配置 → SQL 生成
733    // ========================================================================
734
735    #[test]
736    fn test_end_to_end_mysql_workflow() {
737        let config = MigrationHistoryConfig::mysql();
738
739        let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
740        assert!(ddl.contains("CREATE TABLE IF NOT EXISTS __migrations"));
741
742        let insert = MigrationHistory::insert_sql(&config.table_name, config.db_type).unwrap();
743        assert!(insert.contains("(?, ?, ?)"));
744
745        let delete = MigrationHistory::delete_sql(&config.table_name, config.db_type).unwrap();
746        assert!(delete.contains("WHERE version = ?"));
747
748        let list = MigrationHistory::list_sql(&config.table_name).unwrap();
749        assert!(list.contains("ORDER BY version ASC"));
750
751        let max_batch = MigrationHistory::max_batch_sql(&config.table_name).unwrap();
752        assert!(max_batch.contains("COALESCE(MAX(batch), 0)"));
753    }
754
755    #[test]
756    fn test_end_to_end_postgres_workflow() {
757        let config = MigrationHistoryConfig::postgres();
758
759        let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
760        assert!(ddl.contains("BIGSERIAL"));
761
762        let insert = MigrationHistory::insert_sql(&config.table_name, config.db_type).unwrap();
763        assert!(insert.contains("($1, $2, $3)"));
764
765        let delete = MigrationHistory::delete_sql(&config.table_name, config.db_type).unwrap();
766        assert!(delete.contains("WHERE version = $1"));
767    }
768
769    #[test]
770    fn test_end_to_end_custom_table_name() {
771        let config = MigrationHistoryConfig::default().with_table_name("app_migrations");
772
773        let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
774        assert!(ddl.contains("app_migrations"));
775    }
776}