openehr-store 0.3.0

Engine-agnostic openEHR persistence: storage model, SQL dialect trait, commit semantics, and a conformance suite
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
//! The SQL dialect trait, and DDL generation from [`crate::schema`].
//!
//! # What a dialect is allowed to change
//!
//! Exactly four things: how it spells a type, how it quotes an identifier, how
//! it writes a placeholder, and how it enforces append-only. Everything else —
//! which tables exist, which columns, which indexes, what order they are
//! emitted in — comes from the shared schema and is identical across engines by
//! construction.
//!
//! That boundary is the whole point. The sibling FHIR monorepo in this
//! repository has an audit finding (**F-08**) for an Oracle DDL emitter that
//! silently emitted `MySQL` types, because each port owned a full copy of the
//! generator. Here a dialect cannot emit another engine's schema, because it
//! does not own the schema — only the spellings. [`crate::conformance`]
//! includes a test that asserts no two dialects agree on all of them.

use crate::schema::{ColTy, Column, TABLES, Table};
use core::fmt::Write as _;

/// How a dialect writes a bind placeholder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Placeholder {
    /// `?` — `SQLite`, `MySQL`, `MariaDB`.
    Question,
    /// `$1`, `$2` — `PostgreSQL`.
    Dollar,
    /// `@p1`, `@p2` — SQL Server.
    AtP,
    /// `:1`, `:2` — Oracle.
    Colon,
}

impl Placeholder {
    /// Renders the placeholder for a one-based parameter position.
    ///
    /// ```
    /// use openehr_store::Placeholder;
    ///
    /// assert_eq!(Placeholder::Question.render(1), "?");
    /// assert_eq!(Placeholder::Dollar.render(2), "$2");
    /// assert_eq!(Placeholder::AtP.render(3), "@p3");
    /// assert_eq!(Placeholder::Colon.render(4), ":4");
    /// ```
    #[must_use]
    pub fn render(self, position: usize) -> String {
        match self {
            Self::Question => "?".to_owned(),
            Self::Dollar => format!("${position}"),
            Self::AtP => format!("@p{position}"),
            Self::Colon => format!(":{position}"),
        }
    }
}

/// A schema object a `CREATE` statement can bring into being.
///
/// Passed to [`Dialect::guard`] so an engine that checks a catalogue before
/// creating knows which catalogue to check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectKind {
    /// A table.
    Table,
    /// An index.
    Index,
}

/// How an engine makes a `CREATE` statement safe to re-run.
///
/// `install()` must be idempotent — an operator who runs it twice, or a
/// deployment that retries, must not get a hard error the second time. The
/// three engines differ enough that a single boolean gets it wrong, which is
/// how the first live run against `MySQL` failed: `MySQL` accepts
/// `CREATE TABLE IF NOT EXISTS` and **rejects** `CREATE INDEX IF NOT EXISTS`,
/// so one flag covering both statement kinds emitted a script that created
/// every table and then failed on the first index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Idempotence {
    /// The statement accepts an inline `IF NOT EXISTS` clause.
    IfNotExists,
    /// The statement must be wrapped by [`Dialect::guard`].
    Guard,
    /// No separate statement exists — the object is declared inside its
    /// table, and inherits that table's idempotence.
    Inline,
}

/// One SQL engine's spellings.
///
/// # Implementing one
///
/// Implement [`Dialect::name`], [`Dialect::col_sql`], [`Dialect::quote`], and
/// [`Dialect::placeholder`]. The default methods build the DDL from those and
/// from the shared schema; override one only where the engine genuinely cannot
/// do what the default emits, and say so in the crate's dialect annex.
pub trait Dialect {
    /// The engine's name, as it appears in documentation and error messages.
    fn name(&self) -> &'static str;

    /// The SQL type for a logical column type.
    ///
    /// This is the one function that names engine-specific types, and it is
    /// where every dialect difference that matters actually lives.
    fn col_sql(&self, ty: ColTy) -> String;

    /// Quotes an identifier.
    fn quote(&self, identifier: &str) -> String;

    /// The engine's placeholder style.
    fn placeholder(&self) -> Placeholder;

    /// How `CREATE TABLE` is made re-runnable.
    fn table_idempotence(&self) -> Idempotence {
        Idempotence::IfNotExists
    }

    /// How `CREATE INDEX` is made re-runnable.
    ///
    /// Separate from [`Dialect::table_idempotence`] because `MySQL` treats the
    /// two statements differently; see [`Idempotence`].
    fn index_idempotence(&self) -> Idempotence {
        Idempotence::IfNotExists
    }

