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, Default)]
20pub enum DialectKind {
21    /// PostgreSQL 12+
22    #[default]
23    Postgres,
24    /// MySQL 8.0+
25    Mysql,
26}
27
28impl std::fmt::Display for DialectKind {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.write_str(self.name())
31    }
32}
33
34impl std::str::FromStr for DialectKind {
35    type Err = crate::error::WaypointError;
36
37    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
38        match s.trim().to_lowercase().as_str() {
39            "postgres" | "postgresql" | "pg" => Ok(DialectKind::Postgres),
40            "mysql" | "mariadb" => Ok(DialectKind::Mysql),
41            other => Err(crate::error::WaypointError::ConfigError(format!(
42                "Invalid database engine '{}'. Use 'postgres' or 'mysql'.",
43                other
44            ))),
45        }
46    }
47}
48
49impl DialectKind {
50    /// Canonical lowercase name (`"postgres"` / `"mysql"`).
51    pub fn name(&self) -> &'static str {
52        match self {
53            DialectKind::Postgres => "postgres",
54            DialectKind::Mysql => "mysql",
55        }
56    }
57
58    /// Detect dialect from a connection URL scheme.
59    ///
60    /// Recognises `postgres://`, `postgresql://`, `mysql://`. Returns `None` for
61    /// key=value style PG strings or unknown schemes — caller may need to fall
62    /// back to an explicit `dialect = "..."` config field.
63    pub fn from_url(url: &str) -> Option<Self> {
64        let lower = url.trim_start().to_lowercase();
65        if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
66            Some(DialectKind::Postgres)
67        } else if lower.starts_with("mysql://") {
68            Some(DialectKind::Mysql)
69        } else {
70            None
71        }
72    }
73}
74
75/// Describes how migrations should be split, locked, and tracked on a given engine.
76///
77/// All methods are pure — they operate on strings or return DDL templates and do
78/// not touch a database connection. Connection-dependent operations live on
79/// [`crate::db::DbClient`].
80pub trait DatabaseDialect: Send + Sync {
81    /// Which dialect this is.
82    fn kind(&self) -> DialectKind;
83
84    /// Quote a SQL identifier for safe inclusion in dynamic SQL.
85    ///
86    /// PostgreSQL uses double-quotes (`"name"`), MySQL uses backticks (`\`name\``).
87    /// Doubles any embedded quote character to escape it.
88    fn quote_ident(&self, name: &str) -> String;
89
90    /// Produce a fully-qualified table reference (`schema.table`).
91    ///
92    /// In MySQL the "schema" is the database; in PostgreSQL it's a schema namespace.
93    /// Both use the same `qualifier.identifier` syntax in DDL, just with different
94    /// quoting characters — handled by [`Self::quote_ident`].
95    fn qualified_table(&self, schema: &str, table: &str) -> String {
96        format!("{}.{}", self.quote_ident(schema), self.quote_ident(table))
97    }
98
99    /// DDL to (idempotently) create the schema-history table.
100    ///
101    /// Returns one or more `;`-separated statements. Caller is responsible for
102    /// executing them via the appropriate driver. Schema, table, and index names
103    /// are quoted with [`Self::quote_ident`].
104    ///
105    /// PostgreSQL uses `TIMESTAMPTZ`; MySQL uses `TIMESTAMP` (UTC by convention).
106    /// Both store the same logical columns.
107    fn history_table_ddl(&self, schema: &str, table: &str) -> String;
108
109    /// Whether the engine supports atomic rollback of DDL inside a transaction.
110    ///
111    /// PostgreSQL: `true`. MySQL: `false` (most DDL implicitly commits).
112    /// Used to gate `--transaction` batch mode — when this returns `false`,
113    /// callers should refuse the `batch_transaction` config or return a clear
114    /// error rather than silently no-op.
115    fn supports_transactional_ddl(&self) -> bool;
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn from_url_recognises_postgres() {
124        assert_eq!(
125            DialectKind::from_url("postgres://u:p@h/d"),
126            Some(DialectKind::Postgres)
127        );
128        assert_eq!(
129            DialectKind::from_url("postgresql://u:p@h/d"),
130            Some(DialectKind::Postgres)
131        );
132        assert_eq!(
133            DialectKind::from_url("POSTGRES://u:p@h/d"),
134            Some(DialectKind::Postgres)
135        );
136    }
137
138    #[test]
139    fn from_url_recognises_mysql() {
140        assert_eq!(
141            DialectKind::from_url("mysql://u:p@h/d"),
142            Some(DialectKind::Mysql)
143        );
144        assert_eq!(
145            DialectKind::from_url("  mysql://h/d"),
146            Some(DialectKind::Mysql)
147        );
148    }
149
150    #[test]
151    fn from_url_returns_none_for_kv_or_unknown() {
152        assert_eq!(DialectKind::from_url("host=localhost user=postgres"), None);
153        assert_eq!(DialectKind::from_url("sqlite://x"), None);
154        assert_eq!(DialectKind::from_url(""), None);
155    }
156}