1use std::borrow::Cow;
17
18use rusqlite::Connection;
19use tracing::{info, warn};
20
21use crate::database::DbError;
22
23pub struct Migration {
25 pub version: u32,
28 pub name: Cow<'static, str>,
30 pub up: MigrationStep,
31}
32
33type MigrationFn = Box<dyn Fn(&Connection) -> Result<(), DbError> + Send + Sync>;
40
41pub enum MigrationStep {
43 Sql(Cow<'static, str>),
46 Run(MigrationFn),
51}
52
53impl Migration {
54 pub fn sql(version: u32, name: &'static str, sql: &'static str) -> Self {
56 Migration {
57 version,
58 name: Cow::Borrowed(name),
59 up: MigrationStep::Sql(Cow::Borrowed(sql)),
60 }
61 }
62
63 pub fn sql_owned(version: u32, name: String, sql: String) -> Self {
65 Migration {
66 version,
67 name: Cow::Owned(name),
68 up: MigrationStep::Sql(Cow::Owned(sql)),
69 }
70 }
71
72 pub fn run<F>(version: u32, name: &'static str, f: F) -> Self
75 where
76 F: Fn(&Connection) -> Result<(), DbError> + Send + Sync + 'static,
77 {
78 Migration {
79 version,
80 name: Cow::Borrowed(name),
81 up: MigrationStep::Run(Box::new(f)),
82 }
83 }
84}
85
86pub fn supported_version(migrations: &[Migration]) -> u32 {
93 migrations.len() as u32
94}
95
96#[derive(Debug, thiserror::Error)]
102pub enum MigrationError {
103 #[error(
106 "migration at position {position} has version {found}, expected {expected}: \
107 the registered set must be contiguous 1..=N, strictly ascending"
108 )]
109 NotContiguous {
110 position: usize,
111 found: u32,
112 expected: u32,
113 },
114 #[error(
119 "on-disk schema version {current} is newer than this binary supports \
120 ({supported}); update the app"
121 )]
122 SchemaTooNew { current: u32, supported: u32 },
123 #[error("migration {version} ({name}) failed: {source}")]
126 Failed {
127 version: u32,
128 name: Cow<'static, str>,
129 source: DbError,
130 },
131 #[error("migration ledger access failed: {0}")]
134 Ledger(DbError),
135}
136
137pub fn run_migrations(conn: &Connection, migrations: &[Migration]) -> Result<u32, MigrationError> {
148 let current = validate_registered_migrations(conn, migrations)?;
149
150 for m in migrations.iter().filter(|m| m.version > current) {
151 conn.execute_batch("BEGIN")
152 .map_err(|e| MigrationError::Ledger(DbError::from(e)))?;
153 if let Err(source) = apply_step(conn, &m.up) {
154 rollback_after_failure(conn, m, "migration step");
155 return Err(MigrationError::Failed {
156 version: m.version,
157 name: m.name.clone(),
158 source,
159 });
160 }
161 if let Err(e) = conn.pragma_update(None, "user_version", m.version) {
162 rollback_after_failure(conn, m, "user_version bump");
163 return Err(MigrationError::Ledger(DbError::from(e)));
164 }
165 conn.execute_batch("COMMIT")
166 .map_err(|e| MigrationError::Ledger(DbError::from(e)))?;
167 info!(
168 version = m.version,
169 name = m.name.as_ref(),
170 "applied migration"
171 );
172 }
173
174 read_user_version(conn)
175}
176
177pub(crate) fn run_migrations_in_transaction(
185 conn: &Connection,
186 migrations: &[Migration],
187) -> Result<u32, MigrationError> {
188 let current = validate_registered_migrations(conn, migrations)?;
189 for migration in migrations
190 .iter()
191 .filter(|migration| migration.version > current)
192 {
193 if let Err(source) = apply_step(conn, &migration.up) {
194 return Err(MigrationError::Failed {
195 version: migration.version,
196 name: migration.name.clone(),
197 source,
198 });
199 }
200 conn.pragma_update(None, "user_version", migration.version)
201 .map_err(|error| MigrationError::Ledger(DbError::from(error)))?;
202 }
203 read_user_version(conn)
204}
205
206fn validate_registered_migrations(
207 conn: &Connection,
208 migrations: &[Migration],
209) -> Result<u32, MigrationError> {
210 for (position, migration) in migrations.iter().enumerate() {
214 let expected = position as u32 + 1;
215 if migration.version != expected {
216 return Err(MigrationError::NotContiguous {
217 position,
218 found: migration.version,
219 expected,
220 });
221 }
222 }
223 let current = read_user_version(conn)?;
224 let top = supported_version(migrations);
225 if current > top {
226 return Err(MigrationError::SchemaTooNew {
227 current,
228 supported: top,
229 });
230 }
231 Ok(current)
232}
233
234pub fn ensure_schema_supported(
244 conn: &Connection,
245 migrations: &[Migration],
246) -> Result<u32, MigrationError> {
247 for (i, m) in migrations.iter().enumerate() {
248 let expected = i as u32 + 1;
249 if m.version != expected {
250 return Err(MigrationError::NotContiguous {
251 position: i,
252 found: m.version,
253 expected,
254 });
255 }
256 }
257 let top = supported_version(migrations);
258 let current = read_user_version(conn)?;
259 if current > top {
260 return Err(MigrationError::SchemaTooNew {
261 current,
262 supported: top,
263 });
264 }
265 Ok(current)
266}
267
268fn read_user_version(conn: &Connection) -> Result<u32, MigrationError> {
270 conn.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0))
271 .map(|v| v as u32)
272 .map_err(|e| MigrationError::Ledger(DbError::from(e)))
273}
274
275fn rollback_after_failure(conn: &Connection, m: &Migration, stage: &str) {
279 if let Err(rb) = conn.execute_batch("ROLLBACK") {
280 warn!(
281 version = m.version,
282 name = m.name.as_ref(),
283 stage,
284 error = %rb,
285 "rollback after a failed migration also failed"
286 );
287 }
288}
289
290fn apply_step(conn: &Connection, step: &MigrationStep) -> Result<(), DbError> {
291 match step {
292 MigrationStep::Sql(sql) => conn.execute_batch(sql.as_ref()).map_err(DbError::from),
293 MigrationStep::Run(f) => f(conn),
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
308 fn migration_types_are_send_and_sync() {
309 fn assert_send_sync<T: Send + Sync>() {}
310 assert_send_sync::<Migration>();
311 assert_send_sync::<MigrationStep>();
312 }
313
314 fn user_version(conn: &Connection) -> u32 {
315 read_user_version(conn).expect("read user_version")
316 }
317
318 fn table_exists(conn: &Connection, name: &str) -> bool {
319 conn.query_row(
320 "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
321 [name],
322 |r| r.get::<_, i64>(0),
323 )
324 .expect("query sqlite_master")
325 > 0
326 }
327
328 #[test]
329 fn fresh_db_applies_every_migration_and_lands_at_top() {
330 let conn = Connection::open_in_memory().expect("open");
331 let migrations = vec![
332 Migration::sql(1, "a", "CREATE TABLE a (id TEXT PRIMARY KEY)"),
333 Migration::sql(2, "b", "CREATE TABLE b (id TEXT PRIMARY KEY)"),
334 Migration::sql(3, "c", "CREATE TABLE c (id TEXT PRIMARY KEY)"),
335 ];
336 let version = run_migrations(&conn, &migrations).expect("run migrations");
337 assert_eq!(version, 3);
338 assert_eq!(user_version(&conn), 3);
339 for t in ["a", "b", "c"] {
340 assert!(table_exists(&conn, t), "table {t} should exist");
341 }
342 }
343
344 #[test]
345 fn owned_sql_migration_applies() {
346 let conn = Connection::open_in_memory().expect("open");
347 let migrations = vec![Migration::sql_owned(
348 1,
349 "owned".to_string(),
350 "CREATE TABLE owned (id TEXT PRIMARY KEY)".to_string(),
351 )];
352 let version = run_migrations(&conn, &migrations).expect("run migrations");
353 assert_eq!(version, 1);
354 assert!(table_exists(&conn, "owned"));
355 }
356
357 #[test]
358 fn reopen_with_same_list_is_a_noop() {
359 let conn = Connection::open_in_memory().expect("open");
360 let migrations = || {
361 vec![
362 Migration::sql(1, "a", "CREATE TABLE a (id TEXT PRIMARY KEY)"),
363 Migration::sql(2, "b", "CREATE TABLE b (id TEXT PRIMARY KEY)"),
364 Migration::sql(3, "c", "CREATE TABLE c (id TEXT PRIMARY KEY)"),
365 ]
366 };
367 assert_eq!(run_migrations(&conn, &migrations()).expect("first"), 3);
368 assert_eq!(run_migrations(&conn, &migrations()).expect("second"), 3);
371 assert_eq!(user_version(&conn), 3);
372 }
373
374 #[test]
375 fn run_backfill_mutates_rows_and_bumps_version_together() {
376 let conn = Connection::open_in_memory().expect("open");
377 let migrations = vec![
378 Migration::sql(
379 1,
380 "create",
381 "CREATE TABLE t (id TEXT PRIMARY KEY, n INTEGER NOT NULL)",
382 ),
383 Migration::run(2, "backfill", |conn| {
384 conn.execute("INSERT INTO t (id, n) VALUES ('row', 1)", [])
385 .map_err(DbError::from)?;
386 conn.execute("UPDATE t SET n = 42 WHERE id = 'row'", [])
387 .map_err(DbError::from)?;
388 Ok(())
389 }),
390 ];
391 let version = run_migrations(&conn, &migrations).expect("run migrations");
392 assert_eq!(version, 2);
393 let n: i64 = conn
394 .query_row("SELECT n FROM t WHERE id = 'row'", [], |r| r.get(0))
395 .expect("read backfilled row");
396 assert_eq!(n, 42);
397 }
398
399 #[test]
400 fn failing_run_rolls_back_its_ddl_and_version() {
401 let conn = Connection::open_in_memory().expect("open");
402 let migrations = vec![
406 Migration::sql(1, "create", "CREATE TABLE t (id TEXT PRIMARY KEY)"),
407 Migration::run(2, "boom", |conn| {
408 conn.execute("CREATE TABLE late (id TEXT PRIMARY KEY)", [])
409 .map_err(DbError::from)?;
410 Err(DbError::Message("backfill failed".to_string()))
411 }),
412 ];
413 let err = run_migrations(&conn, &migrations).expect_err("must fail");
414 assert!(matches!(
415 err,
416 MigrationError::Failed {
417 version: 2,
418 name,
419 ..
420 } if name.as_ref() == "boom"
421 ));
422 assert_eq!(
423 user_version(&conn),
424 1,
425 "version must not advance over a failed step"
426 );
427 assert!(
428 !table_exists(&conn, "late"),
429 "the failed step's DDL must have rolled back",
430 );
431 }
432
433 #[test]
434 fn malformed_sets_are_rejected_before_any_ddl() {
435 let conn = Connection::open_in_memory().expect("open");
437 let gap = vec![
438 Migration::sql(1, "a", "CREATE TABLE a (id TEXT PRIMARY KEY)"),
439 Migration::sql(3, "c", "CREATE TABLE c (id TEXT PRIMARY KEY)"),
440 ];
441 assert!(matches!(
442 run_migrations(&conn, &gap),
443 Err(MigrationError::NotContiguous {
444 position: 1,
445 found: 3,
446 expected: 2
447 })
448 ));
449 assert!(!table_exists(&conn, "a"), "no DDL runs on a malformed set");
450
451 let dup = vec![
453 Migration::sql(1, "a", "CREATE TABLE a (id TEXT PRIMARY KEY)"),
454 Migration::sql(1, "a2", "CREATE TABLE a2 (id TEXT PRIMARY KEY)"),
455 ];
456 assert!(matches!(
457 run_migrations(&conn, &dup),
458 Err(MigrationError::NotContiguous {
459 position: 1,
460 found: 1,
461 expected: 2
462 })
463 ));
464
465 let not_from_one = vec![Migration::sql(
467 2,
468 "b",
469 "CREATE TABLE b (id TEXT PRIMARY KEY)",
470 )];
471 assert!(matches!(
472 run_migrations(&conn, ¬_from_one),
473 Err(MigrationError::NotContiguous {
474 position: 0,
475 found: 2,
476 expected: 1
477 })
478 ));
479 }
480
481 #[test]
482 fn ensure_schema_supported_checks_without_migrating() {
483 let migrations = vec![
484 Migration::sql(1, "a", "CREATE TABLE a (id TEXT PRIMARY KEY)"),
485 Migration::sql(2, "b", "CREATE TABLE b (id TEXT PRIMARY KEY)"),
486 ];
487
488 let conn = Connection::open_in_memory().expect("open");
491 conn.pragma_update(None, "user_version", 1u32)
492 .expect("set user_version");
493 assert_eq!(
494 ensure_schema_supported(&conn, &migrations).expect("v1 is supported"),
495 1
496 );
497 assert!(
498 !table_exists(&conn, "b"),
499 "ensure_schema_supported must not apply any migration",
500 );
501 assert_eq!(user_version(&conn), 1, "the version is left untouched");
502
503 let ahead = Connection::open_in_memory().expect("open");
506 ahead
507 .pragma_update(None, "user_version", 5u32)
508 .expect("set user_version");
509 assert!(matches!(
510 ensure_schema_supported(&ahead, &migrations),
511 Err(MigrationError::SchemaTooNew {
512 current: 5,
513 supported: 2
514 })
515 ));
516 }
517
518 #[test]
519 fn schema_newer_than_binary_is_refused() {
520 let conn = Connection::open_in_memory().expect("open");
521 conn.pragma_update(None, "user_version", 5u32)
524 .expect("set user_version");
525 let migrations = vec![
526 Migration::sql(1, "a", "CREATE TABLE a (id TEXT PRIMARY KEY)"),
527 Migration::sql(2, "b", "CREATE TABLE b (id TEXT PRIMARY KEY)"),
528 Migration::sql(3, "c", "CREATE TABLE c (id TEXT PRIMARY KEY)"),
529 ];
530 assert!(matches!(
531 run_migrations(&conn, &migrations),
532 Err(MigrationError::SchemaTooNew {
533 current: 5,
534 supported: 3
535 })
536 ));
537 }
538}