Skip to main content

gaman_core/dialects/
mod.rs

1use thiserror::Error;
2
3use crate::drift::{DriftPropertyDoc, DriftRegistry};
4use crate::migrations::Migration;
5use crate::operations::Operation;
6use crate::parsers::tokens::SqlTokenizer;
7use crate::states::types::EntityKind;
8use crate::states::{Schema, SchemaValidationError};
9
10#[derive(Debug, Error)]
11pub enum DialectError {
12    #[error("unsupported operation '{0}': {1}")]
13    Unsupported(String, String),
14}
15
16/// Error returned when a database URL cannot be mapped to a supported dialect.
17#[derive(Debug, Error, Clone, PartialEq, Eq)]
18pub enum DialectParseError {
19    #[error("database URL is empty")]
20    EmptyUrl,
21    #[error("database URL '{0}' does not contain a dialect scheme")]
22    MissingScheme(String),
23    #[error("unsupported database URL dialect scheme '{0}'")]
24    UnsupportedScheme(String),
25}
26
27/// Database dialect selection and dialect-specific behavior.
28///
29/// Type catalogs under each dialect are intentionally incomplete and frequently updated.
30/// `data_types.rs` lists native built-in types, aliases, and canonicalization rules;
31/// `extension_types.rs` lists popular extension or externally provided types. These
32/// catalogs exist for deterministic canonicalization, typo suggestions, and helpful
33/// prompts. They are not a claim that Gaman knows the full database type universe.
34/// Unknown types must not be rejected solely because they are absent from these catalogs;
35/// migration history and explicit user decisions are the project-local trust boundary.
36/// New dialect implementations must keep native data types and extension/popular
37/// external types in separate files following this layout.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Dialect {
40    Postgres,
41    Sqlite,
42    Mysql,
43}
44
45/// Internal dialect behavior boundary.
46///
47/// Shared planning code asks a processor to canonicalize, validate, finalize,
48/// and render schema changes. Dialect-specific SQL and schema quirks should
49/// live behind this trait instead of leaking into shared code as variant
50/// matches.
51pub(crate) trait DialectProcessor: Sync {
52    fn tokenizer(&self) -> &'static dyn SqlTokenizer;
53
54    fn migration_to_sql(
55        &self,
56        migration: &Migration,
57        start: &Schema,
58    ) -> Result<Vec<String>, DialectError>;
59
60    fn finalize_diff_operations(
61        &self,
62        ops: Vec<Operation>,
63        previous: &Schema,
64        current: &Schema,
65    ) -> Vec<Operation>;
66
67    fn should_merge(&self, table_name: &str, op: &Operation) -> bool;
68
69    fn canonicalize_schema_name(&self, object: EntityKind, schema: Option<&str>) -> Option<String>;
70
71    /// Returns a fast equivalence-normalized view of a type name.
72    ///
73    /// The returned value is a borrowed `&str` into either an alias table entry or
74    /// the original input. It should be used for type-compatibility checks and
75    /// diff/noise reduction, not as a stable stored representation.
76    fn normalize_type<'a>(&self, t: &'a str) -> &'a str;
77
78    /// Returns the canonical, persistent representation for a type name.
79    ///
80    /// This allocates and rewrites known aliases/modifiers into the dialect's
81    /// preferred catalog shape, while preserving unknown/extension types. Use this
82    /// when persisting or normalizing schema state before comparison/validation.
83    fn canonical_type(&self, t: &str) -> String;
84
85    /// Returns the dialect's semantic comparison key for a declared type.
86    ///
87    /// This key never becomes authored schema state. PostgreSQL uses it to
88    /// compare native aliases, while SQLite uses its declared-type affinity.
89    fn type_comparison_key(&self, t: &str) -> String;
90
91    fn is_catalog_type(&self, t: &str) -> bool;
92
93    fn type_suggestions(&self, t: &str) -> Vec<String>;
94
95    fn validate_schema(&self, schema: &Schema) -> Result<(), SchemaValidationError>;
96
97    fn validate_migration(&self, migration: &Migration) -> Result<(), DialectError>;
98
99    fn validate_migration_with_state(
100        &self,
101        migration: &Migration,
102        start: &Schema,
103    ) -> Result<(), DialectError>;
104
105    fn drift_registry(&self) -> &'static DriftRegistry;
106
107    fn normalize_inspected_schema(&self, schema: Schema) -> Result<Schema, SchemaValidationError>;
108
109    fn default_expressions_equal(&self, left: &str, right: &str) -> bool {
110        crate::parsers::tokens::expressions_equal(self.tokenizer(), left, right)
111    }
112}
113
114impl Dialect {
115    /// Returns the stable lowercase name for this dialect.
116    pub fn as_str(&self) -> &'static str {
117        match self {
118            Self::Postgres => "postgres",
119            Self::Sqlite => "sqlite",
120            Self::Mysql => "mysql",
121        }
122    }
123
124    /// Parses a dialect name such as `postgres`, `postgresql`, `sqlite`, `sqlite3`, `mysql`, or `mariadb`.
125    pub fn parse(value: &str) -> Option<Self> {
126        if value.eq_ignore_ascii_case("postgres") || value.eq_ignore_ascii_case("postgresql") {
127            Some(Self::Postgres)
128        } else if value.eq_ignore_ascii_case("sqlite") || value.eq_ignore_ascii_case("sqlite3") {
129            Some(Self::Sqlite)
130        } else if value.eq_ignore_ascii_case("mysql") || value.eq_ignore_ascii_case("mariadb") {
131            Some(Self::Mysql)
132        } else {
133            None
134        }
135    }
136
137    /// Infers the dialect from a database URL scheme.
138    ///
139    /// This accepts normal URL forms such as `postgres://...`, `postgresql://...`,
140    /// `sqlite://...`, `mysql://...`, and `mariadb://...`, plus SQLx's SQLite
141    /// shorthand such as `sqlite::memory:`.
142    pub fn parse_from_url(database_url: &str) -> Result<Self, DialectParseError> {
143        let value = database_url.trim();
144        if value.is_empty() {
145            return Err(DialectParseError::EmptyUrl);
146        }
147
148        let scheme = if value.starts_with("sqlite:") {
149            "sqlite"
150        } else if let Some((scheme, _)) = value.split_once("://") {
151            scheme
152        } else if let Some((scheme, _)) = value.split_once(':') {
153            scheme
154        } else {
155            return Err(DialectParseError::MissingScheme(value.to_string()));
156        };
157
158        Self::parse(scheme).ok_or_else(|| DialectParseError::UnsupportedScheme(scheme.to_string()))
159    }
160
161    pub(crate) fn processor(&self) -> &'static dyn DialectProcessor {
162        match self {
163            Dialect::Postgres => &postgres::POSTGRES,
164            Dialect::Sqlite => &sqlite::SQLITE,
165            Dialect::Mysql => &mysql::MYSQL,
166        }
167    }
168
169    pub(crate) fn tokenizer(&self) -> &'static dyn SqlTokenizer {
170        self.processor().tokenizer()
171    }
172
173    pub(crate) fn default_expressions_equal(&self, left: &str, right: &str) -> bool {
174        self.processor().default_expressions_equal(left, right)
175    }
176
177    #[doc(hidden)]
178    pub fn migration_to_sql(
179        &self,
180        migration: &Migration,
181        _start: &Schema,
182    ) -> Result<Vec<String>, DialectError> {
183        self.processor().migration_to_sql(migration, _start)
184    }
185
186    /// Reorders operations to satisfy database-specific execution constraints.
187    /// The default is a no-op — only databases with ordering requirements need to override.
188    /// Called once per migration after diffing, before SQL generation or writing.
189    pub fn reorder(
190        &self,
191        ops: Vec<Operation>,
192        previous: &Schema,
193        current: &Schema,
194    ) -> Vec<Operation> {
195        self.processor()
196            .finalize_diff_operations(ops, previous, current)
197    }
198
199    /// Whether a decomposed sub-entity op should be folded back into its
200    /// parent CreateTable. Postgres can inline everything; other dialects
201    /// (e.g. SQLite) may need FKs kept inline while indexes stay separate.
202    pub fn should_merge(&self, _table_name: &str, _op: &Operation) -> bool {
203        self.processor().should_merge(_table_name, _op)
204    }
205
206    #[doc(hidden)]
207    pub fn canonicalize_schema_name(
208        &self,
209        object: EntityKind,
210        schema: Option<&str>,
211    ) -> Option<String> {
212        self.processor().canonicalize_schema_name(object, schema)
213    }
214
215    pub fn normalize_type<'a>(&self, t: &'a str) -> &'a str {
216        self.processor().normalize_type(t)
217    }
218
219    pub fn canonical_type(&self, t: &str) -> String {
220        self.processor().canonical_type(t)
221    }
222
223    #[doc(hidden)]
224    pub fn type_comparison_key(&self, t: &str) -> String {
225        self.processor().type_comparison_key(t)
226    }
227
228    pub fn is_catalog_type(&self, t: &str) -> bool {
229        self.processor().is_catalog_type(t)
230    }
231
232    pub fn type_suggestions(&self, t: &str) -> Vec<String> {
233        self.processor().type_suggestions(t)
234    }
235
236    pub fn validate_schema(&self, schema: &Schema) -> Result<(), SchemaValidationError> {
237        self.processor().validate_schema(schema)
238    }
239
240    /// Normalizes a live inspected schema before semantic drift comparison.
241    pub fn normalize_inspected_schema(
242        &self,
243        schema: Schema,
244    ) -> Result<Schema, SchemaValidationError> {
245        self.processor().normalize_inspected_schema(schema)
246    }
247
248    /// Returns the documented semantic drift properties for this dialect.
249    pub fn drift_contract(&self) -> &'static [DriftPropertyDoc] {
250        crate::drift::contract_for(*self)
251    }
252
253    /// Returns the comparator registry that defines semantic drift for this dialect.
254    pub(crate) fn drift_registry(&self) -> &'static DriftRegistry {
255        self.processor().drift_registry()
256    }
257
258    pub fn validate_migration(&self, m: &Migration) -> Result<(), DialectError> {
259        self.processor().validate_migration(m)
260    }
261
262    #[doc(hidden)]
263    pub fn validate_migration_with_state(
264        &self,
265        m: &Migration,
266        _start: &Schema,
267    ) -> Result<(), DialectError> {
268        self.processor().validate_migration_with_state(m, _start)
269    }
270}
271
272mod mysql;
273mod postgres;
274mod sqlite;
275
276#[cfg(test)]
277mod tests {
278    use super::{Dialect, DialectParseError};
279
280    /// Verifies PostgreSQL URLs infer the PostgreSQL dialect.
281    #[test]
282    fn parse_from_url_accepts_postgres_urls() {
283        assert_eq!(
284            Dialect::parse_from_url("postgres://localhost/app"),
285            Ok(Dialect::Postgres)
286        );
287        assert_eq!(
288            Dialect::parse_from_url("postgresql://localhost/app"),
289            Ok(Dialect::Postgres)
290        );
291    }
292
293    /// Verifies SQLite URLs infer the SQLite dialect.
294    #[test]
295    fn parse_from_url_accepts_sqlite_urls() {
296        assert_eq!(
297            Dialect::parse_from_url("sqlite://app.db"),
298            Ok(Dialect::Sqlite)
299        );
300        assert_eq!(
301            Dialect::parse_from_url("sqlite::memory:"),
302            Ok(Dialect::Sqlite)
303        );
304    }
305
306    /// Verifies MySQL and MariaDB URLs infer the MySQL dialect stub.
307    #[test]
308    fn parse_from_url_accepts_mysql_urls() {
309        assert_eq!(
310            Dialect::parse_from_url("mysql://localhost/app"),
311            Ok(Dialect::Mysql)
312        );
313        assert_eq!(
314            Dialect::parse_from_url("mariadb://localhost/app"),
315            Ok(Dialect::Mysql)
316        );
317    }
318
319    /// Verifies unsupported URL schemes return a structured error.
320    #[test]
321    fn parse_from_url_rejects_unsupported_schemes() {
322        assert_eq!(
323            Dialect::parse_from_url("oracle://localhost/app"),
324            Err(DialectParseError::UnsupportedScheme("oracle".to_string()))
325        );
326    }
327
328    /// Verifies URLs without schemes fail before selecting a dialect.
329    #[test]
330    fn parse_from_url_rejects_missing_schemes() {
331        assert_eq!(
332            Dialect::parse_from_url("localhost/app"),
333            Err(DialectParseError::MissingScheme(
334                "localhost/app".to_string()
335            ))
336        );
337    }
338}