Skip to main content

persistant_relational/
conformance.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Contract tests for the relational kind: what [`RelationalBackend`] promises, decided against a real
3//! database. Each leaf produces a matrix row, **on** only when the controls held.
4//!
5//! - `rel.values`: integers, text, bytes and floats written as parameters come back as written, and a
6//!   `NULL` comes back as `Value::Null`.
7//! - `rel.transaction`: a committed write is visible, a rolled-back one is not, and a transaction
8//!   dropped without `commit` rolls back.
9//! - `rel.error.unique`: a duplicate key is reported as [`RelError::UniqueViolation`], not as an
10//!   opaque backend error.
11//! - `rel.foreign_key`: a reference to a missing row is rejected as [`RelError::ForeignKeyViolation`].
12//! - `rel.check_constraint`: a row violating a `CHECK` is rejected as [`RelError::CheckViolation`].
13//!
14//! **Controls.** There is no reference relational engine to use as a positive control, so the positive
15//! control is weaker than in the kv and blob suites: it is that the leaf's schema could be created on
16//! the target at all (a failure to create it is "missing", not "wrong"). The **known-bad** controls are
17//! real: a [`Defect`] wrapper that makes the same target misbehave in one way (no transactions, lossy
18//! bytes, unclassified errors), and, for the two constraint leaves, the same test run against a schema
19//! created *without* the constraint, which must be caught accepting the bad row.
20//!
21//! Statements are written per dialect (this is a probe of the dialect itself, not of translation).
22//! Booleans are left out of `rel.values`: how a driver returns one (`BOOL`, `TINYINT(1)`, `0/1`) is a
23//! documented difference, not a contract.
24
25use crate::{Dialect, RelError, RelationalBackend, RelationalTx, Row, Value};
26use persistant_core::{CapabilityId, Config, Controls, Reason, Row as MatrixRow, Verdict};
27use std::collections::BTreeMap;
28
29/// A leaf this module can decide.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum Leaf {
33    /// `rel.values`
34    Values,
35    /// `rel.transaction`
36    Transaction,
37    /// `rel.error.unique`
38    UniqueClassified,
39    /// `rel.foreign_key`
40    ForeignKey,
41    /// `rel.check_constraint`
42    CheckConstraint,
43}
44
45impl Leaf {
46    /// Every leaf with a test here.
47    pub const ALL: [Leaf; 5] = [
48        Leaf::Values,
49        Leaf::Transaction,
50        Leaf::UniqueClassified,
51        Leaf::ForeignKey,
52        Leaf::CheckConstraint,
53    ];
54
55    /// The capability id.
56    pub fn id(self) -> &'static str {
57        match self {
58            Leaf::Values => "rel.values",
59            Leaf::Transaction => "rel.transaction",
60            Leaf::UniqueClassified => "rel.error.unique",
61            Leaf::ForeignKey => "rel.foreign_key",
62            Leaf::CheckConstraint => "rel.check_constraint",
63        }
64    }
65
66    fn defects(self) -> &'static [Defect] {
67        match self {
68            Leaf::Values => &[Defect::LossyBytes],
69            Leaf::Transaction => &[Defect::NoTransactions],
70            Leaf::UniqueClassified => &[Defect::UnclassifiedErrors],
71            Leaf::ForeignKey | Leaf::CheckConstraint => &[],
72        }
73    }
74
75    /// Whether the leaf has a "schema without the constraint" known-bad run.
76    fn has_unconstrained_control(self) -> bool {
77        matches!(self, Leaf::ForeignKey | Leaf::CheckConstraint)
78    }
79}
80
81/// One way for a wrapped backend to be wrong, for the known-bad controls.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum Defect {
85    /// `begin` returns a transaction whose statements run at once and whose `rollback` and drop do
86    /// nothing.
87    NoTransactions,
88    /// A byte parameter is cut to its first byte.
89    LossyBytes,
90    /// Every error comes back as `RelError::Backend`.
91    UnclassifiedErrors,
92}
93
94/// A backend with one [`Defect`] injected. The wrapped backend does the work.
95#[derive(Debug)]
96pub struct Defective<'a, R> {
97    inner: &'a R,
98    defect: Defect,
99}
100
101impl<'a, R: RelationalBackend> Defective<'a, R> {
102    /// Wrap `inner` so it is wrong in exactly the way `defect` says.
103    pub fn new(inner: &'a R, defect: Defect) -> Self {
104        Defective { inner, defect }
105    }
106}
107
108/// The transaction of a [`Defective`] backend.
109pub enum DefectiveTx<'a, R: RelationalBackend + 'a> {
110    /// The real transaction (the defect is elsewhere).
111    Real(R::Tx<'a>, Defect),
112    /// No transaction at all: statements run on the backend directly.
113    Direct(&'a R),
114}
115
116impl<R: RelationalBackend> RelationalBackend for Defective<'_, R> {
117    type Tx<'a>
118        = DefectiveTx<'a, R>
119    where
120        Self: 'a;
121
122    fn backend(&self) -> &str {
123        self.inner.backend()
124    }
125
126    fn version(&self) -> &str {
127        self.inner.version()
128    }
129
130    fn dialect(&self) -> Dialect {
131        self.inner.dialect()
132    }
133
134    async fn execute(&self, sql: &str, params: &[Value]) -> Result<u64, RelError> {
135        self.inner
136            .execute(sql, &lossy(self.defect, params))
137            .await
138            .map_err(|e| unclassify(self.defect, e))
139    }
140
141    async fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Row>, RelError> {
142        self.inner
143            .query(sql, &lossy(self.defect, params))
144            .await
145            .map_err(|e| unclassify(self.defect, e))
146    }
147
148    async fn begin(&self) -> Result<DefectiveTx<'_, R>, RelError> {
149        if self.defect == Defect::NoTransactions {
150            return Ok(DefectiveTx::Direct(self.inner));
151        }
152        Ok(DefectiveTx::Real(
153            self.inner
154                .begin()
155                .await
156                .map_err(|e| unclassify(self.defect, e))?,
157            self.defect,
158        ))
159    }
160}
161
162fn lossy(defect: Defect, params: &[Value]) -> Vec<Value> {
163    params
164        .iter()
165        .map(|p| match (defect, p) {
166            (Defect::LossyBytes, Value::Bytes(b)) => {
167                Value::Bytes(b.iter().take(1).copied().collect())
168            }
169            _ => p.clone(),
170        })
171        .collect()
172}
173
174fn unclassify(defect: Defect, e: RelError) -> RelError {
175    if defect == Defect::UnclassifiedErrors {
176        RelError::Backend(e.to_string())
177    } else {
178        e
179    }
180}
181
182impl<R: RelationalBackend> RelationalTx for DefectiveTx<'_, R> {
183    async fn execute(&mut self, sql: &str, params: &[Value]) -> Result<u64, RelError> {
184        match self {
185            DefectiveTx::Real(tx, d) => tx
186                .execute(sql, &lossy(*d, params))
187                .await
188                .map_err(|e| unclassify(*d, e)),
189            DefectiveTx::Direct(db) => db.execute(sql, params).await,
190        }
191    }
192
193    async fn query(&mut self, sql: &str, params: &[Value]) -> Result<Vec<Row>, RelError> {
194        match self {
195            DefectiveTx::Real(tx, d) => tx
196                .query(sql, &lossy(*d, params))
197                .await
198                .map_err(|e| unclassify(*d, e)),
199            DefectiveTx::Direct(db) => db.query(sql, params).await,
200        }
201    }
202
203    async fn commit(self) -> Result<(), RelError> {
204        match self {
205            DefectiveTx::Real(tx, d) => tx.commit().await.map_err(|e| unclassify(d, e)),
206            DefectiveTx::Direct(_) => Ok(()),
207        }
208    }
209
210    async fn rollback(self) -> Result<(), RelError> {
211        match self {
212            DefectiveTx::Real(tx, d) => tx.rollback().await.map_err(|e| unclassify(d, e)),
213            DefectiveTx::Direct(_) => Ok(()),
214        }
215    }
216}
217
218/// Why a leaf's test did not pass.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum Failure {
221    /// The backend returned something other than the contract says.
222    Wrong(String),
223    /// The backend could not do something the test needs (including creating the leaf's schema).
224    Backend(RelError),
225}
226
227impl From<RelError> for Failure {
228    fn from(e: RelError) -> Self {
229        Failure::Backend(e)
230    }
231}
232
233fn wrong<T>(msg: impl Into<String>) -> Result<T, Failure> {
234    Err(Failure::Wrong(msg.into()))
235}
236
237// ---- per-dialect statement text -------------------------------------------------------------
238
239#[derive(Clone, Copy)]
240enum Ty {
241    Int,
242    Text,
243    Bytes,
244    Float,
245}
246
247struct Sql(Dialect);
248
249impl Sql {
250    /// The placeholder for parameter `n` (1-based).
251    fn p(&self, n: usize) -> String {
252        match self.0 {
253            Dialect::Postgres => format!("${n}"),
254            Dialect::Sqlite => format!("?{n}"),
255            _ => "?".to_string(),
256        }
257    }
258
259    fn ty(&self, t: Ty) -> &'static str {
260        match (&self.0, t) {
261            (_, Ty::Int) => "BIGINT",
262            (Dialect::Postgres, Ty::Text) => "TEXT",
263            (Dialect::Sqlite, Ty::Text) => "TEXT",
264            (_, Ty::Text) => "VARCHAR(255)",
265            (Dialect::Postgres, Ty::Bytes) => "BYTEA",
266            (Dialect::Sqlite, Ty::Bytes) => "BLOB",
267            (_, Ty::Bytes) => "LONGBLOB",
268            (Dialect::Postgres, Ty::Float) => "DOUBLE PRECISION",
269            (_, Ty::Float) => "DOUBLE",
270        }
271    }
272
273    /// Table options (InnoDB is explicit on MySQL so constraints and transactions exist).
274    fn tail(&self) -> &'static str {
275        match self.0 {
276            Dialect::MySql | Dialect::MariaDb => " ENGINE=InnoDB",
277            _ => "",
278        }
279    }
280
281    /// An integer primary key column definition.
282    fn pk(&self, name: &str) -> String {
283        format!("{name} BIGINT NOT NULL PRIMARY KEY")
284    }
285}
286
287fn supported(d: &Dialect) -> Result<(), Failure> {
288    match d {
289        Dialect::Postgres | Dialect::Sqlite | Dialect::MySql | Dialect::MariaDb => Ok(()),
290        other => Err(Failure::Backend(RelError::Unsupported(format!(
291            "the relational contract tests have no statements for {other}"
292        )))),
293    }
294}
295
296async fn drop_tables<R: RelationalBackend>(b: &R, names: &[&str]) -> Result<(), RelError> {
297    for n in names {
298        b.execute(&format!("DROP TABLE IF EXISTS {n}"), &[]).await?;
299    }
300    Ok(())
301}
302
303/// Run one leaf's test against `b`. `constrained = false` creates the constraint leaves' schema without
304/// the constraint (the known-bad control); it makes no difference to the other leaves. Writes only to
305/// tables named `persistant_rel_*`, which it drops first and last.
306pub async fn run_leaf<R: RelationalBackend>(
307    leaf: Leaf,
308    b: &R,
309    constrained: bool,
310) -> Result<(), Failure> {
311    let d = b.dialect();
312    supported(&d)?;
313    let s = Sql(d);
314    match leaf {
315        Leaf::Values => values(b, &s).await,
316        Leaf::Transaction => transaction(b, &s).await,
317        Leaf::UniqueClassified => unique(b, &s).await,
318        Leaf::ForeignKey => foreign_key(b, &s, constrained).await,
319        Leaf::CheckConstraint => check(b, &s, constrained).await,
320    }
321}
322
323async fn values<R: RelationalBackend>(b: &R, s: &Sql) -> Result<(), Failure> {
324    const T: &str = "persistant_rel_values";
325    drop_tables(b, &[T]).await?;
326    b.execute(
327        &format!(
328            "CREATE TABLE {T} ({}, i {}, t {}, b {}, f {}){}",
329            s.pk("id"),
330            s.ty(Ty::Int),
331            s.ty(Ty::Text),
332            s.ty(Ty::Bytes),
333            s.ty(Ty::Float),
334            s.tail()
335        ),
336        &[],
337    )
338    .await?;
339    let cases: Vec<(i64, Value, Value, Value, Value)> = vec![
340        (
341            1,
342            Value::Int(i64::MIN),
343            Value::Text("ünï ✓ \u{1F980}".to_string()),
344            Value::Bytes(vec![0x00, 0xFF, 0x80, 0x00, 0x7F]),
345            Value::Float(1.5),
346        ),
347        (
348            2,
349            Value::Int(i64::MAX),
350            Value::Text(String::new()),
351            Value::Bytes(vec![]),
352            Value::Float(-0.25),
353        ),
354        (3, Value::Null, Value::Null, Value::Null, Value::Null),
355    ];
356    for (id, i, t, bytes, f) in &cases {
357        b.execute(
358            &format!(
359                "INSERT INTO {T} (id, i, t, b, f) VALUES ({}, {}, {}, {}, {})",
360                s.p(1),
361                s.p(2),
362                s.p(3),
363                s.p(4),
364                s.p(5)
365            ),
366            &[
367                Value::Int(*id),
368                i.clone(),
369                t.clone(),
370                bytes.clone(),
371                f.clone(),
372            ],
373        )
374        .await?;
375    }
376    let rows = b
377        .query(&format!("SELECT id, i, t, b, f FROM {T} ORDER BY id"), &[])
378        .await?;
379    drop_tables(b, &[T]).await?;
380    if rows.len() != cases.len() {
381        return wrong(format!("wrote {} rows, read {}", cases.len(), rows.len()));
382    }
383    for (row, (id, i, t, bytes, f)) in rows.iter().zip(&cases) {
384        let want = [
385            Value::Int(*id),
386            i.clone(),
387            t.clone(),
388            bytes.clone(),
389            f.clone(),
390        ];
391        // An empty byte string and an empty text may come back as NULL on no engine here; they must not.
392        if row.values != want {
393            return wrong(format!("row {id}: wrote {want:?}, read {:?}", row.values));
394        }
395    }
396    Ok(())
397}
398
399async fn count<R: RelationalBackend>(b: &R, table: &str) -> Result<i64, Failure> {
400    let r = b
401        .query(&format!("SELECT COUNT(*) FROM {table}"), &[])
402        .await?;
403    match r.first().and_then(|r| r.int(0)) {
404        Some(n) => Ok(n),
405        None => wrong("COUNT(*) did not come back as an integer"),
406    }
407}
408
409async fn transaction<R: RelationalBackend>(b: &R, s: &Sql) -> Result<(), Failure> {
410    const T: &str = "persistant_rel_tx";
411    drop_tables(b, &[T]).await?;
412    b.execute(
413        &format!(
414            "CREATE TABLE {T} ({}, n BIGINT NOT NULL){}",
415            s.pk("id"),
416            s.tail()
417        ),
418        &[],
419    )
420    .await?;
421    let ins = format!("INSERT INTO {T} (id, n) VALUES ({}, {})", s.p(1), s.p(2));
422    let result = async {
423        // Commit: visible.
424        let mut tx = b.begin().await?;
425        tx.execute(&ins, &[Value::Int(1), Value::Int(10)]).await?;
426        let own = tx.query(&format!("SELECT COUNT(*) FROM {T}"), &[]).await?;
427        if own.first().and_then(|r| r.int(0)) != Some(1) {
428            return wrong("a transaction cannot see its own write");
429        }
430        tx.commit().await?;
431        if count(b, T).await? != 1 {
432            return wrong("a committed write is not visible");
433        }
434        // Rollback: not visible.
435        let mut tx = b.begin().await?;
436        tx.execute(&ins, &[Value::Int(2), Value::Int(20)]).await?;
437        tx.rollback().await?;
438        if count(b, T).await? != 1 {
439            return wrong("a rolled-back write is visible");
440        }
441        // Dropped without commit: rolled back.
442        {
443            let mut tx = b.begin().await?;
444            tx.execute(&ins, &[Value::Int(3), Value::Int(30)]).await?;
445        }
446        if count(b, T).await? != 1 {
447            return wrong("a transaction dropped without commit was not rolled back");
448        }
449        Ok(())
450    }
451    .await;
452    drop_tables(b, &[T]).await?;
453    result
454}
455
456async fn unique<R: RelationalBackend>(b: &R, s: &Sql) -> Result<(), Failure> {
457    const T: &str = "persistant_rel_unique";
458    drop_tables(b, &[T]).await?;
459    b.execute(
460        &format!(
461            "CREATE TABLE {T} ({}, n BIGINT NOT NULL){}",
462            s.pk("id"),
463            s.tail()
464        ),
465        &[],
466    )
467    .await?;
468    let ins = format!("INSERT INTO {T} (id, n) VALUES ({}, {})", s.p(1), s.p(2));
469    let result = async {
470        b.execute(&ins, &[Value::Int(1), Value::Int(1)]).await?;
471        match b.execute(&ins, &[Value::Int(1), Value::Int(2)]).await {
472            Err(RelError::UniqueViolation { .. }) => {}
473            other => return wrong(format!("a duplicate key came back as {other:?}")),
474        }
475        // Inside a transaction too.
476        let mut tx = b.begin().await?;
477        let dup = tx.execute(&ins, &[Value::Int(1), Value::Int(3)]).await;
478        let _ = tx.rollback().await;
479        match dup {
480            Err(RelError::UniqueViolation { .. }) => Ok(()),
481            other => wrong(format!(
482                "a duplicate key in a transaction came back as {other:?}"
483            )),
484        }
485    }
486    .await;
487    drop_tables(b, &[T]).await?;
488    result
489}
490
491async fn foreign_key<R: RelationalBackend>(
492    b: &R,
493    s: &Sql,
494    constrained: bool,
495) -> Result<(), Failure> {
496    const P: &str = "persistant_rel_fk_parent";
497    const C: &str = "persistant_rel_fk_child";
498    drop_tables(b, &[C, P]).await?;
499    b.execute(
500        &format!("CREATE TABLE {P} ({}){}", s.pk("id"), s.tail()),
501        &[],
502    )
503    .await?;
504    let refs = if constrained {
505        format!(", FOREIGN KEY (pid) REFERENCES {P}(id)")
506    } else {
507        String::new()
508    };
509    b.execute(
510        &format!(
511            "CREATE TABLE {C} ({}, pid BIGINT NOT NULL{refs}){}",
512            s.pk("id"),
513            s.tail()
514        ),
515        &[],
516    )
517    .await?;
518    let result = async {
519        b.execute(
520            &format!("INSERT INTO {P} (id) VALUES ({})", s.p(1)),
521            &[Value::Int(1)],
522        )
523        .await?;
524        let ins = format!("INSERT INTO {C} (id, pid) VALUES ({}, {})", s.p(1), s.p(2));
525        b.execute(&ins, &[Value::Int(1), Value::Int(1)]).await?;
526        match b.execute(&ins, &[Value::Int(2), Value::Int(99)]).await {
527            Err(RelError::ForeignKeyViolation { .. }) => {}
528            Ok(_) => return wrong("a reference to a missing row was accepted"),
529            other => {
530                return wrong(format!(
531                    "a reference to a missing row came back as {other:?}"
532                ));
533            }
534        }
535        match b
536            .execute(
537                &format!("DELETE FROM {P} WHERE id = {}", s.p(1)),
538                &[Value::Int(1)],
539            )
540            .await
541        {
542            Err(RelError::ForeignKeyViolation { .. }) => Ok(()),
543            Ok(_) => wrong("a referenced row was deleted"),
544            other => wrong(format!("deleting a referenced row came back as {other:?}")),
545        }
546    }
547    .await;
548    drop_tables(b, &[C, P]).await?;
549    result
550}
551
552async fn check<R: RelationalBackend>(b: &R, s: &Sql, constrained: bool) -> Result<(), Failure> {
553    const T: &str = "persistant_rel_check";
554    drop_tables(b, &[T]).await?;
555    let ck = if constrained { " CHECK (n >= 0)" } else { "" };
556    b.execute(
557        &format!(
558            "CREATE TABLE {T} ({}, n BIGINT NOT NULL{ck}){}",
559            s.pk("id"),
560            s.tail()
561        ),
562        &[],
563    )
564    .await?;
565    let ins = format!("INSERT INTO {T} (id, n) VALUES ({}, {})", s.p(1), s.p(2));
566    let result = async {
567        b.execute(&ins, &[Value::Int(1), Value::Int(0)]).await?;
568        match b.execute(&ins, &[Value::Int(2), Value::Int(-1)]).await {
569            Err(RelError::CheckViolation { .. }) => Ok(()),
570            Ok(_) => wrong("a row violating a CHECK was accepted"),
571            other => wrong(format!("a row violating a CHECK came back as {other:?}")),
572        }
573    }
574    .await;
575    drop_tables(b, &[T]).await?;
576    result
577}
578
579// ---- certification --------------------------------------------------------------------------
580
581/// The outcome of certifying one leaf.
582#[derive(Debug, Clone, PartialEq, Eq)]
583pub struct Certified {
584    /// The leaf.
585    pub leaf: Leaf,
586    /// The row to record.
587    pub row: MatrixRow,
588}
589
590/// Run every leaf here against `target` and return one row per leaf.
591///
592/// A leaf is **on** only if the target passed *and* every known-bad control failed (see the module
593/// docs for the positive control). `config` is the facts a gate can read for this target; `metadata`
594/// is copied into every row with `test=rel::<leaf>` added.
595pub async fn certify<R: RelationalBackend>(
596    target: &R,
597    config: &Config,
598    metadata: &Config,
599) -> Vec<Certified> {
600    let mut out = Vec::new();
601    for leaf in Leaf::ALL {
602        let result = run_leaf(leaf, target, true).await;
603        let positive_passed = !matches!(
604            &result,
605            Err(Failure::Backend(
606                RelError::Unsupported(_) | RelError::Unavailable(_)
607            ))
608        );
609        let mut known_bad_failed = true;
610        for d in leaf.defects() {
611            let bad = Defective::new(target, *d);
612            if run_leaf(leaf, &bad, true).await.is_ok() {
613                known_bad_failed = false;
614            }
615        }
616        if leaf.has_unconstrained_control() && run_leaf(leaf, target, false).await.is_ok() {
617            known_bad_failed = false;
618        }
619        let controls = Controls {
620            positive_passed,
621            known_bad_failed,
622        };
623        let id = CapabilityId::new(leaf.id()).expect("leaf id is valid");
624        let md = metadata.clone().with("test", format!("rel::{}", leaf.id()));
625        let off = |reason: Reason, note: String| MatrixRow {
626            subject_kind: "capability".to_string(),
627            subject: id.clone(),
628            backend: target.backend().to_string(),
629            version: target.version().to_string(),
630            config: config.clone(),
631            enabled: false,
632            reason: Some(reason),
633            metadata: md.clone(),
634            note: note.replace(['\t', '\n'], " "),
635            extra: BTreeMap::new(),
636        };
637        let row = match (
638            &result,
639            controls.positive_passed && controls.known_bad_failed,
640        ) {
641            (Ok(()), true) => Verdict::Exact
642                .into_row(
643                    id.clone(),
644                    target.backend(),
645                    target.version(),
646                    config.clone(),
647                    md.clone(),
648                    controls,
649                )
650                .expect("both controls hold"),
651            (Ok(()), false) => off(
652                Reason::Untested,
653                format!("controls did not hold: {controls:?}"),
654            ),
655            (Err(Failure::Wrong(m)), _) => off(Reason::Wrong, m.clone()),
656            (Err(Failure::Backend(e)), _) => off(Reason::Missing, e.to_string()),
657        };
658        out.push(Certified { leaf, row });
659    }
660    out
661}