1use crate::engine::{ComparePlan, Engine, JoinDiffQuery, TableRef};
12use crate::error::{Result, VeritableError};
13use crate::format::Format;
14
15#[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#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum Verdict {
28 Match,
31 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
44pub 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
56fn 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
116fn 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
147fn 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
160fn 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
224pub 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 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 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 eprintln!(
271 "Cross-engine conformance check: engines disagree on checksums, but cannot run join-diff across engines."
272 );
273 eprintln!("Suggestion: Run HashDiff for cross-engine conformance check.");
275 }
276 Verdict::Differ { src: s, dst: d }
277 })
278}
279
280fn 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 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 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 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")], vec![], vec![
501 vec!["-".into(), "100".into()],
502 vec!["+".into(), "150".into()],
503 vec!["~".into(), "197".into()],
504 ], ],
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 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}