Skip to main content

rust_ef/migration/
dialect.rs

1//! SQL dialect enumeration and type mapping for migration DDL generation.
2
3use super::types::SnapshotColumn;
4
5/// Specifies the database SQL dialect for migration generation.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum MigrationDialect {
8    Postgres,
9    MySql,
10    Sqlite,
11}
12
13impl MigrationDialect {
14    /// Quote an identifier according to dialect rules.
15    ///
16    /// Dotted identifiers are treated as `schema.table` and each part is quoted
17    /// separately (e.g. `"audit"."logs"` / `` `audit`.`logs` ``).
18    pub fn quote(&self, ident: &str) -> String {
19        match self {
20            MigrationDialect::Postgres | MigrationDialect::Sqlite => quote_qualified(ident, '"'),
21            MigrationDialect::MySql => quote_qualified(ident, '`'),
22        }
23    }
24
25    /// Map a Rust type name to the dialect-specific column type.
26    pub fn map_column_type(&self, col: &SnapshotColumn) -> String {
27        // type_name comes from std::any::type_name::<T>() which returns
28        // fully-qualified paths (e.g. "alloc::string::String"). Use ends_with
29        // / contains matching to handle both simple and qualified names.
30        //
31        // Explicit `HasColumnType` wins verbatim (user is responsible for dialect
32        // portability).
33        if let Some(explicit) = &col.column_type {
34            if !explicit.trim().is_empty() {
35                return explicit.trim().to_string();
36            }
37        }
38
39        // `Option<T>` is unwrapped to its inner type so nullable columns keep
40        // their inner mapping (e.g. `Option<String>` + max_length → VARCHAR(n)
41        // instead of TEXT, which is not indexable on MySQL).
42        let tn = inner_type_name(&col.type_name);
43
44        // Auto-increment handling (must be checked before plain i32/i64)
45        if col.is_auto_increment {
46            if tn.ends_with("i32") {
47                return match self {
48                    MigrationDialect::Postgres => "SERIAL".into(),
49                    MigrationDialect::MySql => "INT AUTO_INCREMENT".into(),
50                    MigrationDialect::Sqlite => "INTEGER".into(),
51                };
52            }
53            if tn.ends_with("i64") {
54                return match self {
55                    MigrationDialect::Postgres => "BIGSERIAL".into(),
56                    MigrationDialect::MySql => "BIGINT AUTO_INCREMENT".into(),
57                    MigrationDialect::Sqlite => "INTEGER".into(),
58                };
59            }
60        }
61
62        // Sequence handling (PostgreSQL: plain type + DEFAULT nextval in DDL;
63        // non-PG: fall back to auto_increment syntax)
64        if col.is_sequence {
65            if tn.ends_with("i32") {
66                return match self {
67                    MigrationDialect::Postgres => "INTEGER".into(),
68                    MigrationDialect::MySql => "INT AUTO_INCREMENT".into(),
69                    MigrationDialect::Sqlite => "INTEGER".into(),
70                };
71            }
72            if tn.ends_with("i64") {
73                return match self {
74                    MigrationDialect::Postgres => "BIGINT".into(),
75                    MigrationDialect::MySql => "BIGINT AUTO_INCREMENT".into(),
76                    MigrationDialect::Sqlite => "INTEGER".into(),
77                };
78            }
79        }
80
81        let base: &str = if tn.ends_with("i16") {
82            "SMALLINT"
83        } else if tn.ends_with("i32") {
84            "INTEGER"
85        } else if tn.ends_with("i64") {
86            "BIGINT"
87        } else if tn.ends_with("f32") {
88            "REAL"
89        } else if tn.ends_with("f64") {
90            "DOUBLE PRECISION"
91        } else if tn.ends_with("bool") {
92            "BOOLEAN"
93        } else if tn.ends_with("String") {
94            if col.is_fixed_length {
95                // Fixed-length string (`CHAR(n)`); a length is required by
96                // MySQL. `max_length` normally supplies it.
97                return format!("CHAR({})", col.max_length.unwrap_or(1));
98            }
99            return match col.max_length {
100                Some(n) => format!("VARCHAR({})", n),
101                None => "TEXT".into(),
102            };
103        } else if tn.ends_with("Vec<u8>") {
104            return match self {
105                MigrationDialect::Postgres => "BYTEA".into(),
106                MigrationDialect::MySql | MigrationDialect::Sqlite => "BLOB".into(),
107            };
108        } else if tn.contains("NaiveDateTime") {
109            return match (self, col.precision) {
110                (MigrationDialect::Postgres, Some(p)) => format!("TIMESTAMP({p})"),
111                (MigrationDialect::MySql, Some(p)) => format!("DATETIME({p})"),
112                (MigrationDialect::Postgres, None) => "TIMESTAMP".into(),
113                (MigrationDialect::MySql, None) => "DATETIME".into(),
114                (MigrationDialect::Sqlite, _) => "TEXT".into(),
115            };
116        } else if tn.contains("NaiveDate") {
117            return match self {
118                MigrationDialect::Postgres => "DATE".into(),
119                MigrationDialect::MySql => "DATE".into(),
120                MigrationDialect::Sqlite => "TEXT".into(),
121            };
122        } else if tn.contains("DateTime") {
123            // chrono::DateTime<Utc> → TIMESTAMPTZ (PG) / DATETIME (MySQL) / TEXT (SQLite)
124            return match (self, col.precision) {
125                (MigrationDialect::Postgres, Some(p)) => format!("TIMESTAMPTZ({p})"),
126                (MigrationDialect::MySql, Some(p)) => format!("DATETIME({p})"),
127                (MigrationDialect::Postgres, None) => "TIMESTAMPTZ".into(),
128                (MigrationDialect::MySql, None) => "DATETIME".into(),
129                (MigrationDialect::Sqlite, _) => "TEXT".into(),
130            };
131        } else if tn.contains("Uuid") {
132            return match self {
133                MigrationDialect::Postgres => "UUID".into(),
134                MigrationDialect::MySql => "CHAR(36)".into(),
135                MigrationDialect::Sqlite => "TEXT".into(),
136            };
137        } else if tn.contains("Decimal") {
138            let (p, s) = (col.precision.unwrap_or(38), col.scale.unwrap_or(18));
139            return match self {
140                MigrationDialect::Postgres => {
141                    if col.precision.is_some() {
142                        format!("NUMERIC({p},{s})")
143                    } else {
144                        "NUMERIC".into()
145                    }
146                }
147                MigrationDialect::MySql => format!("DECIMAL({p},{s})"),
148                MigrationDialect::Sqlite => "TEXT".into(),
149            };
150        } else {
151            "TEXT"
152        };
153        base.to_string()
154    }
155}
156
157/// Quotes a possibly dotted identifier (`schema.table`) part by part.
158pub(crate) fn quote_qualified(ident: &str, quote: char) -> String {
159    ident
160        .split('.')
161        .map(|part| format!("{quote}{part}{quote}"))
162        .collect::<Vec<_>>()
163        .join(".")
164}
165
166/// Returns the inner type name for `Option<T>`, or the input unchanged.
167///
168/// Handles both `Option<T>` and fully-qualified
169/// `core::option::Option<T>` / `std::option::Option<T>` spellings produced by
170/// `std::any::type_name`.
171fn inner_type_name(type_name: &str) -> &str {
172    if let Some(pos) = type_name.find("Option<") {
173        let rest = &type_name[pos + "Option<".len()..];
174        return rest.strip_suffix('>').unwrap_or(rest);
175    }
176    type_name
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn option_string_uses_inner_type_and_max_length() {
185        let col = SnapshotColumn {
186            type_name: "core::option::Option<alloc::string::String>".into(),
187            max_length: Some(32),
188            ..Default::default()
189        };
190        assert_eq!(MigrationDialect::MySql.map_column_type(&col), "VARCHAR(32)");
191        assert_eq!(
192            MigrationDialect::Postgres.map_column_type(&col),
193            "VARCHAR(32)"
194        );
195    }
196
197    #[test]
198    fn option_uuid_and_decimal_use_inner_type() {
199        let uuid = SnapshotColumn {
200            type_name: "core::option::Option<uuid::Uuid>".into(),
201            ..Default::default()
202        };
203        assert_eq!(MigrationDialect::MySql.map_column_type(&uuid), "CHAR(36)");
204
205        let decimal = SnapshotColumn {
206            type_name: "core::option::Option<rust_decimal::Decimal>".into(),
207            ..Default::default()
208        };
209        assert_eq!(
210            MigrationDialect::MySql.map_column_type(&decimal),
211            "DECIMAL(38,18)"
212        );
213    }
214}