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}