migrations.rs

  1// Migrations are constructed by domain, and stored in a table in the connection db with domain name,
  2// effected tables, actual query text, and order.
  3// If a migration is run and any of the query texts don't match, the app panics on startup (maybe fallback
  4// to creating a new db?)
  5// Otherwise any missing migrations are run on the connection
  6
  7use anyhow::{anyhow, Result};
  8use indoc::{formatdoc, indoc};
  9
 10use crate::connection::Connection;
 11
 12impl Connection {
 13    pub fn migrate(&self, domain: &'static str, migrations: &[&'static str]) -> Result<()> {
 14        // Setup the migrations table unconditionally
 15        self.exec(indoc! {"
 16            CREATE TABLE IF NOT EXISTS migrations (
 17                domain TEXT,
 18                step INTEGER,
 19                migration TEXT
 20            )"})?()?;
 21
 22        let completed_migrations =
 23            self.select_bound::<&str, (String, usize, String)>(indoc! {"
 24                    SELECT domain, step, migration FROM migrations
 25                    WHERE domain = ?
 26                    ORDER BY step
 27                "})?(domain)?;
 28
 29        let mut store_completed_migration =
 30            self.exec_bound("INSERT INTO migrations (domain, step, migration) VALUES (?, ?, ?)")?;
 31
 32        for (index, migration) in migrations.iter().enumerate() {
 33            if let Some((_, _, completed_migration)) = completed_migrations.get(index) {
 34                if completed_migration != migration {
 35                    return Err(anyhow!(formatdoc! {"
 36                        Migration changed for {} at step {}
 37                        
 38                        Stored migration:
 39                        {}
 40                        
 41                        Proposed migration:
 42                        {}", domain, index, completed_migration, migration}));
 43                } else {
 44                    // Migration already run. Continue
 45                    continue;
 46                }
 47            }
 48
 49            self.exec(migration)?()?;
 50            store_completed_migration((domain, index, *migration))?;
 51        }
 52
 53        Ok(())
 54    }
 55}
 56
 57#[cfg(test)]
 58mod test {
 59    use indoc::indoc;
 60
 61    use crate::connection::Connection;
 62
 63    #[test]
 64    fn test_migrations_are_added_to_table() {
 65        let connection = Connection::open_memory(Some("migrations_are_added_to_table"));
 66
 67        // Create first migration with a single step and run it
 68        connection
 69            .migrate(
 70                "test",
 71                &[indoc! {"
 72                CREATE TABLE test1 (
 73                    a TEXT,
 74                    b TEXT
 75                )"}],
 76            )
 77            .unwrap();
 78
 79        // Verify it got added to the migrations table
 80        assert_eq!(
 81            &connection
 82                .select::<String>("SELECT (migration) FROM migrations")
 83                .unwrap()()
 84            .unwrap()[..],
 85            &[indoc! {"
 86                CREATE TABLE test1 (
 87                    a TEXT,
 88                    b TEXT
 89                )"}],
 90        );
 91
 92        // Add another step to the migration and run it again
 93        connection
 94            .migrate(
 95                "test",
 96                &[
 97                    indoc! {"
 98                    CREATE TABLE test1 (
 99                        a TEXT,
100                        b TEXT
101                    )"},
102                    indoc! {"
103                    CREATE TABLE test2 (
104                        c TEXT,
105                        d TEXT
106                    )"},
107                ],
108            )
109            .unwrap();
110
111        // Verify it is also added to the migrations table
112        assert_eq!(
113            &connection
114                .select::<String>("SELECT (migration) FROM migrations")
115                .unwrap()()
116            .unwrap()[..],
117            &[
118                indoc! {"
119                    CREATE TABLE test1 (
120                        a TEXT,
121                        b TEXT
122                    )"},
123                indoc! {"
124                    CREATE TABLE test2 (
125                        c TEXT,
126                        d TEXT
127                    )"},
128            ],
129        );
130    }
131
132    #[test]
133    fn test_migration_setup_works() {
134        let connection = Connection::open_memory(Some("migration_setup_works"));
135
136        connection
137            .exec(indoc! {"
138                CREATE TABLE IF NOT EXISTS migrations (
139                    domain TEXT,
140                    step INTEGER,
141                    migration TEXT
142                );"})
143            .unwrap()()
144        .unwrap();
145
146        let mut store_completed_migration = connection
147            .exec_bound::<(&str, usize, String)>(indoc! {"
148                INSERT INTO migrations (domain, step, migration)
149                VALUES (?, ?, ?)"})
150            .unwrap();
151
152        let domain = "test_domain";
153        for i in 0..5 {
154            // Create a table forcing a schema change
155            connection
156                .exec(&format!("CREATE TABLE table{} ( test TEXT );", i))
157                .unwrap()()
158            .unwrap();
159
160            store_completed_migration((domain, i, i.to_string())).unwrap();
161        }
162    }
163
164    #[test]
165    fn migrations_dont_rerun() {
166        let connection = Connection::open_memory(Some("migrations_dont_rerun"));
167
168        // Create migration which clears a tabl
169
170        // Manually create the table for that migration with a row
171        connection
172            .exec(indoc! {"
173                CREATE TABLE test_table (
174                    test_column INTEGER
175                );"})
176            .unwrap()()
177        .unwrap();
178        connection
179            .exec(indoc! {"
180            INSERT INTO test_table (test_column) VALUES (1);"})
181            .unwrap()()
182        .unwrap();
183
184        assert_eq!(
185            connection
186                .select_row::<usize>("SELECT * FROM test_table")
187                .unwrap()()
188            .unwrap(),
189            Some(1)
190        );
191
192        // Run the migration verifying that the row got dropped
193        connection
194            .migrate("test", &["DELETE FROM test_table"])
195            .unwrap();
196        assert_eq!(
197            connection
198                .select_row::<usize>("SELECT * FROM test_table")
199                .unwrap()()
200            .unwrap(),
201            None
202        );
203
204        // Recreate the dropped row
205        connection
206            .exec("INSERT INTO test_table (test_column) VALUES (2)")
207            .unwrap()()
208        .unwrap();
209
210        // Run the same migration again and verify that the table was left unchanged
211        connection
212            .migrate("test", &["DELETE FROM test_table"])
213            .unwrap();
214        assert_eq!(
215            connection
216                .select_row::<usize>("SELECT * FROM test_table")
217                .unwrap()()
218            .unwrap(),
219            Some(2)
220        );
221    }
222
223    #[test]
224    fn changed_migration_fails() {
225        let connection = Connection::open_memory(Some("changed_migration_fails"));
226
227        // Create a migration with two steps and run it
228        connection
229            .migrate(
230                "test migration",
231                &[
232                    indoc! {"
233                CREATE TABLE test (
234                    col INTEGER
235                )"},
236                    indoc! {"
237                    INSERT INTO test (col) VALUES (1)"},
238                ],
239            )
240            .unwrap();
241
242        // Create another migration with the same domain but different steps
243        let second_migration_result = connection.migrate(
244            "test migration",
245            &[
246                indoc! {"
247                CREATE TABLE test (
248                    color INTEGER
249                )"},
250                indoc! {"
251                INSERT INTO test (color) VALUES (1)"},
252            ],
253        );
254
255        // Verify new migration returns error when run
256        assert!(second_migration_result.is_err())
257    }
258}