use super::{
MigrationAdapter, MigrationContext, MigrationFile, Result, collect_and_sort_entries,
is_single_migration_dir, should_check_migration,
};
use camino::Utf8Path;
use regex::Regex;
use serde::Deserialize;
use std::sync::LazyLock;
static DIESEL_TIMESTAMP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\d{4}_\d{2}_\d{2}_\d{6}|\d{4}-\d{2}-\d{2}-\d{6}|\d{14})")
.expect("valid regex pattern")
});
const NO_TRANSACTION_HINT: &str =
"Create `metadata.toml` in the migration directory with `run_in_transaction = false`.";
pub struct DieselAdapter;
#[derive(Deserialize, Default)]
struct MetadataFile {
run_in_transaction: Option<bool>,
}
impl MigrationAdapter for DieselAdapter {
fn collect_migration_files(
&self,
dir: &Utf8Path,
start_after: Option<&str>,
check_down: bool,
) -> Result<Vec<MigrationFile>> {
if is_single_migration_dir(dir) {
return Ok(self.process_migration_directory(dir, None, check_down));
}
let entries = collect_and_sort_entries(dir);
let mut files = Vec::new();
for entry in entries {
let Some(path) = Utf8Path::from_path(entry.path()) else {
continue;
};
if entry.file_type().is_dir() {
files.extend(self.process_migration_directory(path, start_after, check_down));
} else if path.extension() == Some("sql") {
let filename = path.file_name().unwrap_or("");
let parsed_timestamp = self.parse_timestamp(filename);
if let Some(ref ts) = parsed_timestamp
&& !should_check_migration(start_after, ts)
{
continue;
}
let timestamp = parsed_timestamp.unwrap_or_else(|| filename.to_string());
files.push(MigrationFile::new(path.to_owned(), timestamp));
}
}
Ok(files)
}
fn parse_timestamp(&self, name: &str) -> Option<String> {
DIESEL_TIMESTAMP_REGEX
.captures(name)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().replace(['_', '-'], ""))
}
fn validate_timestamp(&self, timestamp: &str) -> Result<()> {
let Some(captures) = DIESEL_TIMESTAMP_REGEX.captures(timestamp) else {
return Err(format!(
"Invalid Diesel timestamp format: {timestamp}. Expected: YYYYMMDDHHMMSS, YYYY_MM_DD_HHMMSS, or YYYY-MM-DD-HHMMSS"
).into());
};
if captures.get(0).unwrap().as_str() == timestamp {
Ok(())
} else {
Err(format!(
"Invalid Diesel timestamp format: {timestamp}. Expected: YYYYMMDDHHMMSS, YYYY_MM_DD_HHMMSS, or YYYY-MM-DD-HHMMSS"
).into())
}
}
fn extract_migration_metadata(&self, file_path: &Utf8Path) -> MigrationContext {
let Some(parent) = file_path.parent() else {
return MigrationContext {
run_in_transaction: true,
no_transaction_hint: NO_TRANSACTION_HINT,
};
};
let metadata_path = parent.join("metadata.toml");
let Ok(content) = std::fs::read_to_string(&metadata_path) else {
return MigrationContext {
run_in_transaction: true,
no_transaction_hint: NO_TRANSACTION_HINT,
};
};
let parsed: MetadataFile = toml::from_str(&content).unwrap_or_default();
MigrationContext {
run_in_transaction: parsed.run_in_transaction.unwrap_or(true),
no_transaction_hint: NO_TRANSACTION_HINT,
}
}
}
impl DieselAdapter {
fn process_migration_directory(
&self,
path: &Utf8Path,
start_after: Option<&str>,
check_down: bool,
) -> Vec<MigrationFile> {
let Some(dir_name) = path.file_name() else {
return vec![];
};
let timestamp = self.parse_timestamp(dir_name).unwrap_or_else(|| {
dir_name.to_string()
});
if !should_check_migration(start_after, ×tamp) {
return vec![];
}
let mut files = vec![];
let up_sql = path.join("up.sql");
if up_sql.exists() {
files.push(MigrationFile::new(up_sql, timestamp.clone()));
}
if check_down {
let down_sql = path.join("down.sql");
if down_sql.exists() {
files.push(MigrationFile::new(down_sql, timestamp));
}
}
files
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::should_check_migration;
#[test]
fn test_parse_timestamp_with_underscores() {
let adapter = DieselAdapter;
assert_eq!(
adapter.parse_timestamp("2024_01_01_000000_create_users"),
Some("20240101000000".to_string())
);
}
#[test]
fn test_parse_timestamp_with_dashes() {
let adapter = DieselAdapter;
assert_eq!(
adapter.parse_timestamp("2024-01-01-000000_create_users"),
Some("20240101000000".to_string())
);
}
#[test]
fn test_parse_timestamp_no_separators() {
let adapter = DieselAdapter;
assert_eq!(
adapter.parse_timestamp("20240101000000_create_users"),
Some("20240101000000".to_string())
);
}
#[test]
fn test_parse_timestamp_invalid() {
let adapter = DieselAdapter;
assert_eq!(adapter.parse_timestamp("invalid_name"), None);
assert_eq!(adapter.parse_timestamp("2024_01_01"), None);
}
#[test]
fn test_validate_timestamp() {
let adapter = DieselAdapter;
assert!(adapter.validate_timestamp("2024_01_01_000000").is_ok());
assert!(adapter.validate_timestamp("2024-01-01-000000").is_ok());
assert!(adapter.validate_timestamp("20240101000000").is_ok());
assert!(adapter.validate_timestamp("invalid").is_err());
}
#[test]
fn test_validate_timestamp_with_trailing_chars() {
let adapter = DieselAdapter;
assert!(adapter.validate_timestamp("20240101000000_extra").is_err());
assert!(
adapter
.validate_timestamp("2024_01_01_000000_extra")
.is_err()
);
}
#[test]
fn test_should_check_migration() {
assert!(should_check_migration(None, "20240101000000"));
assert!(should_check_migration(None, "20200101000000"));
assert!(should_check_migration(
Some("20240101000000"),
"20240102000000"
));
assert!(!should_check_migration(
Some("20240101000000"),
"20240101000000"
));
assert!(!should_check_migration(
Some("20240101000000"),
"20231231235959"
));
}
#[test]
fn test_should_check_migration_mixed_formats() {
assert!(should_check_migration(
Some("2024_01_01_000000"),
"20240102000000"
));
assert!(!should_check_migration(
Some("2024_01_01_000000"),
"20240101000000"
));
assert!(should_check_migration(
Some("20240101000000"),
"2024-01-02-000000"
));
assert!(!should_check_migration(
Some("20240101000000"),
"2024-01-01-000000"
));
}
#[test]
fn test_extract_metadata_no_parent_defaults_to_in_transaction() {
let adapter = DieselAdapter;
let meta = adapter.extract_migration_metadata(Utf8Path::new(""));
assert!(meta.run_in_transaction);
}
#[test]
fn test_extract_metadata_no_file_defaults_to_in_transaction() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let sql_file = temp_dir.path().join("up.sql");
std::fs::write(&sql_file, "SELECT 1;").unwrap();
let adapter = DieselAdapter;
let path = Utf8Path::from_path(&sql_file).unwrap();
let meta = adapter.extract_migration_metadata(path);
assert!(meta.run_in_transaction);
}
#[test]
fn test_extract_metadata_run_in_transaction_false() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let sql_file = temp_dir.path().join("up.sql");
std::fs::write(&sql_file, "SELECT 1;").unwrap();
std::fs::write(
temp_dir.path().join("metadata.toml"),
"run_in_transaction = false\n",
)
.unwrap();
let adapter = DieselAdapter;
let path = Utf8Path::from_path(&sql_file).unwrap();
let meta = adapter.extract_migration_metadata(path);
assert!(!meta.run_in_transaction);
}
#[test]
fn test_extract_metadata_run_in_transaction_true_explicit() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let sql_file = temp_dir.path().join("up.sql");
std::fs::write(&sql_file, "SELECT 1;").unwrap();
std::fs::write(
temp_dir.path().join("metadata.toml"),
"run_in_transaction = true\n",
)
.unwrap();
let adapter = DieselAdapter;
let path = Utf8Path::from_path(&sql_file).unwrap();
let meta = adapter.extract_migration_metadata(path);
assert!(meta.run_in_transaction);
}
#[test]
fn test_extract_metadata_empty_toml_defaults_to_in_transaction() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let sql_file = temp_dir.path().join("up.sql");
std::fs::write(&sql_file, "SELECT 1;").unwrap();
std::fs::write(temp_dir.path().join("metadata.toml"), "").unwrap();
let adapter = DieselAdapter;
let path = Utf8Path::from_path(&sql_file).unwrap();
let meta = adapter.extract_migration_metadata(path);
assert!(meta.run_in_transaction);
}
#[test]
fn test_extract_metadata_malformed_toml_defaults_to_in_transaction() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let sql_file = temp_dir.path().join("up.sql");
std::fs::write(&sql_file, "SELECT 1;").unwrap();
std::fs::write(
temp_dir.path().join("metadata.toml"),
"this is not valid toml ][[\n",
)
.unwrap();
let adapter = DieselAdapter;
let path = Utf8Path::from_path(&sql_file).unwrap();
let meta = adapter.extract_migration_metadata(path);
assert!(meta.run_in_transaction);
}
#[test]
fn test_single_migration_dir_skips_down_sql() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let migration_dir = temp_dir.path().join("2024_01_01_000000_test");
fs::create_dir(&migration_dir).unwrap();
fs::write(
migration_dir.join("up.sql"),
"ALTER TABLE users ADD COLUMN admin BOOLEAN;",
)
.unwrap();
fs::write(
migration_dir.join("down.sql"),
"ALTER TABLE users DROP COLUMN admin;",
)
.unwrap();
let adapter = DieselAdapter;
let migration_path =
Utf8Path::from_path(&migration_dir).expect("path should be valid UTF-8");
let files = adapter
.collect_migration_files(migration_path, None, false)
.unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path.as_str().contains("up.sql"));
}
#[test]
fn test_single_migration_dir_includes_down_sql() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let migration_dir = temp_dir.path().join("2024_01_01_000000_test");
fs::create_dir(&migration_dir).unwrap();
fs::write(
migration_dir.join("up.sql"),
"ALTER TABLE users ADD COLUMN admin BOOLEAN;",
)
.unwrap();
fs::write(
migration_dir.join("down.sql"),
"ALTER TABLE users DROP COLUMN admin;",
)
.unwrap();
let adapter = DieselAdapter;
let migration_path =
Utf8Path::from_path(&migration_dir).expect("path should be valid UTF-8");
let files = adapter
.collect_migration_files(migration_path, None, true)
.unwrap();
assert_eq!(files.len(), 2);
let paths: Vec<String> = files.iter().map(|f| f.path.to_string()).collect();
assert!(paths.iter().any(|p| p.contains("up.sql")));
assert!(paths.iter().any(|p| p.contains("down.sql")));
}
}