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}