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}