1use sz_orm_model::DbType;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ColumnType {
40 BigIntermediate,
42 Binary,
44 Boolean,
46 Date,
48 DateTime,
50 Decimal,
52 Float,
54 Integer,
56 Json,
58 String,
60 Text,
62 Time,
64 Timestamp,
66 Uuid,
68}
69
70impl ColumnType {
71 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#[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 pub fn limit(mut self, len: usize) -> Self {
133 self.limit = Some(len);
134 self
135 }
136
137 pub fn not_null(mut self) -> Self {
139 self.nullable = false;
140 self
141 }
142
143 pub fn default_value(mut self, value: impl Into<String>) -> Self {
145 self.default = Some(value.into());
146 self
147 }
148
149 pub fn auto_increment(mut self) -> Self {
151 self.auto_increment = true;
152 self
153 }
154
155 pub fn unique(mut self) -> Self {
157 self.unique = true;
158 self
159 }
160
161 pub fn comment(mut self, comment: impl Into<String>) -> Self {
163 self.comment = Some(comment.into());
164 self
165 }
166
167 pub fn precision(mut self, p: u32, s: u32) -> Self {
169 self.precision = Some((p, s));
170 self
171 }
172
173 pub fn after(mut self, column: impl Into<String>) -> Self {
175 self.after = Some(column.into());
176 self
177 }
178}
179
180#[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 pub fn unique(mut self) -> Self {
191 self.unique = true;
192 self
193 }
194
195 pub fn name(mut self, n: impl Into<String>) -> Self {
197 self.name = Some(n.into());
198 self
199 }
200
201 pub fn index_type(mut self, t: impl Into<String>) -> Self {
203 self.index_type = Some(t.into());
204 self
205 }
206}
207
208#[derive(Debug, Clone, Default)]
210pub struct ForeignKeyOptions {
211 pub on_delete: Option<String>,
212 pub on_update: Option<String>,
213}
214
215impl ForeignKeyOptions {
216 pub fn on_delete_cascade(mut self) -> Self {
218 self.on_delete = Some("CASCADE".to_string());
219 self
220 }
221
222 pub fn on_delete_set_null(mut self) -> Self {
224 self.on_delete = Some("SET NULL".to_string());
225 self
226 }
227
228 pub fn on_delete_restrict(mut self) -> Self {
230 self.on_delete = Some("RESTRICT".to_string());
231 self
232 }
233
234 pub fn on_delete_no_action(mut self) -> Self {
236 self.on_delete = Some("NO ACTION".to_string());
237 self
238 }
239
240 pub fn on_update_cascade(mut self) -> Self {
242 self.on_update = Some("CASCADE".to_string());
243 self
244 }
245
246 pub fn on_delete(mut self, action: impl Into<String>) -> Self {
248 self.on_delete = Some(action.into());
249 self
250 }
251
252 pub fn on_update(mut self, action: impl Into<String>) -> Self {
254 self.on_update = Some(action.into());
255 self
256 }
257}
258
259#[derive(Debug, Clone)]
261struct PhinxColumn {
262 name: String,
263 col_type: ColumnType,
264 options: ColumnOptions,
265}
266
267#[derive(Debug, Clone)]
269struct PhinxIndex {
270 columns: Vec<String>,
271 options: IndexOptions,
272}
273
274#[derive(Debug, Clone)]
276struct PhinxForeignKey {
277 column: String,
278 referenced_table: String,
279 referenced_column: String,
280 options: ForeignKeyOptions,
281}
282
283pub 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 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 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 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 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 pub fn set_primary_key(mut self, columns: Vec<String>) -> Self {
371 self.primary_key = Some(columns);
372 self
373 }
374
375 pub fn if_not_exists(mut self) -> Self {
377 self.if_not_exists = true;
378 self
379 }
380
381 pub fn create(&self, db_type: DbType) -> Result<String, sz_orm_model::DbError> {
383 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 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 if let Some(pk) = &self.primary_key {
408 sql.push_str(&format!(", PRIMARY KEY ({})", pk.join(", ")));
409 }
410
411 for index in &self.indexes {
413 sql.push_str(", ");
414 sql.push_str(&build_index_sql(index));
415 }
416
417 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 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 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 pub fn drop(&self) -> String {
479 format!("DROP TABLE {}", self.table_name)
480 }
481
482 pub fn drop_column_sql(&self, column: &str) -> String {
484 format!("ALTER TABLE {} DROP COLUMN {}", self.table_name, column)
485 }
486
487 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 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 pub fn truncate_sql(&self) -> String {
516 format!("TRUNCATE TABLE {}", self.table_name)
517 }
518}
519
520fn 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 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 if let Some((p, s)) = col.options.precision {
535 sql.push_str(&format!("({}, {})", p, s));
536 }
537
538 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 if !col.options.nullable {
550 sql.push_str(" NOT NULL");
551 }
552
553 if let Some(default) = &col.options.default {
555 sql.push_str(&format!(" DEFAULT {}", default));
556 }
557
558 if col.options.unique {
560 sql.push_str(" UNIQUE");
561 }
562
563 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
573fn 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 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}