Skip to main content

keelson_gen/
typemap.rs

1//! The default type table (`docs/type-mappings.md`, column-type column read
2//! backwards) plus the config's override machinery.
3//!
4//! Resolution order per column, first hit wins:
5//!
6//! 1. `[[types.override]]` — the first override whose scope and matcher fit;
7//! 2. `[types.map]` — the normalised database type;
8//! 3. the built-in default table below;
9//! 4. otherwise [`GenError::UnmappedType`] — an unmapped type is a loud
10//!    failure, never a silent `String`.
11//!
12//! Every type that arrives via 1 or 2 is recorded as an override so the
13//! emitter writes the `assert_bind` line for it.
14
15use crate::config::{Dialect, Matcher, Types};
16use crate::error::{GenError, Result};
17use crate::schema::{ColumnDef, TableDef};
18
19/// A resolved column type: the Rust path to emit, and whether it came from
20/// configuration (and so needs its `assert_bind` line).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ResolvedType {
23    /// The Rust type path, e.g. `i64` or `chrono::NaiveDateTime`.
24    pub rust_type: String,
25    /// True when the type came from `[types.map]` or `[[types.override]]`.
26    pub overridden: bool,
27}
28
29/// Normalise a declared database type for matching: lowercased, precision
30/// stripped (`NUMERIC(10, 2)` → `numeric`, `character varying(255)` →
31/// `character varying`).
32pub(crate) fn normalise(db_type: &str) -> String {
33    let base = match db_type.find('(') {
34        Some(i) => &db_type[..i],
35        None => db_type,
36    };
37    base.trim().to_lowercase()
38}
39
40/// Resolve one column's Rust type.
41pub(crate) fn resolve(
42    dialect: Dialect,
43    types: &Types,
44    table: &TableDef,
45    column: &ColumnDef,
46) -> Result<ResolvedType> {
47    for o in &types.overrides {
48        let in_scope = o.tables.is_empty() || o.tables.contains(&table.name);
49        if in_scope && matches(&o.matcher, column) {
50            return Ok(ResolvedType {
51                rust_type: o.rust_type.clone(),
52                overridden: true,
53            });
54        }
55    }
56    let norm = normalise(&column.db_type);
57    if let Some(t) = types.map.get(&norm) {
58        return Ok(ResolvedType {
59            rust_type: t.clone(),
60            overridden: true,
61        });
62    }
63    match default_type(dialect, &norm, column) {
64        Some(t) => Ok(ResolvedType {
65            rust_type: t.to_owned(),
66            overridden: false,
67        }),
68        None => Err(GenError::UnmappedType {
69            column: format!("{}.{}", table.name, column.name),
70            db_type: column.db_type.clone(),
71        }),
72    }
73}
74
75fn matches(m: &Matcher, c: &ColumnDef) -> bool {
76    if let Some(name) = &m.name
77        && *name != c.name
78    {
79        return false;
80    }
81    if let Some(db_type) = &m.db_type
82        && normalise(db_type) != normalise(&c.db_type)
83    {
84        return false;
85    }
86    if let Some(nullable) = m.nullable
87        && nullable != c.nullable
88    {
89        return false;
90    }
91    if let Some(default) = &m.default
92        && Some(default.as_str()) != c.default.as_deref()
93    {
94        return false;
95    }
96    if let Some(autoincrement) = m.autoincrement
97        && autoincrement != c.autoincrement
98    {
99        return false;
100    }
101    if let Some(comment) = &m.comment
102        && Some(comment.as_str()) != c.comment.as_deref()
103    {
104        return false;
105    }
106    true
107}
108
109/// The built-in default table, per dialect.
110fn default_type(dialect: Dialect, norm: &str, column: &ColumnDef) -> Option<&'static str> {
111    match dialect {
112        Dialect::Psql => psql_default(norm),
113        Dialect::Sqlite => sqlite_default(norm, column),
114        Dialect::Mysql => mysql_default(norm, column),
115    }
116}
117
118/// PostgreSQL: keys are `format_type` output, normalised.
119fn psql_default(norm: &str) -> Option<&'static str> {
120    Some(match norm {
121        "smallint" | "int2" => "i16",
122        "integer" | "int4" => "i32",
123        "bigint" | "int8" => "i64",
124        "real" | "float4" => "f32",
125        "double precision" | "float8" => "f64",
126        "boolean" | "bool" => "bool",
127        "text" | "character varying" | "varchar" | "character" | "bpchar" | "citext" | "name" => {
128            "String"
129        }
130        "bytea" => "Vec<u8>",
131        "date" => "chrono::NaiveDate",
132        "time" | "time without time zone" => "chrono::NaiveTime",
133        "timestamp" | "timestamp without time zone" => "chrono::NaiveDateTime",
134        "timestamptz" | "timestamp with time zone" => "chrono::DateTime<chrono::Utc>",
135        "uuid" => "uuid::Uuid",
136        "numeric" | "decimal" => "rust_decimal::Decimal",
137        "json" | "jsonb" => "serde_json::Value",
138        _ => return None,
139    })
140}
141
142/// SQLite: declared-type text, by affinity-style contains-rules plus the
143/// honest extras the spec records — a declared `BOOLEAN` is a real `bool`,
144/// and a `TEXT` column whose *default* writes `CURRENT_TIMESTAMP` is a
145/// `NaiveDateTime` (the default's naive space-separated form is what the
146/// column will actually hold). Other datetime-intent `TEXT` columns are
147/// `String` until a `[[types.override]]` says otherwise — the schema simply
148/// does not carry the information.
149fn sqlite_default(norm: &str, column: &ColumnDef) -> Option<&'static str> {
150    // Exact names first: the intent-carrying declarations.
151    match norm {
152        "boolean" | "bool" => return Some("bool"),
153        "datetime" | "timestamp" => return Some("chrono::NaiveDateTime"),
154        "date" => return Some("chrono::NaiveDate"),
155        "time" => return Some("chrono::NaiveTime"),
156        "" => return Some("Vec<u8>"), // no declared type: BLOB affinity
157        _ => {}
158    }
159    if norm.contains("int") {
160        return Some("i64"); // SQLite integers are 64-bit; there is no i32 column type
161    }
162    if norm.contains("char") || norm.contains("clob") || norm.contains("text") {
163        if column
164            .default
165            .as_deref()
166            .is_some_and(|d| d.trim().eq_ignore_ascii_case("current_timestamp"))
167        {
168            return Some("chrono::NaiveDateTime");
169        }
170        return Some("String");
171    }
172    if norm.contains("blob") {
173        return Some("Vec<u8>");
174    }
175    if norm.contains("real") || norm.contains("floa") || norm.contains("doub") {
176        return Some("f64");
177    }
178    if norm.contains("dec") || norm.contains("numeric") {
179        return Some("rust_decimal::Decimal");
180    }
181    None
182}
183
184/// MySQL: keys are `information_schema.COLUMNS.COLUMN_TYPE`, normalised —
185/// which keeps the unsigned-ness (`int unsigned`) and, before normalisation,
186/// the display width the one width-carrying decision needs.
187///
188/// The two rules worth stating:
189///
190/// - **`TINYINT(1)` is `bool`.** MySQL has no boolean type; `BOOL`/`BOOLEAN`
191///   are aliases for `TINYINT(1)`, and every driver (sqlx included, see
192///   keelson-sqlx's `decode_value`) reports that exact declaration as
193///   `BOOLEAN`. A wider `TINYINT` is an integer, so the display width is read
194///   from the raw type text before precision is stripped.
195/// - **`DATETIME` is naive, `TIMESTAMP` is zoned.** `docs/type-mappings.md`
196///   maps `chrono::NaiveDateTime` onto `DATETIME` and
197///   `chrono::DateTime<Utc>` onto `TIMESTAMP` (which MySQL converts through
198///   the session zone the execution layer pins to `+00:00`).
199fn mysql_default(norm: &str, column: &ColumnDef) -> Option<&'static str> {
200    let raw = column.db_type.trim().to_lowercase();
201    if raw.starts_with("tinyint(1)") || norm == "bool" || norm == "boolean" {
202        return Some("bool");
203    }
204    Some(match norm {
205        "tinyint" => "i8",
206        "smallint" => "i16",
207        "mediumint" | "int" | "integer" => "i32",
208        "bigint" => "i64",
209        "tinyint unsigned" => "u8",
210        "smallint unsigned" => "u16",
211        "mediumint unsigned" | "int unsigned" | "integer unsigned" => "u32",
212        "bigint unsigned" => "u64",
213        "float" => "f32",
214        "double" | "double precision" | "real" => "f64",
215        "decimal" | "numeric" => "rust_decimal::Decimal",
216        "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext" | "enum" | "set" => {
217            "String"
218        }
219        "binary" | "varbinary" | "tinyblob" | "blob" | "mediumblob" | "longblob" => "Vec<u8>",
220        "date" => "chrono::NaiveDate",
221        "time" => "chrono::NaiveTime",
222        "datetime" => "chrono::NaiveDateTime",
223        "timestamp" => "chrono::DateTime<chrono::Utc>",
224        "json" => "serde_json::Value",
225        // `YEAR`, `BIT`, the spatial types and anything else stay unmapped —
226        // a loud error naming the column, never a silent `String`.
227        _ => return None,
228    })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::config::Config;
235    use crate::schema::TableKind;
236
237    fn col(name: &str, db_type: &str, nullable: bool) -> ColumnDef {
238        ColumnDef {
239            name: name.to_owned(),
240            db_type: db_type.to_owned(),
241            nullable,
242            default: None,
243            autoincrement: false,
244            comment: None,
245        }
246    }
247
248    fn table(name: &str, columns: Vec<ColumnDef>) -> TableDef {
249        TableDef {
250            name: name.to_owned(),
251            kind: TableKind::Table,
252            columns,
253            primary_key: vec![],
254            foreign_keys: vec![],
255            unique_keys: vec![],
256        }
257    }
258
259    fn plain(dialect: Dialect, db_type: &str) -> String {
260        let c = col("c", db_type, false);
261        let t = table("t", vec![c.clone()]);
262        resolve(dialect, &Types::default(), &t, &c)
263            .unwrap()
264            .rust_type
265    }
266
267    #[test]
268    fn the_psql_defaults_follow_the_type_table() {
269        assert_eq!(plain(Dialect::Psql, "integer"), "i32");
270        assert_eq!(plain(Dialect::Psql, "bigint"), "i64");
271        assert_eq!(plain(Dialect::Psql, "text"), "String");
272        assert_eq!(plain(Dialect::Psql, "character varying(255)"), "String");
273        assert_eq!(plain(Dialect::Psql, "boolean"), "bool");
274        assert_eq!(
275            plain(Dialect::Psql, "timestamp with time zone"),
276            "chrono::DateTime<chrono::Utc>"
277        );
278        assert_eq!(
279            plain(Dialect::Psql, "timestamp without time zone"),
280            "chrono::NaiveDateTime"
281        );
282        assert_eq!(plain(Dialect::Psql, "date"), "chrono::NaiveDate");
283        assert_eq!(plain(Dialect::Psql, "uuid"), "uuid::Uuid");
284        assert_eq!(
285            plain(Dialect::Psql, "numeric(10,2)"),
286            "rust_decimal::Decimal"
287        );
288        assert_eq!(plain(Dialect::Psql, "jsonb"), "serde_json::Value");
289    }
290
291    #[test]
292    fn the_sqlite_defaults_follow_declared_types_and_the_spec_notes() {
293        assert_eq!(plain(Dialect::Sqlite, "INTEGER"), "i64");
294        assert_eq!(plain(Dialect::Sqlite, "TEXT"), "String");
295        assert_eq!(plain(Dialect::Sqlite, "BOOLEAN"), "bool");
296        assert_eq!(plain(Dialect::Sqlite, "VARCHAR(80)"), "String");
297        assert_eq!(plain(Dialect::Sqlite, "BLOB"), "Vec<u8>");
298        assert_eq!(plain(Dialect::Sqlite, "REAL"), "f64");
299        assert_eq!(plain(Dialect::Sqlite, "DATETIME"), "chrono::NaiveDateTime");
300
301        // The spec's rule: TEXT whose default is CURRENT_TIMESTAMP holds the
302        // naive datetime the default writes.
303        let mut c = col("created_at", "TEXT", false);
304        c.default = Some("CURRENT_TIMESTAMP".to_owned());
305        let t = table("users", vec![c.clone()]);
306        assert_eq!(
307            resolve(Dialect::Sqlite, &Types::default(), &t, &c)
308                .unwrap()
309                .rust_type,
310            "chrono::NaiveDateTime"
311        );
312    }
313
314    #[test]
315    fn the_mysql_defaults_follow_the_type_table_including_tinyint_one() {
316        assert_eq!(plain(Dialect::Mysql, "int"), "i32");
317        assert_eq!(plain(Dialect::Mysql, "bigint"), "i64");
318        assert_eq!(plain(Dialect::Mysql, "bigint unsigned"), "u64");
319        assert_eq!(plain(Dialect::Mysql, "varchar(255)"), "String");
320        assert_eq!(plain(Dialect::Mysql, "text"), "String");
321        assert_eq!(plain(Dialect::Mysql, "datetime"), "chrono::NaiveDateTime");
322        assert_eq!(
323            plain(Dialect::Mysql, "timestamp"),
324            "chrono::DateTime<chrono::Utc>"
325        );
326        assert_eq!(
327            plain(Dialect::Mysql, "decimal(10,2)"),
328            "rust_decimal::Decimal"
329        );
330        assert_eq!(plain(Dialect::Mysql, "json"), "serde_json::Value");
331        assert_eq!(plain(Dialect::Mysql, "blob"), "Vec<u8>");
332
333        // The one width-carrying decision: TINYINT(1) is MySQL's boolean,
334        // every wider TINYINT is an integer.
335        assert_eq!(plain(Dialect::Mysql, "tinyint(1)"), "bool");
336        assert_eq!(plain(Dialect::Mysql, "tinyint"), "i8");
337        assert_eq!(plain(Dialect::Mysql, "tinyint(4)"), "i8");
338    }
339
340    #[test]
341    fn an_unmapped_mysql_type_is_a_loud_error_too() {
342        let c = col("born", "year", false);
343        let t = table("people", vec![c.clone()]);
344        let err = resolve(Dialect::Mysql, &Types::default(), &t, &c).unwrap_err();
345        assert!(err.to_string().contains("people.born"), "{err}");
346    }
347
348    #[test]
349    fn an_unmapped_type_is_a_loud_error_naming_the_column() {
350        let c = col("shape", "polygon", false);
351        let t = table("zones", vec![c.clone()]);
352        let err = resolve(Dialect::Psql, &Types::default(), &t, &c).unwrap_err();
353        assert!(err.to_string().contains("zones.shape"), "{err}");
354        assert!(err.to_string().contains("polygon"), "{err}");
355    }
356
357    #[test]
358    fn overrides_win_and_are_marked_for_assert_bind() {
359        let cfg = Config::from_toml(
360            r#"
361            dialect = "sqlite"
362            [types.map]
363            "numeric" = "MyMoney"
364            [[types.override]]
365            tables = ["posts"]
366            rust_type = "chrono::NaiveDateTime"
367            [types.override.match]
368            name = "published_at"
369            db_type = "text"
370            "#,
371        )
372        .unwrap();
373
374        let c = col("published_at", "TEXT", true);
375        let t = table("posts", vec![c.clone()]);
376        let r = resolve(Dialect::Sqlite, &cfg.types, &t, &c).unwrap();
377        assert_eq!(r.rust_type, "chrono::NaiveDateTime");
378        assert!(r.overridden);
379
380        // Same column on another table: out of scope, default applies.
381        let t2 = table("drafts", vec![c.clone()]);
382        let r2 = resolve(Dialect::Sqlite, &cfg.types, &t2, &c).unwrap();
383        assert_eq!(r2.rust_type, "String");
384        assert!(!r2.overridden);
385
386        // The db-type map catches what overrides do not.
387        let c3 = col("price", "NUMERIC(10,2)", false);
388        let t3 = table("orders", vec![c3.clone()]);
389        let r3 = resolve(Dialect::Sqlite, &cfg.types, &t3, &c3).unwrap();
390        assert_eq!(r3.rust_type, "MyMoney");
391        assert!(r3.overridden);
392    }
393
394    #[test]
395    fn matcher_fields_are_conjunctive() {
396        let cfg = Config::from_toml(
397            r#"
398            dialect = "sqlite"
399            [[types.override]]
400            rust_type = "X"
401            [types.override.match]
402            db_type = "integer"
403            nullable = false
404            "#,
405        )
406        .unwrap();
407        let yes = col("a", "INTEGER", false);
408        let no = col("a", "INTEGER", true);
409        let t = table("t", vec![yes.clone(), no.clone()]);
410        assert!(
411            resolve(Dialect::Sqlite, &cfg.types, &t, &yes)
412                .unwrap()
413                .overridden
414        );
415        assert!(
416            !resolve(Dialect::Sqlite, &cfg.types, &t, &no)
417                .unwrap()
418                .overridden
419        );
420    }
421}