Skip to content

Commit 021cb71

Browse files
committed
feat: add a opt-in validations of migrations
Some users may want to test downward migrations (#113). This is a proposal for a composable set of checks (called validations), more or less stringent, that users can run in a unit test. More validations could be added in the future. Closes #113
1 parent b57db89 commit 021cb71

12 files changed

+430
-1
lines changed

rusqlite_migration/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mod builder;
3737
pub use builder::MigrationsBuilder;
3838

3939
mod errors;
40+
pub mod validations;
4041

4142
#[cfg(test)]
4243
mod tests;
@@ -897,6 +898,9 @@ impl<'m> Migrations<'m> {
897898
/// Run upward migrations on a temporary in-memory database from first to last, one by one.
898899
/// Convenience method for testing.
899900
///
901+
/// See the [`validations`] module if you want to validate other things as well, like downward
902+
/// migrations.
903+
///
900904
/// # Example
901905
///
902906
/// ```

rusqlite_migration/src/tests/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@
1717
mod builder;
1818

1919
mod core;
20-
mod helpers;
20+
pub(crate) mod helpers;
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright Clément Joly and contributors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
//! Run a more complete set of validations (like requiring a downward migration to be present and
17+
//! to apply cleanly). This is useful in a unit test, to validate the migrations.
18+
//!
19+
//! See also [`Migrations::validate`] for simple cases.
20+
//!
21+
//! # Example
22+
//!
23+
//! ```
24+
//! #[cfg(test)]
25+
//! mod tests {
26+
//!
27+
//! // … Other tests …
28+
//!
29+
//! #[test]
30+
//! fn migrations_test() -> Result<(), dyn Error> {
31+
//! Validations::everything().validate(migrations)?;
32+
//! }
33+
//! }
34+
//! ```
35+
36+
use std::fmt::Display;
37+
38+
use rusqlite::Connection;
39+
40+
use super::Migrations;
41+
42+
#[cfg(test)]
43+
mod tests;
44+
45+
/// Result for validations
46+
pub type Result<'m, T, E = Error> = std::result::Result<T, E>;
47+
48+
/// Enum of possible validation errors.
49+
#[derive(Debug, PartialEq)]
50+
#[non_exhaustive]
51+
pub enum Error {
52+
/// Downward migrations were required for every upward migrations, but some are missing.
53+
MissingDownwardMigrations(Vec<(usize, String)>),
54+
/// Underlying rusqlite_migration error.
55+
RusqliteMigration(crate::Error),
56+
}
57+
58+
impl From<crate::Error> for Error {
59+
fn from(value: crate::Error) -> Self {
60+
Error::RusqliteMigration(value)
61+
}
62+
}
63+
64+
impl From<rusqlite::Error> for Error {
65+
fn from(value: rusqlite::Error) -> Self {
66+
Error::from(crate::Error::from(value))
67+
}
68+
}
69+
70+
impl std::error::Error for Error {
71+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
72+
match self {
73+
Error::MissingDownwardMigrations(_) => None,
74+
Error::RusqliteMigration(error) => Some(error),
75+
}
76+
}
77+
}
78+
79+
impl Display for Error {
80+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81+
match self {
82+
Error::RusqliteMigration(e) => write!(f, "underlying rusqlite migration error: {e}"),
83+
Error::MissingDownwardMigrations(vs) => {
84+
write!(
85+
f,
86+
"the following migrations do not have a corresponding downward migration: "
87+
)?;
88+
for (i, v) in vs {
89+
write!(f, "{i}: {v}, ")?
90+
}
91+
Ok(())
92+
}
93+
}
94+
}
95+
}
96+
97+
#[derive(PartialEq, Debug)]
98+
enum DownwardCheck {
99+
No,
100+
IfPresent,
101+
Required,
102+
}
103+
104+
/// Opt-in checks to validate migrations
105+
pub struct Validations {
106+
downward: DownwardCheck,
107+
}
108+
109+
impl Validations {
110+
/// Apply all possible checks, in their strictest setting. Please note that future versions of
111+
/// the library will add more checks and so this might cause tests to fail when you upgrade the
112+
/// library.
113+
pub fn everything() -> Self {
114+
Self {
115+
downward: DownwardCheck::Required,
116+
}
117+
}
118+
119+
/// Always validate upward migrations
120+
pub fn upward() -> Self {
121+
Self {
122+
downward: DownwardCheck::No,
123+
}
124+
}
125+
126+
/// Validate all downwards migrations found. Allow a downward migration to be missing.
127+
pub fn check_downward_if_present(mut self) -> Self {
128+
self.downward = DownwardCheck::IfPresent;
129+
self
130+
}
131+
132+
/// Validate all downwards migrations found. Error if a downward migration is missing.
133+
pub fn require_downward(mut self) -> Self {
134+
self.downward = DownwardCheck::Required;
135+
self
136+
}
137+
138+
/// Run the validations
139+
pub fn validate(&self, migrations: &Migrations) -> Result<()> {
140+
// Let’s have all fields in scope, to ensure we don’t forgot to use any flags (or any
141+
// future flags)
142+
let Self { downward } = self;
143+
let mut conn = Connection::open_in_memory()?;
144+
let nbr_migrations = migrations.pending_migrations(&conn)? as usize;
145+
if nbr_migrations == 0 {
146+
log::debug!("no migrations defined, they are deemed valid");
147+
return Ok(());
148+
}
149+
150+
// https://mutants.rs/skip_calls.html#with_capacity
151+
let mut missing_downward_migrations =
152+
Vec::with_capacity(if *downward == DownwardCheck::Required {
153+
nbr_migrations
154+
} else {
155+
0
156+
});
157+
158+
// Always check upward migrations and check downward ones depending on flags
159+
for i in 1..=nbr_migrations {
160+
log::debug!("Checking migration number {i}");
161+
migrations.to_version(&mut conn, i)?;
162+
match downward {
163+
DownwardCheck::No => (),
164+
DownwardCheck::Required | DownwardCheck::IfPresent => {
165+
if migrations.ms[i - 1].down.is_some() {
166+
// Revert and reapply, to see if the revert applies cleanly
167+
migrations.to_version(&mut conn, i - 1)?;
168+
migrations.to_version(&mut conn, i)?;
169+
} else if *downward == DownwardCheck::Required {
170+
let m = &migrations.ms[i - 1];
171+
missing_downward_migrations.push((i, format!("{m:?}")))
172+
}
173+
}
174+
};
175+
}
176+
177+
if missing_downward_migrations.is_empty() {
178+
Ok(())
179+
} else {
180+
Err(Error::MissingDownwardMigrations(
181+
missing_downward_migrations,
182+
))
183+
}
184+
}
185+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: rusqlite_migration/src/validations/tests.rs
3+
expression: "Validations::everything().validate(&migrations).unwrap_err()"
4+
snapshot_kind: text
5+
---
6+
the following migrations do not have a corresponding downward migration: 1: M { up: "CREATE TABLE m1(a, b); CREATE TABLE m2(a, b, c);", up_hook: None, down: None, down_hook: None, foreign_key_check: false, comment: None },
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
source: rusqlite_migration/src/validations/tests.rs
3+
expression: v
4+
snapshot_kind: text
5+
---
6+
Err(
7+
RusqliteMigration(
8+
RusqliteError {
9+
query: "Invalid sql",
10+
err: SqliteFailure(
11+
Error {
12+
code: Unknown,
13+
extended_code: 1,
14+
},
15+
Some(
16+
"near \"Invalid\": syntax error",
17+
),
18+
),
19+
},
20+
),
21+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
source: rusqlite_migration/src/validations/tests.rs
3+
expression: "Validations::everything().validate(&migrations)"
4+
snapshot_kind: text
5+
---
6+
Err(
7+
MissingDownwardMigrations(
8+
[
9+
(
10+
6,
11+
"M { up: \"\\n CREATE TABLE fk1(a PRIMARY KEY);\\n CREATE TABLE fk2(\\n a,\\n FOREIGN KEY(a) REFERENCES fk1(a)\\n );\\n INSERT INTO fk1 (a) VALUES ('foo');\\n INSERT INTO fk2 (a) VALUES ('foo');\\n \", up_hook: None, down: None, down_hook: None, foreign_key_check: true, comment: None }",
12+
),
13+
],
14+
),
15+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
source: rusqlite_migration/src/validations/tests.rs
3+
expression: "Validations::everything().validate(&migrations)"
4+
snapshot_kind: text
5+
---
6+
Err(
7+
MissingDownwardMigrations(
8+
[
9+
(
10+
4,
11+
"M { up: \"CREATE TABLE t2(b);\", up_hook: None, down: None, down_hook: None, foreign_key_check: false, comment: None }",
12+
),
13+
],
14+
),
15+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
source: rusqlite_migration/src/validations/tests.rs
3+
expression: "Validations::everything().validate(&migrations)"
4+
snapshot_kind: text
5+
---
6+
Err(
7+
MissingDownwardMigrations(
8+
[
9+
(
10+
1,
11+
"M { up: \"CREATE TABLE m1(a, b); CREATE TABLE m2(a, b, c);\", up_hook: None, down: None, down_hook: None, foreign_key_check: false, comment: None }",
12+
),
13+
],
14+
),
15+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
source: rusqlite_migration/src/validations/tests.rs
3+
expression: "Validations::everything().validate(&migrations)"
4+
snapshot_kind: text
5+
---
6+
Err(
7+
MissingDownwardMigrations(
8+
[
9+
(
10+
3,
11+
"M { up: \"ALTER TABLE t1 RENAME COLUMN b TO c;\", up_hook: None, down: None, down_hook: None, foreign_key_check: false, comment: None }",
12+
),
13+
(
14+
4,
15+
"M { up: \"CREATE TABLE t2(b);\", up_hook: None, down: None, down_hook: None, foreign_key_check: false, comment: None }",
16+
),
17+
(
18+
6,
19+
"M { up: \"\\n CREATE TABLE fk1(a PRIMARY KEY);\\n CREATE TABLE fk2(\\n a,\\n FOREIGN KEY(a) REFERENCES fk1(a)\\n );\\n INSERT INTO fk1 (a) VALUES ('foo');\\n INSERT INTO fk2 (a) VALUES ('foo');\\n \", up_hook: None, down: None, down_hook: None, foreign_key_check: true, comment: None }",
20+
),
21+
],
22+
),
23+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: rusqlite_migration/src/validations/tests.rs
3+
expression: e
4+
snapshot_kind: text
5+
---
6+
underlying rusqlite migration error: rusqlite_migrate error: RusqliteError { query: "", err: InvalidQuery }

0 commit comments

Comments
 (0)