1use std::fmt;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37pub enum HistoryDbType {
38 MySQL,
40 #[default]
42 PostgreSQL,
43 SQLite,
45 Oracle,
47 SqlServer,
49}
50
51impl HistoryDbType {
52 pub fn parse_db_type(s: &str) -> Option<Self> {
63 match s.to_lowercase().as_str() {
64 "mysql" | "mariadb" | "oceanbase" | "tidb" | "polardb" => Some(Self::MySQL),
65 "postgres" | "postgresql" | "kingbase" | "gaussdb" | "clickhouse" => {
66 Some(Self::PostgreSQL)
67 }
68 "sqlite" => Some(Self::SQLite),
69 "oracle" | "dameng" => Some(Self::Oracle),
70 "mssql" | "sqlserver" | "sybase" => Some(Self::SqlServer),
71 _ => None,
72 }
73 }
74
75 fn placeholder(&self, index: usize) -> String {
77 match self {
78 Self::PostgreSQL => format!("${}", index),
79 _ => "?".to_string(),
80 }
81 }
82}
83
84impl fmt::Display for HistoryDbType {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 Self::MySQL => write!(f, "mysql"),
88 Self::PostgreSQL => write!(f, "postgres"),
89 Self::SQLite => write!(f, "sqlite"),
90 Self::Oracle => write!(f, "oracle"),
91 Self::SqlServer => write!(f, "mssql"),
92 }
93 }
94}
95
96fn validate_identifier(name: &str, label: &str) -> Result<(), String> {
101 if name.is_empty() {
102 return Err(format!("{} cannot be empty", label));
103 }
104 if name.len() > 64 {
105 return Err(format!("{} too long (max 64 chars): {}", label, name));
106 }
107 for segment in name.split('.') {
109 if segment.is_empty() {
110 return Err(format!("{} has empty segment: {}", label, name));
111 }
112 let chars: Vec<char> = segment.chars().collect();
113 if chars[0].is_ascii_digit() {
114 return Err(format!("{} cannot start with digit: {}", label, name));
115 }
116 for c in chars {
117 if !c.is_ascii_alphanumeric() && c != '_' {
118 return Err(format!("{} contains invalid char '{}': {}", label, c, name));
119 }
120 }
121 }
122 Ok(())
123}
124
125#[derive(Debug, Clone, Default)]
130pub struct MigrationHistory;
131
132impl MigrationHistory {
133 pub fn create_table_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
144 validate_identifier(table_name, "migration history table name")?;
145
146 let ddl = match db_type {
147 HistoryDbType::MySQL => format!(
149 "CREATE TABLE IF NOT EXISTS {} (\n\
150 \x20 id BIGINT NOT NULL AUTO_INCREMENT,\n\
151 \x20 version VARCHAR(255) NOT NULL,\n\
152 \x20 name VARCHAR(255) NOT NULL,\n\
153 \x20 batch INT NOT NULL,\n\
154 \x20 executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\
155 \x20 PRIMARY KEY (id),\n\
156 \x20 UNIQUE KEY uk_version (version)\n\
157 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
158 table_name
159 ),
160 HistoryDbType::PostgreSQL => format!(
162 "CREATE TABLE IF NOT EXISTS {} (\n\
163 \x20 id BIGSERIAL PRIMARY KEY,\n\
164 \x20 version VARCHAR(255) NOT NULL UNIQUE,\n\
165 \x20 name VARCHAR(255) NOT NULL,\n\
166 \x20 batch INT NOT NULL,\n\
167 \x20 executed_at TIMESTAMP NOT NULL DEFAULT NOW()\n\
168 )",
169 table_name
170 ),
171 HistoryDbType::SQLite => format!(
173 "CREATE TABLE IF NOT EXISTS {} (\n\
174 \x20 id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
175 \x20 version VARCHAR(255) NOT NULL UNIQUE,\n\
176 \x20 name VARCHAR(255) NOT NULL,\n\
177 \x20 batch INT NOT NULL,\n\
178 \x20 executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n\
179 )",
180 table_name
181 ),
182 HistoryDbType::Oracle => format!(
184 "CREATE TABLE {} (\n\
185 \x20 id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\
186 \x20 version VARCHAR2(255) NOT NULL UNIQUE,\n\
187 \x20 name VARCHAR2(255) NOT NULL,\n\
188 \x20 batch NUMBER(10) NOT NULL,\n\
189 \x20 executed_at TIMESTAMP NOT NULL DEFAULT SYSTIMESTAMP\n\
190 )",
191 table_name
192 ),
193 HistoryDbType::SqlServer => format!(
195 "CREATE TABLE {} (\n\
196 \x20 id BIGINT IDENTITY(1,1) PRIMARY KEY,\n\
197 \x20 version NVARCHAR(255) NOT NULL UNIQUE,\n\
198 \x20 name NVARCHAR(255) NOT NULL,\n\
199 \x20 batch INT NOT NULL,\n\
200 \x20 executed_at DATETIME NOT NULL DEFAULT GETDATE()\n\
201 )",
202 table_name
203 ),
204 };
205 Ok(ddl)
206 }
207
208 pub fn insert_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
217 validate_identifier(table_name, "migration history table name")?;
218
219 let p1 = db_type.placeholder(1);
220 let p2 = db_type.placeholder(2);
221 let p3 = db_type.placeholder(3);
222 Ok(format!(
223 "INSERT INTO {} (version, name, batch) VALUES ({}, {}, {})",
224 table_name, p1, p2, p3
225 ))
226 }
227
228 pub fn delete_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
235 validate_identifier(table_name, "migration history table name")?;
236 let p1 = db_type.placeholder(1);
237 Ok(format!("DELETE FROM {} WHERE version = {}", table_name, p1))
238 }
239
240 pub fn list_sql(table_name: &str) -> Result<String, String> {
244 validate_identifier(table_name, "migration history table name")?;
245 Ok(format!(
246 "SELECT version, name, batch, executed_at FROM {} ORDER BY version ASC",
247 table_name
248 ))
249 }
250
251 pub fn max_batch_sql(table_name: &str) -> Result<String, String> {
255 validate_identifier(table_name, "migration history table name")?;
256 Ok(format!(
257 "SELECT COALESCE(MAX(batch), 0) AS max_batch FROM {}",
258 table_name
259 ))
260 }
261
262 pub fn exists_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
266 validate_identifier(table_name, "migration history table name")?;
267 let p1 = db_type.placeholder(1);
268 Ok(format!(
269 "SELECT COUNT(*) AS cnt FROM {} WHERE version = {}",
270 table_name, p1
271 ))
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct MigrationHistoryRecord {
278 pub version: String,
280 pub name: String,
282 pub batch: i32,
284 pub executed_at: String,
286}
287
288impl MigrationHistoryRecord {
289 pub fn new(version: impl Into<String>, name: impl Into<String>, batch: i32) -> Self {
291 Self {
292 version: version.into(),
293 name: name.into(),
294 batch,
295 executed_at: String::new(),
296 }
297 }
298
299 pub fn with_executed_at(mut self, executed_at: impl Into<String>) -> Self {
301 self.executed_at = executed_at.into();
302 self
303 }
304}
305
306#[derive(Debug, Clone)]
308pub struct MigrationHistoryConfig {
309 pub table_name: String,
311 pub db_type: HistoryDbType,
313}
314
315impl Default for MigrationHistoryConfig {
316 fn default() -> Self {
317 Self {
318 table_name: "__migrations".to_string(),
319 db_type: HistoryDbType::default(),
320 }
321 }
322}
323
324impl MigrationHistoryConfig {
325 pub fn mysql() -> Self {
327 Self {
328 table_name: "__migrations".to_string(),
329 db_type: HistoryDbType::MySQL,
330 }
331 }
332
333 pub fn postgres() -> Self {
335 Self {
336 table_name: "__migrations".to_string(),
337 db_type: HistoryDbType::PostgreSQL,
338 }
339 }
340
341 pub fn sqlite() -> Self {
343 Self {
344 table_name: "__migrations".to_string(),
345 db_type: HistoryDbType::SQLite,
346 }
347 }
348
349 pub fn with_table_name(mut self, name: impl Into<String>) -> Self {
351 self.table_name = name.into();
352 self
353 }
354}
355
356#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
369 fn test_db_type_from_str_mysql_family() {
370 assert_eq!(
371 HistoryDbType::parse_db_type("mysql"),
372 Some(HistoryDbType::MySQL)
373 );
374 assert_eq!(
375 HistoryDbType::parse_db_type("MySQL"),
376 Some(HistoryDbType::MySQL)
377 );
378 assert_eq!(
379 HistoryDbType::parse_db_type("mariadb"),
380 Some(HistoryDbType::MySQL)
381 );
382 assert_eq!(
383 HistoryDbType::parse_db_type("oceanbase"),
384 Some(HistoryDbType::MySQL)
385 );
386 assert_eq!(
387 HistoryDbType::parse_db_type("tidb"),
388 Some(HistoryDbType::MySQL)
389 );
390 assert_eq!(
391 HistoryDbType::parse_db_type("polardb"),
392 Some(HistoryDbType::MySQL)
393 );
394 }
395
396 #[test]
397 fn test_db_type_from_str_pg_family() {
398 assert_eq!(
399 HistoryDbType::parse_db_type("postgres"),
400 Some(HistoryDbType::PostgreSQL)
401 );
402 assert_eq!(
403 HistoryDbType::parse_db_type("postgresql"),
404 Some(HistoryDbType::PostgreSQL)
405 );
406 assert_eq!(
407 HistoryDbType::parse_db_type("kingbase"),
408 Some(HistoryDbType::PostgreSQL)
409 );
410 assert_eq!(
411 HistoryDbType::parse_db_type("gaussdb"),
412 Some(HistoryDbType::PostgreSQL)
413 );
414 }
415
416 #[test]
417 fn test_db_type_from_str_others() {
418 assert_eq!(
419 HistoryDbType::parse_db_type("sqlite"),
420 Some(HistoryDbType::SQLite)
421 );
422 assert_eq!(
423 HistoryDbType::parse_db_type("oracle"),
424 Some(HistoryDbType::Oracle)
425 );
426 assert_eq!(
427 HistoryDbType::parse_db_type("dameng"),
428 Some(HistoryDbType::Oracle)
429 );
430 assert_eq!(
431 HistoryDbType::parse_db_type("mssql"),
432 Some(HistoryDbType::SqlServer)
433 );
434 assert_eq!(
435 HistoryDbType::parse_db_type("sqlserver"),
436 Some(HistoryDbType::SqlServer)
437 );
438 assert_eq!(
439 HistoryDbType::parse_db_type("sybase"),
440 Some(HistoryDbType::SqlServer)
441 );
442 }
443
444 #[test]
445 fn test_db_type_from_str_unknown_returns_none() {
446 assert_eq!(HistoryDbType::parse_db_type("redis"), None);
447 assert_eq!(HistoryDbType::parse_db_type("mongodb"), None);
448 assert_eq!(HistoryDbType::parse_db_type(""), None);
449 }
450
451 #[test]
452 fn test_db_type_display() {
453 assert_eq!(format!("{}", HistoryDbType::MySQL), "mysql");
454 assert_eq!(format!("{}", HistoryDbType::PostgreSQL), "postgres");
455 assert_eq!(format!("{}", HistoryDbType::SQLite), "sqlite");
456 assert_eq!(format!("{}", HistoryDbType::Oracle), "oracle");
457 assert_eq!(format!("{}", HistoryDbType::SqlServer), "mssql");
458 }
459
460 #[test]
461 fn test_db_type_default_is_postgres() {
462 assert_eq!(HistoryDbType::default(), HistoryDbType::PostgreSQL);
463 }
464
465 #[test]
470 fn test_validate_identifier_valid() {
471 assert!(validate_identifier("__migrations", "table").is_ok());
472 assert!(validate_identifier("migrations", "table").is_ok());
473 assert!(validate_identifier("public.migrations", "table").is_ok());
474 assert!(validate_identifier("_t123", "table").is_ok());
475 }
476
477 #[test]
478 fn test_validate_identifier_rejects_empty() {
479 assert!(validate_identifier("", "table").is_err());
480 }
481
482 #[test]
483 fn test_validate_identifier_rejects_too_long() {
484 let long = "a".repeat(65);
485 assert!(validate_identifier(&long, "table").is_err());
486 }
487
488 #[test]
489 fn test_validate_identifier_rejects_digit_start() {
490 assert!(validate_identifier("1table", "table").is_err());
491 }
492
493 #[test]
494 fn test_validate_identifier_rejects_special_chars() {
495 assert!(validate_identifier("table;", "table").is_err());
496 assert!(validate_identifier("table'", "table").is_err());
497 assert!(validate_identifier("table--", "table").is_err());
498 assert!(validate_identifier("ta ble", "table").is_err());
499 assert!(validate_identifier("table; DROP TABLE users", "table").is_err());
500 }
501
502 #[test]
503 fn test_validate_identifier_rejects_empty_segment() {
504 assert!(validate_identifier("public..migrations", "table").is_err());
505 assert!(validate_identifier(".migrations", "table").is_err());
506 assert!(validate_identifier("migrations.", "table").is_err());
507 }
508
509 #[test]
514 fn test_create_table_sql_mysql() {
515 let sql = MigrationHistory::create_table_sql("__migrations", HistoryDbType::MySQL).unwrap();
516 assert!(sql.contains("CREATE TABLE IF NOT EXISTS __migrations"));
517 assert!(sql.contains("AUTO_INCREMENT"));
518 assert!(sql.contains("UNIQUE KEY uk_version"));
519 assert!(sql.contains("CURRENT_TIMESTAMP"));
520 }
521
522 #[test]
523 fn test_create_table_sql_postgres() {
524 let sql =
525 MigrationHistory::create_table_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
526 assert!(sql.contains("BIGSERIAL"));
527 assert!(sql.contains("DEFAULT NOW()"));
528 assert!(sql.contains("UNIQUE"));
529 }
530
531 #[test]
532 fn test_create_table_sql_sqlite() {
533 let sql =
534 MigrationHistory::create_table_sql("__migrations", HistoryDbType::SQLite).unwrap();
535 assert!(sql.contains("AUTOINCREMENT"));
536 assert!(sql.contains("CURRENT_TIMESTAMP"));
537 }
538
539 #[test]
540 fn test_create_table_sql_oracle() {
541 let sql =
542 MigrationHistory::create_table_sql("__migrations", HistoryDbType::Oracle).unwrap();
543 assert!(sql.contains("GENERATED BY DEFAULT AS IDENTITY"));
544 assert!(sql.contains("SYSTIMESTAMP"));
545 assert!(sql.contains("VARCHAR2"));
546 assert!(!sql.contains("IF NOT EXISTS"));
548 }
549
550 #[test]
551 fn test_create_table_sql_mssql() {
552 let sql =
553 MigrationHistory::create_table_sql("__migrations", HistoryDbType::SqlServer).unwrap();
554 assert!(sql.contains("IDENTITY(1,1)"));
555 assert!(sql.contains("GETDATE()"));
556 assert!(sql.contains("NVARCHAR"));
557 }
558
559 #[test]
560 fn test_create_table_sql_supports_schema_qualified_name() {
561 let sql =
562 MigrationHistory::create_table_sql("public.migrations", HistoryDbType::PostgreSQL)
563 .unwrap();
564 assert!(sql.contains("public.migrations"));
565 }
566
567 #[test]
568 fn test_create_table_sql_rejects_injection() {
569 let result =
570 MigrationHistory::create_table_sql("m; DROP TABLE users", HistoryDbType::MySQL);
571 assert!(result.is_err());
572 }
573
574 #[test]
579 fn test_insert_sql_mysql_uses_question_mark() {
580 let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::MySQL).unwrap();
581 assert!(sql.contains("INSERT INTO __migrations"));
582 assert!(sql.contains("(?, ?, ?)"));
583 }
584
585 #[test]
586 fn test_insert_sql_postgres_uses_dollar() {
587 let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
588 assert!(sql.contains("($1, $2, $3)"));
589 }
590
591 #[test]
592 fn test_insert_sql_sqlite_uses_question_mark() {
593 let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::SQLite).unwrap();
594 assert!(sql.contains("(?, ?, ?)"));
595 }
596
597 #[test]
598 fn test_insert_sql_oracle_uses_question_mark() {
599 let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::Oracle).unwrap();
600 assert!(sql.contains("(?, ?, ?)"));
601 }
602
603 #[test]
604 fn test_insert_sql_rejects_injection() {
605 let result = MigrationHistory::insert_sql("m; DROP TABLE users", HistoryDbType::MySQL);
606 assert!(result.is_err());
607 }
608
609 #[test]
614 fn test_delete_sql_mysql() {
615 let sql = MigrationHistory::delete_sql("__migrations", HistoryDbType::MySQL).unwrap();
616 assert!(sql.contains("DELETE FROM __migrations WHERE version = ?"));
617 }
618
619 #[test]
620 fn test_delete_sql_postgres() {
621 let sql = MigrationHistory::delete_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
622 assert!(sql.contains("WHERE version = $1"));
623 }
624
625 #[test]
626 fn test_delete_sql_rejects_injection() {
627 let result = MigrationHistory::delete_sql("m; DROP TABLE users", HistoryDbType::MySQL);
628 assert!(result.is_err());
629 }
630
631 #[test]
636 fn test_list_sql() {
637 let sql = MigrationHistory::list_sql("__migrations").unwrap();
638 assert!(sql.contains("SELECT version, name, batch, executed_at"));
639 assert!(sql.contains("FROM __migrations"));
640 assert!(sql.contains("ORDER BY version ASC"));
641 }
642
643 #[test]
644 fn test_max_batch_sql() {
645 let sql = MigrationHistory::max_batch_sql("__migrations").unwrap();
646 assert!(sql.contains("COALESCE(MAX(batch), 0)"));
647 assert!(sql.contains("AS max_batch"));
648 }
649
650 #[test]
651 fn test_exists_sql_mysql() {
652 let sql = MigrationHistory::exists_sql("__migrations", HistoryDbType::MySQL).unwrap();
653 assert!(sql.contains("SELECT COUNT(*) AS cnt"));
654 assert!(sql.contains("WHERE version = ?"));
655 }
656
657 #[test]
658 fn test_exists_sql_postgres() {
659 let sql = MigrationHistory::exists_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
660 assert!(sql.contains("WHERE version = $1"));
661 }
662
663 #[test]
664 fn test_list_sql_rejects_injection() {
665 let result = MigrationHistory::list_sql("m; DROP TABLE users");
666 assert!(result.is_err());
667 }
668
669 #[test]
674 fn test_history_record_new() {
675 let record = MigrationHistoryRecord::new("001", "create_users", 1);
676 assert_eq!(record.version, "001");
677 assert_eq!(record.name, "create_users");
678 assert_eq!(record.batch, 1);
679 assert!(record.executed_at.is_empty());
680 }
681
682 #[test]
683 fn test_history_record_with_executed_at() {
684 let record =
685 MigrationHistoryRecord::new("001", "init", 1).with_executed_at("2026-07-25T10:00:00Z");
686 assert_eq!(record.executed_at, "2026-07-25T10:00:00Z");
687 }
688
689 #[test]
690 fn test_history_record_equality() {
691 let r1 = MigrationHistoryRecord::new("001", "init", 1);
692 let r2 = MigrationHistoryRecord::new("001", "init", 1);
693 assert_eq!(r1, r2);
694 }
695
696 #[test]
701 fn test_config_default() {
702 let config = MigrationHistoryConfig::default();
703 assert_eq!(config.table_name, "__migrations");
704 assert_eq!(config.db_type, HistoryDbType::PostgreSQL);
705 }
706
707 #[test]
708 fn test_config_mysql() {
709 let config = MigrationHistoryConfig::mysql();
710 assert_eq!(config.db_type, HistoryDbType::MySQL);
711 }
712
713 #[test]
714 fn test_config_postgres() {
715 let config = MigrationHistoryConfig::postgres();
716 assert_eq!(config.db_type, HistoryDbType::PostgreSQL);
717 }
718
719 #[test]
720 fn test_config_sqlite() {
721 let config = MigrationHistoryConfig::sqlite();
722 assert_eq!(config.db_type, HistoryDbType::SQLite);
723 }
724
725 #[test]
726 fn test_config_with_table_name() {
727 let config = MigrationHistoryConfig::default().with_table_name("app_migrations");
728 assert_eq!(config.table_name, "app_migrations");
729 }
730
731 #[test]
736 fn test_end_to_end_mysql_workflow() {
737 let config = MigrationHistoryConfig::mysql();
738
739 let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
740 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS __migrations"));
741
742 let insert = MigrationHistory::insert_sql(&config.table_name, config.db_type).unwrap();
743 assert!(insert.contains("(?, ?, ?)"));
744
745 let delete = MigrationHistory::delete_sql(&config.table_name, config.db_type).unwrap();
746 assert!(delete.contains("WHERE version = ?"));
747
748 let list = MigrationHistory::list_sql(&config.table_name).unwrap();
749 assert!(list.contains("ORDER BY version ASC"));
750
751 let max_batch = MigrationHistory::max_batch_sql(&config.table_name).unwrap();
752 assert!(max_batch.contains("COALESCE(MAX(batch), 0)"));
753 }
754
755 #[test]
756 fn test_end_to_end_postgres_workflow() {
757 let config = MigrationHistoryConfig::postgres();
758
759 let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
760 assert!(ddl.contains("BIGSERIAL"));
761
762 let insert = MigrationHistory::insert_sql(&config.table_name, config.db_type).unwrap();
763 assert!(insert.contains("($1, $2, $3)"));
764
765 let delete = MigrationHistory::delete_sql(&config.table_name, config.db_type).unwrap();
766 assert!(delete.contains("WHERE version = $1"));
767 }
768
769 #[test]
770 fn test_end_to_end_custom_table_name() {
771 let config = MigrationHistoryConfig::default().with_table_name("app_migrations");
772
773 let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
774 assert!(ddl.contains("app_migrations"));
775 }
776}