use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::fs::{create_dir_all, read_to_string};
use std::hash::{Hash, Hasher};
use std::path::Path;
use anyhow::{anyhow, Context};
use rorm_declaration::imr::{Annotation, Field, InternalModelFormat, Model};
use rorm_declaration::migration::{Migration, Operation};
use tracing::info;
use crate::linter;
use crate::utils::migrations::{
convert_migration_to_file, convert_migrations_to_internal_models, get_existing_migrations,
};
use crate::utils::question;
use crate::utils::re::RE;
#[derive(Debug)]
pub struct MakeMigrationsOptions {
pub models_file: String,
pub migration_dir: String,
pub name: Option<String>,
pub non_interactive: bool,
pub warnings_disabled: bool,
}
pub fn check_options(options: &MakeMigrationsOptions) -> anyhow::Result<()> {
let models_file = Path::new(options.models_file.as_str());
if !models_file.exists() || !models_file.is_file() {
return Err(anyhow!("Models file does not exist"));
}
let migration_dir = Path::new(options.migration_dir.as_str());
if migration_dir.is_file() {
return Err(anyhow!("Migration directory cannot be created, is a file"));
}
if !migration_dir.exists() {
create_dir_all(migration_dir).with_context(|| "Couldn't create migration directory")?;
}
if let Some(name) = &options.name {
if !RE.migration_allowed_comment.is_match(name.as_str()) {
return Err(anyhow!(
"Custom migration name contains illegal characters!"
));
}
}
Ok(())
}
pub fn get_internal_models(models_file: &str) -> anyhow::Result<InternalModelFormat> {
let internal_str = read_to_string(Path::new(&models_file))
.with_context(|| "Couldn't read internal models file")?;
let internal: InternalModelFormat = serde_json::from_str(internal_str.as_str())
.with_context(|| "Error deserializing internal models file")?;
Ok(internal)
}
pub fn run_make_migrations(options: MakeMigrationsOptions) -> anyhow::Result<()> {
check_options(&options).with_context(|| "Error while checking options")?;
let internal_models = get_internal_models(&options.models_file)
.with_context(|| "Couldn't retrieve internal model files.")?;
linter::check_internal_models(&internal_models).with_context(|| "Model checks failed.")?;
let existing_migrations = get_existing_migrations(&options.migration_dir)
.with_context(|| "An error occurred while deserializing migrations")?;
let mut hasher = DefaultHasher::new();
internal_models.hash(&mut hasher);
let h = hasher.finish();
let mut new_migration = None;
if !existing_migrations.is_empty() {
let last_migration = &existing_migrations[existing_migrations.len() - 1];
if last_migration.hash == h.to_string() {
info!("No changes - nothing to do.");
return Ok(());
}
let constructed = convert_migrations_to_internal_models(&existing_migrations)
.with_context(|| "Error while parsing existing migration files")?;
let last_id: u16 = last_migration.id + 1;
let name = options.name.as_deref().unwrap_or("placeholder");
let mut op: Vec<Operation> = vec![];
let old_lookup: HashMap<String, &Model> = constructed
.models
.iter()
.map(|x| (x.name.clone(), x))
.collect();
let new_lookup: HashMap<String, &Model> = internal_models
.models
.iter()
.map(|x| (x.name.clone(), x))
.collect();
let mut renamed_models: Vec<(&Model, &Model)> = vec![];
let mut new_models: Vec<&Model> = vec![];
let mut deleted_models: Vec<&Model> = vec![];
let mut renamed_fields: HashMap<String, Vec<(&Field, &Field)>> = HashMap::new();
let mut new_fields: HashMap<String, Vec<&Field>> = HashMap::new();
let mut deleted_fields: HashMap<String, Vec<&Field>> = HashMap::new();
let mut altered_fields: HashMap<String, Vec<(&Field, &Field)>> = HashMap::new();
for new_model in &internal_models.models {
if !old_lookup.iter().any(|(a, _)| new_model.name == *a) {
new_models.push(new_model);
}
}
for old_model in &constructed.models {
if !new_lookup.iter().any(|(a, _)| old_model.name == *a) {
deleted_models.push(old_model);
}
}
for new_model in &internal_models.models {
let Some(old_model) = old_lookup.get(&new_model.name) else {
continue;
};
for new_field in &new_model.fields {
if !old_model.fields.iter().any(|z| z.name == new_field.name) {
new_fields
.entry(new_model.name.clone())
.or_default()
.push(new_field);
}
}
for old_field in &old_model.fields {
if !new_model.fields.iter().any(|z| z.name == old_field.name) {
deleted_fields
.entry(new_model.name.clone())
.or_default()
.push(old_field);
}
}
for old_field in &old_model.fields {
for new_field in &new_model.fields {
if old_field.db_type != new_field.db_type
|| old_field.annotations != new_field.annotations
{
altered_fields
.entry(new_model.name.clone())
.or_default()
.push((old_field, new_field));
}
}
}
}
if !new_models.is_empty() && !deleted_models.is_empty() {
for new_model in &new_models {
for old_model in &deleted_models {
if new_model.fields == old_model.fields
&& question(
format!(
"Did you rename the model {} to {}?",
old_model.name, new_model.name
)
.as_str(),
)
{
info!("Renamed model {} to {}.", old_model.name, new_model.name);
renamed_models.push((old_model, new_model));
}
}
}
}
for (old, new) in &renamed_models {
new_models.retain(|x| x != new);
deleted_models.retain(|x| x != old);
op.push(Operation::RenameModel {
old: old.name.clone(),
new: new.name.clone(),
})
}
let mut references: HashMap<String, Vec<Field>> = HashMap::new();
for new_model in &new_models {
let mut normal_fields = vec![];
for new_field in &new_model.fields {
if new_field
.annotations
.iter()
.any(|x| matches!(x, Annotation::ForeignKey(_)))
{
references
.entry(new_model.name.clone())
.or_default()
.push(new_field.clone());
} else {
normal_fields.push(new_field.clone());
}
}
op.push(Operation::CreateModel {
name: new_model.name.clone(),
fields: normal_fields,
});
info!("Created model {}", new_model.name);
}
for (model, fields) in references {
for field in fields {
op.push(Operation::CreateField {
model: model.clone(),
field,
});
}
}
for deleted_model in &deleted_models {
op.push(Operation::DeleteModel {
name: deleted_model.name.clone(),
});
info!("Deleted model {}", deleted_model.name);
}
for (model_name, new_fields) in &new_fields {
if let Some(old_fields) = deleted_fields.get(model_name) {
for new_field in new_fields {
for old_field in old_fields {
if new_field.db_type == old_field.db_type
&& new_field.annotations == old_field.annotations
&& question(
format!(
"Did you rename the field {} of model {model_name} to {}?",
old_field.name, new_field.name
)
.as_str(),
)
{
renamed_fields
.entry(model_name.clone())
.or_default()
.push((old_field, new_field));
info!(
"Renamed field {} of model {model_name} to {}.",
old_field.name, new_field.name
);
}
}
}
}
}
for (model_name, fields) in &renamed_fields {
for (old_field, new_field) in fields {
new_fields
.get_mut(model_name)
.unwrap()
.retain(|x| x.name != new_field.name);
deleted_fields
.get_mut(model_name)
.unwrap()
.retain(|x| x.name != old_field.name);
op.push(Operation::RenameField {
table_name: model_name.clone(),
old: old_field.name.clone(),
new: new_field.name.clone(),
})
}
}
for (model_name, fields) in &new_fields {
for field in fields {
op.push(Operation::CreateField {
model: model_name.clone(),
field: (*field).clone(),
});
info!("Added field {} to model {}", field.name, model_name);
}
}
for (model_name, fields) in &deleted_fields {
for field in fields {
op.push(Operation::DeleteField {
model: model_name.clone(),
name: field.name.clone(),
});
info!("Deleted field {} from model {}", field.name, model_name);
}
}
for (model, af) in &altered_fields {
for (old, new) in af {
if old.db_type != new.db_type {
#[expect(clippy::match_single_binding, reason = "It will be extended™")]
match (old.db_type, new.db_type) {
(_, _) => {
op.push(Operation::DeleteField {
model: model.clone(),
name: old.name.clone(),
});
op.push(Operation::CreateField {
model: model.clone(),
field: (*new).clone(),
});
info!("Recreated field {} on model {}", &new.name, &model);
}
}
} else {
op.push(Operation::DeleteField {
model: model.clone(),
name: old.name.clone(),
});
op.push(Operation::CreateField {
model: model.clone(),
field: (*new).clone(),
});
info!("Recreated field {} on model {}", &new.name, &model);
}
}
}
new_migration = Some(Migration {
hash: h.to_string(),
initial: false,
id: last_id,
name: name.to_string(),
dependency: Some(last_migration.id),
replaces: vec![],
operations: op,
});
} else {
if internal_models.models.is_empty() {
info!("No models found.");
} else {
let mut operations = vec![];
let mut references: HashMap<String, Vec<Field>> = HashMap::new();
operations.extend(internal_models.models.iter().map(|model| {
let mut normal_fields = vec![];
for field in &model.fields {
if field
.annotations
.iter()
.any(|x| matches!(x, Annotation::ForeignKey(_)))
{
references
.entry(model.name.clone())
.or_default()
.push(field.clone());
} else {
normal_fields.push(field.clone());
}
}
info!("Created model {}", model.name);
Operation::CreateModel {
name: model.name.clone(),
fields: normal_fields,
}
}));
operations.extend(references.into_iter().flat_map(|(model, fields)| {
fields
.iter()
.map(|field| Operation::CreateField {
model: model.clone(),
field: field.clone(),
})
.collect::<Vec<Operation>>()
}));
new_migration = Some(Migration {
hash: h.to_string(),
initial: true,
id: 1,
name: match &options.name {
None => "initial".to_string(),
Some(n) => n.clone(),
},
dependency: None,
replaces: vec![],
operations,
});
}
}
if let Some(migration) = new_migration {
let path = Path::new(options.migration_dir.as_str())
.join(format!("{:04}_{}.toml", migration.id, &migration.name));
convert_migration_to_file(migration, &path)
.with_context(|| "Error occurred while converting migration to file")?;
}
info!("Done.");
Ok(())
}