Skip to main content

toolu_orm_cli/migrate/
embedded.rs

1//! Migrations compiled into the binary instead of read from a directory.
2
3use toolu_orm_connection::DbConnection;
4use toolu_orm_core::dialect::Dialect;
5
6use super::apply::{apply_migration, verify_hash};
7use super::error::MigrateError;
8use super::store::{ensure_migrations_table, get_applied_migrations};
9
10/// One migration whose SQL is resolved at compile time, usually by
11/// `include_str!`.
12///
13/// A single-binary distribution — `cargo install`, a tap, a `curl | sh`
14/// installer — has no migrations directory on the target machine, so the bytes
15/// have to travel inside the executable:
16///
17/// ```ignore
18/// const MIGRATIONS: &[EmbeddedMigration] = &[EmbeddedMigration {
19///   name: "0001_init.sql",
20///   sql: include_str!("../migrations/0001_init.sql"),
21///   hash: "sha256:2c8f…",
22/// }];
23/// ```
24///
25/// Baking the SQL in does not weaken the integrity check, it strengthens it:
26/// on disk the file can be edited after install, whereas here
27/// [`verify_hash`](Self::verify_hash) turns an edit to a shipped `.sql` into a
28/// failing test in the project that ships it.
29pub struct EmbeddedMigration<'a> {
30  /// Recorded in `_migrations.name`, and how an already-applied migration is
31  /// recognized. Conventionally the file name, e.g. `"0001_init.sql"`, so a
32  /// project can move between this and [`run_migrate`](super::run_migrate).
33  pub name: &'a str,
34  /// The migration's SQL. Several statements separate with
35  /// `--> statement-breakpoint`, exactly as in a generated file.
36  pub sql: &'a str,
37  /// The expected SHA-256 of `sql`, as `"sha256:…"` — the same string
38  /// `_journal.json` carries for this migration.
39  pub hash: &'a str,
40}
41
42impl EmbeddedMigration<'_> {
43  /// Checks `sql` against `hash` without touching a database, so a project can
44  /// assert its whole list in one `#[test]`.
45  ///
46  /// # Errors
47  ///
48  /// Returns [`MigrateError::HashMismatch`] when the two disagree.
49  pub fn verify_hash(&self) -> Result<(), MigrateError> {
50    verify_hash(self.name, self.sql, self.hash)
51  }
52}
53
54/// Applies pending migrations from an in-memory list instead of a directory.
55///
56/// The counterpart to [`run_migrate`](super::run_migrate), and identical to it
57/// below the byte-fetch: same `_migrations` bookkeeping, same hash check, same
58/// one-transaction-per-migration boundary. A database migrated from a directory
59/// and one migrated from the equivalent list are indistinguishable, so a
60/// project can switch sources between releases.
61///
62/// Migrations apply in slice order, which is the caller's declaration of order
63/// just as `_journal.json` is on disk — they are not sorted by name. Entries
64/// already in `_migrations` are skipped, so the count is how many were newly
65/// applied.
66///
67/// # Errors
68///
69/// Returns [`MigrateError::DuplicateMigration`] when two entries share a name,
70/// before anything is written; [`MigrateError::HashMismatch`] when an entry's
71/// SQL no longer matches its declared hash, leaving earlier entries applied;
72/// or [`MigrateError::Database`] when a statement fails, rolling that migration
73/// back whole.
74pub async fn run_migrate_embedded(
75  conn: &impl DbConnection,
76  migrations: &[EmbeddedMigration<'_>],
77  dialect: Dialect,
78) -> Result<u32, MigrateError> {
79  reject_duplicate_names(migrations)?;
80
81  ensure_migrations_table(conn, dialect).await?;
82  let applied = get_applied_migrations(conn).await?;
83
84  let mut count: u32 = 0;
85  for migration in migrations {
86    if applied.iter().any(|name| name == migration.name) {
87      continue;
88    }
89    apply_migration(conn, migration.name, migration.sql, migration.hash, dialect).await?;
90    count += 1;
91  }
92
93  Ok(count)
94}
95
96/// A hand-written list can repeat a name where a generated journal cannot, and
97/// the repeat has no single SQL body. Caught before the first statement runs,
98/// so the mistake does not surface as a `UNIQUE` violation with earlier
99/// migrations already committed.
100fn reject_duplicate_names(migrations: &[EmbeddedMigration<'_>]) -> Result<(), MigrateError> {
101  let mut seen: Vec<&str> = Vec::with_capacity(migrations.len());
102  let mut duplicates: Vec<&str> = Vec::new();
103
104  for migration in migrations {
105    if seen.contains(&migration.name) {
106      if !duplicates.contains(&migration.name) {
107        duplicates.push(migration.name);
108      }
109    } else {
110      seen.push(migration.name);
111    }
112  }
113
114  if duplicates.is_empty() {
115    return Ok(());
116  }
117  Err(MigrateError::DuplicateMigration(duplicates.join(", ")))
118}