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}