    /// Wraps a statement so that re-running it is a no-op.
    ///
    /// Called only for object kinds whose idempotence is
    /// [`Idempotence::Guard`]. The default returns the statement unchanged,
    /// which is correct only when no kind declares `Guard` —
    /// [`crate::conformance::check_dialect`] fails a dialect that declares
    /// `Guard` and then does not actually wrap, because a guard that is
    /// documented but not emitted is worse than none.
    fn guard(&self, _kind: ObjectKind, _name: &str, statement: &str) -> String {
        statement.to_owned()
    }

    /// The statement terminator used when joining statements into a script.
    fn terminator(&self) -> &'static str {
        ";"
    }

    /// Statements enforcing append-only on a table.
    ///
    /// All six engines this workspace targets can do it with a trigger, and
    /// all six must, because a guarantee enforced only in application code is
    /// a guarantee that ends the first time somebody opens a SQL console.
    ///
    /// The empty default is retained only so that a *new* dialect compiles
    /// before it is finished. It is not a permissible resting state:
    /// [`crate::conformance::check_dialect`] fails any dialect that leaves an
    /// append-only table unenforced. Three dialects inherited this default
    /// silently for as long as they existed (**A-15**) while the shared
    /// documentation described append-only as a property of the design — which
    /// is why the check exists rather than another sentence here.
    fn append_only_sql(&self, _table: &Table) -> Vec<String> {
        Vec::new()
    }

    /// The full DDL script for the shared schema.
    ///
    /// Tables in dependency order, then indexes, then append-only enforcement.
    /// Indexes come after all tables so that a partial failure leaves a
    /// readable schema rather than a half-indexed one.
    fn ddl(&self) -> Vec<String> {
        let mut out = Vec::new();
        for table in TABLES {
            out.push(self.create_table(table));
        }
        if self.index_idempotence() != Idempotence::Inline {
            for table in TABLES {
                for index in table.indexes {
                    out.push(self.create_index(table, index));
                }
            }
        }
        for table in TABLES {
            if table.append_only {
                out.extend(self.append_only_sql(table));
            }
        }
        out
    }

    /// One `CREATE TABLE` statement.
    fn create_table(&self, table: &Table) -> String {
        let mut sql = String::new();
        let exists = if self.table_idempotence() == Idempotence::IfNotExists {
            "IF NOT EXISTS "
        } else {
            ""
        };
        let _ = writeln!(sql, "CREATE TABLE {exists}{} (", self.quote(table.name));
        let mut parts: Vec<String> = Vec::new();
        for column in table.columns {
            parts.push(format!(
                "  {} {}{}",
                self.quote(column.name),
                self.col_sql(column.ty),
                if column.nullable { "" } else { " NOT NULL" }
            ));
        }
        if !table.primary_key.is_empty() {
            let keys: Vec<String> = table.primary_key.iter().map(|k| self.quote(k)).collect();
            parts.push(format!("  PRIMARY KEY ({})", keys.join(", ")));
        }
        for fk in table.foreign_keys {
            parts.push(format!(
                "  FOREIGN KEY ({}) REFERENCES {} ({})",
                self.quote(fk.column),
                self.quote(fk.table),
                self.quote(fk.references)
            ));
        }
        // MySQL cannot say `CREATE INDEX IF NOT EXISTS`, but it can declare the
        // index inside the table, where it inherits the table's own
        // `IF NOT EXISTS`. That is the idiomatic answer rather than a
        // workaround: one statement, one object, one idempotence rule.
        if self.index_idempotence() == Idempotence::Inline {
            for index in table.indexes {
                let columns: Vec<String> = index.columns.iter().map(|c| self.quote(c)).collect();
                parts.push(format!(
                    "  {}KEY {} ({})",
                    if index.unique { "UNIQUE " } else { "" },
                    self.quote(index.name),
                    columns.join(", ")
                ));
            }
        }
        let _ = write!(sql, "{}", parts.join(",\n"));
        let _ = write!(sql, "\n)");
        if self.table_idempotence() == Idempotence::Guard {
            return self.guard(ObjectKind::Table, table.name, &sql);
        }
        sql
    }

    /// One `CREATE INDEX` statement.
    fn create_index(&self, table: &Table, index: &crate::schema::Index) -> String {
        let unique = if index.unique { "UNIQUE " } else { "" };
        let exists = if self.index_idempotence() == Idempotence::IfNotExists {
            "IF NOT EXISTS "
        } else {
            ""
        };
        let columns: Vec<String> = index.columns.iter().map(|c| self.quote(c)).collect();
        let sql = format!(
            "CREATE {unique}INDEX {exists}{} ON {} ({})",
            self.quote(index.name),
            self.quote(table.name),
            columns.join(", ")
        );
        if self.index_idempotence() == Idempotence::Guard {
            return self.guard(ObjectKind::Index, index.name, &sql);
        }
        sql
    }
}

