Skip to main content

toolu_orm_cli/migrate/
baseline.rs

1//! Recording migrations as applied without executing them, so a database whose
2//! schema is already at some version can adopt toolu-orm.
3
4use std::path::Path;
5
6use toolu_orm_connection::DbConnection;
7use toolu_orm_core::dialect::Dialect;
8use toolu_orm_core::journal::{Journal, JournalEntry};
9
10use super::error::MigrateError;
11use super::store::{ensure_migrations_table, get_applied_migrations, record_migration};
12use super::transaction::{begin, commit, rollback_after};
13
14/// Records `names` as already applied without executing their SQL.
15///
16/// For adopting toolu-orm on a database whose schema was established by a prior
17/// migration system. Hashes come from `_journal.json`, so a later
18/// [`run_migrate`](super::run_migrate) still detects a tampered file. Names
19/// already recorded are skipped, so a repeated baseline is a no-op; the count
20/// is the number of rows newly recorded.
21///
22/// The migration files themselves are never read: the journal is the integrity
23/// record, and an adopting project may no longer have every historical `.sql`
24/// on disk.
25///
26/// # Errors
27///
28/// Returns [`MigrateError::NotInJournal`] when any name has no journal entry —
29/// nothing is recorded in that case — [`MigrateError::ReadFile`] when the
30/// journal cannot be read, or [`MigrateError::Database`] on a database failure.
31pub async fn mark_applied(
32  conn: &impl DbConnection,
33  migrations_dir: &str,
34  names: &[&str],
35  dialect: Dialect,
36) -> Result<u32, MigrateError> {
37  let journal = read_journal(migrations_dir)?;
38
39  let unknown: Vec<&str> = names
40    .iter()
41    .copied()
42    .filter(|name| !journal.entries.iter().any(|entry| entry.name == *name))
43    .collect();
44  if !unknown.is_empty() {
45    return Err(MigrateError::NotInJournal(unknown.join(", ")));
46  }
47
48  // Iterating the journal rather than `names` records in journal order, so
49  // `_migrations.id` order keeps matching it, and repeats collapse.
50  let selected: Vec<&JournalEntry> = journal
51    .entries
52    .iter()
53    .filter(|entry| names.contains(&entry.name.as_str()))
54    .collect();
55
56  record_all(conn, &selected, dialect).await
57}
58
59/// Records every journal entry up to and including `last_name` as applied,
60/// without executing their SQL.
61///
62/// The usual adoption shape: a project knows "my database is at 0016", not the
63/// list of sixteen file names. Skipping, counting, and atomicity match
64/// [`mark_applied`].
65///
66/// # Errors
67///
68/// Same as [`mark_applied`]; [`MigrateError::NotInJournal`] when `last_name`
69/// itself has no journal entry.
70pub async fn mark_applied_through(
71  conn: &impl DbConnection,
72  migrations_dir: &str,
73  last_name: &str,
74  dialect: Dialect,
75) -> Result<u32, MigrateError> {
76  let journal = read_journal(migrations_dir)?;
77
78  let position = journal
79    .entries
80    .iter()
81    .position(|entry| entry.name == last_name)
82    .ok_or_else(|| MigrateError::NotInJournal(last_name.to_owned()))?;
83
84  let selected: Vec<&JournalEntry> = journal.entries.iter().take(position + 1).collect();
85
86  record_all(conn, &selected, dialect).await
87}
88
89fn read_journal(migrations_dir: &str) -> Result<Journal, MigrateError> {
90  let journal_path = Path::new(migrations_dir).join("_journal.json");
91  let journal_path_str = journal_path.to_str().ok_or_else(|| {
92    MigrateError::ReadFile(format!("{} is not valid UTF-8", journal_path.display()))
93  })?;
94  Journal::read_from_path(journal_path_str).map_err(|e| MigrateError::ReadFile(format!("{e}")))
95}
96
97/// Records `entries` in one transaction, skipping those `_migrations` already
98/// holds (its `name` column is UNIQUE, so re-inserting would fail).
99///
100/// The already-applied set is read *inside* the transaction, so the skip
101/// decision and the inserts see one state of the table. A baseline racing
102/// another writer on the same names still loses on the `UNIQUE` constraint, and
103/// then rolls back whole: no partial baseline, and the retry records nothing.
104async fn record_all(
105  conn: &impl DbConnection,
106  entries: &[&JournalEntry],
107  dialect: Dialect,
108) -> Result<u32, MigrateError> {
109  ensure_migrations_table(conn, dialect).await?;
110
111  begin(conn).await?;
112
113  let mut count: u32 = 0;
114  let result = async {
115    let applied = get_applied_migrations(conn).await?;
116    for entry in entries {
117      if applied.contains(&entry.name) {
118        continue;
119      }
120      record_migration(conn, &entry.name, &entry.hash, dialect).await?;
121      count += 1;
122    }
123    Ok(())
124  }
125  .await;
126
127  if let Err(e) = result {
128    return Err(rollback_after(conn, e).await);
129  }
130
131  commit(conn).await?;
132  Ok(count)
133}