use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use parsql_migrations::config::MigrationConfig;
pub struct MigrationLoader {
migrations_dir: PathBuf,
config: MigrationConfig,
}
impl MigrationLoader {
pub fn new(migrations_dir: PathBuf, config: MigrationConfig) -> Self {
Self {
migrations_dir,
config,
}
}
pub fn load_sql_migrations(&self) -> Result<Vec<SqlMigration>> {
let mut migrations = Vec::new();
if !self.migrations_dir.exists() {
return Ok(migrations);
}
let entries = fs::read_dir(&self.migrations_dir)
.context("Failed to read migrations directory")?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
if filename.ends_with(".up.sql") {
let base_name = filename.trim_end_matches(".up.sql");
if let Some(underscore_pos) = base_name.find('_') {
let version_str = &base_name[..underscore_pos];
let name = &base_name[underscore_pos + 1..];
if let Ok(version) = version_str.parse::<i64>() {
let up_content = fs::read_to_string(&path)
.context(format!("Failed to read {}", path.display()))?;
let down_path = path.with_file_name(format!("{}.down.sql", base_name));
let down_content = if down_path.exists() {
Some(fs::read_to_string(&down_path)
.context(format!("Failed to read {}", down_path.display()))?)
} else {
None
};
migrations.push(SqlMigration {
version,
name: name.to_string(),
up_sql: up_content,
down_sql: down_content,
file_path: path.clone(),
});
}
}
}
}
}
migrations.sort_by_key(|m| m.version);
Ok(migrations)
}
pub fn get_migration_status_blocking(&self, db_url: &str) -> Result<Vec<MigrationStatus>> {
let migrations = self.load_sql_migrations()?;
let mut statuses = Vec::new();
if db_url.starts_with("sqlite:") {
let path = db_url.strip_prefix("sqlite:").unwrap_or(db_url);
if path != ":memory:" && std::path::Path::new(path).exists() {
let applied = self.get_applied_migrations_sqlite(path)?;
for migration in migrations {
let is_applied = applied.contains(&migration.version);
statuses.push(MigrationStatus {
version: migration.version,
name: migration.name,
applied: is_applied,
applied_at: if is_applied {
applied.iter()
.find(|&&v| v == migration.version)
.map(|_| chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string())
} else {
None
},
});
}
} else {
for migration in migrations {
statuses.push(MigrationStatus {
version: migration.version,
name: migration.name,
applied: false,
applied_at: None,
});
}
}
} else if db_url.starts_with("postgresql://") || db_url.starts_with("postgres://") {
let applied = self.get_applied_migrations_postgres(db_url)?;
for migration in migrations {
let applied_info = applied.iter().find(|(v, _)| *v == migration.version);
let is_applied = applied_info.is_some();
statuses.push(MigrationStatus {
version: migration.version,
name: migration.name,
applied: is_applied,
applied_at: applied_info.map(|(_, timestamp)| timestamp.clone()),
});
}
}
Ok(statuses)
}
pub async fn get_migration_status(&self, db_url: &str) -> Result<Vec<MigrationStatus>> {
self.get_migration_status_blocking(db_url)
}
fn get_applied_migrations_sqlite(&self, db_path: &str) -> Result<Vec<i64>> {
let conn = rusqlite::Connection::open(db_path)?;
let mut applied = Vec::new();
let table_exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)",
[&self.config.table.table_name],
|row| row.get(0),
).unwrap_or(false);
if table_exists {
let mut stmt = conn.prepare(&format!(
"SELECT version FROM {} ORDER BY version",
self.config.table.table_name
))?;
let version_iter = stmt.query_map([], |row| {
row.get::<_, i64>(0)
})?;
for version in version_iter {
applied.push(version?);
}
}
Ok(applied)
}
#[cfg(feature = "postgres")]
fn get_applied_migrations_postgres(&self, db_url: &str) -> Result<Vec<(i64, String)>> {
use postgres::{Client, NoTls};
let mut client = Client::connect(db_url, NoTls)
.context("Failed to connect to PostgreSQL database")?;
let mut applied = Vec::new();
let table_exists: bool = client.query_one(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)",
&[&self.config.table.table_name],
)?.get(0);
if table_exists {
let rows = client.query(
&format!("SELECT version, applied_at FROM {} ORDER BY version", self.config.table.table_name),
&[],
)?;
for row in rows {
let version: i64 = row.get(0);
let applied_at: std::time::SystemTime = row.get(1);
let datetime: chrono::DateTime<chrono::Utc> = applied_at.into();
let timestamp = datetime.format("%Y-%m-%d %H:%M:%S").to_string();
applied.push((version, timestamp));
}
}
Ok(applied)
}
#[cfg(not(feature = "postgres"))]
fn get_applied_migrations_postgres(&self, _db_url: &str) -> Result<Vec<(i64, String)>> {
Err(anyhow::anyhow!("PostgreSQL support not compiled in. Enable 'postgres' feature"))
}
}
pub struct SqlMigration {
pub version: i64,
pub name: String,
pub up_sql: String,
pub down_sql: Option<String>,
pub file_path: PathBuf,
}
pub struct MigrationStatus {
pub version: i64,
pub name: String,
pub applied: bool,
pub applied_at: Option<String>,
}