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