Skip to main content

gize_core/
dialect.rs

1//! The database dialect seam (ADR-015).
2//!
3//! Everything that differs between the supported databases — column types, primary-key
4//! generation, bind placeholders, pool types, integrity-error handling, and whether
5//! `RETURNING` is available — lives here, chosen from `stack.database` in the manifest.
6//! Postgres is the default and its output is byte-identical to the pre-seam generator; SQLite
7//! is the serverless second target; MySQL is the third (ADR-015 amendment).
8
9use crate::FieldType;
10
11/// A supported database dialect.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Dialect {
14    Postgres,
15    Sqlite,
16    MySql,
17}
18
19impl std::fmt::Display for Dialect {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.write_str(match self {
22            Dialect::Postgres => "PostgreSQL",
23            Dialect::Sqlite => "SQLite",
24            Dialect::MySql => "MySQL",
25        })
26    }
27}
28
29impl Dialect {
30    /// Pick the dialect from the manifest's `stack.database` (`"sqlite"` → SQLite,
31    /// `"mysql"` → MySQL, else the Postgres default).
32    pub fn from_database(database: &str) -> Self {
33        if database.eq_ignore_ascii_case("sqlite") {
34            Dialect::Sqlite
35        } else if database.eq_ignore_ascii_case("mysql") {
36            Dialect::MySql
37        } else {
38            Dialect::Postgres
39        }
40    }
41
42    /// The `id` primary-key column DDL (no trailing comma). Postgres generates the UUID in the
43    /// database; SQLite and MySQL have no UUID generator, so the app supplies it on insert.
44    /// MySQL uses `BINARY(16)` — the type sqlx encodes/decodes `uuid::Uuid` to natively, so the
45    /// generated model, binds and `FromRow` stay uniform across dialects.
46    pub fn id_pk_ddl(self) -> &'static str {
47        match self {
48            Dialect::Postgres => "id UUID PRIMARY KEY DEFAULT gen_random_uuid()",
49            Dialect::Sqlite => "id TEXT PRIMARY KEY",
50            Dialect::MySql => "id BINARY(16) PRIMARY KEY",
51        }
52    }
53
54    /// Whether the app must generate `id` on insert (SQLite, MySQL) rather than the database
55    /// (Postgres).
56    pub fn app_generates_id(self) -> bool {
57        matches!(self, Dialect::Sqlite | Dialect::MySql)
58    }
59
60    /// Whether the dialect supports `INSERT/UPDATE ... RETURNING *`. Postgres and SQLite do;
61    /// MySQL does not, so the generated repository re-reads the row with a `SELECT` after
62    /// writing (it knows the `id`, which the app generates).
63    pub fn supports_returning(self) -> bool {
64        !matches!(self, Dialect::MySql)
65    }
66
67    /// The SQL column type for a scalar field.
68    pub fn column_type(self, ty: FieldType) -> &'static str {
69        match self {
70            // Postgres keeps the canonical mapping (ADR-011).
71            Dialect::Postgres => ty.sql_type(),
72            // SQLite storage classes: TEXT / INTEGER / REAL. UUIDs are stored by sqlx as BLOB
73            // but a TEXT-affinity column holds them fine; timestamps are ISO-8601 TEXT.
74            Dialect::Sqlite => match ty {
75                FieldType::String | FieldType::Uuid | FieldType::DateTime => "TEXT",
76                FieldType::Bool | FieldType::I32 | FieldType::I64 => "INTEGER",
77                FieldType::F64 => "REAL",
78            },
79            // MySQL. `String` is `VARCHAR(255)` (indexable, unlike `TEXT` without a prefix
80            // length, so `email UNIQUE` works); UUIDs are `BINARY(16)` (sqlx's native mapping).
81            Dialect::MySql => match ty {
82                FieldType::String => "VARCHAR(255)",
83                FieldType::Uuid => "BINARY(16)",
84                FieldType::DateTime => "DATETIME",
85                FieldType::Bool => "BOOLEAN",
86                FieldType::I32 => "INT",
87                FieldType::I64 => "BIGINT",
88                FieldType::F64 => "DOUBLE",
89            },
90        }
91    }
92
93    /// The `TYPE NOT NULL DEFAULT ...` fragment for the `created_at`/`updated_at` columns.
94    pub fn timestamp_type_default(self) -> &'static str {
95        match self {
96            Dialect::Postgres => "TIMESTAMPTZ NOT NULL DEFAULT now()",
97            // RFC-3339 with a `Z` so sqlx decodes it straight into `DateTime<Utc>`.
98            Dialect::Sqlite => "TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))",
99            // Microseconds, like Postgres: a plain DATETIME keeps whole seconds, so rows created
100            // in the same second would tie and "newest first" (ADR-022) would not hold.
101            Dialect::MySql => "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)",
102        }
103    }
104
105    /// The SQL expression for "now" used in `UPDATE ... SET updated_at = <now>`.
106    pub fn now_expr(self) -> &'static str {
107        match self {
108            Dialect::Postgres => "now()",
109            Dialect::Sqlite => "strftime('%Y-%m-%dT%H:%M:%fZ','now')",
110            Dialect::MySql => "NOW(6)",
111        }
112    }
113
114    /// A bind placeholder for parameter `n` (1-based): `$n` on Postgres, `?n` on SQLite, and a
115    /// bare positional `?` on MySQL (its protocol binds positionally, without an index).
116    pub fn placeholder(self, n: usize) -> String {
117        match self {
118            Dialect::Postgres => format!("${n}"),
119            Dialect::Sqlite => format!("?{n}"),
120            Dialect::MySql => "?".to_string(),
121        }
122    }
123
124    /// The sqlx pool type used in generated code.
125    pub fn pool_type(self) -> &'static str {
126        match self {
127            Dialect::Postgres => "PgPool",
128            Dialect::Sqlite => "SqlitePool",
129            Dialect::MySql => "MySqlPool",
130        }
131    }
132
133    /// The sqlx pool-options type.
134    pub fn pool_options(self) -> &'static str {
135        match self {
136            Dialect::Postgres => "PgPoolOptions",
137            Dialect::Sqlite => "SqlitePoolOptions",
138            Dialect::MySql => "MySqlPoolOptions",
139        }
140    }
141
142    /// The sqlx submodule the pool types live in (`sqlx::postgres` / `sqlx::sqlite` /
143    /// `sqlx::mysql`).
144    pub fn sqlx_module(self) -> &'static str {
145        match self {
146            Dialect::Postgres => "postgres",
147            Dialect::Sqlite => "sqlite",
148            Dialect::MySql => "mysql",
149        }
150    }
151
152    /// The sqlx feature to enable in the generated `Cargo.toml`.
153    pub fn sqlx_feature(self) -> &'static str {
154        match self {
155            Dialect::Postgres => "postgres",
156            Dialect::Sqlite => "sqlite",
157            Dialect::MySql => "mysql",
158        }
159    }
160
161    /// The full `if let sqlx::Error::Database(..) { .. }` block, inside `From<sqlx::Error>`, that
162    /// classifies a database integrity error into `Error::Conflict` (unique violation) or
163    /// `Error::ForeignKey`. Indented to sit at the 8-space body level of the `from` function.
164    ///
165    /// Postgres and SQLite expose distinct codes through `.code()` (SQLSTATE / extended result
166    /// code). MySQL collapses both to SQLSTATE `23000`, so it must read the numeric error code
167    /// (`1062` duplicate key; `1451`, `1452`, `1216`, `1217` foreign key) off the concrete
168    /// `MySqlDatabaseError`.
169    pub fn integrity_error_mapping(self) -> String {
170        let inner = match self {
171            // Postgres SQLSTATE; SQLite extended result codes (2067 unique, 787 foreign key).
172            Dialect::Postgres | Dialect::Sqlite => {
173                let (unique, fk) = if self == Dialect::Postgres {
174                    ("23505", "23503")
175                } else {
176                    ("2067", "787")
177                };
178                [
179                    "            match db.code().as_deref() {".to_string(),
180                    format!("                Some(\"{unique}\") => return Error::Conflict, // unique violation"),
181                    format!("                Some(\"{fk}\") => return Error::ForeignKey, // foreign-key violation"),
182                    "                _ => {}".to_string(),
183                    "            }".to_string(),
184                ]
185                .join("\n")
186            }
187            Dialect::MySql => [
188                "            if let Some(mysql) = db.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>() {",
189                "                match mysql.number() {",
190                "                    1062 => return Error::Conflict, // duplicate key",
191                // 1452/1216: a child row names a missing parent; 1451/1217: a parent row is
192                // still referenced (e.g. deleting a user who still has posts).
193                "                    1451 | 1452 | 1216 | 1217 => return Error::ForeignKey, // foreign-key violation",
194                "                    _ => {}",
195                "                }",
196                "            }",
197            ]
198            .join("\n"),
199        };
200        format!("        if let sqlx::Error::Database(ref db) = error {{\n{inner}\n        }}")
201    }
202
203    /// An example `DATABASE_URL` for `.env.example`.
204    pub fn example_url(self, project: &str) -> String {
205        match self {
206            Dialect::Postgres => {
207                format!("postgres://postgres:postgres@localhost:5432/{project}")
208            }
209            Dialect::Sqlite => format!("sqlite://{project}.db?mode=rwc"),
210            Dialect::MySql => format!("mysql://root:root@localhost:3306/{project}"),
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn postgres_output_is_the_canonical_mapping() {
221        let d = Dialect::from_database("postgres");
222        assert_eq!(
223            d.id_pk_ddl(),
224            "id UUID PRIMARY KEY DEFAULT gen_random_uuid()"
225        );
226        assert_eq!(d.column_type(FieldType::String), "TEXT");
227        assert_eq!(d.column_type(FieldType::DateTime), "TIMESTAMPTZ");
228        assert_eq!(d.placeholder(3), "$3");
229        assert!(!d.app_generates_id());
230        assert_eq!(d.pool_type(), "PgPool");
231    }
232
233    #[test]
234    fn sqlite_maps_types_and_placeholders() {
235        let d = Dialect::from_database("sqlite");
236        assert_eq!(d.id_pk_ddl(), "id TEXT PRIMARY KEY");
237        assert_eq!(d.column_type(FieldType::Uuid), "TEXT");
238        assert_eq!(d.column_type(FieldType::Bool), "INTEGER");
239        assert_eq!(d.column_type(FieldType::F64), "REAL");
240        assert_eq!(d.placeholder(2), "?2");
241        assert!(d.app_generates_id());
242        assert_eq!(d.pool_type(), "SqlitePool");
243        assert_eq!(d.sqlx_feature(), "sqlite");
244    }
245
246    #[test]
247    fn mysql_uses_binary_uuid_positional_binds_and_no_returning() {
248        let d = Dialect::from_database("mysql");
249        assert_eq!(d.id_pk_ddl(), "id BINARY(16) PRIMARY KEY");
250        assert!(d.app_generates_id());
251        assert!(!d.supports_returning());
252        assert_eq!(d.column_type(FieldType::String), "VARCHAR(255)");
253        assert_eq!(d.column_type(FieldType::Uuid), "BINARY(16)");
254        assert_eq!(d.column_type(FieldType::DateTime), "DATETIME");
255        assert_eq!(d.column_type(FieldType::Bool), "BOOLEAN");
256        // MySQL binds positionally: every placeholder is a bare `?`.
257        assert_eq!(d.placeholder(1), "?");
258        assert_eq!(d.placeholder(4), "?");
259        assert_eq!(d.pool_type(), "MySqlPool");
260        assert_eq!(d.sqlx_feature(), "mysql");
261        // Its integrity mapping reads the numeric error code, not SQLSTATE.
262        let mapping = d.integrity_error_mapping();
263        assert!(mapping.contains("MySqlDatabaseError"));
264        assert!(mapping.contains("1062 => return Error::Conflict"));
265        assert!(mapping.contains("1451 | 1452 | 1216 | 1217 => return Error::ForeignKey"));
266    }
267
268    #[test]
269    fn returning_dialects_map_codes_via_sqlstate() {
270        // Postgres and SQLite support RETURNING and classify errors by `.code()`.
271        for d in [Dialect::Postgres, Dialect::Sqlite] {
272            assert!(d.supports_returning());
273            assert!(d.integrity_error_mapping().contains("db.code()"));
274        }
275    }
276
277    #[test]
278    fn defaults_to_postgres() {
279        assert_eq!(Dialect::from_database("mysql"), Dialect::MySql);
280        assert_eq!(Dialect::from_database("cockroach"), Dialect::Postgres);
281        assert_eq!(Dialect::from_database(""), Dialect::Postgres);
282    }
283}