Skip to main content

sz_orm_core/
phinx_migration.rs

1//! Phinx 风格 migration 链式 API
2//!
3//! 提供 Phinx 风格的链式建表/改表 API,更直观易用。
4//!
5//! # 设计
6//!
7//! Phinx 是 PHP 生态流行的 migration 工具(think-orm 默认集成),
8//! 其 API 风格比传统 SchemaBuilder 更直观:
9//!
10//! ```php
11//! $table = $this->table('users');
12//! $table->addColumn('name', 'string', ['limit' => 255])
13//!       ->addColumn('email', 'string', ['limit' => 255])
14//!       ->addIndex(['email'], ['unique' => true])
15//!       ->create();
16//! ```
17//!
18//! 本模块提供 Rust 版本的等价 API:
19//!
20//! ```ignore
21//! use sz_orm_core::phinx_migration::{PhinxTable, ColumnType};
22//! use sz_orm_core::db_type::DbType;
23//!
24//! let sql = PhinxTable::new("users")
25//!     .add_column("name", ColumnType::String, |c| c.limit(255).not_null())
26//!     .add_column("email", ColumnType::String, |c| c.limit(255).not_null())
27//!     .add_column("age", ColumnType::Integer, |_| {})
28//!     .add_index(&["email"], |i| i.unique())
29//!     .add_foreign_key("role_id", "roles", "id", |fk| fk.on_delete_cascade())
30//!     .create(DbType::MySQL);
31//! ```
32
33use crate::db_type::DbType;
34
35/// Phinx 风格列类型枚举
36///
37/// 对应 Phinx 的 `addColumn($name, $type)` 中的 `$type` 参数
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ColumnType {
40    /// 大整数(BIGINT)
41    BigIntermediate,
42    /// 二进制(BLOB / BYTEA)
43    Binary,
44    /// 布尔(TINYINT(1) / BOOLEAN)
45    Boolean,
46    /// 日期(DATE)
47    Date,
48    /// 日期时间(DATETIME / TIMESTAMP)
49    DateTime,
50    /// 定点数(DECIMAL(p,s))
51    Decimal,
52    /// 浮点数(FLOAT / DOUBLE)
53    Float,
54    /// 整数(INT)
55    Integer,
56    /// JSON(MySQL JSON / PG JSONB / SQLite TEXT)
57    Json,
58    /// 字符串(VARCHAR)
59    String,
60    /// 文本(TEXT)
61    Text,
62    /// 时间(TIME)
63    Time,
64    /// 时间戳(TIMESTAMP)
65    Timestamp,
66    /// UUID(CHAR(36) / UUID)
67    Uuid,
68}
69
70impl ColumnType {
71    /// 将抽象列类型转换为具体方言的 SQL 类型字符串
72    pub fn to_sql(self, db_type: DbType) -> &'static str {
73        match (self, db_type) {
74            (ColumnType::BigIntermediate, _) => "BIGINT",
75            (ColumnType::Binary, DbType::PostgreSQL) => "BYTEA",
76            (ColumnType::Binary, DbType::Sqlite) => "BLOB",
77            (ColumnType::Binary, _) => "BLOB",
78            (ColumnType::Boolean, DbType::PostgreSQL) => "BOOLEAN",
79            (ColumnType::Boolean, _) => "TINYINT(1)",
80            (ColumnType::Date, _) => "DATE",
81            (ColumnType::DateTime, DbType::PostgreSQL) => "TIMESTAMP",
82            (ColumnType::DateTime, DbType::Sqlite) => "DATETIME",
83            (ColumnType::DateTime, _) => "DATETIME",
84            (ColumnType::Decimal, _) => "DECIMAL",
85            (ColumnType::Float, DbType::PostgreSQL) => "DOUBLE PRECISION",
86            (ColumnType::Float, _) => "FLOAT",
87            (ColumnType::Integer, _) => "INT",
88            (ColumnType::Json, DbType::PostgreSQL) => "JSONB",
89            (ColumnType::Json, DbType::Sqlite) => "TEXT",
90            (ColumnType::Json, _) => "JSON",
91            (ColumnType::String, _) => "VARCHAR",
92            (ColumnType::Text, _) => "TEXT",
93            (ColumnType::Time, _) => "TIME",
94            (ColumnType::Timestamp, DbType::PostgreSQL) => "TIMESTAMP",
95            (ColumnType::Timestamp, _) => "TIMESTAMP",
96            (ColumnType::Uuid, DbType::PostgreSQL) => "UUID",
97            (ColumnType::Uuid, _) => "CHAR(36)",
98        }
99    }
100}
101
102/// 列选项构建器(Phinx 风格链式配置)
103#[derive(Debug, Clone)]
104pub struct ColumnOptions {
105    pub limit: Option<usize>,
106    pub nullable: bool,
107    pub default: Option<String>,
108    pub auto_increment: bool,
109    pub unique: bool,
110    pub comment: Option<String>,
111    pub precision: Option<(u32, u32)>,
112    pub after: Option<String>,
113}
114
115impl Default for ColumnOptions {
116    fn default() -> Self {
117        Self {
118            limit: None,
119            nullable: true,
120            default: None,
121            auto_increment: false,
122            unique: false,
123            comment: None,
124            precision: None,
125            after: None,
126        }
127    }
128}
129
130impl ColumnOptions {
131    /// 设置列长度(VARCHAR 长度等)
132    pub fn limit(mut self, len: usize) -> Self {
133        self.limit = Some(len);
134        self
135    }
136
137    /// 设置为 NOT NULL(默认是 nullable)
138    pub fn not_null(mut self) -> Self {
139        self.nullable = false;
140        self
141    }
142
143    /// 设置默认值
144    pub fn default_value(mut self, value: impl Into<String>) -> Self {
145        self.default = Some(value.into());
146        self
147    }
148
149    /// 设置为自增主键
150    pub fn auto_increment(mut self) -> Self {
151        self.auto_increment = true;
152        self
153    }
154
155    /// 设置为唯一
156    pub fn unique(mut self) -> Self {
157        self.unique = true;
158        self
159    }
160
161    /// 设置列注释
162    pub fn comment(mut self, comment: impl Into<String>) -> Self {
163        self.comment = Some(comment.into());
164        self
165    }
166
167    /// 设置 DECIMAL 精度(precision, scale)
168    pub fn precision(mut self, p: u32, s: u32) -> Self {
169        self.precision = Some((p, s));
170        self
171    }
172
173    /// MySQL AFTER 子句(指定列插入位置)
174    pub fn after(mut self, column: impl Into<String>) -> Self {
175        self.after = Some(column.into());
176        self
177    }
178}
179
180/// 索引选项构建器(Phinx 风格链式配置)
181#[derive(Debug, Clone, Default)]
182pub struct IndexOptions {
183    pub unique: bool,
184    pub name: Option<String>,
185    pub index_type: Option<String>,
186}
187
188impl IndexOptions {
189    /// 设置为唯一索引
190    pub fn unique(mut self) -> Self {
191        self.unique = true;
192        self
193    }
194
195    /// 设置索引名
196    pub fn name(mut self, n: impl Into<String>) -> Self {
197        self.name = Some(n.into());
198        self
199    }
200
201    /// 设置索引类型(BTREE / HASH / FULLTEXT 等)
202    pub fn index_type(mut self, t: impl Into<String>) -> Self {
203        self.index_type = Some(t.into());
204        self
205    }
206}
207
208/// 外键选项构建器(Phinx 风格链式配置)
209#[derive(Debug, Clone, Default)]
210pub struct ForeignKeyOptions {
211    pub on_delete: Option<String>,
212    pub on_update: Option<String>,
213}
214
215impl ForeignKeyOptions {
216    /// ON DELETE CASCADE
217    pub fn on_delete_cascade(mut self) -> Self {
218        self.on_delete = Some("CASCADE".to_string());
219        self
220    }
221
222    /// ON DELETE SET NULL
223    pub fn on_delete_set_null(mut self) -> Self {
224        self.on_delete = Some("SET NULL".to_string());
225        self
226    }
227
228    /// ON DELETE RESTRICT
229    pub fn on_delete_restrict(mut self) -> Self {
230        self.on_delete = Some("RESTRICT".to_string());
231        self
232    }
233
234    /// ON DELETE NO ACTION
235    pub fn on_delete_no_action(mut self) -> Self {
236        self.on_delete = Some("NO ACTION".to_string());
237        self
238    }
239
240    /// ON UPDATE CASCADE
241    pub fn on_update_cascade(mut self) -> Self {
242        self.on_update = Some("CASCADE".to_string());
243        self
244    }
245
246    /// 自定义 ON DELETE 规则
247    pub fn on_delete(mut self, action: impl Into<String>) -> Self {
248        self.on_delete = Some(action.into());
249        self
250    }
251
252    /// 自定义 ON UPDATE 规则
253    pub fn on_update(mut self, action: impl Into<String>) -> Self {
254        self.on_update = Some(action.into());
255        self
256    }
257}
258
259/// 内部表示:列定义(在 PhinxTable 中累积)
260#[derive(Debug, Clone)]
261struct PhinxColumn {
262    name: String,
263    col_type: ColumnType,
264    options: ColumnOptions,
265}
266
267/// 内部表示:索引定义
268#[derive(Debug, Clone)]
269struct PhinxIndex {
270    columns: Vec<String>,
271    options: IndexOptions,
272}
273
274/// 内部表示:外键定义
275#[derive(Debug, Clone)]
276struct PhinxForeignKey {
277    column: String,
278    referenced_table: String,
279    referenced_column: String,
280    options: ForeignKeyOptions,
281}
282
283/// Phinx 风格表构建器
284///
285/// 提供链式 API 构建 CREATE TABLE / ALTER TABLE 语句
286pub struct PhinxTable {
287    table_name: String,
288    columns: Vec<PhinxColumn>,
289    indexes: Vec<PhinxIndex>,
290    foreign_keys: Vec<PhinxForeignKey>,
291    primary_key: Option<Vec<String>>,
292    if_not_exists: bool,
293}
294
295impl PhinxTable {
296    /// 创建新的表构建器
297    pub fn new(table_name: impl Into<String>) -> Self {
298        Self {
299            table_name: table_name.into(),
300            columns: Vec::new(),
301            indexes: Vec::new(),
302            foreign_keys: Vec::new(),
303            primary_key: None,
304            if_not_exists: false,
305        }
306    }
307
308    /// 添加列(Phinx `addColumn` 等价物)
309    ///
310    /// # 用法
311    ///
312    /// ```ignore
313    /// PhinxTable::new("users")
314    ///     .add_column("name", ColumnType::String, |c| c.limit(255).not_null())
315    ///     .add_column("age", ColumnType::Integer, |_| {})
316    /// ```
317    pub fn add_column<F>(
318        mut self,
319        name: impl Into<String>,
320        col_type: ColumnType,
321        options_fn: F,
322    ) -> Self
323    where
324        F: FnOnce(ColumnOptions) -> ColumnOptions,
325    {
326        let options = options_fn(ColumnOptions::default());
327        self.columns.push(PhinxColumn {
328            name: name.into(),
329            col_type,
330            options,
331        });
332        self
333    }
334
335    /// 添加索引(Phinx `addIndex` 等价物)
336    pub fn add_index<F>(mut self, columns: &[&str], options_fn: F) -> Self
337    where
338        F: FnOnce(IndexOptions) -> IndexOptions,
339    {
340        let options = options_fn(IndexOptions::default());
341        self.indexes.push(PhinxIndex {
342            columns: columns.iter().map(|s| s.to_string()).collect(),
343            options,
344        });
345        self
346    }
347
348    /// 添加外键(Phinx `addForeignKey` 等价物)
349    pub fn add_foreign_key<F>(
350        mut self,
351        column: impl Into<String>,
352        referenced_table: impl Into<String>,
353        referenced_column: impl Into<String>,
354        options_fn: F,
355    ) -> Self
356    where
357        F: FnOnce(ForeignKeyOptions) -> ForeignKeyOptions,
358    {
359        let options = options_fn(ForeignKeyOptions::default());
360        self.foreign_keys.push(PhinxForeignKey {
361            column: column.into(),
362            referenced_table: referenced_table.into(),
363            referenced_column: referenced_column.into(),
364            options,
365        });
366        self
367    }
368
369    /// 设置主键(Phinx 风格,可复合主键)
370    pub fn set_primary_key(mut self, columns: Vec<String>) -> Self {
371        self.primary_key = Some(columns);
372        self
373    }
374
375    /// 设置 IF NOT EXISTS
376    pub fn if_not_exists(mut self) -> Self {
377        self.if_not_exists = true;
378        self
379    }
380
381    /// 生成 CREATE TABLE SQL(Phinx `create()` 等价物)
382    pub fn create(&self, db_type: DbType) -> String {
383        // v0.2.2 修复 C-2:表名/主键列名严格校验
384        crate::sql_safety::validate_identifier(&self.table_name, "table")
385            .expect("invalid table name");
386        if let Some(pk) = &self.primary_key {
387            for col in pk {
388                crate::sql_safety::validate_identifier(col, "primary key column")
389                    .expect("invalid primary key column name");
390            }
391        }
392        let mut sql = String::new();
393        sql.push_str("CREATE TABLE ");
394        if self.if_not_exists {
395            sql.push_str("IF NOT EXISTS ");
396        }
397        sql.push_str(&self.table_name);
398        sql.push_str(" (");
399
400        // 列定义
401        let col_defs: Vec<String> = self
402            .columns
403            .iter()
404            .map(|c| build_column_sql(c, db_type))
405            .collect();
406        sql.push_str(&col_defs.join(", "));
407
408        // 主键
409        if let Some(pk) = &self.primary_key {
410            sql.push_str(&format!(", PRIMARY KEY ({})", pk.join(", ")));
411        }
412
413        // 索引
414        for index in &self.indexes {
415            sql.push_str(", ");
416            sql.push_str(&build_index_sql(index));
417        }
418
419        // 外键(约束名含引用表,避免多表指向同一引用表时冲突)
420        // v0.2.2 修复 C-2:FOREIGN KEY 标识符与 ON DELETE/ON UPDATE 动作严格校验,杜绝 SQL 注入
421        for fk in &self.foreign_keys {
422            crate::sql_safety::validate_identifier(&fk.column, "foreign key column")
423                .expect("invalid foreign key column name");
424            crate::sql_safety::validate_identifier(
425                &fk.referenced_table,
426                "foreign key referenced table",
427            )
428            .expect("invalid foreign key referenced table name");
429            crate::sql_safety::validate_identifier(
430                &fk.referenced_column,
431                "foreign key referenced column",
432            )
433            .expect("invalid foreign key referenced column name");
434            if let Some(on_delete) = &fk.options.on_delete {
435                crate::sql_safety::validate_fk_action(on_delete).expect("invalid ON DELETE action");
436            }
437            if let Some(on_update) = &fk.options.on_update {
438                crate::sql_safety::validate_fk_action(on_update).expect("invalid ON UPDATE action");
439            }
440            // 约束名 fk_{table}_{column}_{ref_table} 由 table/column/ref_table 拼接而成,
441            // 此三者均已校验为合法标识符,故约束名也必然合法(仅含字母数字下划线)
442            sql.push_str(&format!(
443                ", CONSTRAINT fk_{}_{}_{} FOREIGN KEY ({}) REFERENCES {} ({})",
444                self.table_name,
445                fk.column,
446                fk.referenced_table,
447                fk.column,
448                fk.referenced_table,
449                fk.referenced_column
450            ));
451            if let Some(on_delete) = &fk.options.on_delete {
452                sql.push_str(&format!(" ON DELETE {}", on_delete.trim().to_uppercase()));
453            }
454            if let Some(on_update) = &fk.options.on_update {
455                sql.push_str(&format!(" ON UPDATE {}", on_update.trim().to_uppercase()));
456            }
457        }
458
459        sql.push(')');
460        sql
461    }
462
463    /// 生成 ALTER TABLE 添加列 SQL(Phinx `change()` 等价物的一部分)
464    pub fn add_columns_sql(&self, db_type: DbType) -> String {
465        let add_clauses: Vec<String> = self
466            .columns
467            .iter()
468            .map(|c| {
469                let col_sql = build_column_sql(c, db_type);
470                let after_clause = c
471                    .options
472                    .after
473                    .as_ref()
474                    .map(|a| format!(" AFTER {}", a))
475                    .unwrap_or_default();
476                format!("ADD COLUMN {}{}", col_sql, after_clause)
477            })
478            .collect();
479        format!("ALTER TABLE {} {}", self.table_name, add_clauses.join(", "))
480    }
481
482    /// 生成 DROP TABLE SQL(Phinx `drop()` 等价物)
483    pub fn drop(&self) -> String {
484        format!("DROP TABLE {}", self.table_name)
485    }
486
487    /// 生成 DROP COLUMN SQL(Phinx `removeColumn` 等价物)
488    pub fn drop_column_sql(&self, column: &str) -> String {
489        format!("ALTER TABLE {} DROP COLUMN {}", self.table_name, column)
490    }
491
492    /// 生成 RENAME COLUMN SQL(Phinx `renameColumn` 等价物)
493    pub fn rename_column_sql(&self, old_name: &str, new_name: &str) -> String {
494        format!(
495            "ALTER TABLE {} RENAME COLUMN {} TO {}",
496            self.table_name, old_name, new_name
497        )
498    }
499
500    /// 生成 CHANGE COLUMN SQL(Phinx `changeColumn` 等价物)
501    pub fn change_column_sql(&self, column: &str, new_type: ColumnType, db_type: DbType) -> String {
502        let type_str = new_type.to_sql(db_type);
503        match db_type {
504            DbType::MySQL => format!(
505                "ALTER TABLE {} MODIFY COLUMN {} {}",
506                self.table_name, column, type_str
507            ),
508            DbType::PostgreSQL | DbType::Sqlite => format!(
509                "ALTER TABLE {} ALTER COLUMN {} TYPE {}",
510                self.table_name, column, type_str
511            ),
512            _ => format!(
513                "ALTER TABLE {} ALTER COLUMN {} TYPE {}",
514                self.table_name, column, type_str
515            ),
516        }
517    }
518
519    /// 生成 TRUNCATE SQL(Phinx `truncate()` 等价物)
520    pub fn truncate_sql(&self) -> String {
521        format!("TRUNCATE TABLE {}", self.table_name)
522    }
523}
524
525/// 构建单列的 SQL 片段
526fn build_column_sql(col: &PhinxColumn, db_type: DbType) -> String {
527    let mut sql = format!("{} {}", col.name, col.col_type.to_sql(db_type));
528
529    // VARCHAR 长度
530    if let Some(limit) = col.options.limit {
531        match col.col_type {
532            ColumnType::String => sql.push_str(&format!("({})", limit)),
533            ColumnType::Decimal => sql.push_str(&format!("({})", limit)),
534            _ => {}
535        }
536    }
537
538    // DECIMAL 精度
539    if let Some((p, s)) = col.options.precision {
540        sql.push_str(&format!("({}, {})", p, s));
541    }
542
543    // 自增
544    if col.options.auto_increment {
545        match db_type {
546            DbType::MySQL => sql.push_str(" AUTO_INCREMENT"),
547            DbType::PostgreSQL => sql.push_str(" GENERATED BY DEFAULT AS IDENTITY"),
548            DbType::Sqlite => sql.push_str(" AUTOINCREMENT"),
549            _ => {}
550        }
551    }
552
553    // NOT NULL
554    if !col.options.nullable {
555        sql.push_str(" NOT NULL");
556    }
557
558    // DEFAULT
559    if let Some(default) = &col.options.default {
560        sql.push_str(&format!(" DEFAULT {}", default));
561    }
562
563    // UNIQUE
564    if col.options.unique {
565        sql.push_str(" UNIQUE");
566    }
567
568    // COMMENT(仅 MySQL 支持)
569    if let Some(comment) = &col.options.comment {
570        if matches!(db_type, DbType::MySQL) {
571            sql.push_str(&format!(" COMMENT '{}'", comment.replace('\'', "''")));
572        }
573    }
574
575    sql
576}
577
578/// 构建索引 SQL 片段
579fn build_index_sql(index: &PhinxIndex) -> String {
580    let unique_str = if index.options.unique { "UNIQUE " } else { "" };
581    let name = index
582        .options
583        .name
584        .clone()
585        .unwrap_or_else(|| index.columns.join("_"));
586    let type_str = index
587        .options
588        .index_type
589        .as_deref()
590        .map(|t| format!(" USING {}", t))
591        .unwrap_or_default();
592    format!(
593        "{}KEY {} ({}){}",
594        unique_str,
595        name,
596        index.columns.join(", "),
597        type_str
598    )
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_column_type_to_sql_mysql() {
607        assert_eq!(ColumnType::Integer.to_sql(DbType::MySQL), "INT");
608        assert_eq!(ColumnType::String.to_sql(DbType::MySQL), "VARCHAR");
609        assert_eq!(ColumnType::Boolean.to_sql(DbType::MySQL), "TINYINT(1)");
610        assert_eq!(ColumnType::Json.to_sql(DbType::MySQL), "JSON");
611        assert_eq!(ColumnType::Binary.to_sql(DbType::MySQL), "BLOB");
612    }
613
614    #[test]
615    fn test_column_type_to_sql_pg() {
616        assert_eq!(ColumnType::Boolean.to_sql(DbType::PostgreSQL), "BOOLEAN");
617        assert_eq!(ColumnType::Json.to_sql(DbType::PostgreSQL), "JSONB");
618        assert_eq!(ColumnType::Binary.to_sql(DbType::PostgreSQL), "BYTEA");
619        assert_eq!(
620            ColumnType::Float.to_sql(DbType::PostgreSQL),
621            "DOUBLE PRECISION"
622        );
623        assert_eq!(ColumnType::Uuid.to_sql(DbType::PostgreSQL), "UUID");
624    }
625
626    #[test]
627    fn test_column_type_to_sql_sqlite() {
628        assert_eq!(ColumnType::Binary.to_sql(DbType::Sqlite), "BLOB");
629        assert_eq!(ColumnType::Json.to_sql(DbType::Sqlite), "TEXT");
630        assert_eq!(ColumnType::DateTime.to_sql(DbType::Sqlite), "DATETIME");
631    }
632
633    #[test]
634    fn test_column_options_chain() {
635        let opts = ColumnOptions::default()
636            .limit(255)
637            .not_null()
638            .default_value("'active'")
639            .unique()
640            .comment("user status");
641        assert_eq!(opts.limit, Some(255));
642        assert!(!opts.nullable);
643        assert_eq!(opts.default, Some("'active'".to_string()));
644        assert!(opts.unique);
645        assert_eq!(opts.comment, Some("user status".to_string()));
646    }
647
648    #[test]
649    fn test_foreign_key_options_chain() {
650        let opts = ForeignKeyOptions::default()
651            .on_delete_cascade()
652            .on_update_cascade();
653        assert_eq!(opts.on_delete, Some("CASCADE".to_string()));
654        assert_eq!(opts.on_update, Some("CASCADE".to_string()));
655    }
656
657    #[test]
658    fn test_phinx_table_basic_create() {
659        let sql = PhinxTable::new("users")
660            .add_column("id", ColumnType::BigIntermediate, |c| {
661                c.auto_increment().not_null()
662            })
663            .add_column("name", ColumnType::String, |c| c.limit(255).not_null())
664            .add_column("email", ColumnType::String, |c| c.limit(255).not_null())
665            .add_column("age", ColumnType::Integer, |c| c)
666            .set_primary_key(vec!["id".to_string()])
667            .create(DbType::MySQL);
668
669        assert!(sql.contains("CREATE TABLE users"));
670        assert!(sql.contains("id BIGINT AUTO_INCREMENT NOT NULL"));
671        assert!(sql.contains("name VARCHAR(255) NOT NULL"));
672        assert!(sql.contains("email VARCHAR(255) NOT NULL"));
673        assert!(sql.contains("age INT"));
674        assert!(sql.contains("PRIMARY KEY (id)"));
675    }
676
677    #[test]
678    fn test_phinx_table_with_index() {
679        let sql = PhinxTable::new("users")
680            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
681            .add_column("email", ColumnType::String, |c| c.limit(255))
682            .add_index(&["email"], |i| i.unique().name("idx_email"))
683            .create(DbType::MySQL);
684
685        assert!(sql.contains("UNIQUE KEY idx_email (email)"));
686    }
687
688    #[test]
689    fn test_phinx_table_with_foreign_key() {
690        let sql = PhinxTable::new("orders")
691            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
692            .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
693            .add_foreign_key("user_id", "users", "id", |fk| fk.on_delete_cascade())
694            .create(DbType::MySQL);
695
696        assert!(sql.contains(
697            "CONSTRAINT fk_orders_user_id_users FOREIGN KEY (user_id) REFERENCES users (id)"
698        ));
699        assert!(sql.contains("ON DELETE CASCADE"));
700    }
701
702    #[test]
703    fn test_phinx_table_foreign_key_normalizes_action_case() {
704        // v0.2.2 修复 C-2:ON DELETE 大小写不敏感,输出统一为大写
705        let sql = PhinxTable::new("orders")
706            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
707            .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
708            .add_foreign_key("user_id", "users", "id", |fk| fk.on_delete("cascade"))
709            .create(DbType::MySQL);
710        assert!(sql.contains("ON DELETE CASCADE"));
711    }
712
713    #[test]
714    #[should_panic(expected = "invalid foreign key column name")]
715    fn test_phinx_table_rejects_sql_injection_in_fk_column() {
716        let _ = PhinxTable::new("orders")
717            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
718            .add_foreign_key("user_id; DROP TABLE", "users", "id", |fk| fk)
719            .create(DbType::MySQL);
720    }
721
722    #[test]
723    #[should_panic(expected = "invalid foreign key referenced table name")]
724    fn test_phinx_table_rejects_sql_injection_in_fk_ref_table() {
725        let _ = PhinxTable::new("orders")
726            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
727            .add_foreign_key("user_id", "users; DROP TABLE users", "id", |fk| fk)
728            .create(DbType::MySQL);
729    }
730
731    #[test]
732    #[should_panic(expected = "invalid ON DELETE action")]
733    fn test_phinx_table_rejects_sql_injection_in_on_delete() {
734        let _ = PhinxTable::new("orders")
735            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
736            .add_foreign_key("user_id", "users", "id", |fk| {
737                fk.on_delete("CASCADE; DROP TABLE users")
738            })
739            .create(DbType::MySQL);
740    }
741
742    #[test]
743    #[should_panic(expected = "invalid table name")]
744    fn test_phinx_table_rejects_sql_injection_in_table_name() {
745        let _ = PhinxTable::new("orders; DROP TABLE orders")
746            .add_column("id", ColumnType::Integer, |c| c.not_null())
747            .create(DbType::MySQL);
748    }
749
750    #[test]
751    fn test_phinx_table_pg_dialect() {
752        let sql = PhinxTable::new("users")
753            .add_column("id", ColumnType::BigIntermediate, |c| {
754                c.auto_increment().not_null()
755            })
756            .add_column("data", ColumnType::Json, |c| c)
757            .add_column("is_active", ColumnType::Boolean, |c| c)
758            .create(DbType::PostgreSQL);
759
760        assert!(sql.contains("id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL"));
761        assert!(sql.contains("data JSONB"));
762        assert!(sql.contains("is_active BOOLEAN"));
763    }
764
765    #[test]
766    fn test_phinx_table_sqlite_dialect() {
767        let sql = PhinxTable::new("users")
768            .add_column("id", ColumnType::BigIntermediate, |c| {
769                c.auto_increment().not_null()
770            })
771            .add_column("data", ColumnType::Json, |c| c)
772            .create(DbType::Sqlite);
773
774        assert!(sql.contains("id BIGINT AUTOINCREMENT NOT NULL"));
775        assert!(sql.contains("data TEXT"));
776    }
777
778    #[test]
779    fn test_phinx_table_if_not_exists() {
780        let sql = PhinxTable::new("users")
781            .if_not_exists()
782            .add_column("id", ColumnType::Integer, |c| c.not_null())
783            .create(DbType::MySQL);
784
785        assert!(sql.contains("CREATE TABLE IF NOT EXISTS users"));
786    }
787
788    #[test]
789    fn test_phinx_table_drop() {
790        let table = PhinxTable::new("users");
791        assert_eq!(table.drop(), "DROP TABLE users");
792    }
793
794    #[test]
795    fn test_phinx_table_truncate() {
796        let table = PhinxTable::new("users");
797        assert_eq!(table.truncate_sql(), "TRUNCATE TABLE users");
798    }
799
800    #[test]
801    fn test_phinx_table_drop_column() {
802        let table = PhinxTable::new("users");
803        assert_eq!(
804            table.drop_column_sql("old_col"),
805            "ALTER TABLE users DROP COLUMN old_col"
806        );
807    }
808
809    #[test]
810    fn test_phinx_table_rename_column() {
811        let table = PhinxTable::new("users");
812        assert_eq!(
813            table.rename_column_sql("old", "new"),
814            "ALTER TABLE users RENAME COLUMN old TO new"
815        );
816    }
817
818    #[test]
819    fn test_phinx_table_change_column_mysql() {
820        let table = PhinxTable::new("users");
821        let sql = table.change_column_sql("name", ColumnType::String, DbType::MySQL);
822        assert_eq!(sql, "ALTER TABLE users MODIFY COLUMN name VARCHAR");
823    }
824
825    #[test]
826    fn test_phinx_table_change_column_pg() {
827        let table = PhinxTable::new("users");
828        let sql = table.change_column_sql("name", ColumnType::String, DbType::PostgreSQL);
829        assert_eq!(sql, "ALTER TABLE users ALTER COLUMN name TYPE VARCHAR");
830    }
831
832    #[test]
833    fn test_phinx_table_decimal_precision() {
834        let sql = PhinxTable::new("products")
835            .add_column("price", ColumnType::Decimal, |c| {
836                c.precision(10, 2).not_null()
837            })
838            .create(DbType::MySQL);
839
840        assert!(sql.contains("price DECIMAL(10, 2) NOT NULL"));
841    }
842
843    #[test]
844    fn test_phinx_table_add_columns_sql() {
845        let table = PhinxTable::new("users")
846            .add_column("email", ColumnType::String, |c| c.limit(255))
847            .add_column("age", ColumnType::Integer, |c| c);
848        let sql = table.add_columns_sql(DbType::MySQL);
849        assert!(sql.contains("ALTER TABLE users"));
850        assert!(sql.contains("ADD COLUMN email VARCHAR(255)"));
851        assert!(sql.contains("ADD COLUMN age INT"));
852    }
853
854    #[test]
855    fn test_phinx_table_compound_primary_key() {
856        let sql = PhinxTable::new("user_roles")
857            .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
858            .add_column("role_id", ColumnType::BigIntermediate, |c| c.not_null())
859            .set_primary_key(vec!["user_id".to_string(), "role_id".to_string()])
860            .create(DbType::MySQL);
861
862        assert!(sql.contains("PRIMARY KEY (user_id, role_id)"));
863    }
864
865    #[test]
866    fn test_phinx_table_comment_mysql() {
867        let sql = PhinxTable::new("users")
868            .add_column("status", ColumnType::String, |c| {
869                c.limit(50).comment("用户状态:active/inactive")
870            })
871            .create(DbType::MySQL);
872
873        assert!(sql.contains("COMMENT '用户状态:active/inactive'"));
874    }
875
876    #[test]
877    fn test_phinx_table_after_mysql() {
878        let table =
879            PhinxTable::new("users").add_column("email", ColumnType::String, |c| c.after("name"));
880        let sql = table.add_columns_sql(DbType::MySQL);
881        assert!(sql.contains("AFTER name"));
882    }
883
884    #[test]
885    fn test_index_options_chain() {
886        let opts = IndexOptions::default()
887            .unique()
888            .name("idx_custom")
889            .index_type("BTREE");
890        assert!(opts.unique);
891        assert_eq!(opts.name, Some("idx_custom".to_string()));
892        assert_eq!(opts.index_type, Some("BTREE".to_string()));
893    }
894}