Skip to main content

sz_orm_migration/
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_migration::phinx_migration::{PhinxTable, ColumnType};
22//! use sz_orm_migration::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 sz_orm_model::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) -> Result<String, sz_orm_model::DbError> {
383        // v0.2.2 修复 C-2:表名/主键列名严格校验
384        sz_orm_model::sql_safety::validate_identifier(&self.table_name, "table")?;
385        if let Some(pk) = &self.primary_key {
386            for col in pk {
387                sz_orm_model::sql_safety::validate_identifier(col, "primary key column")?;
388            }
389        }
390        let mut sql = String::new();
391        sql.push_str("CREATE TABLE ");
392        if self.if_not_exists {
393            sql.push_str("IF NOT EXISTS ");
394        }
395        sql.push_str(&self.table_name);
396        sql.push_str(" (");
397
398        // 列定义
399        let col_defs: Vec<String> = self
400            .columns
401            .iter()
402            .map(|c| build_column_sql(c, db_type))
403            .collect();
404        sql.push_str(&col_defs.join(", "));
405
406        // 主键
407        if let Some(pk) = &self.primary_key {
408            sql.push_str(&format!(", PRIMARY KEY ({})", pk.join(", ")));
409        }
410
411        // 索引
412        for index in &self.indexes {
413            sql.push_str(", ");
414            sql.push_str(&build_index_sql(index));
415        }
416
417        // 外键(约束名含引用表,避免多表指向同一引用表时冲突)
418        // v0.2.2 修复 C-2:FOREIGN KEY 标识符与 ON DELETE/ON UPDATE 动作严格校验,杜绝 SQL 注入
419        for fk in &self.foreign_keys {
420            sz_orm_model::sql_safety::validate_identifier(&fk.column, "foreign key column")?;
421            sz_orm_model::sql_safety::validate_identifier(
422                &fk.referenced_table,
423                "foreign key referenced table",
424            )?;
425            sz_orm_model::sql_safety::validate_identifier(
426                &fk.referenced_column,
427                "foreign key referenced column",
428            )?;
429            if let Some(on_delete) = &fk.options.on_delete {
430                sz_orm_model::sql_safety::validate_fk_action(on_delete)?;
431            }
432            if let Some(on_update) = &fk.options.on_update {
433                sz_orm_model::sql_safety::validate_fk_action(on_update)?;
434            }
435            // 约束名 fk_{table}_{column}_{ref_table} 由 table/column/ref_table 拼接而成,
436            // 此三者均已校验为合法标识符,故约束名也必然合法(仅含字母数字下划线)
437            sql.push_str(&format!(
438                ", CONSTRAINT fk_{}_{}_{} FOREIGN KEY ({}) REFERENCES {} ({})",
439                self.table_name,
440                fk.column,
441                fk.referenced_table,
442                fk.column,
443                fk.referenced_table,
444                fk.referenced_column
445            ));
446            if let Some(on_delete) = &fk.options.on_delete {
447                sql.push_str(&format!(" ON DELETE {}", on_delete.trim().to_uppercase()));
448            }
449            if let Some(on_update) = &fk.options.on_update {
450                sql.push_str(&format!(" ON UPDATE {}", on_update.trim().to_uppercase()));
451            }
452        }
453
454        sql.push(')');
455        Ok(sql)
456    }
457
458    /// 生成 ALTER TABLE 添加列 SQL(Phinx `change()` 等价物的一部分)
459    pub fn add_columns_sql(&self, db_type: DbType) -> String {
460        let add_clauses: Vec<String> = self
461            .columns
462            .iter()
463            .map(|c| {
464                let col_sql = build_column_sql(c, db_type);
465                let after_clause = c
466                    .options
467                    .after
468                    .as_ref()
469                    .map(|a| format!(" AFTER {}", a))
470                    .unwrap_or_default();
471                format!("ADD COLUMN {}{}", col_sql, after_clause)
472            })
473            .collect();
474        format!("ALTER TABLE {} {}", self.table_name, add_clauses.join(", "))
475    }
476
477    /// 生成 DROP TABLE SQL(Phinx `drop()` 等价物)
478    pub fn drop(&self) -> String {
479        format!("DROP TABLE {}", self.table_name)
480    }
481
482    /// 生成 DROP COLUMN SQL(Phinx `removeColumn` 等价物)
483    pub fn drop_column_sql(&self, column: &str) -> String {
484        format!("ALTER TABLE {} DROP COLUMN {}", self.table_name, column)
485    }
486
487    /// 生成 RENAME COLUMN SQL(Phinx `renameColumn` 等价物)
488    pub fn rename_column_sql(&self, old_name: &str, new_name: &str) -> String {
489        format!(
490            "ALTER TABLE {} RENAME COLUMN {} TO {}",
491            self.table_name, old_name, new_name
492        )
493    }
494
495    /// 生成 CHANGE COLUMN SQL(Phinx `changeColumn` 等价物)
496    pub fn change_column_sql(&self, column: &str, new_type: ColumnType, db_type: DbType) -> String {
497        let type_str = new_type.to_sql(db_type);
498        match db_type {
499            DbType::MySQL => format!(
500                "ALTER TABLE {} MODIFY COLUMN {} {}",
501                self.table_name, column, type_str
502            ),
503            DbType::PostgreSQL | DbType::Sqlite => format!(
504                "ALTER TABLE {} ALTER COLUMN {} TYPE {}",
505                self.table_name, column, type_str
506            ),
507            _ => format!(
508                "ALTER TABLE {} ALTER COLUMN {} TYPE {}",
509                self.table_name, column, type_str
510            ),
511        }
512    }
513
514    /// 生成 TRUNCATE SQL(Phinx `truncate()` 等价物)
515    pub fn truncate_sql(&self) -> String {
516        format!("TRUNCATE TABLE {}", self.table_name)
517    }
518}
519
520/// 构建单列的 SQL 片段
521fn build_column_sql(col: &PhinxColumn, db_type: DbType) -> String {
522    let mut sql = format!("{} {}", col.name, col.col_type.to_sql(db_type));
523
524    // VARCHAR 长度
525    if let Some(limit) = col.options.limit {
526        match col.col_type {
527            ColumnType::String => sql.push_str(&format!("({})", limit)),
528            ColumnType::Decimal => sql.push_str(&format!("({})", limit)),
529            _ => {}
530        }
531    }
532
533    // DECIMAL 精度
534    if let Some((p, s)) = col.options.precision {
535        sql.push_str(&format!("({}, {})", p, s));
536    }
537
538    // 自增
539    if col.options.auto_increment {
540        match db_type {
541            DbType::MySQL => sql.push_str(" AUTO_INCREMENT"),
542            DbType::PostgreSQL => sql.push_str(" GENERATED BY DEFAULT AS IDENTITY"),
543            DbType::Sqlite => sql.push_str(" AUTOINCREMENT"),
544            _ => {}
545        }
546    }
547
548    // NOT NULL
549    if !col.options.nullable {
550        sql.push_str(" NOT NULL");
551    }
552
553    // DEFAULT
554    if let Some(default) = &col.options.default {
555        sql.push_str(&format!(" DEFAULT {}", default));
556    }
557
558    // UNIQUE
559    if col.options.unique {
560        sql.push_str(" UNIQUE");
561    }
562
563    // COMMENT(仅 MySQL 支持)
564    if let Some(comment) = &col.options.comment {
565        if matches!(db_type, DbType::MySQL) {
566            sql.push_str(&format!(" COMMENT '{}'", comment.replace('\'', "''")));
567        }
568    }
569
570    sql
571}
572
573/// 构建索引 SQL 片段
574fn build_index_sql(index: &PhinxIndex) -> String {
575    let unique_str = if index.options.unique { "UNIQUE " } else { "" };
576    let name = index
577        .options
578        .name
579        .clone()
580        .unwrap_or_else(|| index.columns.join("_"));
581    let type_str = index
582        .options
583        .index_type
584        .as_deref()
585        .map(|t| format!(" USING {}", t))
586        .unwrap_or_default();
587    format!(
588        "{}KEY {} ({}){}",
589        unique_str,
590        name,
591        index.columns.join(", "),
592        type_str
593    )
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_column_type_to_sql_mysql() {
602        assert_eq!(ColumnType::Integer.to_sql(DbType::MySQL), "INT");
603        assert_eq!(ColumnType::String.to_sql(DbType::MySQL), "VARCHAR");
604        assert_eq!(ColumnType::Boolean.to_sql(DbType::MySQL), "TINYINT(1)");
605        assert_eq!(ColumnType::Json.to_sql(DbType::MySQL), "JSON");
606        assert_eq!(ColumnType::Binary.to_sql(DbType::MySQL), "BLOB");
607    }
608
609    #[test]
610    fn test_column_type_to_sql_pg() {
611        assert_eq!(ColumnType::Boolean.to_sql(DbType::PostgreSQL), "BOOLEAN");
612        assert_eq!(ColumnType::Json.to_sql(DbType::PostgreSQL), "JSONB");
613        assert_eq!(ColumnType::Binary.to_sql(DbType::PostgreSQL), "BYTEA");
614        assert_eq!(
615            ColumnType::Float.to_sql(DbType::PostgreSQL),
616            "DOUBLE PRECISION"
617        );
618        assert_eq!(ColumnType::Uuid.to_sql(DbType::PostgreSQL), "UUID");
619    }
620
621    #[test]
622    fn test_column_type_to_sql_sqlite() {
623        assert_eq!(ColumnType::Binary.to_sql(DbType::Sqlite), "BLOB");
624        assert_eq!(ColumnType::Json.to_sql(DbType::Sqlite), "TEXT");
625        assert_eq!(ColumnType::DateTime.to_sql(DbType::Sqlite), "DATETIME");
626    }
627
628    #[test]
629    fn test_column_options_chain() {
630        let opts = ColumnOptions::default()
631            .limit(255)
632            .not_null()
633            .default_value("'active'")
634            .unique()
635            .comment("user status");
636        assert_eq!(opts.limit, Some(255));
637        assert!(!opts.nullable);
638        assert_eq!(opts.default, Some("'active'".to_string()));
639        assert!(opts.unique);
640        assert_eq!(opts.comment, Some("user status".to_string()));
641    }
642
643    #[test]
644    fn test_foreign_key_options_chain() {
645        let opts = ForeignKeyOptions::default()
646            .on_delete_cascade()
647            .on_update_cascade();
648        assert_eq!(opts.on_delete, Some("CASCADE".to_string()));
649        assert_eq!(opts.on_update, Some("CASCADE".to_string()));
650    }
651
652    #[test]
653    fn test_phinx_table_basic_create() {
654        let sql = PhinxTable::new("users")
655            .add_column("id", ColumnType::BigIntermediate, |c| {
656                c.auto_increment().not_null()
657            })
658            .add_column("name", ColumnType::String, |c| c.limit(255).not_null())
659            .add_column("email", ColumnType::String, |c| c.limit(255).not_null())
660            .add_column("age", ColumnType::Integer, |c| c)
661            .set_primary_key(vec!["id".to_string()])
662            .create(DbType::MySQL)
663            .unwrap();
664
665        assert!(sql.contains("CREATE TABLE users"));
666        assert!(sql.contains("id BIGINT AUTO_INCREMENT NOT NULL"));
667        assert!(sql.contains("name VARCHAR(255) NOT NULL"));
668        assert!(sql.contains("email VARCHAR(255) NOT NULL"));
669        assert!(sql.contains("age INT"));
670        assert!(sql.contains("PRIMARY KEY (id)"));
671    }
672
673    #[test]
674    fn test_phinx_table_with_index() {
675        let sql = PhinxTable::new("users")
676            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
677            .add_column("email", ColumnType::String, |c| c.limit(255))
678            .add_index(&["email"], |i| i.unique().name("idx_email"))
679            .create(DbType::MySQL)
680            .unwrap();
681
682        assert!(sql.contains("UNIQUE KEY idx_email (email)"));
683    }
684
685    #[test]
686    fn test_phinx_table_with_foreign_key() {
687        let sql = PhinxTable::new("orders")
688            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
689            .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
690            .add_foreign_key("user_id", "users", "id", |fk| fk.on_delete_cascade())
691            .create(DbType::MySQL)
692            .unwrap();
693
694        assert!(sql.contains(
695            "CONSTRAINT fk_orders_user_id_users FOREIGN KEY (user_id) REFERENCES users (id)"
696        ));
697        assert!(sql.contains("ON DELETE CASCADE"));
698    }
699
700    #[test]
701    fn test_phinx_table_foreign_key_normalizes_action_case() {
702        // v0.2.2 修复 C-2:ON DELETE 大小写不敏感,输出统一为大写
703        let sql = PhinxTable::new("orders")
704            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
705            .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
706            .add_foreign_key("user_id", "users", "id", |fk| fk.on_delete("cascade"))
707            .create(DbType::MySQL)
708            .unwrap();
709        assert!(sql.contains("ON DELETE CASCADE"));
710    }
711
712    #[test]
713    fn test_phinx_table_rejects_sql_injection_in_fk_column() {
714        let err = PhinxTable::new("orders")
715            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
716            .add_foreign_key("user_id; DROP TABLE", "users", "id", |fk| fk)
717            .create(DbType::MySQL)
718            .unwrap_err();
719        assert!(matches!(err, sz_orm_model::DbError::InvalidInput(_)));
720    }
721
722    #[test]
723    fn test_phinx_table_rejects_sql_injection_in_fk_ref_table() {
724        let err = PhinxTable::new("orders")
725            .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
726            .add_foreign_key("user_id", "users; DROP TABLE users", "id", |fk| fk)
727            .create(DbType::MySQL)
728            .unwrap_err();
729        assert!(matches!(err, sz_orm_model::DbError::InvalidInput(_)));
730    }
731
732    #[test]
733    fn test_phinx_table_rejects_sql_injection_in_on_delete() {
734        let err = 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            .unwrap_err();
741        assert!(matches!(err, sz_orm_model::DbError::InvalidInput(_)));
742    }
743
744    #[test]
745    fn test_phinx_table_rejects_sql_injection_in_table_name() {
746        let err = PhinxTable::new("orders; DROP TABLE orders")
747            .add_column("id", ColumnType::Integer, |c| c.not_null())
748            .create(DbType::MySQL)
749            .unwrap_err();
750        assert!(matches!(err, sz_orm_model::DbError::InvalidInput(_)));
751    }
752
753    #[test]
754    fn test_phinx_table_pg_dialect() {
755        let sql = PhinxTable::new("users")
756            .add_column("id", ColumnType::BigIntermediate, |c| {
757                c.auto_increment().not_null()
758            })
759            .add_column("data", ColumnType::Json, |c| c)
760            .add_column("is_active", ColumnType::Boolean, |c| c)
761            .create(DbType::PostgreSQL)
762            .unwrap();
763
764        assert!(sql.contains("id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL"));
765        assert!(sql.contains("data JSONB"));
766        assert!(sql.contains("is_active BOOLEAN"));
767    }
768
769    #[test]
770    fn test_phinx_table_sqlite_dialect() {
771        let sql = PhinxTable::new("users")
772            .add_column("id", ColumnType::BigIntermediate, |c| {
773                c.auto_increment().not_null()
774            })
775            .add_column("data", ColumnType::Json, |c| c)
776            .create(DbType::Sqlite)
777            .unwrap();
778
779        assert!(sql.contains("id BIGINT AUTOINCREMENT NOT NULL"));
780        assert!(sql.contains("data TEXT"));
781    }
782
783    #[test]
784    fn test_phinx_table_if_not_exists() {
785        let sql = PhinxTable::new("users")
786            .if_not_exists()
787            .add_column("id", ColumnType::Integer, |c| c.not_null())
788            .create(DbType::MySQL)
789            .unwrap();
790
791        assert!(sql.contains("CREATE TABLE IF NOT EXISTS users"));
792    }
793
794    #[test]
795    fn test_phinx_table_drop() {
796        let table = PhinxTable::new("users");
797        assert_eq!(table.drop(), "DROP TABLE users");
798    }
799
800    #[test]
801    fn test_phinx_table_truncate() {
802        let table = PhinxTable::new("users");
803        assert_eq!(table.truncate_sql(), "TRUNCATE TABLE users");
804    }
805
806    #[test]
807    fn test_phinx_table_drop_column() {
808        let table = PhinxTable::new("users");
809        assert_eq!(
810            table.drop_column_sql("old_col"),
811            "ALTER TABLE users DROP COLUMN old_col"
812        );
813    }
814
815    #[test]
816    fn test_phinx_table_rename_column() {
817        let table = PhinxTable::new("users");
818        assert_eq!(
819            table.rename_column_sql("old", "new"),
820            "ALTER TABLE users RENAME COLUMN old TO new"
821        );
822    }
823
824    #[test]
825    fn test_phinx_table_change_column_mysql() {
826        let table = PhinxTable::new("users");
827        let sql = table.change_column_sql("name", ColumnType::String, DbType::MySQL);
828        assert_eq!(sql, "ALTER TABLE users MODIFY COLUMN name VARCHAR");
829    }
830
831    #[test]
832    fn test_phinx_table_change_column_pg() {
833        let table = PhinxTable::new("users");
834        let sql = table.change_column_sql("name", ColumnType::String, DbType::PostgreSQL);
835        assert_eq!(sql, "ALTER TABLE users ALTER COLUMN name TYPE VARCHAR");
836    }
837
838    #[test]
839    fn test_phinx_table_decimal_precision() {
840        let sql = PhinxTable::new("products")
841            .add_column("price", ColumnType::Decimal, |c| {
842                c.precision(10, 2).not_null()
843            })
844            .create(DbType::MySQL)
845            .unwrap();
846
847        assert!(sql.contains("price DECIMAL(10, 2) NOT NULL"));
848    }
849
850    #[test]
851    fn test_phinx_table_add_columns_sql() {
852        let table = PhinxTable::new("users")
853            .add_column("email", ColumnType::String, |c| c.limit(255))
854            .add_column("age", ColumnType::Integer, |c| c);
855        let sql = table.add_columns_sql(DbType::MySQL);
856        assert!(sql.contains("ALTER TABLE users"));
857        assert!(sql.contains("ADD COLUMN email VARCHAR(255)"));
858        assert!(sql.contains("ADD COLUMN age INT"));
859    }
860
861    #[test]
862    fn test_phinx_table_compound_primary_key() {
863        let sql = PhinxTable::new("user_roles")
864            .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
865            .add_column("role_id", ColumnType::BigIntermediate, |c| c.not_null())
866            .set_primary_key(vec!["user_id".to_string(), "role_id".to_string()])
867            .create(DbType::MySQL)
868            .unwrap();
869
870        assert!(sql.contains("PRIMARY KEY (user_id, role_id)"));
871    }
872
873    #[test]
874    fn test_phinx_table_comment_mysql() {
875        let sql = PhinxTable::new("users")
876            .add_column("status", ColumnType::String, |c| {
877                c.limit(50).comment("用户状态:active/inactive")
878            })
879            .create(DbType::MySQL)
880            .unwrap();
881
882        assert!(sql.contains("COMMENT '用户状态:active/inactive'"));
883    }
884
885    #[test]
886    fn test_phinx_table_after_mysql() {
887        let table =
888            PhinxTable::new("users").add_column("email", ColumnType::String, |c| c.after("name"));
889        let sql = table.add_columns_sql(DbType::MySQL);
890        assert!(sql.contains("AFTER name"));
891    }
892
893    #[test]
894    fn test_index_options_chain() {
895        let opts = IndexOptions::default()
896            .unique()
897            .name("idx_custom")
898            .index_type("BTREE");
899        assert!(opts.unique);
900        assert_eq!(opts.name, Some("idx_custom".to_string()));
901        assert_eq!(opts.index_type, Some("BTREE".to_string()));
902    }
903}