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}