Skip to main content

toolu_orm_cli/
generate.rs

1//! Migration file generation from schema diffs.
2
3use std::path::Path;
4
5use toolu_orm_core::dialect::Dialect;
6use toolu_orm_core::diff::diff;
7use toolu_orm_core::error::DbCoreError;
8use toolu_orm_core::journal::{compute_hash, Journal};
9use toolu_orm_core::schema::SchemaRegistry;
10use toolu_orm_core::snapshot::Snapshot;
11use toolu_orm_core::sql::generate_sql_for;
12
13const ZERO_SNAPSHOT_PARENT: &str = "00000000-0000-0000-0000-000000000000";
14
15/// Generates a migration file from the diff between the latest snapshot and the current schema.
16///
17/// # Errors
18///
19/// Returns `DbCoreError` if reading/writing the snapshot, journal, or migration file fails.
20pub fn run_generate(
21  registry: &SchemaRegistry,
22  migrations_dir: &str,
23  name: &str,
24  dialect: Dialect,
25) -> Result<Option<String>, DbCoreError> {
26  let journal_path = Path::new(migrations_dir).join("_journal.json");
27  let journal_path_str = journal_path.to_str().ok_or_else(|| {
28    DbCoreError::JournalRead(format!("non-UTF8 path: {}", journal_path.display()))
29  })?;
30  let mut journal = Journal::read_from_path(journal_path_str)?;
31
32  let old_snapshot = {
33    let mut found = None;
34    for entry in journal.entries.iter().rev() {
35      let base = entry.name.strip_suffix(".sql").unwrap_or(&entry.name);
36      let snap_name = format!("{base}.snapshot.json");
37      let snap_path = Path::new(migrations_dir).join(&snap_name);
38      if snap_path.exists() {
39        let snap_str = snap_path.to_str().ok_or_else(|| {
40          DbCoreError::SnapshotRead(format!("non-UTF8 path: {}", snap_path.display()))
41        })?;
42        found = Some(Snapshot::read_from_path(snap_str)?);
43        break;
44      }
45    }
46    match found {
47      Some(snapshot) => snapshot,
48      None if journal.entries.is_empty() => Snapshot::empty(),
49      None => {
50        return Err(DbCoreError::SnapshotRead(
51          "no snapshot file found for any journal entry — create a snapshot for the latest migration before generating".to_owned(),
52        ));
53      },
54    }
55  };
56
57  let ops = diff(&old_snapshot, registry);
58  if ops.is_empty() {
59    return Ok(None);
60  }
61
62  let sql = generate_sql_for(&ops, dialect);
63  let hash = compute_hash(&sql);
64  let next = journal.next_migration_number();
65  let filename = format!("{next:04}_{name}.sql");
66  let snapshot_name = format!("{next:04}_{name}.snapshot.json");
67
68  let sql_path = Path::new(migrations_dir).join(&filename);
69  std::fs::write(&sql_path, &sql)
70    .map_err(|e| DbCoreError::MigrationWrite(format!("{}: {e}", sql_path.display())))?;
71
72  let mut new_snapshot = Snapshot::from_registry(registry);
73  new_snapshot.dialect = dialect.as_str().to_owned();
74  new_snapshot.prev_id = if old_snapshot.tables.is_empty() {
75    ZERO_SNAPSHOT_PARENT.to_owned()
76  } else {
77    old_snapshot.id.clone()
78  };
79  new_snapshot.enums.clone_from(&old_snapshot.enums);
80  new_snapshot.meta.clone_from(&old_snapshot.meta);
81
82  let snap_path = Path::new(migrations_dir).join(&snapshot_name);
83  let snap_str = snap_path
84    .to_str()
85    .ok_or_else(|| DbCoreError::SnapshotWrite(format!("non-UTF8 path: {}", snap_path.display())))?;
86  new_snapshot.write_to_path(snap_str)?;
87
88  journal.add_entry(&filename, &hash);
89  journal.write_to_path(journal_path_str)?;
90
91  Ok(Some(filename))
92}