use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum HistoryDbType {
MySQL,
#[default]
PostgreSQL,
SQLite,
Oracle,
SqlServer,
}
impl HistoryDbType {
pub fn parse_db_type(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"mysql" | "mariadb" | "oceanbase" | "tidb" | "polardb" => Some(Self::MySQL),
"postgres" | "postgresql" | "kingbase" | "gaussdb" | "clickhouse" => {
Some(Self::PostgreSQL)
}
"sqlite" => Some(Self::SQLite),
"oracle" | "dameng" => Some(Self::Oracle),
"mssql" | "sqlserver" | "sybase" => Some(Self::SqlServer),
_ => None,
}
}
fn placeholder(&self, index: usize) -> String {
match self {
Self::PostgreSQL => format!("${}", index),
_ => "?".to_string(),
}
}
}
impl fmt::Display for HistoryDbType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MySQL => write!(f, "mysql"),
Self::PostgreSQL => write!(f, "postgres"),
Self::SQLite => write!(f, "sqlite"),
Self::Oracle => write!(f, "oracle"),
Self::SqlServer => write!(f, "mssql"),
}
}
}
fn validate_identifier(name: &str, label: &str) -> Result<(), String> {
if name.is_empty() {
return Err(format!("{} cannot be empty", label));
}
if name.len() > 64 {
return Err(format!("{} too long (max 64 chars): {}", label, name));
}
for segment in name.split('.') {
if segment.is_empty() {
return Err(format!("{} has empty segment: {}", label, name));
}
let chars: Vec<char> = segment.chars().collect();
if chars[0].is_ascii_digit() {
return Err(format!("{} cannot start with digit: {}", label, name));
}
for c in chars {
if !c.is_ascii_alphanumeric() && c != '_' {
return Err(format!("{} contains invalid char '{}': {}", label, c, name));
}
}
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct MigrationHistory;
impl MigrationHistory {
pub fn create_table_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
validate_identifier(table_name, "migration history table name")?;
let ddl = match db_type {
HistoryDbType::MySQL => format!(
"CREATE TABLE IF NOT EXISTS {} (\n\
\x20 id BIGINT NOT NULL AUTO_INCREMENT,\n\
\x20 version VARCHAR(255) NOT NULL,\n\
\x20 name VARCHAR(255) NOT NULL,\n\
\x20 batch INT NOT NULL,\n\
\x20 executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\
\x20 PRIMARY KEY (id),\n\
\x20 UNIQUE KEY uk_version (version)\n\
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
table_name
),
HistoryDbType::PostgreSQL => format!(
"CREATE TABLE IF NOT EXISTS {} (\n\
\x20 id BIGSERIAL PRIMARY KEY,\n\
\x20 version VARCHAR(255) NOT NULL UNIQUE,\n\
\x20 name VARCHAR(255) NOT NULL,\n\
\x20 batch INT NOT NULL,\n\
\x20 executed_at TIMESTAMP NOT NULL DEFAULT NOW()\n\
)",
table_name
),
HistoryDbType::SQLite => format!(
"CREATE TABLE IF NOT EXISTS {} (\n\
\x20 id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
\x20 version VARCHAR(255) NOT NULL UNIQUE,\n\
\x20 name VARCHAR(255) NOT NULL,\n\
\x20 batch INT NOT NULL,\n\
\x20 executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n\
)",
table_name
),
HistoryDbType::Oracle => format!(
"CREATE TABLE {} (\n\
\x20 id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n\
\x20 version VARCHAR2(255) NOT NULL UNIQUE,\n\
\x20 name VARCHAR2(255) NOT NULL,\n\
\x20 batch NUMBER(10) NOT NULL,\n\
\x20 executed_at TIMESTAMP NOT NULL DEFAULT SYSTIMESTAMP\n\
)",
table_name
),
HistoryDbType::SqlServer => format!(
"CREATE TABLE {} (\n\
\x20 id BIGINT IDENTITY(1,1) PRIMARY KEY,\n\
\x20 version NVARCHAR(255) NOT NULL UNIQUE,\n\
\x20 name NVARCHAR(255) NOT NULL,\n\
\x20 batch INT NOT NULL,\n\
\x20 executed_at DATETIME NOT NULL DEFAULT GETDATE()\n\
)",
table_name
),
};
Ok(ddl)
}
pub fn insert_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
validate_identifier(table_name, "migration history table name")?;
let p1 = db_type.placeholder(1);
let p2 = db_type.placeholder(2);
let p3 = db_type.placeholder(3);
Ok(format!(
"INSERT INTO {} (version, name, batch) VALUES ({}, {}, {})",
table_name, p1, p2, p3
))
}
pub fn delete_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
validate_identifier(table_name, "migration history table name")?;
let p1 = db_type.placeholder(1);
Ok(format!("DELETE FROM {} WHERE version = {}", table_name, p1))
}
pub fn list_sql(table_name: &str) -> Result<String, String> {
validate_identifier(table_name, "migration history table name")?;
Ok(format!(
"SELECT version, name, batch, executed_at FROM {} ORDER BY version ASC",
table_name
))
}
pub fn max_batch_sql(table_name: &str) -> Result<String, String> {
validate_identifier(table_name, "migration history table name")?;
Ok(format!(
"SELECT COALESCE(MAX(batch), 0) AS max_batch FROM {}",
table_name
))
}
pub fn exists_sql(table_name: &str, db_type: HistoryDbType) -> Result<String, String> {
validate_identifier(table_name, "migration history table name")?;
let p1 = db_type.placeholder(1);
Ok(format!(
"SELECT COUNT(*) AS cnt FROM {} WHERE version = {}",
table_name, p1
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationHistoryRecord {
pub version: String,
pub name: String,
pub batch: i32,
pub executed_at: String,
}
impl MigrationHistoryRecord {
pub fn new(version: impl Into<String>, name: impl Into<String>, batch: i32) -> Self {
Self {
version: version.into(),
name: name.into(),
batch,
executed_at: String::new(),
}
}
pub fn with_executed_at(mut self, executed_at: impl Into<String>) -> Self {
self.executed_at = executed_at.into();
self
}
}
#[derive(Debug, Clone)]
pub struct MigrationHistoryConfig {
pub table_name: String,
pub db_type: HistoryDbType,
}
impl Default for MigrationHistoryConfig {
fn default() -> Self {
Self {
table_name: "__migrations".to_string(),
db_type: HistoryDbType::default(),
}
}
}
impl MigrationHistoryConfig {
pub fn mysql() -> Self {
Self {
table_name: "__migrations".to_string(),
db_type: HistoryDbType::MySQL,
}
}
pub fn postgres() -> Self {
Self {
table_name: "__migrations".to_string(),
db_type: HistoryDbType::PostgreSQL,
}
}
pub fn sqlite() -> Self {
Self {
table_name: "__migrations".to_string(),
db_type: HistoryDbType::SQLite,
}
}
pub fn with_table_name(mut self, name: impl Into<String>) -> Self {
self.table_name = name.into();
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_db_type_from_str_mysql_family() {
assert_eq!(
HistoryDbType::parse_db_type("mysql"),
Some(HistoryDbType::MySQL)
);
assert_eq!(
HistoryDbType::parse_db_type("MySQL"),
Some(HistoryDbType::MySQL)
);
assert_eq!(
HistoryDbType::parse_db_type("mariadb"),
Some(HistoryDbType::MySQL)
);
assert_eq!(
HistoryDbType::parse_db_type("oceanbase"),
Some(HistoryDbType::MySQL)
);
assert_eq!(
HistoryDbType::parse_db_type("tidb"),
Some(HistoryDbType::MySQL)
);
assert_eq!(
HistoryDbType::parse_db_type("polardb"),
Some(HistoryDbType::MySQL)
);
}
#[test]
fn test_db_type_from_str_pg_family() {
assert_eq!(
HistoryDbType::parse_db_type("postgres"),
Some(HistoryDbType::PostgreSQL)
);
assert_eq!(
HistoryDbType::parse_db_type("postgresql"),
Some(HistoryDbType::PostgreSQL)
);
assert_eq!(
HistoryDbType::parse_db_type("kingbase"),
Some(HistoryDbType::PostgreSQL)
);
assert_eq!(
HistoryDbType::parse_db_type("gaussdb"),
Some(HistoryDbType::PostgreSQL)
);
}
#[test]
fn test_db_type_from_str_others() {
assert_eq!(
HistoryDbType::parse_db_type("sqlite"),
Some(HistoryDbType::SQLite)
);
assert_eq!(
HistoryDbType::parse_db_type("oracle"),
Some(HistoryDbType::Oracle)
);
assert_eq!(
HistoryDbType::parse_db_type("dameng"),
Some(HistoryDbType::Oracle)
);
assert_eq!(
HistoryDbType::parse_db_type("mssql"),
Some(HistoryDbType::SqlServer)
);
assert_eq!(
HistoryDbType::parse_db_type("sqlserver"),
Some(HistoryDbType::SqlServer)
);
assert_eq!(
HistoryDbType::parse_db_type("sybase"),
Some(HistoryDbType::SqlServer)
);
}
#[test]
fn test_db_type_from_str_unknown_returns_none() {
assert_eq!(HistoryDbType::parse_db_type("redis"), None);
assert_eq!(HistoryDbType::parse_db_type("mongodb"), None);
assert_eq!(HistoryDbType::parse_db_type(""), None);
}
#[test]
fn test_db_type_display() {
assert_eq!(format!("{}", HistoryDbType::MySQL), "mysql");
assert_eq!(format!("{}", HistoryDbType::PostgreSQL), "postgres");
assert_eq!(format!("{}", HistoryDbType::SQLite), "sqlite");
assert_eq!(format!("{}", HistoryDbType::Oracle), "oracle");
assert_eq!(format!("{}", HistoryDbType::SqlServer), "mssql");
}
#[test]
fn test_db_type_default_is_postgres() {
assert_eq!(HistoryDbType::default(), HistoryDbType::PostgreSQL);
}
#[test]
fn test_validate_identifier_valid() {
assert!(validate_identifier("__migrations", "table").is_ok());
assert!(validate_identifier("migrations", "table").is_ok());
assert!(validate_identifier("public.migrations", "table").is_ok());
assert!(validate_identifier("_t123", "table").is_ok());
}
#[test]
fn test_validate_identifier_rejects_empty() {
assert!(validate_identifier("", "table").is_err());
}
#[test]
fn test_validate_identifier_rejects_too_long() {
let long = "a".repeat(65);
assert!(validate_identifier(&long, "table").is_err());
}
#[test]
fn test_validate_identifier_rejects_digit_start() {
assert!(validate_identifier("1table", "table").is_err());
}
#[test]
fn test_validate_identifier_rejects_special_chars() {
assert!(validate_identifier("table;", "table").is_err());
assert!(validate_identifier("table'", "table").is_err());
assert!(validate_identifier("table--", "table").is_err());
assert!(validate_identifier("ta ble", "table").is_err());
assert!(validate_identifier("table; DROP TABLE users", "table").is_err());
}
#[test]
fn test_validate_identifier_rejects_empty_segment() {
assert!(validate_identifier("public..migrations", "table").is_err());
assert!(validate_identifier(".migrations", "table").is_err());
assert!(validate_identifier("migrations.", "table").is_err());
}
#[test]
fn test_create_table_sql_mysql() {
let sql = MigrationHistory::create_table_sql("__migrations", HistoryDbType::MySQL).unwrap();
assert!(sql.contains("CREATE TABLE IF NOT EXISTS __migrations"));
assert!(sql.contains("AUTO_INCREMENT"));
assert!(sql.contains("UNIQUE KEY uk_version"));
assert!(sql.contains("CURRENT_TIMESTAMP"));
}
#[test]
fn test_create_table_sql_postgres() {
let sql =
MigrationHistory::create_table_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
assert!(sql.contains("BIGSERIAL"));
assert!(sql.contains("DEFAULT NOW()"));
assert!(sql.contains("UNIQUE"));
}
#[test]
fn test_create_table_sql_sqlite() {
let sql =
MigrationHistory::create_table_sql("__migrations", HistoryDbType::SQLite).unwrap();
assert!(sql.contains("AUTOINCREMENT"));
assert!(sql.contains("CURRENT_TIMESTAMP"));
}
#[test]
fn test_create_table_sql_oracle() {
let sql =
MigrationHistory::create_table_sql("__migrations", HistoryDbType::Oracle).unwrap();
assert!(sql.contains("GENERATED BY DEFAULT AS IDENTITY"));
assert!(sql.contains("SYSTIMESTAMP"));
assert!(sql.contains("VARCHAR2"));
assert!(!sql.contains("IF NOT EXISTS"));
}
#[test]
fn test_create_table_sql_mssql() {
let sql =
MigrationHistory::create_table_sql("__migrations", HistoryDbType::SqlServer).unwrap();
assert!(sql.contains("IDENTITY(1,1)"));
assert!(sql.contains("GETDATE()"));
assert!(sql.contains("NVARCHAR"));
}
#[test]
fn test_create_table_sql_supports_schema_qualified_name() {
let sql =
MigrationHistory::create_table_sql("public.migrations", HistoryDbType::PostgreSQL)
.unwrap();
assert!(sql.contains("public.migrations"));
}
#[test]
fn test_create_table_sql_rejects_injection() {
let result =
MigrationHistory::create_table_sql("m; DROP TABLE users", HistoryDbType::MySQL);
assert!(result.is_err());
}
#[test]
fn test_insert_sql_mysql_uses_question_mark() {
let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::MySQL).unwrap();
assert!(sql.contains("INSERT INTO __migrations"));
assert!(sql.contains("(?, ?, ?)"));
}
#[test]
fn test_insert_sql_postgres_uses_dollar() {
let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
assert!(sql.contains("($1, $2, $3)"));
}
#[test]
fn test_insert_sql_sqlite_uses_question_mark() {
let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::SQLite).unwrap();
assert!(sql.contains("(?, ?, ?)"));
}
#[test]
fn test_insert_sql_oracle_uses_question_mark() {
let sql = MigrationHistory::insert_sql("__migrations", HistoryDbType::Oracle).unwrap();
assert!(sql.contains("(?, ?, ?)"));
}
#[test]
fn test_insert_sql_rejects_injection() {
let result = MigrationHistory::insert_sql("m; DROP TABLE users", HistoryDbType::MySQL);
assert!(result.is_err());
}
#[test]
fn test_delete_sql_mysql() {
let sql = MigrationHistory::delete_sql("__migrations", HistoryDbType::MySQL).unwrap();
assert!(sql.contains("DELETE FROM __migrations WHERE version = ?"));
}
#[test]
fn test_delete_sql_postgres() {
let sql = MigrationHistory::delete_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
assert!(sql.contains("WHERE version = $1"));
}
#[test]
fn test_delete_sql_rejects_injection() {
let result = MigrationHistory::delete_sql("m; DROP TABLE users", HistoryDbType::MySQL);
assert!(result.is_err());
}
#[test]
fn test_list_sql() {
let sql = MigrationHistory::list_sql("__migrations").unwrap();
assert!(sql.contains("SELECT version, name, batch, executed_at"));
assert!(sql.contains("FROM __migrations"));
assert!(sql.contains("ORDER BY version ASC"));
}
#[test]
fn test_max_batch_sql() {
let sql = MigrationHistory::max_batch_sql("__migrations").unwrap();
assert!(sql.contains("COALESCE(MAX(batch), 0)"));
assert!(sql.contains("AS max_batch"));
}
#[test]
fn test_exists_sql_mysql() {
let sql = MigrationHistory::exists_sql("__migrations", HistoryDbType::MySQL).unwrap();
assert!(sql.contains("SELECT COUNT(*) AS cnt"));
assert!(sql.contains("WHERE version = ?"));
}
#[test]
fn test_exists_sql_postgres() {
let sql = MigrationHistory::exists_sql("__migrations", HistoryDbType::PostgreSQL).unwrap();
assert!(sql.contains("WHERE version = $1"));
}
#[test]
fn test_list_sql_rejects_injection() {
let result = MigrationHistory::list_sql("m; DROP TABLE users");
assert!(result.is_err());
}
#[test]
fn test_history_record_new() {
let record = MigrationHistoryRecord::new("001", "create_users", 1);
assert_eq!(record.version, "001");
assert_eq!(record.name, "create_users");
assert_eq!(record.batch, 1);
assert!(record.executed_at.is_empty());
}
#[test]
fn test_history_record_with_executed_at() {
let record =
MigrationHistoryRecord::new("001", "init", 1).with_executed_at("2026-07-25T10:00:00Z");
assert_eq!(record.executed_at, "2026-07-25T10:00:00Z");
}
#[test]
fn test_history_record_equality() {
let r1 = MigrationHistoryRecord::new("001", "init", 1);
let r2 = MigrationHistoryRecord::new("001", "init", 1);
assert_eq!(r1, r2);
}
#[test]
fn test_config_default() {
let config = MigrationHistoryConfig::default();
assert_eq!(config.table_name, "__migrations");
assert_eq!(config.db_type, HistoryDbType::PostgreSQL);
}
#[test]
fn test_config_mysql() {
let config = MigrationHistoryConfig::mysql();
assert_eq!(config.db_type, HistoryDbType::MySQL);
}
#[test]
fn test_config_postgres() {
let config = MigrationHistoryConfig::postgres();
assert_eq!(config.db_type, HistoryDbType::PostgreSQL);
}
#[test]
fn test_config_sqlite() {
let config = MigrationHistoryConfig::sqlite();
assert_eq!(config.db_type, HistoryDbType::SQLite);
}
#[test]
fn test_config_with_table_name() {
let config = MigrationHistoryConfig::default().with_table_name("app_migrations");
assert_eq!(config.table_name, "app_migrations");
}
#[test]
fn test_end_to_end_mysql_workflow() {
let config = MigrationHistoryConfig::mysql();
let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
assert!(ddl.contains("CREATE TABLE IF NOT EXISTS __migrations"));
let insert = MigrationHistory::insert_sql(&config.table_name, config.db_type).unwrap();
assert!(insert.contains("(?, ?, ?)"));
let delete = MigrationHistory::delete_sql(&config.table_name, config.db_type).unwrap();
assert!(delete.contains("WHERE version = ?"));
let list = MigrationHistory::list_sql(&config.table_name).unwrap();
assert!(list.contains("ORDER BY version ASC"));
let max_batch = MigrationHistory::max_batch_sql(&config.table_name).unwrap();
assert!(max_batch.contains("COALESCE(MAX(batch), 0)"));
}
#[test]
fn test_end_to_end_postgres_workflow() {
let config = MigrationHistoryConfig::postgres();
let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
assert!(ddl.contains("BIGSERIAL"));
let insert = MigrationHistory::insert_sql(&config.table_name, config.db_type).unwrap();
assert!(insert.contains("($1, $2, $3)"));
let delete = MigrationHistory::delete_sql(&config.table_name, config.db_type).unwrap();
assert!(delete.contains("WHERE version = $1"));
}
#[test]
fn test_end_to_end_custom_table_name() {
let config = MigrationHistoryConfig::default().with_table_name("app_migrations");
let ddl = MigrationHistory::create_table_sql(&config.table_name, config.db_type).unwrap();
assert!(ddl.contains("app_migrations"));
}
}