use sqlx::{AnyPool, Row};
use floz_macros_core::model::snapshot::{SnapshotModel, SnapshotField};
pub struct Migrator {
pool: AnyPool,
}
impl Migrator {
pub fn new(pool: AnyPool) -> Self {
Self { pool }
}
pub async fn migrate_table(&self, table_name: &str, history: &[SnapshotModel]) -> Result<(), String> {
let live_model: Option<SnapshotModel> = self.introspect_table(table_name).await
.map_err(|e| format!("Introspection failed: {}", e))?;
if live_model.is_none() {
println!("Table {} does not exist. Creating from v0.", table_name);
return self.apply_transitions(None, history).await;
}
let live_model = live_model.unwrap();
let mut target_index = None;
for (i, snapshot) in history.iter().enumerate().rev() {
if self.is_structural_match(&live_model, snapshot) {
target_index = Some(i);
break;
}
}
if let Some(idx) = target_index {
if idx == history.len() - 1 {
return Ok(());
}
let upcoming_history = &history[idx..];
return self.apply_transitions(Some(&live_model), upcoming_history).await;
} else {
return Err("Error: Database structure does not match any known historical state. Tampering detected.".to_string());
}
}
pub async fn rollback_table(&self, table_name: &str, history: &[SnapshotModel]) -> Result<(), String> {
let live_model: Option<SnapshotModel> = self.introspect_table(table_name).await
.map_err(|e| format!("Introspection failed: {}", e))?;
if live_model.is_none() {
return Err(format!("Table {} does not exist, cannot rollback.", table_name));
}
let live_model = live_model.unwrap();
let mut target_index = None;
for (i, snapshot) in history.iter().enumerate().rev() {
if self.is_structural_match(&live_model, snapshot) {
target_index = Some(i);
break;
}
}
if let Some(idx) = target_index {
if idx == 0 {
println!("Table {} is already at v0. Rollback would drop it (not implemented automatically for safety).", table_name);
return Ok(());
}
let target_version = &history[idx - 1];
return self.apply_transitions(Some(&live_model), std::slice::from_ref(target_version)).await;
} else {
return Err("Error: Database structure does not match any known historical state. Tampering detected.".to_string());
}
}
async fn introspect_table(&self, table_name: &str) -> sqlx::Result<Option<SnapshotModel>> {
let rows = sqlx::query(
"SELECT column_name, data_type, is_nullable, character_maximum_length, column_default
FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'"
)
.bind(table_name)
.fetch_all(&self.pool)
.await?;
if rows.is_empty() {
return Ok(None);
}
let mut db_columns = Vec::new();
for row in rows {
let col_name: String = row.get("column_name");
let data_type: String = row.get("data_type");
db_columns.push(SnapshotField {
rust_name: col_name.clone(),
column_name: col_name,
type_info: data_type, modifiers: Vec::new(),
renamed_from: None,
});
}
Ok(Some(SnapshotModel {
name: table_name.to_string(), table_name: table_name.to_string(),
db_columns,
relationships: Vec::new(),
constraints: Vec::new(),
}))
}
fn is_structural_match(&self, live: &SnapshotModel, snapshot: &SnapshotModel) -> bool {
if live.db_columns.len() != snapshot.db_columns.len() {
return false;
}
for live_col in &live.db_columns {
if !snapshot.db_columns.iter().any(|c| c.column_name == live_col.column_name) {
return false; }
}
true
}
async fn apply_transitions(&self, current: Option<&SnapshotModel>, upcoming: &[SnapshotModel]) -> Result<(), String> {
let mut current_state = current;
let mut tx = self.pool.begin().await.map_err(|e| e.to_string())?;
for next_state in upcoming {
let sqls = self.diff_snapshots(current_state, next_state);
for sql in sqls {
println!("INFO: Executing: {}", sql);
sqlx::query(&sql).execute(&mut *tx).await.map_err(|e| format!("Migration failed: {}\nSQL: {}", e, sql))?;
}
current_state = Some(next_state);
}
tx.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
pub fn diff_snapshots(&self, a: Option<&SnapshotModel>, b: &SnapshotModel) -> Vec<String> {
let mut queries = Vec::new();
let table = &b.table_name;
if let Some(old) = a {
for new_col in &b.db_columns {
if let Some(ref old_name) = new_col.renamed_from {
queries.push(format!("ALTER TABLE {} RENAME COLUMN {} TO {};", table, old_name, new_col.column_name));
}
}
for old_col in &old.db_columns {
if b.db_columns.iter().any(|c| c.renamed_from.as_ref() == Some(&old_col.column_name)) { continue; }
if !b.db_columns.iter().any(|c| c.column_name == old_col.column_name) {
queries.push(format!("ALTER TABLE {} DROP COLUMN {};", table, old_col.column_name));
}
}
for new_col in &b.db_columns {
if new_col.renamed_from.is_some() { continue; }
if !old.db_columns.iter().any(|c| c.column_name == new_col.column_name) {
let pg_type = self.map_type_to_pg(&new_col.type_info);
let mut def = format!("ALTER TABLE {} ADD COLUMN {} {}", table, new_col.column_name, pg_type);
if new_col.modifiers.contains(&"Primary".to_string()) { def.push_str(" PRIMARY KEY"); }
if new_col.modifiers.contains(&"Unique".to_string()) { def.push_str(" UNIQUE"); }
if !new_col.modifiers.contains(&"Nullable".to_string()) { def.push_str(" NOT NULL"); }
queries.push(def);
}
}
} else {
let mut defs = Vec::new();
for col in &b.db_columns {
let pg_type = self.map_type_to_pg(&col.type_info);
let mut def = format!("{} {}", col.column_name, pg_type);
if col.modifiers.contains(&"Primary".to_string()) { def.push_str(" PRIMARY KEY"); }
if col.modifiers.contains(&"AutoIncrement".to_string()) {
def = def.replace("INTEGER", "SERIAL").replace("BIGINT", "BIGSERIAL");
}
if col.modifiers.contains(&"Unique".to_string()) { def.push_str(" UNIQUE"); }
if !col.modifiers.contains(&"Nullable".to_string()) { def.push_str(" NOT NULL"); }
defs.push(def);
}
queries.push(format!("CREATE TABLE {} (\n {}\n);", table, defs.join(",\n ")));
}
queries
}
pub fn map_type_to_pg(&self, ti: &str) -> String {
if ti.starts_with("Varchar") || ti.starts_with("Decimal") {
ti.to_uppercase()
} else {
match ti {
"Integer" => "INTEGER".to_string(),
"BigInt" => "BIGINT".to_string(),
"Text" => "TEXT".to_string(),
"DateTime" => "TIMESTAMPTZ".to_string(),
"Uuid" => "UUID".to_string(),
"Bool" => "BOOLEAN".to_string(),
_ => "TEXT".to_string(),
}
}
}
}