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