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