Skip to main content

waypoint_core/dialect/
mod.rs

1//! Database dialect abstraction.
2//!
3//! Waypoint targets multiple SQL engines. Dialect-specific behavior — identifier
4//! quoting, history-table DDL, lock-level mapping for DDL operations, statement
5//! splitter rules, and so on — is funneled through the [`DatabaseDialect`] trait
6//! so that the rest of the codebase can be engine-agnostic where possible and
7//! explicit about engine-specific paths where not.
8//!
9//! Connection-dependent operations live on [`crate::db::DbClient`] which dispatches
10//! based on its variant (Postgres / MySQL).
11
12#[cfg(feature = "postgres")]
13pub mod postgres;
14
15#[cfg(feature = "mysql")]
16pub mod mysql;
17
18/// Identifier of which dialect a connection or piece of code targets.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum DialectKind {
21    /// PostgreSQL 12+
22    Postgres,
23    /// MySQL 8.0+
24    Mysql,
25}
26
27impl DialectKind {
28    pub fn name(&self) -> &'static str {
29        match self {
30            DialectKind::Postgres => "postgres",
31            DialectKind::Mysql => "mysql",
32        }
33    }
34
35    /// Detect dialect from a connection URL scheme.
36    ///
37    /// Recognises `postgres://`, `postgresql://`, `mysql://`. Returns `None` for
38    /// key=value style PG strings or unknown schemes — caller may need to fall
39    /// back to an explicit `dialect = "..."` config field.
40    pub fn from_url(url: &str) -> Option<Self> {
41        let lower = url.trim_start().to_lowercase();
42        if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
43            Some(DialectKind::Postgres)
44        } else if lower.starts_with("mysql://") {
45            Some(DialectKind::Mysql)
46        } else {
47            None
48        }
49    }
50}
51
52/// Describes how migrations should be split, locked, and tracked on a given engine.
53///
54/// All methods are pure — they operate on strings or return DDL templates and do
55/// not touch a database connection. Connection-dependent operations live on
56/// [`crate::db::DbClient`].
57pub trait DatabaseDialect: Send + Sync {
58    /// Which dialect this is.
59    fn kind(&self) -> DialectKind;
60
61    /// Quote a SQL identifier for safe inclusion in dynamic SQL.
62    ///
63    /// PostgreSQL uses double-quotes (`"name"`), MySQL uses backticks (`\`name\``).
64    /// Doubles any embedded quote character to escape it.
65    fn quote_ident(&self, name: &str) -> String;
66
67    /// Produce a fully-qualified table reference (`schema.table`).
68    ///
69    /// In MySQL the "schema" is the database; in PostgreSQL it's a schema namespace.
70    /// Both use the same `qualifier.identifier` syntax in DDL, just with different
71    /// quoting characters — handled by [`Self::quote_ident`].
72    fn qualified_table(&self, schema: &str, table: &str) -> String {
73        format!("{}.{}", self.quote_ident(schema), self.quote_ident(table))
74    }
75
76    /// DDL to (idempotently) create the schema-history table.
77    ///
78    /// Returns one or more `;`-separated statements. Caller is responsible for
79    /// executing them via the appropriate driver. Schema, table, and index names
80    /// are quoted with [`Self::quote_ident`].
81    ///
82    /// PostgreSQL uses `TIMESTAMPTZ`; MySQL uses `TIMESTAMP` (UTC by convention).
83    /// Both store the same logical columns.
84    fn history_table_ddl(&self, schema: &str, table: &str) -> String;
85
86    /// Whether the engine supports atomic rollback of DDL inside a transaction.
87    ///
88    /// PostgreSQL: `true`. MySQL: `false` (most DDL implicitly commits).
89    /// Used to gate `--transaction` batch mode — when this returns `false`,
90    /// callers should refuse the `batch_transaction` config or return a clear
91    /// error rather than silently no-op.
92    fn supports_transactional_ddl(&self) -> bool;
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn from_url_recognises_postgres() {
101        assert_eq!(
102            DialectKind::from_url("postgres://u:p@h/d"),
103            Some(DialectKind::Postgres)
104        );
105        assert_eq!(
106            DialectKind::from_url("postgresql://u:p@h/d"),
107            Some(DialectKind::Postgres)
108        );
109        assert_eq!(
110            DialectKind::from_url("POSTGRES://u:p@h/d"),
111            Some(DialectKind::Postgres)
112        );
113    }
114
115    #[test]
116    fn from_url_recognises_mysql() {
117        assert_eq!(
118            DialectKind::from_url("mysql://u:p@h/d"),
119            Some(DialectKind::Mysql)
120        );
121        assert_eq!(
122            DialectKind::from_url("  mysql://h/d"),
123            Some(DialectKind::Mysql)
124        );
125    }
126
127    #[test]
128    fn from_url_returns_none_for_kv_or_unknown() {
129        assert_eq!(DialectKind::from_url("host=localhost user=postgres"), None);
130        assert_eq!(DialectKind::from_url("sqlite://x"), None);
131        assert_eq!(DialectKind::from_url(""), None);
132    }
133}