1use crate::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, crate::DbError> {
383 crate::sql_safety::validate_identifier(&self.table_name, "table")?;
385 if let Some(pk) = &self.primary_key {
386 for col in pk {
387 crate::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 crate::sql_safety::validate_identifier(&fk.column, "foreign key column")?;
421 crate::sql_safety::validate_identifier(
422 &fk.referenced_table,
423 "foreign key referenced table",
424 )?;
425 crate::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 crate::sql_safety::validate_fk_action(on_delete)?;
431 }
432 if let Some(on_update) = &fk.options.on_update {
433 crate::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 use crate::DbError;
600
601 #[test]
602 fn test_column_type_to_sql_mysql() {
603 assert_eq!(ColumnType::Integer.to_sql(DbType::MySQL), "INT");
604 assert_eq!(ColumnType::String.to_sql(DbType::MySQL), "VARCHAR");
605 assert_eq!(ColumnType::Boolean.to_sql(DbType::MySQL), "TINYINT(1)");
606 assert_eq!(ColumnType::Json.to_sql(DbType::MySQL), "JSON");
607 assert_eq!(ColumnType::Binary.to_sql(DbType::MySQL), "BLOB");
608 }
609
610 #[test]
611 fn test_column_type_to_sql_pg() {
612 assert_eq!(ColumnType::Boolean.to_sql(DbType::PostgreSQL), "BOOLEAN");
613 assert_eq!(ColumnType::Json.to_sql(DbType::PostgreSQL), "JSONB");
614 assert_eq!(ColumnType::Binary.to_sql(DbType::PostgreSQL), "BYTEA");
615 assert_eq!(
616 ColumnType::Float.to_sql(DbType::PostgreSQL),
617 "DOUBLE PRECISION"
618 );
619 assert_eq!(ColumnType::Uuid.to_sql(DbType::PostgreSQL), "UUID");
620 }
621
622 #[test]
623 fn test_column_type_to_sql_sqlite() {
624 assert_eq!(ColumnType::Binary.to_sql(DbType::Sqlite), "BLOB");
625 assert_eq!(ColumnType::Json.to_sql(DbType::Sqlite), "TEXT");
626 assert_eq!(ColumnType::DateTime.to_sql(DbType::Sqlite), "DATETIME");
627 }
628
629 #[test]
630 fn test_column_options_chain() {
631 let opts = ColumnOptions::default()
632 .limit(255)
633 .not_null()
634 .default_value("'active'")
635 .unique()
636 .comment("user status");
637 assert_eq!(opts.limit, Some(255));
638 assert!(!opts.nullable);
639 assert_eq!(opts.default, Some("'active'".to_string()));
640 assert!(opts.unique);
641 assert_eq!(opts.comment, Some("user status".to_string()));
642 }
643
644 #[test]
645 fn test_foreign_key_options_chain() {
646 let opts = ForeignKeyOptions::default()
647 .on_delete_cascade()
648 .on_update_cascade();
649 assert_eq!(opts.on_delete, Some("CASCADE".to_string()));
650 assert_eq!(opts.on_update, Some("CASCADE".to_string()));
651 }
652
653 #[test]
654 fn test_phinx_table_basic_create() {
655 let sql = PhinxTable::new("users")
656 .add_column("id", ColumnType::BigIntermediate, |c| {
657 c.auto_increment().not_null()
658 })
659 .add_column("name", ColumnType::String, |c| c.limit(255).not_null())
660 .add_column("email", ColumnType::String, |c| c.limit(255).not_null())
661 .add_column("age", ColumnType::Integer, |c| c)
662 .set_primary_key(vec!["id".to_string()])
663 .create(DbType::MySQL)
664 .unwrap();
665
666 assert!(sql.contains("CREATE TABLE users"));
667 assert!(sql.contains("id BIGINT AUTO_INCREMENT NOT NULL"));
668 assert!(sql.contains("name VARCHAR(255) NOT NULL"));
669 assert!(sql.contains("email VARCHAR(255) NOT NULL"));
670 assert!(sql.contains("age INT"));
671 assert!(sql.contains("PRIMARY KEY (id)"));
672 }
673
674 #[test]
675 fn test_phinx_table_with_index() {
676 let sql = PhinxTable::new("users")
677 .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
678 .add_column("email", ColumnType::String, |c| c.limit(255))
679 .add_index(&["email"], |i| i.unique().name("idx_email"))
680 .create(DbType::MySQL)
681 .unwrap();
682
683 assert!(sql.contains("UNIQUE KEY idx_email (email)"));
684 }
685
686 #[test]
687 fn test_phinx_table_with_foreign_key() {
688 let sql = PhinxTable::new("orders")
689 .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
690 .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
691 .add_foreign_key("user_id", "users", "id", |fk| fk.on_delete_cascade())
692 .create(DbType::MySQL)
693 .unwrap();
694
695 assert!(sql.contains(
696 "CONSTRAINT fk_orders_user_id_users FOREIGN KEY (user_id) REFERENCES users (id)"
697 ));
698 assert!(sql.contains("ON DELETE CASCADE"));
699 }
700
701 #[test]
702 fn test_phinx_table_foreign_key_normalizes_action_case() {
703 let sql = PhinxTable::new("orders")
705 .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
706 .add_column("user_id", ColumnType::BigIntermediate, |c| c.not_null())
707 .add_foreign_key("user_id", "users", "id", |fk| fk.on_delete("cascade"))
708 .create(DbType::MySQL)
709 .unwrap();
710 assert!(sql.contains("ON DELETE CASCADE"));
711 }
712
713 #[test]
714 fn test_phinx_table_rejects_sql_injection_in_fk_column() {
715 let err = PhinxTable::new("orders")
716 .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
717 .add_foreign_key("user_id; DROP TABLE", "users", "id", |fk| fk)
718 .create(DbType::MySQL)
719 .unwrap_err();
720 assert!(
721 matches!(err, DbError::InvalidInput(ref msg) if msg.contains("invalid foreign key column")),
722 "expected InvalidInput about fk column, got: {err}"
723 );
724 }
725
726 #[test]
727 fn test_phinx_table_rejects_sql_injection_in_fk_ref_table() {
728 let err = PhinxTable::new("orders")
729 .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
730 .add_foreign_key("user_id", "users; DROP TABLE users", "id", |fk| fk)
731 .create(DbType::MySQL)
732 .unwrap_err();
733 assert!(
734 matches!(err, DbError::InvalidInput(ref msg) if msg.contains("invalid foreign key referenced table")),
735 "expected InvalidInput about fk ref table, got: {err}"
736 );
737 }
738
739 #[test]
740 fn test_phinx_table_rejects_sql_injection_in_on_delete() {
741 let err = PhinxTable::new("orders")
742 .add_column("id", ColumnType::BigIntermediate, |c| c.auto_increment())
743 .add_foreign_key("user_id", "users", "id", |fk| {
744 fk.on_delete("CASCADE; DROP TABLE users")
745 })
746 .create(DbType::MySQL)
747 .unwrap_err();
748 assert!(
749 matches!(err, DbError::InvalidInput(ref msg) if msg.contains("invalid foreign key action")),
750 "expected InvalidInput about fk action, got: {err}"
751 );
752 }
753
754 #[test]
755 fn test_phinx_table_rejects_sql_injection_in_table_name() {
756 let err = PhinxTable::new("orders; DROP TABLE orders")
757 .add_column("id", ColumnType::Integer, |c| c.not_null())
758 .create(DbType::MySQL)
759 .unwrap_err();
760 assert!(
761 matches!(err, DbError::InvalidInput(ref msg) if msg.contains("invalid table")),
762 "expected InvalidInput about table name, got: {err}"
763 );
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}