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 codes — lives here, chosen from
5//! `stack.database` in the manifest. Postgres is the default and its output is byte-identical
6//! to the pre-seam generator; SQLite is the second target (serverless, great for tests).
7
8use crate::FieldType;
9
10/// A supported database dialect.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Dialect {
13    Postgres,
14    Sqlite,
15}
16
17impl Dialect {
18    /// Pick the dialect from the manifest's `stack.database` (`"sqlite"` → SQLite, else the
19    /// Postgres default).
20    pub fn from_database(database: &str) -> Self {
21        if database.eq_ignore_ascii_case("sqlite") {
22            Dialect::Sqlite
23        } else {
24            Dialect::Postgres
25        }
26    }
27
28    /// The `id` primary-key column DDL (no trailing comma). Postgres generates the UUID in the
29    /// database; SQLite has no UUID generator, so the app supplies it on insert.
30    pub fn id_pk_ddl(self) -> &'static str {
31        match self {
32            Dialect::Postgres => "id UUID PRIMARY KEY DEFAULT gen_random_uuid()",
33            Dialect::Sqlite => "id TEXT PRIMARY KEY",
34        }
35    }
36
37    /// Whether the app must generate `id` on insert (SQLite) rather than the database (Postgres).
38    pub fn app_generates_id(self) -> bool {
39        matches!(self, Dialect::Sqlite)
40    }
41
42    /// The SQL column type for a scalar field.
43    pub fn column_type(self, ty: FieldType) -> &'static str {
44        match self {
45            // Postgres keeps the canonical mapping (ADR-011).
46            Dialect::Postgres => ty.sql_type(),
47            // SQLite storage classes: TEXT / INTEGER / REAL. UUIDs are stored by sqlx as BLOB
48            // but a TEXT-affinity column holds them fine; timestamps are ISO-8601 TEXT.
49            Dialect::Sqlite => match ty {
50                FieldType::String | FieldType::Uuid | FieldType::DateTime => "TEXT",
51                FieldType::Bool | FieldType::I32 | FieldType::I64 => "INTEGER",
52                FieldType::F64 => "REAL",
53            },
54        }
55    }
56
57    /// The `TYPE NOT NULL DEFAULT ...` fragment for the `created_at`/`updated_at` columns.
58    pub fn timestamp_type_default(self) -> &'static str {
59        match self {
60            Dialect::Postgres => "TIMESTAMPTZ NOT NULL DEFAULT now()",
61            // RFC-3339 with a `Z` so sqlx decodes it straight into `DateTime<Utc>`.
62            Dialect::Sqlite => "TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))",
63        }
64    }
65
66    /// The SQL expression for "now" used in `UPDATE ... SET updated_at = <now>`.
67    pub fn now_expr(self) -> &'static str {
68        match self {
69            Dialect::Postgres => "now()",
70            Dialect::Sqlite => "strftime('%Y-%m-%dT%H:%M:%fZ','now')",
71        }
72    }
73
74    /// A bind placeholder for parameter `n` (1-based): `$n` on Postgres, `?n` on SQLite.
75    pub fn placeholder(self, n: usize) -> String {
76        match self {
77            Dialect::Postgres => format!("${n}"),
78            Dialect::Sqlite => format!("?{n}"),
79        }
80    }
81
82    /// The sqlx pool type used in generated code.
83    pub fn pool_type(self) -> &'static str {
84        match self {
85            Dialect::Postgres => "PgPool",
86            Dialect::Sqlite => "SqlitePool",
87        }
88    }
89
90    /// The sqlx pool-options type.
91    pub fn pool_options(self) -> &'static str {
92        match self {
93            Dialect::Postgres => "PgPoolOptions",
94            Dialect::Sqlite => "SqlitePoolOptions",
95        }
96    }
97
98    /// The sqlx submodule the pool types live in (`sqlx::postgres` / `sqlx::sqlite`).
99    pub fn sqlx_module(self) -> &'static str {
100        match self {
101            Dialect::Postgres => "postgres",
102            Dialect::Sqlite => "sqlite",
103        }
104    }
105
106    /// The sqlx feature to enable in the generated `Cargo.toml`.
107    pub fn sqlx_feature(self) -> &'static str {
108        match self {
109            Dialect::Postgres => "postgres",
110            Dialect::Sqlite => "sqlite",
111        }
112    }
113
114    /// The integrity-error code a unique violation reports (mapped to 409 in generated code).
115    /// Postgres SQLSTATE `23505`; SQLite extended result code `2067` (SQLITE_CONSTRAINT_UNIQUE).
116    pub fn unique_violation_code(self) -> &'static str {
117        match self {
118            Dialect::Postgres => "23505",
119            Dialect::Sqlite => "2067",
120        }
121    }
122
123    /// The integrity-error code a foreign-key violation reports (mapped to 409).
124    /// Postgres `23503`; SQLite `787` (SQLITE_CONSTRAINT_FOREIGNKEY).
125    pub fn foreign_key_violation_code(self) -> &'static str {
126        match self {
127            Dialect::Postgres => "23503",
128            Dialect::Sqlite => "787",
129        }
130    }
131
132    /// An example `DATABASE_URL` for `.env.example`.
133    pub fn example_url(self, project: &str) -> String {
134        match self {
135            Dialect::Postgres => {
136                format!("postgres://postgres:postgres@localhost:5432/{project}")
137            }
138            Dialect::Sqlite => format!("sqlite://{project}.db?mode=rwc"),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn postgres_output_is_the_canonical_mapping() {
149        let d = Dialect::from_database("postgres");
150        assert_eq!(
151            d.id_pk_ddl(),
152            "id UUID PRIMARY KEY DEFAULT gen_random_uuid()"
153        );
154        assert_eq!(d.column_type(FieldType::String), "TEXT");
155        assert_eq!(d.column_type(FieldType::DateTime), "TIMESTAMPTZ");
156        assert_eq!(d.placeholder(3), "$3");
157        assert!(!d.app_generates_id());
158        assert_eq!(d.pool_type(), "PgPool");
159    }
160
161    #[test]
162    fn sqlite_maps_types_and_placeholders() {
163        let d = Dialect::from_database("sqlite");
164        assert_eq!(d.id_pk_ddl(), "id TEXT PRIMARY KEY");
165        assert_eq!(d.column_type(FieldType::Uuid), "TEXT");
166        assert_eq!(d.column_type(FieldType::Bool), "INTEGER");
167        assert_eq!(d.column_type(FieldType::F64), "REAL");
168        assert_eq!(d.placeholder(2), "?2");
169        assert!(d.app_generates_id());
170        assert_eq!(d.pool_type(), "SqlitePool");
171        assert_eq!(d.sqlx_feature(), "sqlite");
172    }
173
174    #[test]
175    fn defaults_to_postgres() {
176        assert_eq!(Dialect::from_database("mysql"), Dialect::Postgres);
177        assert_eq!(Dialect::from_database(""), Dialect::Postgres);
178    }
179}