Skip to main content

miden_node_db/migration/
builder.rs

1use anyhow::{Context, Result};
2use rusqlite::Connection;
3
4use super::entry::{CodeMigrationFn, Migration, SqlMigration, apply_migration};
5use super::{Migrator, SchemaHash};
6
7/// Builds a [`Migrator`] while computing expected schema hashes on an in-memory database.
8///
9/// ```ignore
10/// use miden_node_db::migration::{Migrator, SchemaHash};
11/// use rusqlite::Transaction;
12///
13/// fn add_item_height(tx: &Transaction<'_>) -> anyhow::Result<()> {
14///     tx.execute_batch("ALTER TABLE items ADD COLUMN height INTEGER;")?;
15///     Ok(())
16/// }
17///
18/// fn migrator() -> anyhow::Result<Migrator> {
19///     Migrator::builder()?
20///         .push_retired(
21///             "001_create_items",
22///             "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
23///         )?
24///         .push_sql("002_index_items", "CREATE INDEX idx_items_value ON items(value);")?
25///         .push_code("003_add_item_height", add_item_height)?
26///         .build()
27/// }
28///
29/// const EXPECTED_SCHEMA_HASHES: [SchemaHash; 3] = [
30///     SchemaHash::from_hex(
31///         "1111111111111111111111111111111111111111111111111111111111111111",
32///     ),
33///     SchemaHash::from_hex(
34///         "2222222222222222222222222222222222222222222222222222222222222222",
35///     ),
36///     SchemaHash::from_hex(
37///         "3333333333333333333333333333333333333333333333333333333333333333",
38///     ),
39/// ];
40///
41/// #[test]
42/// fn migration_schema_hashes_are_stable() -> anyhow::Result<()> {
43///     let migrator = migrator()?;
44///
45///     assert_eq!(migrator.schema_hashes(), &EXPECTED_SCHEMA_HASHES);
46///     Ok(())
47/// }
48/// ```
49pub struct MigratorBuilder {
50    /// Connection to an in-memory SQLite database used to verify the migrations as they are added.
51    reference: Connection,
52    /// Migrator being built.
53    migrator: Migrator,
54}
55
56impl MigratorBuilder {
57    pub(super) fn new() -> Result<Self> {
58        let reference = Connection::open_in_memory()
59            .context("failed to create in-memory migration database")?;
60
61        Ok(Self { reference, migrator: Migrator::empty() })
62    }
63
64    /// Adds a pure SQL retired migration.
65    ///
66    /// Retired migrations initialize fresh databases from SQL that replaces old active migrations. They
67    /// must be pushed before any active migration.
68    pub fn push_retired(mut self, name: &'static str, sql: &'static str) -> Result<Self> {
69        let version = self.migrator.next_version();
70        let migration = SqlMigration::new(name, sql);
71        let hash: SchemaHash = apply_migration(&mut self.reference, version, &migration)
72            .with_context(|| format!("failed to apply retired migration {version} \"{name}\""))?;
73
74        self.migrator.push_retired_unchecked(migration, hash);
75        Ok(self)
76    }
77
78    /// Adds a SQL migration that remains supported for existing databases.
79    pub fn push_sql(mut self, name: &'static str, sql: &'static str) -> Result<Self> {
80        let version = self.migrator.next_version();
81        let migration = Migration::sql(name, sql);
82        let hash: SchemaHash = apply_migration(&mut self.reference, version, &migration)
83            .with_context(|| format!("failed to apply SQL migration {version} \"{name}\""))?;
84
85        self.migrator.push_active_unchecked(migration, hash);
86        Ok(self)
87    }
88
89    /// Adds a Rust migration function.
90    pub fn push_code(mut self, name: &'static str, apply: CodeMigrationFn) -> Result<Self> {
91        let version = self.migrator.next_version();
92        let migration = Migration::code(name, apply);
93        let hash: SchemaHash = apply_migration(&mut self.reference, version, &migration)
94            .with_context(|| format!("failed to apply code migration {version} \"{name}\""))?;
95
96        self.migrator.push_active_unchecked(migration, hash);
97        Ok(self)
98    }
99
100    /// Returns a migrator containing all migrations and their expected schema hashes.
101    pub fn build(self) -> Result<Migrator> {
102        self.migrator.validate()?;
103        Ok(self.migrator)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use anyhow::Result;
110    use rusqlite::{Connection, Transaction};
111
112    use super::super::{Migrator, SchemaHash};
113    use crate::migration::SchemaHashes;
114
115    fn add_item_height(tx: &Transaction<'_>) -> Result<()> {
116        tx.execute_batch("ALTER TABLE items ADD COLUMN height INTEGER;")?;
117        Ok(())
118    }
119
120    #[test]
121    fn empty_builder_returns_error() -> Result<()> {
122        let err = Migrator::builder()?.build().expect_err("empty builder should fail");
123        assert!(err.to_string().contains("cannot build migrator without migrations"));
124        Ok(())
125    }
126
127    #[test]
128    #[should_panic(expected = "cannot add retired migration after active migrations have started")]
129    fn panics_when_adding_retired_after_active_migration() {
130        let _builder = Migrator::builder()
131            .expect("builder should be created")
132            .push_sql("create items", "CREATE TABLE items (id INTEGER PRIMARY KEY);")
133            .expect("SQL migration should be added")
134            .push_retired("add notes", "CREATE TABLE notes (id INTEGER PRIMARY KEY);");
135    }
136
137    #[test]
138    fn exposes_schema_hashes() -> Result<()> {
139        let reference = Connection::open_in_memory()?;
140        reference.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);")?;
141        let retired_hash = SchemaHash::new(&reference)?;
142        reference.execute_batch("CREATE INDEX idx_items_value ON items(value);")?;
143        let sql_hash = SchemaHash::new(&reference)?;
144        reference.execute_batch("ALTER TABLE items ADD COLUMN height INTEGER;")?;
145        let final_hash = SchemaHash::new(&reference)?;
146
147        let migrator = Migrator::builder()?
148            .push_retired(
149                "create items",
150                "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
151            )?
152            .push_sql("index item values", "CREATE INDEX idx_items_value ON items(value);")?
153            .push_code("add item height", add_item_height)?
154            .build()?;
155
156        assert_eq!(migrator.schema_hashes(), SchemaHashes(&[retired_hash, sql_hash, final_hash]));
157        Ok(())
158    }
159}