Skip to main content

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}