use crate::schema::{ColTy, Column, TABLES, Table};
use core::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Placeholder {
Question,
Dollar,
AtP,
Colon,
}
impl Placeholder {
#[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}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectKind {
Table,
Index,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Idempotence {
IfNotExists,
Guard,
Inline,
}
pub trait Dialect {
fn name(&self) -> &'static str;
fn col_sql(&self, ty: ColTy) -> String;
fn quote(&self, identifier: &str) -> String;
fn placeholder(&self) -> Placeholder;
fn table_idempotence(&self) -> Idempotence {
Idempotence::IfNotExists
}
fn index_idempotence(&self) -> Idempotence {
Idempotence::IfNotExists
}
fn guard(&self, _kind: ObjectKind, _name: &str, statement: &str) -> String {
statement.to_owned()
}
fn terminator(&self) -> &'static str {
";"
}
fn append_only_sql(&self, _table: &Table) -> Vec<String> {
Vec::new()
}
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
}
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)
));
}
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
}
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
}
}
#[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")
}
#[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};
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");
for table in TABLES {
let quoted = Minimal.quote(table.name);
assert!(
statements
.iter()
.any(|s| s.starts_with("CREATE TABLE") && s.contains("ed)),
"{} has no CREATE TABLE",
table.name
);
}
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}");
assert!(sql.contains("BLOB"), "no digest column typed: {sql}");
assert!(sql.contains("TEXT"), "{sql}");
}
#[test]
fn the_terminator_ends_every_statement_in_the_script() {
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() {
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() {
for table in TABLES {
assert!(Minimal.append_only_sql(table).is_empty());
}
}
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();
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"
);
assert!(
!statements.iter().any(|s| s.contains("CREATE INDEX")),
"an inlining dialect emitted a separate index"
);
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() {
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");
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"));
}
}