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}