Skip to main content

octofhir_sof/sql_generator/
ddl.rs

1//! `CREATE TABLE` (DDL) generation for the columns a ViewDefinition produces.
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::column::ColumnType;
7
8use super::GeneratedColumn;
9
10/// SQL dialect for DDL type names.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Dialect {
13    /// ISO/IEC 9075 ANSI SQL — the SQL-on-FHIR v2 default type mapping.
14    #[default]
15    Ansi,
16    /// PostgreSQL types (TEXT, JSONB, TIMESTAMPTZ, …).
17    Postgres,
18    /// DuckDB types (VARCHAR, JSON, TIMESTAMP, …).
19    DuckDb,
20}
21
22impl FromStr for Dialect {
23    type Err = String;
24
25    fn from_str(s: &str) -> Result<Self, Self::Err> {
26        match s.trim().to_lowercase().as_str() {
27            "ansi" | "sql" => Ok(Self::Ansi),
28            "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
29            "duckdb" | "duck" => Ok(Self::DuckDb),
30            other => Err(format!(
31                "unknown dialect `{other}` (expected ansi, postgres or duckdb)"
32            )),
33        }
34    }
35}
36
37impl fmt::Display for Dialect {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.write_str(match self {
40            Self::Ansi => "ansi",
41            Self::Postgres => "postgres",
42            Self::DuckDb => "duckdb",
43        })
44    }
45}
46
47impl Dialect {
48    /// The dialect's type name for a column type.
49    fn type_name(&self, ty: ColumnType) -> &'static str {
50        match self {
51            Self::Ansi => ty.ansi_type(),
52            Self::Postgres => ty.sql_type(),
53            Self::DuckDb => match ty {
54                ColumnType::String => "VARCHAR",
55                ColumnType::Integer => "INTEGER",
56                ColumnType::Integer64 => "BIGINT",
57                ColumnType::Decimal => "DOUBLE",
58                ColumnType::Boolean => "BOOLEAN",
59                ColumnType::Date => "DATE",
60                ColumnType::DateTime | ColumnType::Instant => "TIMESTAMP",
61                ColumnType::Time => "TIME",
62                ColumnType::Base64Binary => "BLOB",
63                ColumnType::Json => "JSON",
64            },
65        }
66    }
67}
68
69/// Render a `CREATE TABLE` statement for the given columns.
70pub fn create_table(table: &str, columns: &[GeneratedColumn], dialect: Dialect) -> String {
71    let ident = quote_ident(table);
72    if columns.is_empty() {
73        return format!("CREATE TABLE {ident} ();\n");
74    }
75    let mut out = format!("CREATE TABLE {ident} (\n");
76    for (i, col) in columns.iter().enumerate() {
77        let comma = if i + 1 < columns.len() { "," } else { "" };
78        out.push_str(&format!(
79            "  {} {}{}\n",
80            quote_ident(&col.name),
81            dialect.type_name(col.col_type),
82            comma
83        ));
84    }
85    out.push_str(");\n");
86    out
87}
88
89/// Double-quote a SQL identifier, escaping embedded quotes.
90fn quote_ident(name: &str) -> String {
91    format!("\"{}\"", name.replace('"', "\"\""))
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn cols() -> Vec<GeneratedColumn> {
99        vec![
100            GeneratedColumn {
101                name: "id".into(),
102                expression: String::new(),
103                alias: "id".into(),
104                col_type: ColumnType::String,
105            },
106            GeneratedColumn {
107                name: "age".into(),
108                expression: String::new(),
109                alias: "age".into(),
110                col_type: ColumnType::Integer,
111            },
112            GeneratedColumn {
113                name: "born".into(),
114                expression: String::new(),
115                alias: "born".into(),
116                col_type: ColumnType::Date,
117            },
118        ]
119    }
120
121    #[test]
122    fn ansi_ddl_uses_character_varying() {
123        let sql = create_table("patient_view", &cols(), Dialect::Ansi);
124        assert!(sql.contains("CREATE TABLE \"patient_view\" ("));
125        assert!(sql.contains("\"id\" CHARACTER VARYING,"));
126        assert!(sql.contains("\"age\" INT,"));
127        assert!(sql.contains("\"born\" CHARACTER VARYING\n"));
128        assert!(sql.trim_end().ends_with(");"));
129    }
130
131    #[test]
132    fn postgres_ddl_uses_native_types() {
133        let sql = create_table("v", &cols(), Dialect::Postgres);
134        assert!(sql.contains("\"id\" TEXT,"));
135        assert!(sql.contains("\"age\" INTEGER,"));
136        assert!(sql.contains("\"born\" DATE\n"));
137    }
138
139    #[test]
140    fn dialect_parses() {
141        assert_eq!("pg".parse::<Dialect>().unwrap(), Dialect::Postgres);
142        assert_eq!("DuckDB".parse::<Dialect>().unwrap(), Dialect::DuckDb);
143        assert!("oracle".parse::<Dialect>().is_err());
144    }
145}