Skip to main content

vrtb_core/
conformance.rs

1//! Engine-agnostic conformance checking.
2//!
3//! This is the reusable logic the integration tests used to inline: run a
4//! dialect's whole-table checksum SQL through an [`Engine`], parse the single
5//! result row, and compare two sides. Because it speaks only the [`Engine`] /
6//! [`Dialect`] traits, it works for same-engine *and* cross-engine comparisons
7//! and carries no database-driver dependencies — the concrete engines live in
8//! the adapter crates (`vrtb-pg`, `vrtb-duck`), and the live tests that exercise
9//! real databases live in the CLI crate.
10
11use crate::engine::{ComparePlan, Engine, JoinDiffQuery, TableRef};
12use crate::error::{Result, VeritableError};
13use crate::format::Format;
14
15/// The fast-exit precheck tuple: row count plus the two 64-bit checksum halves
16/// (kept as decimal strings — they are unsigned 64-bit values that may exceed
17/// `i64`, and the engines already hand them back as text).
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ChecksumResult {
20    pub count: u64,
21    pub sum_h1: String,
22    pub sum_h2: String,
23}
24
25/// Outcome of comparing two sides.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum Verdict {
28    /// Counts and both checksum halves matched — the tables are equal under the
29    /// joindiff fast-exit precheck.
30    Match,
31    /// At least one of count / h1 / h2 differed.
32    Differ {
33        src: ChecksumResult,
34        dst: ChecksumResult,
35    },
36}
37
38impl Verdict {
39    pub fn is_match(&self) -> bool {
40        matches!(self, Verdict::Match)
41    }
42}
43
44/// Build the dialect's whole-table checksum SQL, run it on `engine`, and parse
45/// the single `(cnt, sum_h1, sum_h2)` row it returns.
46pub fn whole_table_checksum(
47    engine: &dyn Engine,
48    table: &TableRef,
49    plan: &ComparePlan,
50) -> Result<ChecksumResult> {
51    let sql = engine.dialect().whole_table_checksum_sql(table, plan)?;
52    let rows = engine.execute(&sql)?;
53    parse_checksum_row(rows)
54}
55
56// Render the three joindiff row groups in the requested format. Following the
57// classic diff convention (see `DiffOp`): `src_only` rows are left-only (`-`),
58// `dst_only` rows are right-only (`+`), and `differing` rows share a key but
59// disagree on non-key columns (`~`).
60fn output_diff(
61    src_only: &[Vec<String>],
62    dst_only: &[Vec<String>],
63    differing: &[Vec<String>],
64    format: Format,
65) -> Result<()> {
66    match format {
67        Format::Human => {
68            println!("DIFFER — row-level differences");
69            println!(
70                "  {} only in src, {} only in dst, {} differing",
71                src_only.len(),
72                dst_only.len(),
73                differing.len()
74            );
75            for row in src_only {
76                println!("  - {}", row.join(" | "));
77            }
78            for row in dst_only {
79                println!("  + {}", row.join(" | "));
80            }
81            for row in differing {
82                println!("  ~ {}", row.join(" | "));
83            }
84        }
85        Format::Summary => {
86            println!(
87                "differ: {} only in src, {} only in dst, {} differing",
88                src_only.len(),
89                dst_only.len(),
90                differing.len()
91            );
92        }
93        Format::Json => {
94            println!(
95                r#"{{"result":"differ","src_only":{},"dst_only":{},"differing":{}}}"#,
96                json_rows(src_only),
97                json_rows(dst_only),
98                json_rows(differing)
99            );
100        }
101        Format::Jsonl => {
102            for (op, rows) in [
103                ("src_only", src_only),
104                ("dst_only", dst_only),
105                ("differing", differing),
106            ] {
107                for row in rows {
108                    println!(r#"{{"op":"{}","row":{}}}"#, op, json_row(row));
109                }
110            }
111        }
112    }
113    Ok(())
114}
115
116// Minimal JSON rendering — core carries no serde dependency (see `verdict_json`
117// in the CLI, which hand-rolls output the same way).
118fn json_escape(s: &str) -> String {
119    let mut out = String::with_capacity(s.len() + 2);
120    for c in s.chars() {
121        match c {
122            '"' => out.push_str("\\\""),
123            '\\' => out.push_str("\\\\"),
124            '\n' => out.push_str("\\n"),
125            '\r' => out.push_str("\\r"),
126            '\t' => out.push_str("\\t"),
127            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
128            c => out.push(c),
129        }
130    }
131    out
132}
133
134fn json_row(row: &[String]) -> String {
135    let cells: Vec<String> = row
136        .iter()
137        .map(|c| format!("\"{}\"", json_escape(c)))
138        .collect();
139    format!("[{}]", cells.join(","))
140}
141
142fn json_rows(rows: &[Vec<String>]) -> String {
143    let items: Vec<String> = rows.iter().map(|r| json_row(r)).collect();
144    format!("[{}]", items.join(","))
145}
146
147// Tiny local identifier helpers. Deliberate duplication of vrtb_utils::sql:
148// core cannot depend on vrtb-utils (utils depends on core), and these two are
149// the only SQL fragments core ever builds itself.
150fn quoted(name: &str) -> String {
151    format!("\"{}\"", name.replace('"', "\"\""))
152}
153fn qualified(t: &TableRef) -> String {
154    match &t.schema {
155        Some(s) => format!("{}.{}", quoted(s), quoted(&t.name)),
156        None => quoted(&t.name),
157    }
158}
159
160// Server-side materialization: one CTAS builds the op/key/src_row/dst_row diff
161// table inside the src-side database, then a single count read-back drives the
162// stdout summary. Row values never cross the wire — only per-op counts and the
163// table name do (README §0.3; STATUS §6 carve-out).
164fn materialize_diff(
165    engine: &dyn Engine,
166    a: &TableRef,
167    b: &TableRef,
168    plan: &ComparePlan,
169    target: &TableRef,
170    format: Format,
171) -> Result<()> {
172    let ctas = engine.dialect().materialize_sql(a, b, plan, target)?;
173    engine
174        .execute(&ctas)
175        .map_err(|e| VeritableError::Engine(format!("materialize {}: {e}", qualified(target))))?;
176
177    let counts_sql = format!(
178        "SELECT \"op\", COUNT(*) FROM {} GROUP BY \"op\"",
179        qualified(target)
180    );
181    let (mut src_only, mut dst_only, mut differing) = (0u64, 0u64, 0u64);
182    for r in engine.execute(&counts_sql)? {
183        if r.len() < 2 {
184            return Err(VeritableError::Query(
185                "materialize count query returned fewer than 2 columns".into(),
186            ));
187        }
188        let n: u64 = r[1].parse().map_err(|e| {
189            VeritableError::Query(format!("could not parse materialize count {:?}: {e}", r[1]))
190        })?;
191        match r[0].as_str() {
192            "-" => src_only = n,
193            "+" => dst_only = n,
194            "~" => differing = n,
195            other => {
196                return Err(VeritableError::Query(format!(
197                    "unexpected op {other:?} in materialized table"
198                )));
199            }
200        }
201    }
202
203    let table = qualified(target);
204    match format {
205        Format::Human => {
206            println!("DIFFER — row-level differences");
207            println!("  {src_only} only in src, {dst_only} only in dst, {differing} differing");
208            println!(
209                "  materialized {} rows to {table}",
210                src_only + dst_only + differing
211            );
212        }
213        Format::Summary => println!(
214            "differ: {src_only} only in src, {dst_only} only in dst, {differing} differing (materialized to {table})"
215        ),
216        Format::Json | Format::Jsonl => println!(
217            r#"{{"result":"differ","materialized":{{"table":"{}","src_only":{src_only},"dst_only":{dst_only},"differing":{differing}}}}}"#,
218            json_escape(&table)
219        ),
220    }
221    Ok(())
222}
223
224/// Checksum both sides (which may be backed by different engines) and compare.
225/// On a same-engine differ: stream differing keys (default) or, with
226/// `materialize`, write the full diff server-side into that table instead.
227pub fn conformance_check(
228    src: &dyn Engine,
229    src_table: &TableRef,
230    dst: &dyn Engine,
231    dst_table: &TableRef,
232    plan: &ComparePlan,
233    format: Format,
234    materialize: Option<&TableRef>,
235) -> Result<Verdict> {
236    let s = whole_table_checksum(src, src_table, plan)?;
237    let d = whole_table_checksum(dst, dst_table, plan)?;
238    Ok(if s == d {
239        if materialize.is_some() && matches!(format, Format::Human | Format::Summary) {
240            println!("nothing to materialize — tables already match");
241        }
242        Verdict::Match
243    } else {
244        // Row-level joindiff needs both tables on one connection.
245        if src.name() == dst.name() {
246            match materialize {
247                Some(target) => {
248                    materialize_diff(src, src_table, dst_table, plan, target, format)?;
249                }
250                None => {
251                    let sql: JoinDiffQuery =
252                        src.dialect().joindiff_sql(src_table, dst_table, plan)?;
253                    let src_only_rows = src.execute(&sql.left_only)?;
254                    let dst_only_rows = src.execute(&sql.right_only)?;
255                    let diff_rows = src.execute(&sql.differing)?;
256                    output_diff(&src_only_rows, &dst_only_rows, &diff_rows, format)?;
257                }
258            }
259        } else {
260            // Defense in depth — the CLI rejects this before connecting.
261            if materialize.is_some() {
262                return Err(VeritableError::Config(
263                    "--materialize requires src and dst on the same connection (joindiff); \
264                     cross-engine materialize arrives with hashdiff"
265                        .into(),
266                ));
267            }
268            // Cross-engine conformance check: engines disagree on checksums
269            // but we cannot run a join-diff across engines. Just report the mismatch.
270            eprintln!(
271                "Cross-engine conformance check: engines disagree on checksums, but cannot run join-diff across engines."
272            );
273            // We will suggest running HashDiff for cross-engine conformance check
274            eprintln!("Suggestion: Run HashDiff for cross-engine conformance check.");
275        }
276        Verdict::Differ { src: s, dst: d }
277    })
278}
279
280// Pull the single checksum row out of an engine result set. The checksum SQL
281// always selects exactly `cnt, sum_h1, sum_h2` in that order.
282fn parse_checksum_row(rows: Vec<Vec<String>>) -> Result<ChecksumResult> {
283    let row = rows
284        .into_iter()
285        .next()
286        .ok_or_else(|| VeritableError::Query("checksum query returned no rows".into()))?;
287    if row.len() < 3 {
288        return Err(VeritableError::Query(format!(
289            "checksum query returned {} columns, expected 3 (cnt, sum_h1, sum_h2)",
290            row.len()
291        )));
292    }
293    let count = row[0].parse::<u64>().map_err(|e| {
294        VeritableError::Query(format!("could not parse row count {:?}: {e}", row[0]))
295    })?;
296    Ok(ChecksumResult {
297        count,
298        sum_h1: row[1].clone(),
299        sum_h2: row[2].clone(),
300    })
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::engine::{ColumnSchema, Dialect, JoinDiffQuery, LogicalType, Segment, TableSchema};
307    use crate::format::Format;
308
309    // A dialect whose checksum SQL is a sentinel string; the mock engine ignores
310    // the SQL text and returns whatever rows it was seeded with.
311    struct MockDialect;
312    impl Dialect for MockDialect {
313        fn whole_table_checksum_sql(&self, t: &TableRef, _p: &ComparePlan) -> Result<String> {
314            Ok(format!("CHECKSUM {}", t.name))
315        }
316        fn joindiff_sql(
317            &self,
318            _a: &TableRef,
319            _b: &TableRef,
320            _p: &ComparePlan,
321        ) -> Result<JoinDiffQuery> {
322            Ok(JoinDiffQuery {
323                left_only: "LEFT".into(),
324                right_only: "RIGHT".into(),
325                differing: "DIFF".into(),
326            })
327        }
328        fn materialize_sql(
329            &self,
330            _a: &TableRef,
331            _b: &TableRef,
332            _p: &ComparePlan,
333            target: &TableRef,
334        ) -> Result<String> {
335            Ok(format!("CTAS {}", target.name))
336        }
337        fn normalize_column(&self, _c: &ColumnSchema) -> Result<String> {
338            unimplemented!()
339        }
340        fn digest_expr(&self, _c: &[String]) -> Result<String> {
341            unimplemented!()
342        }
343        fn keyspace_bounds_sql(&self, _t: &TableRef, _k: &ColumnSchema) -> Result<String> {
344            unimplemented!()
345        }
346        fn segment_checksum_sql(
347            &self,
348            _t: &TableRef,
349            _p: &ComparePlan,
350            _s: &Segment,
351        ) -> Result<String> {
352            unimplemented!()
353        }
354        fn segment_rows_sql(&self, _t: &TableRef, _p: &ComparePlan) -> Result<String> {
355            unimplemented!()
356        }
357    }
358
359    use std::cell::RefCell;
360    use std::collections::VecDeque;
361
362    struct MockEngine {
363        dialect: MockDialect,
364        name: &'static str,
365        rows: Vec<Vec<String>>,
366        scripted: RefCell<VecDeque<Vec<Vec<String>>>>,
367        executed: RefCell<Vec<String>>,
368    }
369    impl MockEngine {
370        /// Every execute returns the same `rows` (legacy behavior).
371        fn new(rows: Vec<Vec<String>>) -> Self {
372            MockEngine {
373                dialect: MockDialect,
374                name: "mock",
375                rows,
376                scripted: RefCell::new(VecDeque::new()),
377                executed: RefCell::new(Vec::new()),
378            }
379        }
380        /// Consecutive executes pop `responses` front-to-back, then fall back to empty.
381        fn scripted(name: &'static str, responses: Vec<Vec<Vec<String>>>) -> Self {
382            MockEngine {
383                dialect: MockDialect,
384                name,
385                rows: Vec::new(),
386                scripted: RefCell::new(responses.into()),
387                executed: RefCell::new(Vec::new()),
388            }
389        }
390    }
391    impl Engine for MockEngine {
392        fn name(&self) -> &str {
393            self.name
394        }
395        fn introspect(&self, _t: &TableRef) -> Result<TableSchema> {
396            unimplemented!()
397        }
398        fn dialect(&self) -> &dyn Dialect {
399            &self.dialect
400        }
401        fn execute(&self, sql: &str) -> Result<Vec<Vec<String>>> {
402            self.executed.borrow_mut().push(sql.to_string());
403            if let Some(r) = self.scripted.borrow_mut().pop_front() {
404                return Ok(r);
405            }
406            Ok(self.rows.clone())
407        }
408    }
409
410    fn row(cnt: &str, h1: &str, h2: &str) -> Vec<String> {
411        vec![cnt.into(), h1.into(), h2.into()]
412    }
413
414    fn plan() -> ComparePlan {
415        ComparePlan {
416            key: ColumnSchema {
417                name: "id".into(),
418                ty: LogicalType::Int,
419                nullable: false,
420                default_value: None,
421                primary_key: true,
422            },
423            columns: vec![],
424        }
425    }
426
427    fn tref(name: &str) -> TableRef {
428        TableRef {
429            schema: None,
430            name: name.into(),
431        }
432    }
433
434    #[test]
435    fn parses_checksum_row() {
436        let eng = MockEngine::new(vec![row("10000", "123", "456")]);
437        let r = whole_table_checksum(&eng, &tref("t"), &plan()).unwrap();
438        assert_eq!(
439            r,
440            ChecksumResult {
441                count: 10000,
442                sum_h1: "123".into(),
443                sum_h2: "456".into()
444            }
445        );
446    }
447
448    #[test]
449    fn identical_sides_match() {
450        let a = MockEngine::new(vec![row("5", "aaa", "bbb")]);
451        let b = MockEngine::new(vec![row("5", "aaa", "bbb")]);
452        let v =
453            conformance_check(&a, &tref("t"), &b, &tref("t"), &plan(), Format::Summary, None).unwrap();
454        assert!(v.is_match());
455    }
456
457    #[test]
458    fn differing_checksum_differs() {
459        let a = MockEngine::new(vec![row("5", "aaa", "bbb")]);
460        let b = MockEngine::new(vec![row("5", "aaa", "ZZZ")]);
461        let v =
462            conformance_check(&a, &tref("t"), &b, &tref("t"), &plan(), Format::Summary, None).unwrap();
463        match v {
464            Verdict::Differ { src, dst } => {
465                assert_eq!(src.sum_h2, "bbb");
466                assert_eq!(dst.sum_h2, "ZZZ");
467            }
468            Verdict::Match => panic!("expected Differ"),
469        }
470    }
471
472    #[test]
473    fn differing_count_differs() {
474        let a = MockEngine::new(vec![row("5", "aaa", "bbb")]);
475        let b = MockEngine::new(vec![row("6", "aaa", "bbb")]);
476        let v =
477            conformance_check(&a, &tref("t"), &b, &tref("t"), &plan(), Format::Summary, None).unwrap();
478        assert!(!v.is_match());
479    }
480
481    #[test]
482    fn empty_result_is_error() {
483        let eng = MockEngine::new(vec![]);
484        assert!(whole_table_checksum(&eng, &tref("t"), &plan()).is_err());
485    }
486
487    #[test]
488    fn too_few_columns_is_error() {
489        let eng = MockEngine::new(vec![vec!["5".into(), "aaa".into()]]);
490        assert!(whole_table_checksum(&eng, &tref("t"), &plan()).is_err());
491    }
492
493    #[test]
494    fn materialize_runs_ctas_and_counts_only() {
495        let src = MockEngine::scripted(
496            "mock",
497            vec![
498                vec![row("5", "aaa", "bbb")], // whole-table checksum
499                vec![],                       // CTAS — no rows
500                vec![
501                    vec!["-".into(), "100".into()],
502                    vec!["+".into(), "150".into()],
503                    vec!["~".into(), "197".into()],
504                ], // count read-back
505            ],
506        );
507        let dst = MockEngine::scripted("mock", vec![vec![row("5", "aaa", "ZZZ")]]);
508        let target = tref("report");
509        let v = conformance_check(
510            &src, &tref("s"), &dst, &tref("d"), &plan(), Format::Summary, Some(&target),
511        )
512        .unwrap();
513        assert!(!v.is_match());
514        let executed = src.executed.borrow();
515        // checksum + CTAS + counts — and NO joindiff key-fetch queries.
516        assert_eq!(executed.len(), 3, "executed: {executed:?}");
517        assert_eq!(executed[1], "CTAS report");
518        assert!(executed[2].contains("GROUP BY \"op\""), "{}", executed[2]);
519        assert_eq!(dst.executed.borrow().len(), 1);
520    }
521
522    #[test]
523    fn materialize_on_match_creates_nothing() {
524        let src = MockEngine::new(vec![row("5", "aaa", "bbb")]);
525        let dst = MockEngine::new(vec![row("5", "aaa", "bbb")]);
526        let target = tref("report");
527        let v = conformance_check(
528            &src, &tref("s"), &dst, &tref("d"), &plan(), Format::Summary, Some(&target),
529        )
530        .unwrap();
531        assert!(v.is_match());
532        assert_eq!(src.executed.borrow().len(), 1, "checksum only — no CTAS");
533    }
534
535    #[test]
536    fn cross_engine_materialize_is_error() {
537        let src = MockEngine::scripted("mock", vec![vec![row("5", "aaa", "bbb")]]);
538        let dst = MockEngine::scripted("other", vec![vec![row("5", "aaa", "ZZZ")]]);
539        let target = tref("report");
540        let r = conformance_check(
541            &src, &tref("s"), &dst, &tref("d"), &plan(), Format::Summary, Some(&target),
542        );
543        assert!(r.is_err());
544        assert_eq!(src.executed.borrow().len(), 1, "no CTAS was attempted");
545    }
546}