Skip to main content

openehr_store/
dialect.rs

1//! The SQL dialect trait, and DDL generation from [`crate::schema`].
2//!
3//! # What a dialect is allowed to change
4//!
5//! Exactly four things: how it spells a type, how it quotes an identifier, how
6//! it writes a placeholder, and how it enforces append-only. Everything else —
7//! which tables exist, which columns, which indexes, what order they are
8//! emitted in — comes from the shared schema and is identical across engines by
9//! construction.
10//!
11//! That boundary is the whole point. The sibling FHIR monorepo in this
12//! repository has an audit finding (**F-08**) for an Oracle DDL emitter that
13//! silently emitted `MySQL` types, because each port owned a full copy of the
14//! generator. Here a dialect cannot emit another engine's schema, because it
15//! does not own the schema — only the spellings. [`crate::conformance`]
16//! includes a test that asserts no two dialects agree on all of them.
17
18use crate::schema::{ColTy, Column, TABLES, Table};
19use core::fmt::Write as _;
20
21/// How a dialect writes a bind placeholder.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum Placeholder {
25    /// `?` — `SQLite`, `MySQL`, `MariaDB`.
26    Question,
27    /// `$1`, `$2` — `PostgreSQL`.
28    Dollar,
29    /// `@p1`, `@p2` — SQL Server.
30    AtP,
31    /// `:1`, `:2` — Oracle.
32    Colon,
33}
34
35impl Placeholder {
36    /// Renders the placeholder for a one-based parameter position.
37    ///
38    /// ```
39    /// use openehr_store::Placeholder;
40    ///
41    /// assert_eq!(Placeholder::Question.render(1), "?");
42    /// assert_eq!(Placeholder::Dollar.render(2), "$2");
43    /// assert_eq!(Placeholder::AtP.render(3), "@p3");
44    /// assert_eq!(Placeholder::Colon.render(4), ":4");
45    /// ```
46    #[must_use]
47    pub fn render(self, position: usize) -> String {
48        match self {
49            Self::Question => "?".to_owned(),
50            Self::Dollar => format!("${position}"),
51            Self::AtP => format!("@p{position}"),
52            Self::Colon => format!(":{position}"),
53        }
54    }
55}
56
57/// A schema object a `CREATE` statement can bring into being.
58///
59/// Passed to [`Dialect::guard`] so an engine that checks a catalogue before
60/// creating knows which catalogue to check.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ObjectKind {
63    /// A table.
64    Table,
65    /// An index.
66    Index,
67}
68
69/// How an engine makes a `CREATE` statement safe to re-run.
70///
71/// `install()` must be idempotent — an operator who runs it twice, or a
72/// deployment that retries, must not get a hard error the second time. The
73/// three engines differ enough that a single boolean gets it wrong, which is
74/// how the first live run against `MySQL` failed: `MySQL` accepts
75/// `CREATE TABLE IF NOT EXISTS` and **rejects** `CREATE INDEX IF NOT EXISTS`,
76/// so one flag covering both statement kinds emitted a script that created
77/// every table and then failed on the first index.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Idempotence {
80    /// The statement accepts an inline `IF NOT EXISTS` clause.
81    IfNotExists,
82    /// The statement must be wrapped by [`Dialect::guard`].
83    Guard,
84    /// No separate statement exists — the object is declared inside its
85    /// table, and inherits that table's idempotence.
86    Inline,
87}
88
89/// One SQL engine's spellings.
90///
91/// # Implementing one
92///
93/// Implement [`Dialect::name`], [`Dialect::col_sql`], [`Dialect::quote`], and
94/// [`Dialect::placeholder`]. The default methods build the DDL from those and
95/// from the shared schema; override one only where the engine genuinely cannot
96/// do what the default emits, and say so in the crate's dialect annex.
97pub trait Dialect {
98    /// The engine's name, as it appears in documentation and error messages.
99    fn name(&self) -> &'static str;
100
101    /// The SQL type for a logical column type.
102    ///
103    /// This is the one function that names engine-specific types, and it is
104    /// where every dialect difference that matters actually lives.
105    fn col_sql(&self, ty: ColTy) -> String;
106
107    /// Quotes an identifier.
108    fn quote(&self, identifier: &str) -> String;
109
110    /// The engine's placeholder style.
111    fn placeholder(&self) -> Placeholder;
112
113    /// How `CREATE TABLE` is made re-runnable.
114    fn table_idempotence(&self) -> Idempotence {
115        Idempotence::IfNotExists
116    }
117
118    /// How `CREATE INDEX` is made re-runnable.
119    ///
120    /// Separate from [`Dialect::table_idempotence`] because `MySQL` treats the
121    /// two statements differently; see [`Idempotence`].
122    fn index_idempotence(&self) -> Idempotence {
123        Idempotence::IfNotExists
124    }
125
126    /// Wraps a statement so that re-running it is a no-op.
127    ///
128    /// Called only for object kinds whose idempotence is
129    /// [`Idempotence::Guard`]. The default returns the statement unchanged,
130    /// which is correct only when no kind declares `Guard` —
131    /// [`crate::conformance::check_dialect`] fails a dialect that declares
132    /// `Guard` and then does not actually wrap, because a guard that is
133    /// documented but not emitted is worse than none.
134    fn guard(&self, _kind: ObjectKind, _name: &str, statement: &str) -> String {
135        statement.to_owned()
136    }
137
138    /// The statement terminator used when joining statements into a script.
139    fn terminator(&self) -> &'static str {
140        ";"
141    }
142
143    /// Statements enforcing append-only on a table.
144    ///
145    /// All six engines this workspace targets can do it with a trigger, and
146    /// all six must, because a guarantee enforced only in application code is
147    /// a guarantee that ends the first time somebody opens a SQL console.
148    ///
149    /// The empty default is retained only so that a *new* dialect compiles
150    /// before it is finished. It is not a permissible resting state:
151    /// [`crate::conformance::check_dialect`] fails any dialect that leaves an
152    /// append-only table unenforced. Three dialects inherited this default
153    /// silently for as long as they existed (**A-15**) while the shared
154    /// documentation described append-only as a property of the design — which
155    /// is why the check exists rather than another sentence here.
156    fn append_only_sql(&self, _table: &Table) -> Vec<String> {
157        Vec::new()
158    }
159
160    /// The full DDL script for the shared schema.
161    ///
162    /// Tables in dependency order, then indexes, then append-only enforcement.
163    /// Indexes come after all tables so that a partial failure leaves a
164    /// readable schema rather than a half-indexed one.
165    fn ddl(&self) -> Vec<String> {
166        let mut out = Vec::new();
167        for table in TABLES {
168            out.push(self.create_table(table));
169        }
170        if self.index_idempotence() != Idempotence::Inline {
171            for table in TABLES {
172                for index in table.indexes {
173                    out.push(self.create_index(table, index));
174                }
175            }
176        }
177        for table in TABLES {
178            if table.append_only {
179                out.extend(self.append_only_sql(table));
180            }
181        }
182        out
183    }
184
185    /// One `CREATE TABLE` statement.
186    fn create_table(&self, table: &Table) -> String {
187        let mut sql = String::new();
188        let exists = if self.table_idempotence() == Idempotence::IfNotExists {
189            "IF NOT EXISTS "
190        } else {
191            ""
192        };
193        let _ = writeln!(sql, "CREATE TABLE {exists}{} (", self.quote(table.name));
194        let mut parts: Vec<String> = Vec::new();
195        for column in table.columns {
196            parts.push(format!(
197                "  {} {}{}",
198                self.quote(column.name),
199                self.col_sql(column.ty),
200                if column.nullable { "" } else { " NOT NULL" }
201            ));
202        }
203        if !table.primary_key.is_empty() {
204            let keys: Vec<String> = table.primary_key.iter().map(|k| self.quote(k)).collect();
205            parts.push(format!("  PRIMARY KEY ({})", keys.join(", ")));
206        }
207        for fk in table.foreign_keys {
208            parts.push(format!(
209                "  FOREIGN KEY ({}) REFERENCES {} ({})",
210                self.quote(fk.column),
211                self.quote(fk.table),
212                self.quote(fk.references)
213            ));
214        }
215        // MySQL cannot say `CREATE INDEX IF NOT EXISTS`, but it can declare the
216        // index inside the table, where it inherits the table's own
217        // `IF NOT EXISTS`. That is the idiomatic answer rather than a
218        // workaround: one statement, one object, one idempotence rule.
219        if self.index_idempotence() == Idempotence::Inline {
220            for index in table.indexes {
221                let columns: Vec<String> = index.columns.iter().map(|c| self.quote(c)).collect();
222                parts.push(format!(
223                    "  {}KEY {} ({})",
224                    if index.unique { "UNIQUE " } else { "" },
225                    self.quote(index.name),
226                    columns.join(", ")
227                ));
228            }
229        }
230        let _ = write!(sql, "{}", parts.join(",\n"));
231        let _ = write!(sql, "\n)");
232        if self.table_idempotence() == Idempotence::Guard {
233            return self.guard(ObjectKind::Table, table.name, &sql);
234        }
235        sql
236    }
237
238    /// One `CREATE INDEX` statement.
239    fn create_index(&self, table: &Table, index: &crate::schema::Index) -> String {
240        let unique = if index.unique { "UNIQUE " } else { "" };
241        let exists = if self.index_idempotence() == Idempotence::IfNotExists {
242            "IF NOT EXISTS "
243        } else {
244            ""
245        };
246        let columns: Vec<String> = index.columns.iter().map(|c| self.quote(c)).collect();
247        let sql = format!(
248            "CREATE {unique}INDEX {exists}{} ON {} ({})",
249            self.quote(index.name),
250            self.quote(table.name),
251            columns.join(", ")
252        );
253        if self.index_idempotence() == Idempotence::Guard {
254            return self.guard(ObjectKind::Index, index.name, &sql);
255        }
256        sql
257    }
258}
259
260/// Renders a dialect's DDL as one script.
261///
262/// # Errors
263///
264/// Never fails; the signature is infallible and this returns a `String`
265/// directly. Present as a free function so callers do not have to import the
266/// trait to get a script.
267#[must_use]
268pub fn ddl_script<D: Dialect + ?Sized>(dialect: &D) -> String {
269    let terminator = dialect.terminator();
270    dialect
271        .ddl()
272        .into_iter()
273        .map(|statement| format!("{statement}{terminator}\n"))
274        .collect::<Vec<_>>()
275        .join("\n")
276}
277
278/// Checks that a column type maps to something plausible.
279///
280/// Used by [`crate::conformance::check_dialect`]; exposed so a new dialect's
281/// own tests can call it directly.
282#[must_use]
283pub fn column_sql<D: Dialect + ?Sized>(dialect: &D, column: &Column) -> String {
284    dialect.col_sql(column.ty)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::{Dialect, Idempotence, ObjectKind, Placeholder, ddl_script};
290    use crate::schema::{ColTy, TABLES, Table};
291
292    /// The smallest thing that can be a dialect.
293    ///
294    /// Every default in this trait is exercised only by the six engine crates,
295    /// and `cargo mutants` runs the tests of the crate it mutates — so 25 of 27
296    /// viable mutants here survived `openehr-store`'s own suite, including
297    /// `ddl -> vec![]` and `terminator -> ""`. Each would fail every engine
298    /// crate's golden test, in another job, after this crate reported success
299    /// (`lib:A-09`).
300    ///
301    /// This crate is the engine-agnostic half. The shared generator can be
302    /// tested without an engine, and now is.
303    struct Minimal;
304
305    impl Dialect for Minimal {
306        fn name(&self) -> &'static str {
307            "minimal"
308        }
309        fn col_sql(&self, ty: ColTy) -> String {
310            match ty {
311                ColTy::Digest => "BLOB".to_owned(),
312                ColTy::Int | ColTy::Bool | ColTy::InstantUtc => "INTEGER".to_owned(),
313                _ => "TEXT".to_owned(),
314            }
315        }
316        fn quote(&self, identifier: &str) -> String {
317            format!("\"{identifier}\"")
318        }
319        fn placeholder(&self) -> Placeholder {
320            Placeholder::Question
321        }
322    }
323
324    #[test]
325    fn the_shared_generator_emits_a_statement_for_every_table_and_index() {
326        let statements = Minimal.ddl();
327        assert!(!statements.is_empty(), "no DDL was emitted");
328
329        // One CREATE TABLE per declared table, and the tables are the schema's
330        // — a dialect defines none of its own (`db:M3.22`).
331        for table in TABLES {
332            let quoted = Minimal.quote(table.name);
333            assert!(
334                statements
335                    .iter()
336                    .any(|s| s.starts_with("CREATE TABLE") && s.contains(&quoted)),
337                "{} has no CREATE TABLE",
338                table.name
339            );
340        }
341
342        // Indexes are separate statements unless the dialect inlines them.
343        assert_ne!(Minimal.index_idempotence(), Idempotence::Inline);
344        let expected: usize = TABLES.iter().map(|t| t.indexes.len()).sum();
345        assert_eq!(
346            statements
347                .iter()
348                .filter(|s| s.contains("CREATE INDEX") || s.contains("CREATE UNIQUE INDEX"))
349                .count(),
350            expected
351        );
352    }
353
354    #[test]
355    fn a_column_carries_its_quoted_name_and_its_engine_type() {
356        let version = TABLES
357            .iter()
358            .find(|t| t.name == "openehr_version")
359            .expect("the schema declares openehr_version");
360        let sql = Minimal.create_table(version);
361
362        assert!(sql.contains(&Minimal.quote("uid")), "{sql}");
363        // `ColTy::Digest` is the one that must not become text (`db:M3.40`).
364        assert!(sql.contains("BLOB"), "no digest column typed: {sql}");
365        assert!(sql.contains("TEXT"), "{sql}");
366    }
367
368    #[test]
369    fn the_terminator_ends_every_statement_in_the_script() {
370        // Statements span lines — a CREATE TABLE is many — so this counts
371        // terminators against statements rather than checking line endings.
372        let terminator = Minimal.terminator();
373        assert!(!terminator.is_empty(), "a statement needs an end");
374        let script = ddl_script(&Minimal);
375        assert_eq!(
376            script.matches(terminator).count(),
377            Minimal.ddl().len(),
378            "one terminator per statement"
379        );
380    }
381
382    #[test]
383    fn the_default_guard_is_the_identity_and_says_so() {
384        // A dialect declaring `Guard` and inheriting this default emits bare,
385        // non-idempotent DDL that *reads* as protected — which is what SQL
386        // Server and Oracle did until a live run exposed it. `check_dialect`
387        // refuses that combination; this pins the default it refuses.
388        let bare = "CREATE SOMETHING x";
389        assert_eq!(Minimal.guard(ObjectKind::Table, "x", bare), bare);
390        assert_eq!(Minimal.table_idempotence(), Idempotence::IfNotExists);
391    }
392
393    #[test]
394    fn append_only_is_not_emitted_by_a_dialect_that_declares_none() {
395        // The default is empty, and `check_dialect` is what refuses a dialect
396        // that inherits it while the schema marks tables append-only
397        // (`db:M3.36`). Asserted here so the default cannot quietly acquire a
398        // statement.
399        for table in TABLES {
400            assert!(Minimal.append_only_sql(table).is_empty());
401        }
402    }
403
404    /// A dialect that takes the *other* branch of every idempotence decision.
405    ///
406    /// `Minimal` declares `IfNotExists` for tables and inherits the default for
407    /// indexes, so half of `create_table` and `create_index` was never
408    /// executed — every `==` on an `Idempotence` survived mutation. Two
409    /// dialects are needed because the branches are mutually exclusive by
410    /// construction.
411    struct Guarded;
412
413    impl Dialect for Guarded {
414        fn name(&self) -> &'static str {
415            "guarded"
416        }
417        fn col_sql(&self, _ty: ColTy) -> String {
418            "TEXT".to_owned()
419        }
420        fn quote(&self, identifier: &str) -> String {
421            format!("[{identifier}]")
422        }
423        fn placeholder(&self) -> Placeholder {
424            Placeholder::Question
425        }
426        fn table_idempotence(&self) -> Idempotence {
427            Idempotence::Guard
428        }
429        fn index_idempotence(&self) -> Idempotence {
430            Idempotence::Inline
431        }
432        fn guard(&self, _kind: ObjectKind, name: &str, statement: &str) -> String {
433            format!("IF NOT PRESENT [{name}] BEGIN {statement} END")
434        }
435        fn append_only_sql(&self, table: &Table) -> Vec<String> {
436            vec![format!("LOCK {}", self.quote(table.name))]
437        }
438    }
439
440    #[test]
441    fn a_guarding_dialect_wraps_its_tables_and_inlines_its_indexes() {
442        let statements = Guarded.ddl();
443
444        // `Guard` means the statement is wrapped, and `IfNotExists` is not
445        // emitted — a dialect that declared `Guard` and inherited the identity
446        // default would emit bare DDL that reads as protected.
447        assert!(
448            statements
449                .iter()
450                .any(|s| s.starts_with("IF NOT PRESENT") && s.contains("CREATE TABLE")),
451            "tables were not guarded"
452        );
453        assert!(
454            !statements.iter().any(|s| s.contains("IF NOT EXISTS")),
455            "a guarding dialect emitted IF NOT EXISTS as well"
456        );
457
458        // `Inline` means no separate CREATE INDEX statements at all.
459        assert!(
460            !statements.iter().any(|s| s.contains("CREATE INDEX")),
461            "an inlining dialect emitted a separate index"
462        );
463
464        // And an append-only declaration reaches the script, for the
465        // append-only tables and no others (`db:M3.36`).
466        let locks: Vec<_> = statements
467            .iter()
468            .filter(|s| s.starts_with("LOCK"))
469            .collect();
470        assert_eq!(locks.len(), TABLES.iter().filter(|t| t.append_only).count());
471        assert!(locks.iter().any(|s| s.contains("openehr_version")));
472    }
473
474    #[test]
475    fn the_default_terminator_is_a_semicolon() {
476        // Asserted against a literal, not against `terminator()` — a test that
477        // compares a function with itself passes whatever the function says.
478        assert_eq!(Minimal.terminator(), ";");
479    }
480
481    #[test]
482    fn column_sql_is_the_dialect_type_for_that_column() {
483        let version = TABLES
484            .iter()
485            .find(|t| t.name == "openehr_version")
486            .expect("the schema declares openehr_version");
487        let uid = version
488            .columns
489            .iter()
490            .find(|c| c.name == "uid")
491            .expect("openehr_version has a uid");
492
493        // It renders the *type* and nothing else — nullability and the name
494        // belong to `create_table`. Asserted against `col_sql` so a wrapper
495        // that returned a constant fails, and against a literal so one that
496        // returned nothing does too.
497        assert_eq!(super::column_sql(&Minimal, uid), Minimal.col_sql(uid.ty));
498        assert_eq!(super::column_sql(&Minimal, uid), "TEXT");
499        assert!(Minimal.create_table(version).contains("NOT NULL"));
500    }
501}