use crate::migration::*;
use crate::utils::trad::{t, tf};
use anyhow::{Context, Result};
use chrono::Utc;
use std::fs;
use std::path::Path;
pub use crate::utils::*;
pub fn parse_create_file(path: &str) -> Result<ParsedSchema> {
let source: String =
fs::read_to_string(path).with_context(|| format!("Cannot read file: {}", path))?;
parse_seaorm_source(&source).with_context(|| format!("Cannot parse: {}", path))
}
fn extend_snapshot_content(ext_schema: &ParsedSchema) -> String {
format!(
"{SNAPSHOT_LENGTHS_MARKER}\n{}",
generate_create_file(ext_schema)
)
}
fn records_lengths(path: &str) -> Result<bool> {
Ok(fs::read_to_string(path)?.starts_with(SNAPSHOT_LENGTHS_MARKER))
}
fn snapshot_upgrades(
entities_path: &str,
schemas: &[ParsedSchema],
main_changes: &[Changes],
extend_planned: &[(ParsedSchema, Changes)],
migrations_path: &str,
) -> Result<Vec<(String, String)>> {
let mut upgrades = Vec::new();
for schema in schemas {
let path = snapshot_file_path(migrations_path, &schema.table_name);
if Path::new(&path).exists()
&& !main_changes
.iter()
.any(|c| c.table_name == schema.table_name)
&& !records_lengths(&path)?
{
upgrades.push((path, generate_snapshot_file(schema)));
}
}
for ext_schema in merge_extend_schemas(scan_extend_blocks(entities_path)?) {
let path = extend_snapshot_file_path(migrations_path, &ext_schema.table_name);
if Path::new(&path).exists()
&& !extend_planned
.iter()
.any(|(s, _)| s.table_name == ext_schema.table_name)
&& !records_lengths(&path)?
{
upgrades.push((path, extend_snapshot_content(&ext_schema)));
}
}
Ok(upgrades)
}
pub(crate) fn previous_snapshot(path: &str, current: &ParsedSchema) -> Result<ParsedSchema> {
let mut previous = parse_create_file(path)?;
if !records_lengths(path)? {
for col in &mut previous.columns {
if let Some(cur) = current.columns.iter().find(|c| c.name == col.name) {
col.max_length = cur.max_length;
}
}
}
Ok(previous)
}
const FRAMEWORK_TABLES: &[&str] = &[
"eihwaz_users",
"eihwaz_groupes",
"eihwaz_groupes_droits",
"eihwaz_users_groupes",
"eihwaz_sessions",
"eihwaz_reset_tokens",
];
pub fn scan_entities(entities_path: &str) -> Result<Vec<ParsedSchema>> {
let mut schemas = Vec::new();
let mut list_schemas: Vec<ParsedSchema> = Vec::new();
let mut targets: std::collections::HashMap<String, (String, String)> = FRAMEWORK_TABLES
.iter()
.map(|t| (t.to_string(), (t.to_string(), "id".to_string())))
.collect();
let mut entries: Vec<_> = fs::read_dir(entities_path)
.with_context(|| format!("Cannot read entities directory: {}", entities_path))?
.collect::<std::io::Result<_>>()?;
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Some(module) = path.file_stem().and_then(|n| n.to_str()) else {
continue;
};
if module == "mod" {
continue;
}
let source = fs::read_to_string(&path)
.with_context(|| format!("Cannot read file: {}", path.display()))?;
if let Some(ParsedModel { schema, lists, .. }) = parse_model_from_source(&source)
.with_context(|| format!("Invalid model!{{}} in {}", path.display()))?
{
let pk = schema
.primary_key
.as_ref()
.map_or_else(|| "id".to_string(), |pk| pk.name.clone());
targets.insert(module.to_string(), (schema.table_name.clone(), pk));
if FRAMEWORK_TABLES.contains(&schema.table_name.as_str()) {
continue;
}
schemas.push(schema);
list_schemas.extend(lists);
}
}
for schema in &mut schemas {
for fk in &mut schema.foreign_keys {
let Some((table, pk)) = targets.get(&fk.to_table) else {
anyhow::bail!(
"{}: `belongs_to: {} via {}` — no model `{}` ({}/{}.rs) and no framework table of that name",
schema.table_name,
fk.to_table,
fk.from_column,
fk.to_table,
entities_path,
fk.to_table
);
};
fk.to_table = table.clone();
fk.to_column = pk.clone();
}
}
schemas.extend(list_schemas);
let mut seen = std::collections::HashSet::new();
for schema in &schemas {
if !seen.insert(schema.table_name.as_str()) {
anyhow::bail!(
"two tables are named `{}` — a list field's table is `<owner table>_<field>`: rename the field or the other table",
schema.table_name
);
}
}
Ok(schemas)
}
pub fn update_migration_lib(migrations_path: &str, module_name: &str) -> Result<()> {
let lib = lib_path(migrations_path);
let box_entry = format!("Box::new({}::Migration)", module_name);
let mut state = if Path::new(&lib).exists() {
parse_lib_state(&fs::read_to_string(&lib)?)
} else {
LibState::default()
};
strip_sea_orm_cli_placeholder_file(migrations_path);
if state.mods.contains(&module_name.to_string()) {
return Ok(());
}
state.mods.push(module_name.to_string());
state
.entries
.retain(|e| !e.contains(SEA_ORM_CLI_PLACEHOLDER_MODULE));
state.entries.push(box_entry);
fs::write(&lib, render_lib(&state))?;
Ok(())
}
const SEA_ORM_CLI_PLACEHOLDER_MODULE: &str = "m20220101_000001_create_table";
#[derive(Default)]
struct LibState {
uses_migrations_table: bool,
mods: Vec<String>,
entries: Vec<String>,
}
fn find_vec_span(content: &str) -> Option<(usize, usize)> {
let start = content.find("vec![")?;
let open = start + "vec![".len() - 1;
let mut depth = 0i32;
for (i, ch) in content[open..].char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some((open, open + i + 1));
}
}
_ => {}
}
}
None
}
fn extract_box_entries(vec_content: &str) -> Vec<String> {
let mut entries = Vec::new();
let mut rest = vec_content;
while let Some(start) = rest.find("Box::new(") {
let after = &rest[start + "Box::new(".len()..];
let Some(end) = after.find(')') else { break };
entries.push(format!("Box::new({})", &after[..end]));
rest = &after[end + 1..];
}
entries
}
fn parse_lib_state(content: &str) -> LibState {
let uses_migrations_table = content.contains("migrations_table");
let mut mods = Vec::new();
for line in content.lines() {
if let Some(rest) = line.trim().strip_prefix("mod ")
&& let Some(name) = rest.strip_suffix(';')
&& name != SEA_ORM_CLI_PLACEHOLDER_MODULE
&& !mods.contains(&name.to_string())
{
mods.push(name.to_string());
}
}
let entries = match find_vec_span(content) {
Some((s, e)) => extract_box_entries(&content[s..e])
.into_iter()
.filter(|entry| !entry.contains(SEA_ORM_CLI_PLACEHOLDER_MODULE))
.collect(),
None => Vec::new(),
};
LibState {
uses_migrations_table,
mods,
entries,
}
}
fn render_lib(state: &LibState) -> String {
let mut out = String::new();
if state.uses_migrations_table {
out.push_str("use runique::prelude::migrations_table;\n");
}
out.push_str("use sea_orm_migration::prelude::*;\n");
for m in &state.mods {
out.push_str(&format!("mod {};\n", m));
}
out.push('\n');
out.push_str("pub struct Migrator;\n\n");
out.push_str("#[async_trait::async_trait]\n");
out.push_str("impl MigratorTrait for Migrator {\n");
out.push_str(" fn migrations() -> Vec<Box<dyn MigrationTrait>> {\n ");
if state.entries.is_empty() {
out.push_str("vec![]");
} else {
out.push_str("vec![\n");
for e in &state.entries {
out.push_str(" ");
out.push_str(e);
out.push_str(",\n");
}
out.push_str(" ]");
}
out.push_str("\n }\n}\n");
out
}
fn strip_sea_orm_cli_placeholder_file(migrations_path: &str) {
let placeholder_file = format!("{}/{}.rs", migrations_path, SEA_ORM_CLI_PLACEHOLDER_MODULE);
if Path::new(&placeholder_file).exists() {
let _ = fs::remove_file(&placeholder_file);
}
}
pub(crate) fn topological_sort_changes(
changes: Vec<crate::migration::utils::types::Changes>,
) -> Vec<crate::migration::utils::types::Changes> {
use std::collections::{BTreeMap, BTreeSet};
let new_tables: BTreeSet<String> = changes
.iter()
.filter(|c| c.is_new_table)
.map(|c| c.table_name.clone())
.collect();
let mut waits_for: BTreeMap<String, BTreeSet<String>> = new_tables
.iter()
.map(|t| (t.clone(), BTreeSet::new()))
.collect();
for change in changes.iter().filter(|c| c.is_new_table) {
for fk in &change.added_fks {
if new_tables.contains(&fk.to_table) && fk.to_table != change.table_name {
waits_for
.entry(change.table_name.clone())
.or_default()
.insert(fk.to_table.clone());
}
}
}
let mut sorted_names: Vec<String> = Vec::with_capacity(new_tables.len());
while !waits_for.is_empty() {
let next = waits_for
.iter()
.find(|(_, deps)| deps.is_empty())
.or_else(|| waits_for.iter().next())
.map(|(t, _)| t.clone())
.expect("waits_for is not empty");
waits_for.remove(&next);
for deps in waits_for.values_mut() {
deps.remove(&next);
}
sorted_names.push(next);
}
let mut by_name: BTreeMap<String, crate::migration::utils::types::Changes> = changes
.into_iter()
.map(|c| (c.table_name.clone(), c))
.collect();
let mut result: Vec<crate::migration::utils::types::Changes> =
Vec::with_capacity(by_name.len());
for name in sorted_names {
if let Some(c) = by_name.remove(&name) {
result.push(c);
}
}
result.extend(by_name.into_values());
result
}
pub fn collect_destructive_messages(all_changes: &[Changes]) -> Vec<String> {
let dropped = all_changes.iter().flat_map(|c| {
c.dropped_columns
.iter()
.map(|col| format!(" {}.{}: DROP COLUMN (data loss)", c.table_name, col.name))
});
let type_changes = all_changes.iter().flat_map(|c| {
c.modified_columns
.iter()
.filter(|(old, new)| {
old.col_type != new.col_type
|| old.enum_string_values.is_empty() != new.enum_string_values.is_empty()
})
.map(|(old, new)| {
if old.col_type != new.col_type {
format!(
" {}.{}: type {} -> {}",
c.table_name, old.name, old.col_type, new.col_type
)
} else if new.enum_string_values.is_empty() {
format!(
" {}.{}: enum -> {} (manual migration required)",
c.table_name, old.name, new.col_type
)
} else {
format!(
" {}.{}: {} -> enum (manual migration required)",
c.table_name, old.name, old.col_type
)
}
})
});
let nullable_to_required = all_changes.iter().flat_map(|c| {
c.modified_columns
.iter()
.filter(|(old, new)| old.nullable && !new.nullable && old.col_type == new.col_type)
.map(|(_, new)| {
format!(
" {}.{}: nullable -> not_null (requires a default or backfill)",
c.table_name, new.name
)
})
});
let length_shrinks = all_changes.iter().flat_map(|c| {
c.modified_columns
.iter()
.filter(|(old, new)| {
old.col_type == new.col_type && length_may_shrink(old.max_length, new.max_length)
})
.map(|(old, new)| {
let len =
|l: Option<u32>| l.map_or_else(|| "default".to_string(), |n| n.to_string());
format!(
" {}.{}: length {} -> {} (longer values would be cut or refused)",
c.table_name,
new.name,
len(old.max_length),
len(new.max_length)
)
})
});
let dropped_fks = all_changes.iter().flat_map(|c| {
c.dropped_fks.iter().map(|fk| {
format!(
" {}.{}: DROP FOREIGN KEY -> {} (orphan records possible)",
c.table_name, fk.from_column, fk.to_table
)
})
});
let cascade_fks = all_changes
.iter()
.filter(|c| !c.is_new_table)
.flat_map(|c| {
c.added_fks
.iter()
.filter(|fk| fk.on_delete.to_uppercase() == "CASCADE")
.map(|fk| {
format!(
" {}.{}: ADD FOREIGN KEY -> {} ON DELETE CASCADE (existing rows may be deleted)",
c.table_name, fk.from_column, fk.to_table
)
})
});
dropped
.chain(type_changes)
.chain(nullable_to_required)
.chain(length_shrinks)
.chain(dropped_fks)
.chain(cascade_fks)
.collect()
}
fn length_may_shrink(old: Option<u32>, new: Option<u32>) -> bool {
match (old, new) {
(Some(old), Some(new)) => new < old,
(None, Some(_)) => true,
(Some(old), None) => old > 255,
(None, None) => false,
}
}
fn report_and_bail_if_any(
messages: &[String],
header_key: &str,
bail_key: &str,
force: bool,
) -> Result<()> {
if messages.is_empty() || force {
return Ok(());
}
eprintln!("\n{}", t(header_key));
for msg in messages {
eprintln!("{}", msg);
}
anyhow::bail!("{}", t(bail_key));
}
fn check_destructive(all_changes: &[Changes], force: bool) -> Result<()> {
let blocking = collect_destructive_messages(all_changes);
report_and_bail_if_any(
&blocking,
"makemigrations.destructive_detected",
"makemigrations.destructive_require_force",
force,
)
}
fn collect_long_identifier_messages(all_changes: &[Changes]) -> Vec<String> {
const MAX_LEN: usize = 63;
let mut messages = Vec::new();
for change in all_changes {
for fk in change.added_fks.iter().chain(change.dropped_fks.iter()) {
let name = format!(
"{}_{}_{}_fkey",
change.table_name, fk.from_column, fk.to_table
);
if name.len() > MAX_LEN {
messages.push(format!(
" {name} ({len} characters, max {MAX_LEN}) — shorten the table name, '{col}', or the target table name",
len = name.len(),
col = fk.from_column,
));
}
}
for idx in change
.added_indexes
.iter()
.chain(change.dropped_indexes.iter())
{
if idx.name.len() > MAX_LEN {
messages.push(format!(
" {} ({} characters, max {MAX_LEN}) — shorten the table or column names in this index",
idx.name,
idx.name.len(),
));
}
}
}
messages
}
fn check_identifier_lengths(all_changes: &[Changes]) -> Result<()> {
let long_ids = collect_long_identifier_messages(all_changes);
report_and_bail_if_any(
&long_ids,
"makemigrations.long_identifier_detected",
"makemigrations.long_identifier_rename_required",
false,
)
}
pub fn seaorm_alter_module_name(timestamp: &str, table: &str) -> String {
format!("m{}_alter_{}_table", timestamp, table)
}
pub fn seaorm_alter_file_path(migrations_path: &str, timestamp: &str, table: &str) -> String {
format!(
"{}/{}.rs",
migrations_path,
seaorm_alter_module_name(timestamp, table)
)
}
pub fn seaorm_extend_module_name(timestamp: &str, table: &str) -> String {
format!("m{}_extend_{}_table", timestamp, table)
}
pub fn seaorm_extend_file_path(migrations_path: &str, timestamp: &str, table: &str) -> String {
format!(
"{}/m{}_extend_{}_table.rs",
migrations_path, timestamp, table
)
}
pub fn scan_extend_blocks(entities_path: &str) -> Result<Vec<ParsedSchema>> {
let mut schemas = Vec::new();
let entries = fs::read_dir(entities_path)
.with_context(|| format!("Cannot read entities directory: {}", entities_path))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
if path.file_name().and_then(|n| n.to_str()) == Some("mod.rs") {
continue;
}
let source = fs::read_to_string(&path)
.with_context(|| format!("Cannot read file: {}", path.display()))?;
let blocks = parse_extend_blocks_from_source(&source)
.with_context(|| format!("Invalid extend!{{}} in {}", path.display()))?;
for schema in blocks {
schemas.push(schema);
}
}
Ok(schemas)
}
pub fn merge_extend_schemas(schemas: Vec<ParsedSchema>) -> Vec<ParsedSchema> {
use std::collections::HashMap;
let mut by_table: HashMap<String, Vec<ParsedColumn>> = HashMap::new();
let mut order: Vec<String> = Vec::new();
for schema in schemas {
if !by_table.contains_key(&schema.table_name) {
order.push(schema.table_name.clone());
by_table.insert(schema.table_name.clone(), Vec::new());
}
by_table
.get_mut(&schema.table_name)
.unwrap()
.extend(schema.columns);
}
order
.into_iter()
.map(|table_name| {
let columns = by_table.remove(&table_name).unwrap_or_default();
ParsedSchema {
table_name,
primary_key: None,
columns,
foreign_keys: Vec::new(),
indexes: Vec::new(),
}
})
.collect()
}
pub fn run(entities_path: &str, migrations_path: &str, force: bool) -> Result<()> {
let schemas = scan_entities(entities_path)?;
fs::create_dir_all(snapshot_dir(migrations_path))?;
let mut main_changes = compute_main_changes(&schemas, migrations_path)?;
let extend_planned = plan_extend_changes(entities_path, migrations_path)?;
let upgrades = snapshot_upgrades(
entities_path,
&schemas,
&main_changes,
&extend_planned,
migrations_path,
)?;
if main_changes.is_empty() && extend_planned.is_empty() {
for (path, content) in &upgrades {
fs::write(path, content)?;
}
ensure_admin_migration_positioned(migrations_path)?;
if !upgrades.is_empty() {
println!(
"{}",
tf("makemigrations.snapshots_upgraded", &[upgrades.len()])
);
}
return Ok(());
}
let mut destructive_set: Vec<Changes> = main_changes.clone();
destructive_set.extend(extend_planned.iter().map(|(_, c)| c.clone()));
check_destructive(&destructive_set, force)?;
check_identifier_lengths(&destructive_set)?;
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
main_changes = topological_sort_changes(main_changes);
let mut plan = Plan::default();
build_main_plan(
&mut plan,
&main_changes,
&schemas,
migrations_path,
×tamp,
);
build_extend_plan(&mut plan, &extend_planned, migrations_path, ×tamp);
plan.files.extend(upgrades);
let module_count = plan.lib_modules.len();
commit_plan(&plan, migrations_path)?;
println!("{}", tf("makemigrations.files_ready", &[module_count]));
Ok(())
}
#[derive(Default)]
struct Plan {
files: Vec<(String, String)>,
dirs: Vec<String>,
lib_modules: Vec<String>,
}
fn compute_main_changes(schemas: &[ParsedSchema], migrations_path: &str) -> Result<Vec<Changes>> {
let mut all_changes: Vec<Changes> = Vec::new();
for schema in schemas {
let snap_path = snapshot_file_path(migrations_path, &schema.table_name);
let changes = if Path::new(&snap_path).exists() {
let previous = previous_snapshot(&snap_path, schema)?;
diff_schemas(&previous, schema)
} else {
Changes {
table_name: schema.table_name.clone(),
added_columns: db_columns(schema).into_iter().cloned().collect(),
dropped_columns: vec![],
modified_columns: vec![],
renamed_columns: vec![],
added_fks: schema.foreign_keys.clone(),
dropped_fks: vec![],
added_indexes: schema.indexes.clone(),
dropped_indexes: vec![],
is_new_table: true,
enum_renames: vec![],
enum_value_adds: vec![],
enum_value_drops: vec![],
}
};
if !changes.is_empty() {
all_changes.push(changes);
}
}
Ok(all_changes)
}
fn cycle_keys<'a>(
all_changes: &[Changes],
schemas: &'a [ParsedSchema],
) -> std::collections::HashMap<&'a str, CycleKeys<'a>> {
use std::collections::{HashMap, HashSet};
let new_tables: HashSet<&str> = all_changes
.iter()
.filter(|c| c.is_new_table)
.map(|c| c.table_name.as_str())
.collect();
let mut created: HashSet<&str> = HashSet::new();
let mut keys: HashMap<&'a str, CycleKeys<'a>> = HashMap::new();
for change in all_changes.iter().filter(|c| c.is_new_table) {
let Some(schema) = schemas.iter().find(|s| s.table_name == change.table_name) else {
continue;
};
let table = schema.table_name.as_str();
for fk in &schema.foreign_keys {
let target = fk.to_table.as_str();
if target != table && new_tables.contains(target) && !created.contains(target) {
keys.entry(table).or_default().forward.push(fk);
if let Some(target) = schemas.iter().find(|s| s.table_name == target) {
keys.entry(target.table_name.as_str())
.or_default()
.closing
.push((table, fk));
}
}
}
created.insert(table);
}
keys
}
fn build_main_plan(
plan: &mut Plan,
all_changes: &[Changes],
schemas: &[ParsedSchema],
migrations_path: &str,
timestamp: &str,
) {
let cycle_keys = cycle_keys(all_changes, schemas);
let no_cycle = CycleKeys::default();
for change in all_changes {
let schema = schemas
.iter()
.find(|s| s.table_name == change.table_name)
.unwrap();
plan.files.push((
snapshot_file_path(migrations_path, &change.table_name),
generate_snapshot_file(schema),
));
if change.is_new_table {
let module_name = seaorm_create_module_name(timestamp, &change.table_name);
let seaorm_path =
seaorm_create_file_path(migrations_path, timestamp, &change.table_name);
let keys = cycle_keys
.get(change.table_name.as_str())
.unwrap_or(&no_cycle);
plan.files
.push((seaorm_path, generate_create_file_in_cycle(schema, keys)));
plan.lib_modules.push(module_name);
} else {
let module_name = seaorm_alter_module_name(timestamp, &change.table_name);
let seaorm_path =
seaorm_alter_file_path(migrations_path, timestamp, &change.table_name);
plan.files.push((seaorm_path, generate_alter_file(change)));
plan.lib_modules.push(module_name);
}
}
}
fn build_extend_plan(
plan: &mut Plan,
planned: &[(ParsedSchema, Changes)],
migrations_path: &str,
timestamp: &str,
) {
if planned.is_empty() {
return;
}
plan.dirs.push(extend_snapshot_dir(migrations_path));
for (ext_schema, changes) in planned {
plan.files.push((
extend_snapshot_file_path(migrations_path, &ext_schema.table_name),
extend_snapshot_content(ext_schema),
));
let module_name = seaorm_extend_module_name(timestamp, &ext_schema.table_name);
let seaorm_path =
seaorm_extend_file_path(migrations_path, timestamp, &ext_schema.table_name);
plan.files.push((seaorm_path, generate_alter_file(changes)));
plan.lib_modules.push(module_name);
}
}
fn commit_plan(plan: &Plan, migrations_path: &str) -> Result<()> {
for dir in &plan.dirs {
fs::create_dir_all(dir)?;
}
let lib_file = lib_path(migrations_path);
let lib_backup: Option<String> = if Path::new(&lib_file).exists() {
Some(fs::read_to_string(&lib_file)?)
} else {
None
};
let mut file_backups: StrMap = StrMap::new();
for (path, _) in &plan.files {
if Path::new(path).exists()
&& let Ok(prev) = fs::read_to_string(path)
{
file_backups.insert(path.clone(), prev);
}
}
let mut written: Vec<String> = Vec::new();
let write_result: Result<()> = (|| {
for (path, content) in &plan.files {
fs::write(path, content).with_context(|| format!("Failed to write: {}", path))?;
written.push(path.clone());
}
for module_name in &plan.lib_modules {
update_migration_lib(migrations_path, module_name)?;
}
ensure_admin_migration_positioned(migrations_path)?;
Ok(())
})();
if let Err(e) = write_result {
eprintln!(
"\n[makemigrations] Error: {}. Rollback generated files...",
e
);
for path in &written {
match file_backups.get(path) {
Some(prev) => {
if let Err(re) = fs::write(path, prev) {
eprintln!(" warning: cannot restore {} : {}", path, re);
} else {
eprintln!(" restored: {}", path);
}
}
None => {
if let Err(re) = fs::remove_file(path) {
eprintln!(" warning: cannot delete {} : {}", path, re);
} else {
eprintln!(" deleted: {}", path);
}
}
}
}
match lib_backup {
Some(content) => {
let _ = fs::write(&lib_file, content);
eprintln!(" lib.rs restored");
}
None => {
let _ = fs::remove_file(&lib_file);
}
}
return Err(e);
}
Ok(())
}
pub fn ensure_admin_migration_positioned(migrations_path: &str) -> Result<()> {
let lib_file = lib_path(migrations_path);
if !Path::new(&lib_file).exists() {
return Ok(());
}
let content = fs::read_to_string(&lib_file)?;
strip_sea_orm_cli_placeholder_file(migrations_path);
let mut state = parse_lib_state(&content);
state.uses_migrations_table = true;
let admin_box = "Box::new(migrations_table::AdminTableMigration)".to_string();
let sessions_box = "Box::new(migrations_table::EihwazSessionsMigration)".to_string();
let reset_box = "Box::new(migrations_table::EihwazResetTokensMigration)".to_string();
let users_box = "Box::new(migrations_table::EihwazUsersMigration)".to_string();
const FRAMEWORK_TABLE_PATTERNS: &[&str] = &[
"create_eihwaz_users_table",
"create_eihwaz_groupes_table",
"create_eihwaz_groupes_droits_table",
"create_eihwaz_users_groupes_table",
"create_eihwaz_sessions_table",
"create_eihwaz_reset_tokens_table",
];
state
.entries
.retain(|e| e != &users_box && e != &sessions_box && e != &reset_box && e != &admin_box);
state
.mods
.retain(|m| !FRAMEWORK_TABLE_PATTERNS.iter().any(|pat| m.contains(pat)));
state.entries.splice(
0..0,
[
users_box.clone(),
sessions_box.clone(),
admin_box.clone(),
reset_box.clone(),
],
);
let result = render_lib(&state);
if result != content {
fs::write(&lib_file, &result)?;
}
Ok(())
}
fn plan_extend_changes(
entities_path: &str,
migrations_path: &str,
) -> Result<Vec<(ParsedSchema, Changes)>> {
let raw_extends = scan_extend_blocks(entities_path)?;
if raw_extends.is_empty() {
return Ok(Vec::new());
}
let extend_schemas = merge_extend_schemas(raw_extends);
let mut planned: Vec<(ParsedSchema, Changes)> = Vec::new();
for ext_schema in extend_schemas {
let snap_path = extend_snapshot_file_path(migrations_path, &ext_schema.table_name);
let changes = if Path::new(&snap_path).exists() {
let previous = previous_snapshot(&snap_path, &ext_schema)?;
diff_schemas(&previous, &ext_schema)
} else {
Changes {
table_name: ext_schema.table_name.clone(),
added_columns: ext_schema.columns.clone(),
dropped_columns: vec![],
modified_columns: vec![],
renamed_columns: vec![],
added_fks: vec![],
dropped_fks: vec![],
added_indexes: vec![],
dropped_indexes: vec![],
is_new_table: false, enum_renames: vec![],
enum_value_adds: vec![],
enum_value_drops: vec![],
}
};
if changes.is_empty() {
continue;
}
planned.push((ext_schema, changes));
}
Ok(planned)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migration::utils::types::{ParsedColumn, ParsedFk};
fn temp(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("rq_mk_{tag}_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).unwrap();
dir
}
fn schema(table: &str, fks: &[&str]) -> ParsedSchema {
ParsedSchema {
table_name: table.to_string(),
primary_key: Some(ParsedColumn {
name: "id".into(),
col_type: "Integer".into(),
..ParsedColumn::default()
}),
columns: vec![ParsedColumn {
name: "title".into(),
col_type: "String".into(),
max_length: Some(50),
..ParsedColumn::default()
}],
foreign_keys: fks
.iter()
.map(|to| ParsedFk {
from_column: format!("{to}_id"),
to_table: (*to).to_string(),
to_column: "id".into(),
on_delete: "Cascade".into(),
on_update: "NoAction".into(),
})
.collect(),
indexes: vec![],
}
}
fn new_table(s: &ParsedSchema) -> Changes {
Changes {
table_name: s.table_name.clone(),
added_columns: s.columns.clone(),
dropped_columns: vec![],
modified_columns: vec![],
renamed_columns: vec![],
added_fks: s.foreign_keys.clone(),
dropped_fks: vec![],
added_indexes: vec![],
dropped_indexes: vec![],
is_new_table: true,
enum_renames: vec![],
enum_value_adds: vec![],
enum_value_drops: vec![],
}
}
#[test]
fn an_extend_snapshot_is_marked_as_recording_lengths() {
let content = extend_snapshot_content(&schema("eihwaz_users", &[]));
assert!(content.starts_with(SNAPSHOT_LENGTHS_MARKER), "{content}");
assert!(content.contains("create_table"), "{content}");
}
#[test]
fn only_untouched_old_snapshots_are_upgraded() {
let (entities, migrations) = (temp("ent"), temp("mig"));
let (e, m) = (entities.to_str().unwrap(), migrations.to_str().unwrap());
fs::create_dir_all(snapshot_dir(m)).unwrap();
let tables = [
schema("old_untouched", &[]),
schema("old_changed", &[]),
schema("recorded", &[]),
];
fs::write(snapshot_file_path(m, "old_untouched"), "// old").unwrap();
fs::write(snapshot_file_path(m, "old_changed"), "// old").unwrap();
fs::write(
snapshot_file_path(m, "recorded"),
generate_snapshot_file(&tables[2]),
)
.unwrap();
fs::write(
entities.join("ext.rs"),
r#"extend! { table: "eihwaz_users", fields: { bio: text } }"#,
)
.unwrap();
fs::create_dir_all(extend_snapshot_dir(m)).unwrap();
fs::write(extend_snapshot_file_path(m, "eihwaz_users"), "// old").unwrap();
let changed = [new_table(&tables[1])];
let upgrades = snapshot_upgrades(e, &tables, &changed, &[], m).unwrap();
let paths: Vec<&str> = upgrades.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
[
snapshot_file_path(m, "old_untouched").as_str(),
extend_snapshot_file_path(m, "eihwaz_users").as_str()
]
);
assert!(upgrades[1].1.starts_with(SNAPSHOT_LENGTHS_MARKER));
let ext = merge_extend_schemas(scan_extend_blocks(e).unwrap()).remove(0);
let planned = [(ext.clone(), new_table(&ext))];
let upgrades = snapshot_upgrades(e, &tables, &changed, &planned, m).unwrap();
assert_eq!(upgrades.len(), 1);
let _ = (
fs::remove_dir_all(&entities),
fs::remove_dir_all(&migrations),
);
}
#[test]
fn lib_entries_are_read_whatever_the_layout() {
let content = "fn m() -> Vec<X> { vec![Box::new(a::Migration), Box::new(b::Migration)] }";
let (s, e) = find_vec_span(content).unwrap();
assert_eq!(
&content[s..e],
"[Box::new(a::Migration), Box::new(b::Migration)]"
);
assert_eq!(
extract_box_entries(&content[s..e]),
["Box::new(a::Migration)", "Box::new(b::Migration)"]
);
assert_eq!(find_vec_span("no list here"), None);
}
#[test]
fn without_any_model_the_framework_migrations_are_wired_in() {
let (entities, migrations) = (temp("ent_none"), temp("mig_none"));
let (e, m) = (entities.to_str().unwrap(), migrations.to_str().unwrap());
fs::write(
migrations.join("lib.rs"),
"pub use sea_orm_migration::prelude::*;\n\nmod m20220101_000001_create_table;\n\n\
pub struct Migrator;\n\n#[async_trait::async_trait]\nimpl MigratorTrait for Migrator {\n \
fn migrations() -> Vec<Box<dyn MigrationTrait>> {\n \
vec![Box::new(m20220101_000001_create_table::Migration)]\n }\n}\n",
)
.unwrap();
fs::write(
migrations.join("m20220101_000001_create_table.rs"),
"// placeholder",
)
.unwrap();
run(e, m, false).unwrap();
let lib = fs::read_to_string(migrations.join("lib.rs")).unwrap();
let placeholder_file = migrations.join("m20220101_000001_create_table.rs").exists();
let _ = (
fs::remove_dir_all(&entities),
fs::remove_dir_all(&migrations),
);
assert!(
lib.contains("migrations_table::EihwazUsersMigration"),
"{lib}"
);
assert!(!lib.contains("m20220101_000001_create_table"), "{lib}");
assert!(!placeholder_file);
}
#[test]
fn the_sea_orm_cli_placeholder_is_removed() {
let dir = temp("placeholder");
let m = dir.to_str().unwrap();
let file = format!("{m}/{SEA_ORM_CLI_PLACEHOLDER_MODULE}.rs");
fs::write(&file, "// placeholder").unwrap();
fs::write(dir.join("keep.rs"), "// mine").unwrap();
strip_sea_orm_cli_placeholder_file(m);
let (gone, kept) = (!Path::new(&file).exists(), dir.join("keep.rs").exists());
let _ = fs::remove_dir_all(&dir);
assert!(gone && kept);
}
#[test]
fn existing_targets_do_not_change_the_creation_order() {
let zeta = schema("zeta", &["users"]);
let alpha = schema("alpha", &["zeta"]);
let sorted = topological_sort_changes(vec![new_table(&alpha), new_table(&zeta)]);
let names: Vec<&str> = sorted.iter().map(|c| c.table_name.as_str()).collect();
assert_eq!(names, ["zeta", "alpha"]);
}
#[test]
fn length_shrinks_at_their_boundaries() {
assert!(length_may_shrink(Some(100), Some(50)));
assert!(!length_may_shrink(Some(50), Some(50)), "same length");
assert!(!length_may_shrink(Some(50), Some(100)));
assert!(length_may_shrink(Some(300), None), "MySQL's VARCHAR(255)");
assert!(!length_may_shrink(Some(255), None));
assert!(!length_may_shrink(Some(100), None));
}
#[test]
fn identifier_lengths_at_the_postgres_limit() {
let mut change = new_table(&schema("t", &[]));
let fk_to = |len: usize| ParsedFk {
to_table: "x".repeat(len - "t_c__fkey".len()),
from_column: "c".into(),
to_column: "id".into(),
on_delete: "Cascade".into(),
on_update: "NoAction".into(),
};
let index = |len: usize| crate::migration::utils::types::ParsedIndex {
name: "i".repeat(len),
columns: vec!["title".into()],
unique: false,
};
change.added_fks = vec![fk_to(63)];
change.added_indexes = vec![index(63)];
assert!(collect_long_identifier_messages(std::slice::from_ref(&change)).is_empty());
assert!(check_identifier_lengths(std::slice::from_ref(&change)).is_ok());
change.added_fks = vec![fk_to(64)];
change.added_indexes = vec![index(64)];
assert_eq!(
collect_long_identifier_messages(std::slice::from_ref(&change)).len(),
2
);
assert!(check_identifier_lengths(&[change]).is_err());
}
#[test]
fn only_keys_to_later_new_tables_wait() {
let schemas = [schema("a", &["b", "a"]), schema("b", &["a"])];
let changes = [new_table(&schemas[0]), new_table(&schemas[1])];
let keys = cycle_keys(&changes, &schemas);
let forward: Vec<&str> = keys["a"]
.forward
.iter()
.map(|f| f.to_table.as_str())
.collect();
assert_eq!(forward, ["b"], "self key and earlier table excluded");
assert!(
keys.get("b").is_none_or(|k| k.forward.is_empty()),
"a is created before b"
);
assert_eq!(keys["b"].closing.len(), 1);
}
#[test]
fn extend_blocks_are_planned_until_their_snapshot_matches() {
let (entities, migrations) = (temp("ext_ent"), temp("ext_mig"));
let (e, m) = (entities.to_str().unwrap(), migrations.to_str().unwrap());
fs::write(
entities.join("ext.rs"),
r#"extend! { table: "eihwaz_users", fields: { bio: text } }"#,
)
.unwrap();
let planned = plan_extend_changes(e, m).unwrap();
assert_eq!(planned.len(), 1);
assert_eq!(planned[0].1.added_columns[0].name, "bio");
let _ = (
fs::remove_dir_all(&entities),
fs::remove_dir_all(&migrations),
);
}
}