1use crate::db_type::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>,
107 pub nullable: bool,
109 pub default: Option<String>,
111 pub auto_increment: bool,
113 pub unique: bool,
115 pub comment: Option<String>,
117 pub precision: Option<(u32, u32)>,
119 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 pub fn limit(mut self, len: usize) -> Self {
141 self.limit = Some(len);
142 self
143 }
144
145 pub fn not_null(mut self) -> Self {
147 self.nullable = false;
148 self
149 }
150
151 pub fn default_value(mut self, value: impl Into<String>) -> Self {
153 self.default = Some(value.into());
154 self
155 }
156
157 pub fn auto_increment(mut self) -> Self {
159 self.auto_increment = true;
160 self
161 }
162
163 pub fn unique(mut self) -> Self {
165 self.unique = true;
166 self
167 }
168
169 pub fn comment(mut self, comment: impl Into<String>) -> Self {
171 self.comment = Some(comment.into());
172 self
173 }
174
175 pub fn precision(mut self, p: u32, s: u32) -> Self {
177 self.precision = Some((p, s));
178 self
179 }
180
181 pub fn after(mut self, column: impl Into<String>) -> Self {
183 self.after = Some(column.into());
184 self
185 }
186}
187
188#[derive(Debug, Clone, Default)]
190pub struct IndexOptions {
191 pub unique: bool,
193 pub name: Option<String>,
195 pub index_type: Option<String>,
197}
198
199impl IndexOptions {
200 pub fn unique(mut self) -> Self {
202 self.unique = true;
203 self
204 }
205
206 pub fn name(mut self, n: impl Into<String>) -> Self {
208 self.name = Some(n.into());
209 self
210 }
211
212 pub fn index_type(mut self, t: impl Into<String>) -> Self {
214 self.index_type = Some(t.into());
215 self
216 }
217}
218
219#[derive(Debug, Clone, Default)]
221pub struct ForeignKeyOptions {
222 pub on_delete: Option<String>,
224 pub on_update: Option<String>,
226}
227
228impl ForeignKeyOptions {
229 pub fn on_delete_cascade(mut self) -> Self {
231 self.on_delete = Some("CASCADE".to_string());
232 self
233 }
234
235 pub fn on_delete_set_null(mut self) -> Self {
237 self.on_delete = Some("SET NULL".to_string());
238 self
239 }
240
241 pub fn on_delete_restrict(mut self) -> Self {
243 self.on_delete = Some("RESTRICT".to_string());
244 self
245 }
246
247 pub fn on_delete_no_action(mut self) -> Self {
249 self.on_delete = Some("NO ACTION".to_string());
250 self
251 }
252
253 pub fn on_update_cascade(mut self) -> Self {
255 self.on_update = Some("CASCADE".to_string());
256 self
257 }
258
259 pub fn on_delete(mut self, action: impl Into<String>) -> Self {
261 self.on_delete = Some(action.into());
262 self
263 }
264
265 pub fn on_update(mut self, action: impl Into<String>) -> Self {
267 self.on_update = Some(action.into());
268 self
269 }
270}
271
272#[derive(Debug, Clone)]
274struct PhinxColumn {
275 name: String,
276 col_type: ColumnType,
277 options: ColumnOptions,
278}
279
280#[derive(Debug, Clone)]
282struct PhinxIndex {
283 columns: Vec<String>,
284 options: IndexOptions,
285}
286
287#[derive(Debug, Clone)]
289struct PhinxForeignKey {
290 column: String,
291 referenced_table: String,
292 referenced_column: String,
293 options: ForeignKeyOptions,
294}
295
296pub 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 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 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 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 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 pub fn set_primary_key(mut self, columns: Vec<String>) -> Self {
384 self.primary_key = Some(columns);
385 self
386 }
387
388 pub fn if_not_exists(mut self) -> Self {
390 self.if_not_exists = true;
391 self
392 }
393
394 pub fn create(&self, db_type: DbType) -> Result<String, crate::DbError> {
396 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 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 if let Some(pk) = &self.primary_key {
421 sql.push_str(&format!(", PRIMARY KEY ({})", pk.join(", ")));
422 }
423
424 for index in &self.indexes {
426 sql.push_str(", ");
427 sql.push_str(&build_index_sql(index));
428 }
429
430 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 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 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 pub fn drop(&self) -> String {
492 format!("DROP TABLE {}", self.table_name)
493 }
494
495 pub fn drop_column_sql(&self, column: &str) -> String {
497 format!("ALTER TABLE {} DROP COLUMN {}", self.table_name, column)
498 }
499
500 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 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 pub fn truncate_sql(&self) -> String {
529 format!("TRUNCATE TABLE {}", self.table_name)
530 }
531}
532
533fn 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 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 if let Some((p, s)) = col.options.precision {
548 sql.push_str(&format!("({}, {})", p, s));
549 }
550
551 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 if !col.options.nullable {
563 sql.push_str(" NOT NULL");
564 }
565
566 if let Some(default) = &col.options.default {
568 sql.push_str(&format!(" DEFAULT {}", default));
569 }
570
571 if col.options.unique {
573 sql.push_str(" UNIQUE");
574 }
575
576 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
586fn 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 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}