Skip to main content

ubiquisync_sql/
dialect.rs

1//! SQL dialect: the points where SQL flavors genuinely diverge.
2//!
3//! The sync engine is storage-agnostic: it builds SQL as strings and runs
4//! them through a backend connection. The few places where SQL flavors
5//! actually differ — placeholder syntax, scalar-max function, collation,
6//! and type names (via [`DbType::sql_type`](crate::db::DbType::sql_type)) — are captured here as a closed
7//! [`SqlDialect`] enum.
8//!
9//! A dialect is a property of the *SQL flavor*, not the driver: rusqlite, D1,
10//! and Durable Objects are three backends but all speak [`SqlDialect::Sqlite`].
11//! Backend crates (`ubiquisync-sqlite`, `ubiquisync-postgres`) therefore don't
12//! implement a dialect — they only report which one they are. Centralizing the
13//! divergences here keeps every cross-flavor difference in one auditable place.
14
15/// The SQL flavor a backend speaks.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SqlDialect {
18    /// SQLite (and SQLite-compatible backends like D1, Durable Objects).
19    Sqlite,
20    /// PostgreSQL.
21    Postgres,
22}
23
24impl SqlDialect {
25    /// Renders a positional bind placeholder for parameter `n` (1-based):
26    /// `?n` on SQLite, `$n` on Postgres.
27    pub fn placeholder(&self, n: usize) -> String {
28        match self {
29            SqlDialect::Sqlite => format!("?{n}"),
30            SqlDialect::Postgres => format!("${n}"),
31        }
32    }
33
34    /// Scalar two-argument max function: `MAX` on SQLite, `GREATEST` on
35    /// Postgres. Callers must keep the `COALESCE` wrapping around the
36    /// arguments — SQLite's `MAX` returns NULL if *any* argument is NULL
37    /// while Postgres's `GREATEST` ignores NULLs; the COALESCE is what makes
38    /// both backends merge identically. Do not simplify it away.
39    pub fn scalar_max(&self) -> &'static str {
40        match self {
41            SqlDialect::Sqlite => "MAX",
42            SqlDialect::Postgres => "GREATEST",
43        }
44    }
45
46    /// Collation suffix appended to text comparisons that must order
47    /// bytewise (the LWW value-byte tiebreak, pull-sync cursor iteration).
48    /// Empty on SQLite (TEXT already compares with BINARY collation);
49    /// ` COLLATE "C"` on Postgres, whose default collation is locale-aware.
50    pub fn text_collate(&self) -> &'static str {
51        match self {
52            SqlDialect::Sqlite => "",
53            SqlDialect::Postgres => " COLLATE \"C\"",
54        }
55    }
56
57    /// `CREATE TABLE` suffix that stores the table clustered by its primary key
58    /// instead of a synthetic row id: ` WITHOUT ROWID` on SQLite, empty on Postgres.
59    pub fn without_rowid(&self) -> &'static str {
60        match self {
61            SqlDialect::Sqlite => " WITHOUT ROWID",
62            SqlDialect::Postgres => "",
63        }
64    }
65
66    /// Null-safe equality operator: `IS` on SQLite, `IS NOT DISTINCT FROM` on
67    /// Postgres. Both are infix (`a <op> b`) and treat two NULLs as equal —
68    /// unlike `=`, which yields NULL when either side is NULL.
69    pub fn null_safe_eq(&self) -> &'static str {
70        match self {
71            SqlDialect::Sqlite => "IS",
72            SqlDialect::Postgres => "IS NOT DISTINCT FROM",
73        }
74    }
75}
76
77/// Hands out positional bind placeholders in sequence (`?1`/`$1`, `?2`/`$2`, …)
78/// for the given dialect, so a query builder doesn't track indices by hand.
79pub struct PlaceholderGen {
80    dialect: SqlDialect,
81    next_idx: usize,
82}
83
84impl PlaceholderGen {
85    /// Start a generator for `dialect`; the first placeholder is number 1.
86    pub fn new(dialect: SqlDialect) -> Self {
87        Self {
88            dialect,
89            next_idx: 1,
90        }
91    }
92
93    /// Render the next placeholder and advance the counter.
94    pub fn next_placeholder(&mut self) -> String {
95        let p = self.dialect.placeholder(self.next_idx);
96        self.next_idx += 1;
97        p
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn placeholder_syntax_per_dialect() {
107        assert_eq!(SqlDialect::Sqlite.placeholder(3), "?3");
108        assert_eq!(SqlDialect::Postgres.placeholder(3), "$3");
109    }
110
111    #[test]
112    fn scalar_max_per_dialect() {
113        assert_eq!(SqlDialect::Sqlite.scalar_max(), "MAX");
114        assert_eq!(SqlDialect::Postgres.scalar_max(), "GREATEST");
115    }
116
117    #[test]
118    fn text_collate_per_dialect() {
119        // SQLite already compares bytewise; Postgres needs an explicit "C".
120        assert_eq!(SqlDialect::Sqlite.text_collate(), "");
121        assert_eq!(SqlDialect::Postgres.text_collate(), " COLLATE \"C\"");
122    }
123
124    #[test]
125    fn null_safe_eq_per_dialect() {
126        assert_eq!(SqlDialect::Sqlite.null_safe_eq(), "IS");
127        assert_eq!(SqlDialect::Postgres.null_safe_eq(), "IS NOT DISTINCT FROM");
128    }
129}