/// Renders a dialect's DDL as one script.
///
/// # Errors
///
/// Never fails; the signature is infallible and this returns a `String`
/// directly. Present as a free function so callers do not have to import the
/// trait to get a script.
#[must_use]
pub fn ddl_script<D: Dialect + ?Sized>(dialect: &D) -> String {
    let terminator = dialect.terminator();
    dialect
        .ddl()
        .into_iter()
        .map(|statement| format!("{statement}{terminator}\n"))
        .collect::<Vec<_>>()
        .join("\n")
}

/// Checks that a column type maps to something plausible.
///
/// Used by [`crate::conformance::check_dialect`]; exposed so a new dialect's
/// own tests can call it directly.
#[must_use]
pub fn column_sql<D: Dialect + ?Sized>(dialect: &D, column: &Column) -> String {
    dialect.col_sql(column.ty)
}

#[cfg(test)]
mod tests {
    use super::{Dialect, Idempotence, ObjectKind, Placeholder, ddl_script};
    use crate::schema::{ColTy, TABLES, Table};

    /// The smallest thing that can be a dialect.
    ///
    /// Every default in this trait is exercised only by the six engine crates,
    /// and `cargo mutants` runs the tests of the crate it mutates — so 25 of 27
    /// viable mutants here survived `openehr-store`'s own suite, including
    /// `ddl -> vec![]` and `terminator -> ""`. Each would fail every engine
    /// crate's golden test, in another job, after this crate reported success
    /// (`lib:A-09`).
    ///
    /// This crate is the engine-agnostic half. The shared generator can be
    /// tested without an engine, and now is.
    struct Minimal;

