Skip to main content

persistant_relational/
migrate_conformance.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Contract tests for the migration ledger ([`Migrates`]): what it promises, decided against a real
3//! database. Each leaf produces a matrix row, **on** only when the controls held.
4//!
5//! - `rel.migrate.apply`: migrations are applied in version order, the ledger lists them, and a second
6//!   run applies nothing and does not run them again.
7//! - `rel.migrate.checksum`: changing the text of an applied migration is refused as
8//!   [`MigrateError::ChecksumMismatch`], not silently re-run or ignored.
9//! - `rel.migrate.newer`: a program that knows fewer migrations than the database has applied is
10//!   refused as [`MigrateError::NewerThanProgram`].
11//! - `rel.migrate.atomic`: a migration whose statements fail part-way leaves none of its row changes,
12//!   keeps the migrations before it, and is reported as [`MigrateError::Failed`] with the database's own
13//!   classification of the failure.
14//! - `rel.migrate.ddl_atomic`: the same, for a table the failed migration created. MySQL and MariaDB
15//!   commit DDL implicitly and cannot undo it, so this leaf is off there.
16//!
17//! **Where it runs.** The ledger belongs to the connection's schema or database, so these tests must be
18//! pointed at a **disposable** one (a scratch schema, a scratch database, a fresh file): each leaf
19//! drops the ledger and its own tables first and last.
20//!
21//! **Controls.** The known-bad control is a real one: [`Defect::NoMigrationLedger`] wraps the same
22//! target in a migrator that runs every migration's statements each time with no ledger, no checksum
23//! and no transaction. Every leaf must fail it. The positive control is weaker (as for the other
24//! relational leaves): the leaf's own tables could be created on the target at all.
25
26use crate::conformance::{Defect, Defective, Failure};
27use crate::{
28    Dialect, MigrateError, MigrateReport, Migrates, Migration, RelError, RelationalBackend, Value,
29};
30use persistant_core::{CapabilityId, Config, Controls, Reason, Row as MatrixRow, Verdict};
31use std::collections::BTreeMap;
32
33/// A leaf this module can decide.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[non_exhaustive]
36pub enum MigrateLeaf {
37    /// `rel.migrate.apply`
38    Apply,
39    /// `rel.migrate.checksum`
40    Checksum,
41    /// `rel.migrate.newer`
42    Newer,
43    /// `rel.migrate.atomic`
44    Atomic,
45    /// `rel.migrate.ddl_atomic`
46    DdlAtomic,
47}
48
49impl MigrateLeaf {
50    /// Every leaf with a test here.
51    pub const ALL: [MigrateLeaf; 5] = [
52        MigrateLeaf::Apply,
53        MigrateLeaf::Checksum,
54        MigrateLeaf::Newer,
55        MigrateLeaf::Atomic,
56        MigrateLeaf::DdlAtomic,
57    ];
58
59    /// The capability id.
60    pub fn id(self) -> &'static str {
61        match self {
62            MigrateLeaf::Apply => "rel.migrate.apply",
63            MigrateLeaf::Checksum => "rel.migrate.checksum",
64            MigrateLeaf::Newer => "rel.migrate.newer",
65            MigrateLeaf::Atomic => "rel.migrate.atomic",
66            MigrateLeaf::DdlAtomic => "rel.migrate.ddl_atomic",
67        }
68    }
69}
70
71const T: &str = "persistant_mig_t";
72const T2: &str = "persistant_mig_t2";
73
74fn wrong<X>(msg: impl Into<String>) -> Result<X, Failure> {
75    Err(Failure::Wrong(msg.into()))
76}
77
78fn mig(version: i64, sql: &str) -> Migration {
79    Migration::new(version, format!("persistant migrate leaf {version}"), sql)
80}
81
82fn create_t() -> Migration {
83    mig(
84        1,
85        &format!("CREATE TABLE {T} (id BIGINT PRIMARY KEY, n BIGINT NOT NULL)"),
86    )
87}
88
89async fn reset<M: Migrates>(m: &M) -> Result<(), RelError> {
90    for t in [T, T2, "_sqlx_migrations"] {
91        m.execute(&format!("DROP TABLE IF EXISTS {t}"), &[]).await?;
92    }
93    Ok(())
94}
95
96async fn count<M: Migrates>(m: &M, table: &str) -> Result<i64, RelError> {
97    let rows = m
98        .query(&format!("SELECT COUNT(*) FROM {table}"), &[])
99        .await?;
100    Ok(rows.first().and_then(|r| r.int(0)).unwrap_or(-1))
101}
102
103fn migrated<X>(r: Result<X, MigrateError>, what: &str) -> Result<X, Failure> {
104    r.or_else(|e| wrong(format!("{what}: {e}")))
105}
106
107/// Run one leaf's test against `m`. Writes only to tables named `persistant_mig_*` and to the ledger
108/// `_sqlx_migrations`, which it drops first and last.
109pub async fn run_leaf<M: Migrates>(leaf: MigrateLeaf, m: &M) -> Result<(), Failure> {
110    if matches!(leaf, MigrateLeaf::DdlAtomic)
111        && matches!(m.dialect(), Dialect::MySql | Dialect::MariaDb)
112    {
113        return Err(Failure::Backend(RelError::Unsupported(
114            "DDL commits implicitly: a failed migration cannot undo the tables it created"
115                .to_string(),
116        )));
117    }
118    reset(m).await?;
119    let result = match leaf {
120        MigrateLeaf::Apply => apply(m).await,
121        MigrateLeaf::Checksum => checksum(m).await,
122        MigrateLeaf::Newer => newer(m).await,
123        MigrateLeaf::Atomic => atomic(m).await,
124        MigrateLeaf::DdlAtomic => ddl_atomic(m).await,
125    };
126    reset(m).await?;
127    result
128}
129
130async fn apply<M: Migrates>(m: &M) -> Result<(), Failure> {
131    let migs = [
132        create_t(),
133        mig(2, &format!("INSERT INTO {T} (id, n) VALUES (1, 1)")),
134    ];
135    let first = migrated(m.migrate(&migs).await, "the first run")?;
136    if first.applied != [1, 2] || !first.already_applied.is_empty() {
137        return wrong(format!(
138            "the first run reported {first:?}, not [1, 2] applied"
139        ));
140    }
141    if count(m, T).await? != 1 {
142        return wrong("migration 2 did not leave its row");
143    }
144    let ledger = migrated(m.applied_versions().await, "reading the ledger")?;
145    if ledger != [1, 2] {
146        return wrong(format!("the ledger lists {ledger:?}, not [1, 2]"));
147    }
148    let again = migrated(m.migrate(&migs).await, "the second run")?;
149    let want = MigrateReport {
150        applied: vec![],
151        already_applied: vec![1, 2],
152    };
153    if again != want {
154        return wrong(format!("the second run reported {again:?}, not {want:?}"));
155    }
156    if count(m, T).await? != 1 {
157        return wrong("the second run ran a migration again");
158    }
159    Ok(())
160}
161
162async fn checksum<M: Migrates>(m: &M) -> Result<(), Failure> {
163    let migs = [
164        create_t(),
165        mig(2, &format!("INSERT INTO {T} (id, n) VALUES (1, 1)")),
166    ];
167    migrated(m.migrate(&migs).await, "the first run")?;
168    let edited = [
169        create_t(),
170        mig(2, &format!("INSERT INTO {T} (id, n) VALUES (1, 2)")),
171    ];
172    match m.migrate(&edited).await {
173        Err(MigrateError::ChecksumMismatch { version: 2 }) => Ok(()),
174        other => wrong(format!(
175            "an applied migration was edited and the run answered {other:?}, not a checksum mismatch on 2"
176        )),
177    }
178}
179
180async fn newer<M: Migrates>(m: &M) -> Result<(), Failure> {
181    let migs = [
182        create_t(),
183        mig(2, &format!("INSERT INTO {T} (id, n) VALUES (1, 1)")),
184    ];
185    migrated(m.migrate(&migs).await, "the first run")?;
186    match m.migrate(&migs[..1]).await {
187        Err(MigrateError::NewerThanProgram { version: 2 }) => Ok(()),
188        other => wrong(format!(
189            "a program that knows only migration 1 met a database at 2 and the run answered {other:?}"
190        )),
191    }
192}
193
194async fn atomic<M: Migrates>(m: &M) -> Result<(), Failure> {
195    let migs = [
196        create_t(),
197        mig(
198            2,
199            &format!(
200                "INSERT INTO {T} (id, n) VALUES (1, 1); INSERT INTO {T} (id, n) VALUES (1, 2)"
201            ),
202        ),
203    ];
204    match m.migrate(&migs).await {
205        Err(MigrateError::Failed {
206            version: 2,
207            error: RelError::UniqueViolation { .. },
208        }) => {}
209        other => {
210            return wrong(format!(
211                "a migration whose second statement repeats a key answered {other:?}, not a classified failure of 2"
212            ));
213        }
214    }
215    if count(m, T).await? != 0 {
216        return wrong("the failed migration left the rows of its first statement behind");
217    }
218    let ledger = migrated(m.applied_versions().await, "reading the ledger")?;
219    if ledger != [1] {
220        return wrong(format!(
221            "after the failure the ledger lists {ledger:?}, not [1]"
222        ));
223    }
224    Ok(())
225}
226
227async fn ddl_atomic<M: Migrates>(m: &M) -> Result<(), Failure> {
228    let migs = [
229        create_t(),
230        mig(
231            2,
232            &format!(
233                "CREATE TABLE {T2} (id BIGINT PRIMARY KEY); INSERT INTO {T} (id, n) VALUES (1, 1); INSERT INTO {T} (id, n) VALUES (1, 2)"
234            ),
235        ),
236    ];
237    if !matches!(
238        m.migrate(&migs).await,
239        Err(MigrateError::Failed { version: 2, .. })
240    ) {
241        return wrong("the failing migration was not reported as failed");
242    }
243    // Control: the table migration 1 made is there and can be counted; a table that is not there cannot.
244    count(m, T).await?;
245    if count(m, T2).await.is_ok() {
246        return wrong("the failed migration left the table it created behind");
247    }
248    Ok(())
249}
250
251/// The outcome of certifying one migration leaf.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct MigrationCertified {
254    /// The leaf.
255    pub leaf: MigrateLeaf,
256    /// The row to record.
257    pub row: MatrixRow,
258}
259
260/// Run every migration leaf against `target` (a **disposable** ledger, see the module docs) and return
261/// one row per leaf. A leaf is **on** only if the target passed *and* the known-bad control failed.
262pub async fn certify<M: Migrates>(
263    target: &M,
264    config: &Config,
265    metadata: &Config,
266) -> Vec<MigrationCertified> {
267    let mut out = Vec::new();
268    for leaf in MigrateLeaf::ALL {
269        let result = run_leaf(leaf, target).await;
270        let positive_passed = !matches!(
271            &result,
272            Err(Failure::Backend(
273                RelError::Unsupported(_) | RelError::Unavailable(_)
274            ))
275        );
276        let bad = Defective::new(target, Defect::NoMigrationLedger);
277        let known_bad_failed = run_leaf(leaf, &bad).await.is_err();
278        let controls = Controls {
279            positive_passed,
280            known_bad_failed,
281        };
282        let id = CapabilityId::new(leaf.id()).expect("leaf id is valid");
283        let md = metadata.clone().with("test", format!("rel::{}", leaf.id()));
284        let off = |reason: Reason, note: String| MatrixRow {
285            subject_kind: "capability".to_string(),
286            subject: id.clone(),
287            backend: target.backend().to_string(),
288            version: target.version().to_string(),
289            config: config.clone(),
290            enabled: false,
291            reason: Some(reason),
292            metadata: md.clone(),
293            note: note.replace(['\t', '\n'], " "),
294            extra: BTreeMap::new(),
295        };
296        let row = match (
297            &result,
298            controls.positive_passed && controls.known_bad_failed,
299        ) {
300            (Ok(()), true) => Verdict::Exact
301                .into_row(
302                    id.clone(),
303                    target.backend(),
304                    target.version(),
305                    config.clone(),
306                    md.clone(),
307                    controls,
308                )
309                .expect("both controls hold"),
310            (Ok(()), false) => off(
311                Reason::Untested,
312                format!("controls did not hold: {controls:?}"),
313            ),
314            (Err(Failure::Wrong(m)), _) => off(Reason::Wrong, m.clone()),
315            (Err(Failure::Backend(e)), _) => off(Reason::Missing, e.to_string()),
316        };
317        out.push(MigrationCertified { leaf, row });
318    }
319    out
320}
321
322/// The known-bad migrator: no ledger, no checksum, no transaction. It runs each migration's
323/// statements (split on `;`) straight on the wrapped backend every time it is asked.
324impl<R: RelationalBackend> Migrates for Defective<'_, R> {
325    async fn migrate(&self, migrations: &[Migration]) -> Result<MigrateReport, MigrateError> {
326        if self.defect() != Defect::NoMigrationLedger {
327            return Err(MigrateError::Backend(
328                "this defect does not wrap a migrator".to_string(),
329            ));
330        }
331        let mut applied = Vec::new();
332        for m in migrations {
333            for stmt in m.sql.split(';').map(str::trim).filter(|s| !s.is_empty()) {
334                self.inner()
335                    .execute(stmt, &[] as &[Value])
336                    .await
337                    .map_err(|error| MigrateError::Failed {
338                        version: m.version,
339                        error,
340                    })?;
341            }
342            applied.push(m.version);
343        }
344        Ok(MigrateReport {
345            applied,
346            already_applied: Vec::new(),
347        })
348    }
349
350    async fn applied_versions(&self) -> Result<Vec<i64>, MigrateError> {
351        Ok(Vec::new())
352    }
353}