vrtb_pg/dialect.rs
1use vrtb_core::engine::{
2 ColumnSchema, ComparePlan, Dialect, JoinDiffQuery, LogicalType, Segment, TableRef,
3};
4use vrtb_core::error::Result;
5use vrtb_utils::checks::{TWO_POW_64, checksum_select};
6use vrtb_utils::sql::{
7 aliased_key, from_table, json_object_args, mismatch_condition, outer_join_from, quote_ident,
8 wrap,
9};
10
11pub struct PgDialect;
12
13// postgres specific canonical expression for a column, or Err if the type is unsupported.
14fn canonical_expr(col: &ColumnSchema) -> Result<String> {
15 let payload = match col.ty {
16 // Minimal decimal text. `42` -> "v42", `-7` -> "v-7". Unambiguous: both
17 // engines render integers identically, no scale or locale to negotiate.
18 LogicalType::Int => format!("CAST({} AS VARCHAR)", quote_ident(&col.name)),
19
20 // Raw UTF-8, passed through. This is the ONLY type whose payload can
21 // contain the 0x1F separator (or a 'v'), so it's the only one that can
22 // shift column boundaries — see the digest builder's collision note.
23 LogicalType::String => format!("CAST({} AS VARCHAR)", quote_ident(&col.name)),
24
25 // Booleans canonicalize to '1' / '0'. CAST(bool AS VARCHAR) would give
26 // 'true'/'false' in Postgres but 't'/'f' elsewhere — so spell it out
27 // explicitly instead of trusting each engine's text rendering.
28 LogicalType::Boolean => {
29 format!("CASE WHEN {} THEN '1' ELSE '0' END", quote_ident(&col.name))
30 }
31
32 // Lowercase hex. Postgres ENCODE(bytea,'hex') is already lowercase,
33 // which is the canonical form — no LOWER() needed here, but DuckDB's
34 // equivalent (hex()) is UPPER, so its dialect WILL need LOWER().
35 LogicalType::Binary => format!("ENCODE({}, 'hex')", quote_ident(&col.name)),
36
37 // Decimal — correct target form (fixed-point text at the *negotiated*
38 // scale; trailing zeros preserved, e.g. 1.5 at scale 2 -> "1.50"):
39 LogicalType::Decimal { scale } => {
40 format!(
41 "CAST({} AS NUMERIC(38, {}))::TEXT",
42 quote_ident(&col.name),
43 scale
44 )
45 }
46 // Blocked on: `scale` must come from build_plan's negotiation, not
47 // col.ty — two sides at scale 2 vs 4 is an error unless --coerce-scale.
48
49 // Timestamp — naive wall-clock, fixed width:
50 LogicalType::Timestamp { precision } => {
51 // Render the stored value as text exactly as-is, always 6 fractional
52 // digits. No tz conversion: these are NAIVE timestamps (no zone), so
53 // the wall-clock IS the canonical value — comparing it byte-for-byte is
54 // deterministic and engine-independent. (Applying AT TIME ZONE 'UTC'
55 // here would interpret the naive value as UTC and yield a timestamptz,
56 // which each engine then re-localizes differently — a conformance
57 // hazard. UTC-normalizing genuinely tz-aware columns is a separate
58 // branch, gated on --assume-tz.)
59 let rendered = format!(
60 "TO_CHAR({}, 'YYYY-MM-DD HH24:MI:SS.US')",
61 quote_ident(&col.name)
62 );
63
64 // Reduce to precision p by TRUNCATING THE TEXT, not by casting the value.
65 // Why text-slice and not CAST(... AS timestamp(p)): casting rounds, and
66 // engines disagree on rounding-vs-truncation at the boundary. String
67 // slicing is the ONE reduction guaranteed bit-identical on every engine —
68 // which is the entire job of canonicalization. The full render is fixed
69 // layout: 19 chars "YYYY-MM-DD HH24:MI:SS" + '.' + 6 digits = 26 total.
70 // precision == 6 → keep all 26
71 // precision in 1..=5 → keep 19 + 1 (dot) + precision = 20 + precision
72 // precision == 0 → drop the dot too, keep 19
73 match precision {
74 6 => rendered,
75 0 => format!("LEFT({}, 19)", rendered),
76 _ => format!("LEFT({}, {})", rendered, 20 + precision),
77 }
78 }
79 };
80 Ok(wrap(col, payload))
81}
82
83impl Dialect for PgDialect {
84 // Joindiff fast-exit precheck: COUNT(*) + one order-independent whole-table
85 // checksum per side. If both match across sides, the tables are identical
86 // and we stop — the common case made cheap.
87 fn whole_table_checksum_sql(&self, table: &TableRef, plan: &ComparePlan) -> Result<String> {
88 // Include the key in the checksum as well as the compared columns, so a
89 // changed key is itself a detectable difference
90 let mut canon_cols: Vec<String> = vec![canonical_expr(&plan.key)?];
91 for col in &plan.columns {
92 canon_cols.push(canonical_expr(col)?);
93 }
94
95 // Per-row digest: concatenate canonical columns with chr(31) (0x1F, ASCII
96 // unit separator) between them, then MD5. The separator keeps column
97 // boundaries from sliding — without it ("ab","c") and ("a","bc") would
98 // hash identically. (It's a structural aid, not injection-proof: a string
99 // payload can itself contain 0x1F.
100 let digest = format!("MD5({})", canon_cols.join(" || chr(31) || "));
101
102 // ---- The fiddly part: 128-bit MD5 -> two UNSIGNED 64-bit halves. ----
103 // MD5 returns 32 hex chars. Split into the top 16 and bottom 16.
104 // ('x' || hex16)::bit(64)::bigint reinterprets the bits as a *signed*
105 // i64 — negative whenever the high bit is set. Convert to the unsigned
106 // value branchlessly with: (b + 2^64) % 2^64 (a no-op when b >= 0).
107 // NUMERIC carries this without overflow; BIGINT would wrap.
108 let half_unsigned = |start: i32| {
109 format!(
110 "(((('x' || substr({digest}, {start}, 16))::bit(64)::bigint)::numeric \
111 + {TWO_POW_64}) % {TWO_POW_64})"
112 )
113 };
114
115 // Per segment/table: SUM each half, reduce mod 2^64, and carry COUNT(*).
116 // Summation is what makes this order-independent — no ORDER BY, which is
117 // most of the speed. Each engine MUST reproduce this arithmetic bit-for-
118 // bit; the conformance suite is what enforces that. The SELECT skeleton
119 // is shared with segment_checksum_sql via checks::checksum_select.
120 Ok(checksum_select(
121 table,
122 &half_unsigned(1),
123 &half_unsigned(17),
124 ))
125 }
126
127 // joindiff: full outer join
128 fn joindiff_sql(
129 &self,
130 a: &TableRef,
131 b: &TableRef,
132 plan: &ComparePlan,
133 ) -> Result<JoinDiffQuery> {
134 let join_key = quote_ident(&plan.key.name);
135 let join = outer_join_from(a, b, &plan.key);
136 let left_only = format!(
137 "SELECT {} {} WHERE b.{} IS NULL",
138 aliased_key("a", &plan.key),
139 join,
140 join_key
141 );
142 let right_only = format!(
143 "SELECT {} {} WHERE a.{} IS NULL",
144 aliased_key("b", &plan.key),
145 join,
146 join_key
147 );
148 let differing = format!(
149 "SELECT {} {} WHERE a.{} IS NOT NULL AND b.{} IS NOT NULL AND ({})",
150 aliased_key("a", &plan.key),
151 join,
152 join_key,
153 join_key,
154 mismatch_condition(plan)
155 );
156 Ok(JoinDiffQuery {
157 left_only,
158 right_only,
159 differing,
160 })
161 }
162
163 // materialize: one server-side CTAS, three UNION ALL branches over the same
164 // join skeleton joindiff uses. `key` is selected UNqualified — after USING
165 // it is the coalesced value, correct in all three branches. Values go into
166 // JSONB columns built only from the plan's compared columns; they never
167 // leave the database (README §0.3) — the caller reads back counts only.
168 fn materialize_sql(
169 &self,
170 a: &TableRef,
171 b: &TableRef,
172 plan: &ComparePlan,
173 target: &TableRef,
174 ) -> Result<String> {
175 let join = outer_join_from(a, b, &plan.key);
176 let key = quote_ident(&plan.key.name);
177 let src_json = format!("jsonb_build_object({})", json_object_args("a", &plan.columns));
178 let dst_json = format!("jsonb_build_object({})", json_object_args("b", &plan.columns));
179 Ok(format!(
180 "CREATE TABLE {target} AS \
181 SELECT '-' AS \"op\", {key} AS \"key\", {src_json} AS \"src_row\", CAST(NULL AS JSONB) AS \"dst_row\" {join} WHERE b.{key} IS NULL \
182 UNION ALL SELECT '+', {key}, CAST(NULL AS JSONB), {dst_json} {join} WHERE a.{key} IS NULL \
183 UNION ALL SELECT '~', {key}, {src_json}, {dst_json} {join} WHERE a.{key} IS NOT NULL AND b.{key} IS NOT NULL AND ({mismatch})",
184 target = from_table(target),
185 mismatch = mismatch_condition(plan),
186 ))
187 }
188
189 // hashdiff: normalization matrix - One column -> canonical SQL expression
190 fn normalize_column(&self, _col: &ColumnSchema) -> Result<String> {
191 todo!()
192 }
193
194 // hashdiff: per-row digest from canonical expressions -> md5 -> two u64 halves
195 fn digest_expr(&self, _canon_cols: &[String]) -> Result<String> {
196 todo!()
197 }
198
199 // hashdiff: bound the keyspace
200 fn keyspace_bounds_sql(&self, _table: &TableRef, _key: &ColumnSchema) -> Result<String> {
201 todo!()
202 }
203
204 // hashdiff: one segment's checksum tuple, server-side execution
205 fn segment_checksum_sql(
206 &self,
207 _table: &TableRef,
208 _plan: &ComparePlan,
209 _segment: &Segment,
210 ) -> Result<String> {
211 todo!()
212 }
213
214 // hashdiff: leaf rows for a narrowed, still-disagreeing segment
215 fn segment_rows_sql(&self, _table: &TableRef, _plan: &ComparePlan) -> Result<String> {
216 todo!()
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn col(name: &str, ty: LogicalType) -> ColumnSchema {
225 ColumnSchema {
226 name: name.into(),
227 ty,
228 nullable: true,
229 default_value: None,
230 primary_key: false,
231 }
232 }
233
234 fn tref(name: &str) -> TableRef {
235 TableRef { schema: None, name: name.into() }
236 }
237
238 #[test]
239 fn materialize_sql_builds_three_branch_ctas() {
240 let plan = ComparePlan {
241 key: col("id", LogicalType::Int),
242 columns: vec![col("name", LogicalType::String)],
243 };
244 let sql = PgDialect
245 .materialize_sql(&tref("src"), &tref("dst"), &plan, &tref("report"))
246 .unwrap();
247 assert!(sql.starts_with("CREATE TABLE \"report\" AS SELECT "));
248 assert!(sql.contains(
249 "SELECT '-' AS \"op\", \"id\" AS \"key\", \
250 jsonb_build_object('name', a.\"name\") AS \"src_row\", \
251 CAST(NULL AS JSONB) AS \"dst_row\" \
252 FROM \"src\" a FULL OUTER JOIN \"dst\" b USING (\"id\") WHERE b.\"id\" IS NULL"
253 ));
254 assert!(sql.contains("UNION ALL SELECT '+', \"id\", CAST(NULL AS JSONB), jsonb_build_object('name', b.\"name\")"));
255 assert!(sql.contains("UNION ALL SELECT '~', \"id\", jsonb_build_object('name', a.\"name\"), jsonb_build_object('name', b.\"name\")"));
256 assert!(sql.contains("WHERE a.\"id\" IS NOT NULL AND b.\"id\" IS NOT NULL AND ("));
257 }
258}