    impl Dialect for Minimal {
        fn name(&self) -> &'static str {
            "minimal"
        }
        fn col_sql(&self, ty: ColTy) -> String {
            match ty {
                ColTy::Digest => "BLOB".to_owned(),
                ColTy::Int | ColTy::Bool | ColTy::InstantUtc => "INTEGER".to_owned(),
                _ => "TEXT".to_owned(),
            }
        }
        fn quote(&self, identifier: &str) -> String {
            format!("\"{identifier}\"")
        }
        fn placeholder(&self) -> Placeholder {
            Placeholder::Question
        }
    }

    #[test]
    fn the_shared_generator_emits_a_statement_for_every_table_and_index() {
        let statements = Minimal.ddl();
        assert!(!statements.is_empty(), "no DDL was emitted");

        // One CREATE TABLE per declared table, and the tables are the schema's
        // — a dialect defines none of its own (`db:M3.22`).
        for table in TABLES {
            let quoted = Minimal.quote(table.name);
            assert!(
                statements
                    .iter()
                    .any(|s| s.starts_with("CREATE TABLE") && s.contains(&quoted)),
                "{} has no CREATE TABLE",
                table.name
            );
        }

        // Indexes are separate statements unless the dialect inlines them.
        assert_ne!(Minimal.index_idempotence(), Idempotence::Inline);
        let expected: usize = TABLES.iter().map(|t| t.indexes.len()).sum();
        assert_eq!(
            statements
                .iter()
                .filter(|s| s.contains("CREATE INDEX") || s.contains("CREATE UNIQUE INDEX"))
                .count(),
            expected
        );
    }

    #[test]
    fn a_column_carries_its_quoted_name_and_its_engine_type() {
        let version = TABLES
            .iter()
            .find(|t| t.name == "openehr_version")
            .expect("the schema declares openehr_version");
        let sql = Minimal.create_table(version);

        assert!(sql.contains(&Minimal.quote("uid")), "{sql}");
        // `ColTy::Digest` is the one that must not become text (`db:M3.40`).
        assert!(sql.contains("BLOB"), "no digest column typed: {sql}");
        assert!(sql.contains("TEXT"), "{sql}");
    }

    #[test]
    fn the_terminator_ends_every_statement_in_the_script() {
        // Statements span lines — a CREATE TABLE is many — so this counts
        // terminators against statements rather than checking line endings.
        let terminator = Minimal.terminator();
        assert!(!terminator.is_empty(), "a statement needs an end");
        let script = ddl_script(&Minimal);
        assert_eq!(
            script.matches(terminator).count(),
            Minimal.ddl().len(),
            "one terminator per statement"
        );
    }

    #[test]
    fn the_default_guard_is_the_identity_and_says_so() {
        // A dialect declaring `Guard` and inheriting this default emits bare,
        // non-idempotent DDL that *reads* as protected — which is what SQL
        // Server and Oracle did until a live run exposed it. `check_dialect`
        // refuses that combination; this pins the default it refuses.
        let bare = "CREATE SOMETHING x";
        assert_eq!(Minimal.guard(ObjectKind::Table, "x", bare), bare);
        assert_eq!(Minimal.table_idempotence(), Idempotence::IfNotExists);
    }

    #[test]
    fn append_only_is_not_emitted_by_a_dialect_that_declares_none() {
        // The default is empty, and `check_dialect` is what refuses a dialect
        // that inherits it while the schema marks tables append-only
        // (`db:M3.36`). Asserted here so the default cannot quietly acquire a
        // statement.
        for table in TABLES {
            assert!(Minimal.append_only_sql(table).is_empty());
        }
    }

    /// A dialect that takes the *other* branch of every idempotence decision.
    ///
    /// `Minimal` declares `IfNotExists` for tables and inherits the default for
    /// indexes, so half of `create_table` and `create_index` was never
    /// executed — every `==` on an `Idempotence` survived mutation. Two
    /// dialects are needed because the branches are mutually exclusive by
    /// construction.
    struct Guarded;

    impl Dialect for Guarded {
        fn name(&self) -> &'static str {
            "guarded"
        }
        fn col_sql(&self, _ty: ColTy) -> String {
            "TEXT".to_owned()
        }
        fn quote(&self, identifier: &str) -> String {
            format!("[{identifier}]")
        }
        fn placeholder(&self) -> Placeholder {
            Placeholder::Question
        }
        fn table_idempotence(&self) -> Idempotence {
            Idempotence::Guard
        }
        fn index_idempotence(&self) -> Idempotence {
            Idempotence::Inline
        }
        fn guard(&self, _kind: ObjectKind, name: &str, statement: &str) -> String {
            format!("IF NOT PRESENT [{name}] BEGIN {statement} END")
        }
        fn append_only_sql(&self, table: &Table) -> Vec<String> {
            vec![format!("LOCK {}", self.quote(table.name))]
        }
    }

    #[test]
    fn a_guarding_dialect_wraps_its_tables_and_inlines_its_indexes() {
        let statements = Guarded.ddl();

        // `Guard` means the statement is wrapped, and `IfNotExists` is not
        // emitted — a dialect that declared `Guard` and inherited the identity
        // default would emit bare DDL that reads as protected.
        assert!(
            statements
                .iter()
                .any(|s| s.starts_with("IF NOT PRESENT") && s.contains("CREATE TABLE")),
            "tables were not guarded"
        );
        assert!(
            !statements.iter().any(|s| s.contains("IF NOT EXISTS")),
            "a guarding dialect emitted IF NOT EXISTS as well"
        );

        // `Inline` means no separate CREATE INDEX statements at all.
        assert!(
            !statements.iter().any(|s| s.contains("CREATE INDEX")),
            "an inlining dialect emitted a separate index"
        );

        // And an append-only declaration reaches the script, for the
        // append-only tables and no others (`db:M3.36`).
        let locks: Vec<_> = statements
            .iter()
            .filter(|s| s.starts_with("LOCK"))
            .collect();
        assert_eq!(locks.len(), TABLES.iter().filter(|t| t.append_only).count());
        assert!(locks.iter().any(|s| s.contains("openehr_version")));
    }

    #[test]
    fn the_default_terminator_is_a_semicolon() {
        // Asserted against a literal, not against `terminator()` — a test that
        // compares a function with itself passes whatever the function says.
        assert_eq!(Minimal.terminator(), ";");
    }

    #[test]
    fn column_sql_is_the_dialect_type_for_that_column() {
        let version = TABLES
            .iter()
            .find(|t| t.name == "openehr_version")
            .expect("the schema declares openehr_version");
        let uid = version
            .columns
            .iter()
            .find(|c| c.name == "uid")
            .expect("openehr_version has a uid");

        // It renders the *type* and nothing else — nullability and the name
        // belong to `create_table`. Asserted against `col_sql` so a wrapper
        // that returned a constant fails, and against a literal so one that
        // returned nothing does too.
        assert_eq!(super::column_sql(&Minimal, uid), Minimal.col_sql(uid.ty));
        assert_eq!(super::column_sql(&Minimal, uid), "TEXT");
        assert!(Minimal.create_table(version).contains("NOT NULL"));
    }
}