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("ed)),
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}