use anyhow::{Context, Result};
use chrono::Utc;
use std::path::{Path, PathBuf};
use ormada_schema::{ColumnType, OnDeleteAction, SchemaOperation, TableSchema};
pub struct MigrationGenerator<'a> {
migrations_path: &'a Path,
}
impl<'a> MigrationGenerator<'a> {
pub fn new(migrations_path: &'a Path) -> Self {
Self { migrations_path }
}
pub fn generate(
&self,
migration_id: &str,
models: &[TableSchema],
operations: &[SchemaOperation],
) -> Result<PathBuf> {
let file_name = format!("{}.rs", migration_id);
let file_path = self.migrations_path.join(&file_name);
let content = self.generate_content(migration_id, models, operations);
std::fs::write(&file_path, content)
.with_context(|| format!("Failed to write migration file: {}", file_path.display()))?;
self.update_mod_rs(migration_id)?;
Ok(file_path)
}
fn generate_content(
&self,
migration_id: &str,
models: &[TableSchema],
operations: &[SchemaOperation],
) -> String {
let mut content = String::new();
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
content.push_str(&format!(
r#"//! Migration: {}
//!
//! Generated: {}
//! Generated by `ormada migrate make`
//!
//! Review this file before applying.
use ormada::prelude::*;
"#,
migration_id, timestamp
));
let mut tables_written = std::collections::HashSet::new();
for op in operations {
match op {
SchemaOperation::CreateTable(schema) => {
if tables_written.insert(&schema.name) {
content.push_str(&self.generate_table_schema(migration_id, schema, false));
content.push('\n');
}
}
SchemaOperation::AddColumn { table, .. }
| SchemaOperation::DropColumn { table, .. }
| SchemaOperation::RenameColumn { table, .. }
| SchemaOperation::AlterColumn { table, .. } => {
if tables_written.insert(table) {
if let Some(model) = models.iter().find(|m| &m.name == table) {
content.push_str(&self.generate_table_schema(
migration_id,
model,
true,
));
content.push('\n');
}
}
}
_ => {}
}
}
content
}
fn generate_table_schema(
&self,
migration_id: &str,
schema: &TableSchema,
is_delta: bool,
) -> String {
let mut s = String::new();
let struct_name = to_pascal_case(&schema.name);
if is_delta {
s.push_str(&format!(
"#[ormada_schema(table = \"{}\", migration = \"{}\", extends = {})]\n",
schema.name, migration_id, struct_name
));
} else {
s.push_str(&format!(
"#[ormada_schema(table = \"{}\", migration = \"{}\")]\n",
schema.name, migration_id
));
}
s.push_str(&format!("pub struct {} {{\n", struct_name));
for col in &schema.columns {
if col.dropped {
s.push_str(" #[drop]\n");
s.push_str(&format!(" pub {}: (),\n", col.name));
continue;
}
if let Some(ref from) = col.renamed_from {
s.push_str(&format!(" #[rename(from = \"{}\")]\n", from));
}
if col.primary_key {
if col.auto_increment {
s.push_str(" #[primary_key]\n");
} else {
s.push_str(" #[primary_key(auto_increment = false)]\n");
}
}
if let Some(fk) = schema.foreign_keys.iter().find(|fk| fk.column == col.name) {
let on_delete = format_on_delete(&fk.on_delete);
let ref_table = to_pascal_case(&fk.references_table);
if let Some(on_del) = on_delete {
s.push_str(&format!(
" #[foreign_key({}, on_delete = {})]\n",
ref_table, on_del
));
} else {
s.push_str(&format!(" #[foreign_key({})]\n", ref_table));
}
}
if col.indexed {
if let Some(ref name) = col.index_name {
s.push_str(&format!(" #[index(name = \"{}\")]\n", name));
} else {
s.push_str(" #[index]\n");
}
}
if col.unique {
s.push_str(" #[unique]\n");
}
if let Some(len) = col.max_length {
s.push_str(&format!(" #[max_length({})]\n", len));
}
if let Some(ref default) = col.default {
s.push_str(&format!(" #[default({})]\n", default));
}
if col.soft_delete {
s.push_str(" #[soft_delete]\n");
}
if col.nullable {
s.push_str(" #[nullable]\n");
}
let rust_type = column_type_to_rust(&col.column_type, col.nullable);
s.push_str(&format!(" pub {}: {},\n", col.name, rust_type));
}
s.push_str("}\n");
s
}
fn update_mod_rs(&self, migration_id: &str) -> Result<()> {
let mod_path = self.migrations_path.join("mod.rs");
let mut content = if mod_path.exists() {
std::fs::read_to_string(&mod_path)?
} else {
"//! Database migrations\n\n".to_string()
};
let mod_line = format!("pub mod {};", migration_id);
if !content.contains(&mod_line) {
content.push_str(&format!("{}\n", mod_line));
}
std::fs::write(&mod_path, content)?;
Ok(())
}
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => String::new(),
}
})
.collect()
}
fn format_on_delete(action: &OnDeleteAction) -> Option<&'static str> {
match action {
OnDeleteAction::Cascade => Some("Cascade"),
OnDeleteAction::SetNull => Some("SetNull"),
OnDeleteAction::SetDefault => Some("SetDefault"),
OnDeleteAction::Restrict => Some("Restrict"),
OnDeleteAction::NoAction => None, }
}
fn column_type_to_rust(col_type: &ColumnType, nullable: bool) -> String {
let base_type = match col_type {
ColumnType::Boolean => "bool",
ColumnType::SmallInteger => "i16",
ColumnType::Integer => "i32",
ColumnType::BigInteger => "i64",
ColumnType::Float => "f32",
ColumnType::Double => "f64",
ColumnType::Decimal { .. } => "rust_decimal::Decimal",
ColumnType::String(_) => "String",
ColumnType::Text => "String",
ColumnType::Binary => "Vec<u8>",
ColumnType::Date => "chrono::NaiveDate",
ColumnType::Time => "chrono::NaiveTime",
ColumnType::DateTime => "chrono::NaiveDateTime",
ColumnType::TimestampTz => "DateTimeWithTimeZone",
ColumnType::Uuid => "uuid::Uuid",
ColumnType::Json | ColumnType::JsonB => "serde_json::Value",
};
if nullable {
format!("Option<{}>", base_type)
} else {
base_type.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ormada_schema::{ColumnSchema, ForeignKeySchema};
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("books"), "Books");
assert_eq!(to_pascal_case("book_authors"), "BookAuthors");
assert_eq!(to_pascal_case("user_profile_settings"), "UserProfileSettings");
}
#[test]
fn test_column_type_to_rust() {
assert_eq!(column_type_to_rust(&ColumnType::Integer, false), "i32");
assert_eq!(column_type_to_rust(&ColumnType::Integer, true), "Option<i32>");
assert_eq!(column_type_to_rust(&ColumnType::String(Some(200)), false), "String");
assert_eq!(
column_type_to_rust(&ColumnType::TimestampTz, true),
"Option<DateTimeWithTimeZone>"
);
}
#[test]
fn test_format_on_delete() {
assert_eq!(format_on_delete(&OnDeleteAction::Cascade), Some("Cascade"));
assert_eq!(format_on_delete(&OnDeleteAction::SetNull), Some("SetNull"));
assert_eq!(format_on_delete(&OnDeleteAction::NoAction), None);
}
fn make_test_generator() -> MigrationGenerator<'static> {
MigrationGenerator::new(std::path::Path::new("/tmp/migrations"))
}
#[test]
fn test_generate_table_with_primary_key() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut id_col = ColumnSchema::new("id", ColumnType::Integer);
id_col.primary_key = true;
id_col.auto_increment = true;
schema.columns.push(id_col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[primary_key]"));
assert!(output.contains("pub id: i32"));
}
#[test]
fn test_generate_table_with_foreign_key() {
let gen = make_test_generator();
let mut schema = TableSchema::new("books");
let mut id_col = ColumnSchema::new("id", ColumnType::Integer);
id_col.primary_key = true;
schema.columns.push(id_col);
let author_id_col = ColumnSchema::new("author_id", ColumnType::Integer);
schema.columns.push(author_id_col);
schema.foreign_keys.push(
ForeignKeySchema::new("author_id", "authors", "id").on_delete(OnDeleteAction::Cascade),
);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[foreign_key(Authors, on_delete = Cascade)]"));
assert!(output.contains("pub author_id: i32"));
}
#[test]
fn test_generate_table_with_nullable() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut col = ColumnSchema::new("bio", ColumnType::Text);
col.nullable = true;
schema.columns.push(col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[nullable]"));
assert!(output.contains("pub bio: Option<String>"));
}
#[test]
fn test_generate_table_with_index() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut col = ColumnSchema::new("email", ColumnType::String(Some(255)));
col.indexed = true;
schema.columns.push(col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[index]"));
}
#[test]
fn test_generate_table_with_unique() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut col = ColumnSchema::new("email", ColumnType::String(Some(255)));
col.unique = true;
schema.columns.push(col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[unique]"));
}
#[test]
fn test_generate_table_with_max_length() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut col = ColumnSchema::new("name", ColumnType::String(Some(100)));
col.max_length = Some(100);
schema.columns.push(col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[max_length(100)]"));
}
#[test]
fn test_generate_table_with_default() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut col = ColumnSchema::new("active", ColumnType::Boolean);
col.default = Some("true".to_string());
schema.columns.push(col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[default(true)]"));
}
#[test]
fn test_generate_table_with_soft_delete() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut col = ColumnSchema::new("deleted_at", ColumnType::TimestampTz);
col.nullable = true;
col.soft_delete = true;
schema.columns.push(col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[soft_delete]"));
assert!(output.contains("#[nullable]"));
}
#[test]
fn test_generate_no_pub_mod_wrapper() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
schema.columns.push(ColumnSchema::new("id", ColumnType::Integer));
let ops = vec![SchemaOperation::CreateTable(schema.clone())];
let content = gen.generate_content("m001_test", &[schema], &ops);
assert!(!content.contains("pub mod m001_test {"));
assert!(content.contains("pub struct Users {"));
assert!(content.contains("Generated:"));
}
#[test]
fn test_generate_datetime_field_types() {
let gen = make_test_generator();
let mut schema = TableSchema::new("posts");
schema.columns.push(ColumnSchema::new("id", ColumnType::Integer));
schema.columns.push(ColumnSchema::new("created_at", ColumnType::TimestampTz));
schema.columns.push(ColumnSchema::new("published_date", ColumnType::Date));
schema.columns.push(ColumnSchema::new("event_time", ColumnType::Time));
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("pub created_at: DateTimeWithTimeZone"));
assert!(output.contains("pub published_date: chrono::NaiveDate"));
assert!(output.contains("pub event_time: chrono::NaiveTime"));
}
#[test]
fn test_generate_nullable_datetime() {
let gen = make_test_generator();
let mut schema = TableSchema::new("users");
let mut col = ColumnSchema::new("deleted_at", ColumnType::TimestampTz);
col.nullable = true;
schema.columns.push(col);
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("#[nullable]"));
assert!(output.contains("pub deleted_at: Option<DateTimeWithTimeZone>"));
}
#[test]
fn test_generate_complete_model() {
let gen = make_test_generator();
let mut schema = TableSchema::new("books");
let mut id_col = ColumnSchema::new("id", ColumnType::Integer);
id_col.primary_key = true;
id_col.auto_increment = true;
schema.columns.push(id_col);
schema.columns.push(ColumnSchema::new("title", ColumnType::String(Some(200))));
let author_id_col = ColumnSchema::new("author_id", ColumnType::Integer);
schema.columns.push(author_id_col);
schema.foreign_keys.push(
ForeignKeySchema::new("author_id", "authors", "id").on_delete(OnDeleteAction::Cascade),
);
schema.columns.push(ColumnSchema::new("created_at", ColumnType::TimestampTz));
schema.columns.push(ColumnSchema::new("updated_at", ColumnType::TimestampTz));
let output = gen.generate_table_schema("m001_initial", &schema, false);
assert!(
output.contains("#[ormada_schema(table = \"books\", migration = \"m001_initial\")]")
);
assert!(output.contains("pub struct Books {"));
assert!(output.contains("#[primary_key]"));
assert!(output.contains("pub id: i32"));
assert!(output.contains("pub title: String"));
assert!(output.contains("#[foreign_key(Authors, on_delete = Cascade)]"));
assert!(output.contains("pub author_id: i32"));
assert!(output.contains("pub created_at: DateTimeWithTimeZone"));
assert!(output.contains("pub updated_at: DateTimeWithTimeZone"));
}
#[test]
fn test_generate_all_column_types() {
let gen = make_test_generator();
let mut schema = TableSchema::new("all_types");
schema.columns.push(ColumnSchema::new("bool_col", ColumnType::Boolean));
schema.columns.push(ColumnSchema::new("i16_col", ColumnType::SmallInteger));
schema.columns.push(ColumnSchema::new("i32_col", ColumnType::Integer));
schema.columns.push(ColumnSchema::new("i64_col", ColumnType::BigInteger));
schema.columns.push(ColumnSchema::new("f32_col", ColumnType::Float));
schema.columns.push(ColumnSchema::new("f64_col", ColumnType::Double));
schema.columns.push(ColumnSchema::new("string_col", ColumnType::String(None)));
schema.columns.push(ColumnSchema::new("text_col", ColumnType::Text));
schema.columns.push(ColumnSchema::new("binary_col", ColumnType::Binary));
schema.columns.push(ColumnSchema::new("date_col", ColumnType::Date));
schema.columns.push(ColumnSchema::new("time_col", ColumnType::Time));
schema.columns.push(ColumnSchema::new("datetime_col", ColumnType::DateTime));
schema.columns.push(ColumnSchema::new("timestamp_col", ColumnType::TimestampTz));
schema.columns.push(ColumnSchema::new("uuid_col", ColumnType::Uuid));
schema.columns.push(ColumnSchema::new("json_col", ColumnType::Json));
let output = gen.generate_table_schema("m001", &schema, false);
assert!(output.contains("pub bool_col: bool"));
assert!(output.contains("pub i16_col: i16"));
assert!(output.contains("pub i32_col: i32"));
assert!(output.contains("pub i64_col: i64"));
assert!(output.contains("pub f32_col: f32"));
assert!(output.contains("pub f64_col: f64"));
assert!(output.contains("pub string_col: String"));
assert!(output.contains("pub text_col: String"));
assert!(output.contains("pub binary_col: Vec<u8>"));
assert!(output.contains("pub date_col: chrono::NaiveDate"));
assert!(output.contains("pub time_col: chrono::NaiveTime"));
assert!(output.contains("pub datetime_col: chrono::NaiveDateTime"));
assert!(output.contains("pub timestamp_col: DateTimeWithTimeZone"));
assert!(output.contains("pub uuid_col: uuid::Uuid"));
assert!(output.contains("pub json_col: serde_json::Value"));
}
}