use indexmap::IndexMap;
use chrono::Utc;
use crate::tree::Tree;
use crate::models::ModelDef;
pub struct MigrationGenerator;
impl MigrationGenerator {
pub fn generate(tree: &Tree) -> IndexMap<String, String> {
let mut files = IndexMap::new();
let now = Utc::now();
let mut timestamp_idx = 0;
for model in tree.all_models() {
let ts = now.format("%Y%m%d%H%M%S").to_string();
let unique_ts = format!("{}{:02}", &ts[..14], timestamp_idx);
let sql = Self::generate_migration(model);
let path = format!("migrations/{}_{}.sql", unique_ts, model.table_name());
files.insert(path, sql);
timestamp_idx += 1;
}
files
}
fn generate_migration(model: &ModelDef) -> String {
let mut lines = vec![];
let table = model.table_name();
lines.push(format!("-- Create {} table", table));
lines.push(format!("CREATE TABLE {} (", table));
let mut columns = vec![];
if model.primary_key == "id" {
columns.push(" id SERIAL PRIMARY KEY".to_string());
} else {
columns.push(format!(" {} SERIAL PRIMARY KEY", model.primary_key));
}
for (name, column) in &model.columns {
if name == &model.primary_key {
continue;
}
let col_sql = Self::column_to_sql(name, column);
columns.push(col_sql);
}
if model.timestamps {
if !model.columns.contains_key("created_at") {
columns.push(" created_at TIMESTAMP NOT NULL DEFAULT NOW()".to_string());
}
if !model.columns.contains_key("updated_at") {
columns.push(" updated_at TIMESTAMP NOT NULL DEFAULT NOW()".to_string());
}
}
if model.has_soft_deletes() && !model.columns.contains_key("deleted_at") {
columns.push(" deleted_at TIMESTAMP".to_string());
}
for (name, column) in &model.columns {
if column.is_foreign {
let target = column.foreign_target
.clone()
.unwrap_or_else(|| {
let ref_table = name.strip_suffix("_id")
.map(|n| pluralize(&n.to_lowercase()))
.unwrap_or_else(|| "unknown".to_string());
format!("{}.id", ref_table)
});
let ref_parts: Vec<&str> = target.splitn(2, '.').collect();
let ref_table = ref_parts.first().unwrap_or(&"unknown");
let ref_col = ref_parts.get(1).unwrap_or(&"id");
columns.push(format!(
" FOREIGN KEY ({}) REFERENCES {}({}) ON DELETE CASCADE",
name, ref_table, ref_col
));
}
}
for index in &model.indexes {
let cols = index.columns.join(", ");
let index_type = index.index_type.as_deref().unwrap_or("INDEX");
if index_type == "unique" {
columns.push(format!(" UNIQUE ({})", cols));
} else {
columns.push(format!(" INDEX ({})", cols));
}
}
lines.push(columns.join(",\n"));
lines.push(");".to_string());
lines.join("\n")
}
fn column_to_sql(name: &str, column: &crate::models::Column) -> String {
let sql_type = Self::data_type_to_sql(column);
let nullable = if column.has_modifier("nullable") { "" } else { " NOT NULL" };
let default = column.get_modifier("default")
.and_then(|m| m.value.as_ref())
.map(|v| format!(" DEFAULT '{}'", v))
.unwrap_or_default();
let unique = if column.has_modifier("unique") { " UNIQUE" } else { "" };
format!(" {} {}{}{}{}", name, sql_type, nullable, default, unique)
}
fn data_type_to_sql(column: &crate::models::Column) -> String {
match column.data_type.as_str() {
"string" => {
if let Some(attr) = column.attributes.first() {
format!("VARCHAR({})", attr)
} else {
"VARCHAR(255)".to_string()
}
}
"char" => {
if let Some(attr) = column.attributes.first() {
format!("CHAR({})", attr)
} else {
"CHAR(1)".to_string()
}
}
"text" | "mediumtext" => "TEXT".to_string(),
"longtext" => "TEXT".to_string(),
"integer" | "unsignedInteger" => "INTEGER".to_string(),
"bigInteger" | "unsignedBigInteger" => "BIGINT".to_string(),
"smallInteger" | "tinyInteger" => "SMALLINT".to_string(),
"boolean" => "BOOLEAN".to_string(),
"float" => "FLOAT".to_string(),
"double" => "DOUBLE PRECISION".to_string(),
"decimal" => {
if let Some(attr) = column.attributes.first() {
let parts: Vec<&str> = attr.split(',').collect();
if parts.len() == 2 {
format!("NUMERIC({}, {})", parts[0], parts[1])
} else {
format!("NUMERIC({}, 2)", attr)
}
} else {
"NUMERIC(10, 2)".to_string()
}
}
"date" => "DATE".to_string(),
"datetime" | "datetimeTz" => "TIMESTAMP".to_string(),
"timestamp" | "timestampTz" => "TIMESTAMP".to_string(),
"json" | "jsonb" => "JSONB".to_string(),
"uuid" => "UUID".to_string(),
"ulid" => "VARCHAR(26)".to_string(),
"ipAddress" => "INET".to_string(),
"macAddress" => "MACADDR".to_string(),
"enum" => {
if let Some(attr) = column.attributes.first() {
let values: Vec<&str> = attr.split(',').collect();
let vals: Vec<String> = values.iter().map(|v| format!("'{}'", v.trim())).collect();
format!("VARCHAR(255) CHECK ({}.{} IN ({}))", column.name, column.name, vals.join(", "))
} else {
"VARCHAR(255)".to_string()
}
}
"rememberToken" => "VARCHAR(100)".to_string(),
"softDeletes" | "softDeletesTz" => "TIMESTAMP".to_string(),
"timestamps" | "timestampsTz" => "TIMESTAMP".to_string(),
_ => "VARCHAR(255)".to_string(),
}
}
}
fn pluralize(s: &str) -> String {
if s.ends_with('y') && !s.ends_with("ay") && !s.ends_with("ey") && !s.ends_with("oy") && !s.ends_with("uy") {
format!("{}ies", &s[..s.len() - 1])
} else if s.ends_with('s') || s.ends_with('x') || s.ends_with('z') || s.ends_with("ch") || s.ends_with("sh") {
format!("{}es", s)
} else {
format!("{}s", s)
}
}