summaryrefslogtreecommitdiff
path: root/src/migration_discovery.rs
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2025-06-11 20:20:04 -0600
committermo khan <mo@mokhan.ca>2025-06-11 20:20:04 -0600
commitc28b7088b6fad045060a52b6e1a2249e876090e3 (patch)
treea8fc26fd5365d4988d9206b32d94f51047cf0bcc /src/migration_discovery.rs
parent19ca22e604f9bcdf6b25f973f81b2486b0dcb789 (diff)
refactor: extract domain model
Diffstat (limited to 'src/migration_discovery.rs')
-rw-r--r--src/migration_discovery.rs296
1 files changed, 296 insertions, 0 deletions
diff --git a/src/migration_discovery.rs b/src/migration_discovery.rs
new file mode 100644
index 0000000..fabc660
--- /dev/null
+++ b/src/migration_discovery.rs
@@ -0,0 +1,296 @@
+use anyhow::{Result, anyhow};
+use chrono::Utc;
+use std::collections::BTreeMap;
+use std::path::Path;
+
+#[derive(Debug, Clone)]
+pub struct Migration {
+ pub version: i64,
+ pub name: String,
+ pub sql: String,
+}
+
+/// Migration discovery that reads migration files from the filesystem at runtime
+pub struct RuntimeMigrationDiscovery {
+ migrations_dir: std::path::PathBuf,
+ migrations: BTreeMap<i64, Migration>,
+}
+
+impl RuntimeMigrationDiscovery {
+ pub fn new<P: AsRef<Path>>(migrations_dir: P) -> Result<Self> {
+ let migrations_dir = migrations_dir.as_ref().to_path_buf();
+ let mut discovery = Self {
+ migrations_dir,
+ migrations: BTreeMap::new(),
+ };
+ discovery.discover_migrations()?;
+ Ok(discovery)
+ }
+
+ fn discover_migrations(&mut self) -> Result<()> {
+ if !self.migrations_dir.exists() {
+ return Err(anyhow!(
+ "Migrations directory does not exist: {:?}",
+ self.migrations_dir
+ ));
+ }
+
+ let entries = std::fs::read_dir(&self.migrations_dir)?;
+
+ for entry in entries {
+ let entry = entry?;
+ let path = entry.path();
+
+ if path.is_file() && path.extension().map_or(false, |ext| ext == "sql") {
+ let migration = self.parse_migration_file(&path)?;
+ self.migrations.insert(migration.version, migration);
+ }
+ }
+
+ Ok(())
+ }
+
+ fn parse_migration_file(&self, path: &Path) -> Result<Migration> {
+ let file_name = path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .ok_or_else(|| anyhow!("Invalid migration file name: {:?}", path))?;
+
+ let (version, name) = parse_migration_filename(file_name)?;
+ let sql = std::fs::read_to_string(path)?;
+
+ Ok(Migration { version, name, sql })
+ }
+
+ pub fn get_migrations(&self) -> Vec<&Migration> {
+ self.migrations.values().collect()
+ }
+
+ pub fn get_migration(&self, version: i64) -> Option<&Migration> {
+ self.migrations.get(&version)
+ }
+
+ pub fn get_pending_migrations(&self, current_version: i64) -> Vec<&Migration> {
+ self.migrations
+ .values()
+ .filter(|m| m.version > current_version)
+ .collect()
+ }
+
+ pub fn refresh(&mut self) -> Result<()> {
+ self.migrations.clear();
+ self.discover_migrations()
+ }
+}
+
+/// Parse migration filename to extract timestamp and name
+/// Expected format: "20231201123456_initial_schema.sql" -> (20231201123456, "initial_schema")
+fn parse_migration_filename(filename: &str) -> Result<(i64, String)> {
+ if !filename.ends_with(".sql") {
+ return Err(anyhow!(
+ "Migration file must have .sql extension: {}",
+ filename
+ ));
+ }
+
+ let name_without_ext = &filename[..filename.len() - 4];
+
+ // Find first underscore
+ let underscore_pos = name_without_ext.find('_').ok_or_else(|| {
+ anyhow!(
+ "Migration filename must be in format 'YYYYMMDDHHMMSS_name.sql': {}",
+ filename
+ )
+ })?;
+
+ let timestamp_str = &name_without_ext[..underscore_pos];
+ let name = &name_without_ext[underscore_pos + 1..];
+
+ // Validate timestamp format (should be 14 digits for YYYYMMDDHHMMSS)
+ if timestamp_str.len() != 14 {
+ return Err(anyhow!(
+ "Timestamp must be 14 digits (YYYYMMDDHHMMSS) in migration filename: {}",
+ filename
+ ));
+ }
+
+ let timestamp = timestamp_str
+ .parse::<i64>()
+ .map_err(|_| anyhow!("Invalid timestamp in migration filename: {}", filename))?;
+
+ // Basic validation of timestamp format
+ let year = timestamp / 10000000000;
+ let month = (timestamp / 100000000) % 100;
+ let day = (timestamp / 1000000) % 100;
+
+ if year < 2000 || year > 3000 {
+ return Err(anyhow!("Invalid year in timestamp: {}", timestamp_str));
+ }
+ if month < 1 || month > 12 {
+ return Err(anyhow!("Invalid month in timestamp: {}", timestamp_str));
+ }
+ if day < 1 || day > 31 {
+ return Err(anyhow!("Invalid day in timestamp: {}", timestamp_str));
+ }
+
+ Ok((timestamp, name.to_string()))
+}
+
+/// Generate a timestamp prefix for a new migration file
+/// Returns a string in format YYYYMMDDHHMMSS
+pub fn generate_migration_timestamp() -> String {
+ let now = Utc::now();
+ now.format("%Y%m%d%H%M%S").to_string()
+}
+
+/// Generate a full migration filename with timestamp prefix
+/// Example: generate_migration_filename("add_users_table") -> "20231201123456_add_users_table.sql"
+pub fn generate_migration_filename(name: &str) -> String {
+ format!("{}_{}.sql", generate_migration_timestamp(), name)
+}
+
+/// Trait for migration discovery to allow different implementations
+pub trait MigrationDiscovery {
+ fn get_migrations(&self) -> Vec<&Migration>;
+ fn get_pending_migrations(&self, current_version: i64) -> Vec<&Migration>;
+}
+
+impl MigrationDiscovery for RuntimeMigrationDiscovery {
+ fn get_migrations(&self) -> Vec<&Migration> {
+ self.get_migrations()
+ }
+
+ fn get_pending_migrations(&self, current_version: i64) -> Vec<&Migration> {
+ self.get_pending_migrations(current_version)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use tempfile::TempDir;
+
+ #[test]
+ fn test_parse_migration_filename() {
+ let cases = vec![
+ (
+ "20231201123456_initial_schema.sql",
+ Ok((20231201123456, "initial_schema".to_string())),
+ ),
+ (
+ "20231202140000_add_users_table.sql",
+ Ok((20231202140000, "add_users_table".to_string())),
+ ),
+ (
+ "20240315091530_complex_migration_name.sql",
+ Ok((20240315091530, "complex_migration_name".to_string())),
+ ),
+ ("invalid.sql", Err(())), // No underscore
+ ("abc_invalid.sql", Err(())), // Non-numeric timestamp
+ ("20231201123456_no_extension", Err(())), // No .sql extension
+ ("123_too_short.sql", Err(())), // Timestamp too short
+ ("202312011234567_too_long.sql", Err(())), // Timestamp too long
+ ("20231300123456_invalid_month.sql", Err(())), // Invalid month
+ ("20231232123456_invalid_day.sql", Err(())), // Invalid day
+ ("19991201123456_invalid_year.sql", Err(())), // Year too old
+ ];
+
+ for (input, expected) in cases {
+ let result = parse_migration_filename(input);
+ match expected {
+ Ok((expected_timestamp, expected_name)) => {
+ assert!(
+ result.is_ok(),
+ "Expected success for {}, got error: {:?}",
+ input,
+ result
+ );
+ let (timestamp, name) = result.unwrap();
+ assert_eq!(
+ timestamp, expected_timestamp,
+ "Timestamp mismatch for {}",
+ input
+ );
+ assert_eq!(name, expected_name, "Name mismatch for {}", input);
+ }
+ Err(_) => {
+ assert!(
+ result.is_err(),
+ "Expected error for {}, got success: {:?}",
+ input,
+ result
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_runtime_migration_discovery() {
+ // Create a temporary directory with migration files
+ let temp_dir = TempDir::new().unwrap();
+ let migrations_dir = temp_dir.path();
+
+ // Create test migration files with timestamp format
+ fs::write(
+ migrations_dir.join("20231201120000_initial_schema.sql"),
+ "CREATE TABLE users (id INTEGER PRIMARY KEY);",
+ )
+ .unwrap();
+
+ fs::write(
+ migrations_dir.join("20231201130000_add_posts.sql"),
+ "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER);",
+ )
+ .unwrap();
+
+ // Test discovery
+ let discovery = RuntimeMigrationDiscovery::new(migrations_dir).unwrap();
+ let migrations = discovery.get_migrations();
+
+ assert_eq!(migrations.len(), 2);
+
+ // Migrations should be sorted by timestamp
+ assert_eq!(migrations[0].version, 20231201120000);
+ assert_eq!(migrations[0].name, "initial_schema");
+ assert_eq!(migrations[1].version, 20231201130000);
+ assert_eq!(migrations[1].name, "add_posts");
+ }
+
+ #[test]
+ fn test_pending_migrations() {
+ // Create a temporary directory with migration files
+ let temp_dir = TempDir::new().unwrap();
+ let migrations_dir = temp_dir.path();
+
+ fs::write(
+ migrations_dir.join("20231201120000_initial_schema.sql"),
+ "CREATE TABLE users (id INTEGER PRIMARY KEY);",
+ )
+ .unwrap();
+
+ fs::write(
+ migrations_dir.join("20231201130000_add_posts.sql"),
+ "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER);",
+ )
+ .unwrap();
+
+ let discovery = RuntimeMigrationDiscovery::new(migrations_dir).unwrap();
+
+ // No pending migrations if we're at the latest timestamp
+ let pending = discovery.get_pending_migrations(20231201130000);
+ assert!(pending.is_empty());
+
+ // All migrations are pending if we're at timestamp 0
+ let pending = discovery.get_pending_migrations(0);
+ assert_eq!(pending.len(), 2);
+ assert_eq!(pending[0].version, 20231201120000);
+ assert_eq!(pending[1].version, 20231201130000);
+
+ // One migration pending if we're at the first timestamp
+ let pending = discovery.get_pending_migrations(20231201120000);
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0].version, 20231201130000);
+ }
+}