1use std::collections::HashMap;
8use std::io::{BufRead, BufReader, Write};
9use std::path::Path;
10
11use crate::error::{IoError, Result};
12
13#[derive(Debug, Clone, PartialEq)]
19pub enum CdcValue {
20 Null,
22 Bool(bool),
24 Int(i64),
26 Float(f64),
28 Text(String),
30 Bytes(Vec<u8>),
32}
33
34impl CdcValue {
35 pub fn to_display(&self) -> String {
37 match self {
38 CdcValue::Null => "NULL".to_string(),
39 CdcValue::Bool(b) => b.to_string(),
40 CdcValue::Int(i) => i.to_string(),
41 CdcValue::Float(f) => f.to_string(),
42 CdcValue::Text(s) => s.clone(),
43 CdcValue::Bytes(b) => format!("<bytes len={}>", b.len()),
44 }
45 }
46}
47
48pub type RowImage = HashMap<String, CdcValue>;
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum CdcOperation {
54 Insert,
56 Update,
58 Delete,
60 Read,
62 SchemaChange,
64}
65
66impl CdcOperation {
67 fn from_str(s: &str) -> Option<Self> {
68 match s {
69 "c" | "insert" | "INSERT" | "i" => Some(CdcOperation::Insert),
70 "u" | "update" | "UPDATE" => Some(CdcOperation::Update),
71 "d" | "delete" | "DELETE" => Some(CdcOperation::Delete),
72 "r" | "read" | "READ" => Some(CdcOperation::Read),
73 "s" | "schema" | "SCHEMA" => Some(CdcOperation::SchemaChange),
74 _ => None,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct CdcEvent {
82 pub lsn: u64,
84 pub source_ts_ms: i64,
86 pub table: String,
88 pub operation: CdcOperation,
90 pub before: Option<RowImage>,
92 pub after: Option<RowImage>,
94 pub transaction_id: Option<String>,
96}
97
98impl CdcEvent {
99 pub fn insert(lsn: u64, ts_ms: i64, table: impl Into<String>, after: RowImage) -> Self {
101 CdcEvent {
102 lsn,
103 source_ts_ms: ts_ms,
104 table: table.into(),
105 operation: CdcOperation::Insert,
106 before: None,
107 after: Some(after),
108 transaction_id: None,
109 }
110 }
111
112 pub fn update(
114 lsn: u64,
115 ts_ms: i64,
116 table: impl Into<String>,
117 before: RowImage,
118 after: RowImage,
119 ) -> Self {
120 CdcEvent {
121 lsn,
122 source_ts_ms: ts_ms,
123 table: table.into(),
124 operation: CdcOperation::Update,
125 before: Some(before),
126 after: Some(after),
127 transaction_id: None,
128 }
129 }
130
131 pub fn delete(lsn: u64, ts_ms: i64, table: impl Into<String>, before: RowImage) -> Self {
133 CdcEvent {
134 lsn,
135 source_ts_ms: ts_ms,
136 table: table.into(),
137 operation: CdcOperation::Delete,
138 before: Some(before),
139 after: None,
140 transaction_id: None,
141 }
142 }
143}
144
145#[derive(Debug, Default)]
154pub struct CdcLog {
155 events: Vec<CdcEvent>,
156 next_lsn: u64,
158}
159
160impl CdcLog {
161 pub fn new() -> Self {
163 CdcLog {
164 events: Vec::new(),
165 next_lsn: 0,
166 }
167 }
168
169 pub fn with_start_lsn(start: u64) -> Self {
171 CdcLog {
172 events: Vec::new(),
173 next_lsn: start,
174 }
175 }
176
177 pub fn push(&mut self, event: CdcEvent) {
180 if event.lsn >= self.next_lsn {
181 self.next_lsn = event.lsn + 1;
182 }
183 self.events.push(event);
184 }
185
186 pub fn insert(&mut self, ts_ms: i64, table: impl Into<String>, after: RowImage) -> u64 {
188 let lsn = self.next_lsn;
189 self.next_lsn += 1;
190 self.events.push(CdcEvent::insert(lsn, ts_ms, table, after));
191 lsn
192 }
193
194 pub fn update(
196 &mut self,
197 ts_ms: i64,
198 table: impl Into<String>,
199 before: RowImage,
200 after: RowImage,
201 ) -> u64 {
202 let lsn = self.next_lsn;
203 self.next_lsn += 1;
204 self.events
205 .push(CdcEvent::update(lsn, ts_ms, table, before, after));
206 lsn
207 }
208
209 pub fn delete(&mut self, ts_ms: i64, table: impl Into<String>, before: RowImage) -> u64 {
211 let lsn = self.next_lsn;
212 self.next_lsn += 1;
213 self.events
214 .push(CdcEvent::delete(lsn, ts_ms, table, before));
215 lsn
216 }
217
218 pub fn len(&self) -> usize {
220 self.events.len()
221 }
222
223 pub fn is_empty(&self) -> bool {
225 self.events.is_empty()
226 }
227
228 pub fn events(&self) -> &[CdcEvent] {
230 &self.events
231 }
232
233 pub fn events_for_table(&self, table: &str) -> Vec<&CdcEvent> {
235 self.events.iter().filter(|e| e.table == table).collect()
236 }
237
238 pub fn write_ndjson<W: Write>(&self, mut writer: W) -> Result<()> {
240 for ev in &self.events {
241 let line = event_to_ndjson(ev);
242 writer
243 .write_all(line.as_bytes())
244 .map_err(|e| IoError::Io(e))?;
245 writer.write_all(b"\n").map_err(|e| IoError::Io(e))?;
246 }
247 Ok(())
248 }
249
250 pub fn read_ndjson<R: BufRead>(reader: R) -> Result<Self> {
252 let mut log = CdcLog::new();
253 for (line_no, line_res) in reader.lines().enumerate() {
254 let line = line_res.map_err(|e| IoError::Io(e))?;
255 let trimmed = line.trim();
256 if trimmed.is_empty() {
257 continue;
258 }
259 let ev = ndjson_to_event(trimmed).map_err(|e| {
260 IoError::ParseError(format!("CDC NDJSON line {}: {}", line_no + 1, e))
261 })?;
262 log.push(ev);
263 }
264 Ok(log)
265 }
266}
267
268pub fn replay_cdc(
280 log: &CdcLog,
281 table: &str,
282 pk_columns: &[&str],
283) -> Result<HashMap<String, RowImage>> {
284 if pk_columns.is_empty() {
285 return Err(IoError::ValidationError(
286 "pk_columns must not be empty".to_string(),
287 ));
288 }
289
290 let mut state: HashMap<String, RowImage> = HashMap::new();
291
292 for ev in log.events_for_table(table) {
293 match ev.operation {
294 CdcOperation::Insert | CdcOperation::Read => {
295 if let Some(after) = &ev.after {
296 let pk = extract_pk(after, pk_columns)?;
297 state.insert(pk, after.clone());
298 }
299 }
300 CdcOperation::Update => {
301 if let Some(after) = &ev.after {
302 let pk = extract_pk(after, pk_columns)?;
303 state.insert(pk, after.clone());
304 }
305 }
306 CdcOperation::Delete => {
307 if let Some(before) = &ev.before {
308 let pk = extract_pk(before, pk_columns)?;
309 state.remove(&pk);
310 }
311 }
312 CdcOperation::SchemaChange => {
313 }
315 }
316 }
317
318 Ok(state)
319}
320
321fn extract_pk(row: &RowImage, pk_columns: &[&str]) -> Result<String> {
322 let mut parts = Vec::with_capacity(pk_columns.len());
323 for col in pk_columns {
324 let val = row.get(*col).ok_or_else(|| {
325 IoError::ValidationError(format!("PK column '{}' not found in row image", col))
326 })?;
327 parts.push(val.to_display());
328 }
329 Ok(parts.join("\x00"))
330}
331
332#[derive(Debug, Default)]
338pub struct CdcColumnarSnapshot {
339 pub columns: Vec<String>,
341 pub data: HashMap<String, Vec<CdcValue>>,
343 pub row_count: usize,
345}
346
347impl CdcColumnarSnapshot {
348 fn new(columns: Vec<String>) -> Self {
349 let mut data = HashMap::new();
350 for col in &columns {
351 data.insert(col.clone(), Vec::new());
352 }
353 CdcColumnarSnapshot {
354 columns,
355 data,
356 row_count: 0,
357 }
358 }
359
360 fn push_row(&mut self, row: &RowImage) {
361 for col in &self.columns {
362 let val = row.get(col).cloned().unwrap_or(CdcValue::Null);
363 if let Some(col_data) = self.data.get_mut(col) {
364 col_data.push(val);
365 }
366 }
367 self.row_count += 1;
368 }
369}
370
371pub fn cdc_to_parquet(
377 log: &CdcLog,
378 table: &str,
379 pk_columns: &[&str],
380 column_order: Option<&[&str]>,
381) -> Result<CdcColumnarSnapshot> {
382 let state = replay_cdc(log, table, pk_columns)?;
383
384 let columns: Vec<String> = if let Some(order) = column_order {
386 order.iter().map(|s| s.to_string()).collect()
387 } else {
388 if let Some(row) = state.values().next() {
390 let mut cols: Vec<String> = row.keys().cloned().collect();
391 cols.sort();
392 cols
393 } else {
394 return Ok(CdcColumnarSnapshot::default());
395 }
396 };
397
398 let mut snapshot = CdcColumnarSnapshot::new(columns);
399
400 let mut sorted_rows: Vec<(&String, &RowImage)> = state.iter().collect();
402 sorted_rows.sort_by_key(|(pk, _)| pk.as_str());
403
404 for (_, row) in sorted_rows {
405 snapshot.push_row(row);
406 }
407
408 Ok(snapshot)
409}
410
411pub fn debezium_json_parser(json: &str, lsn: u64) -> Result<CdcEvent> {
430 let trimmed = json.trim();
431 let payload_str = extract_payload_str(trimmed);
433
434 let op_str = extract_string_field(payload_str, "op")
435 .ok_or_else(|| IoError::ParseError("Debezium JSON missing 'op' field".to_string()))?;
436
437 let operation = CdcOperation::from_str(&op_str)
438 .ok_or_else(|| IoError::ParseError(format!("Unknown Debezium op '{}'", op_str)))?;
439
440 let ts_ms = extract_i64_field(payload_str, "ts_ms").unwrap_or(0);
441
442 let table = extract_source_table(payload_str).unwrap_or_else(|| "unknown".to_string());
444
445 let before = extract_row_object(payload_str, "before");
446 let after = extract_row_object(payload_str, "after");
447
448 Ok(CdcEvent {
449 lsn,
450 source_ts_ms: ts_ms,
451 table,
452 operation,
453 before,
454 after,
455 transaction_id: extract_string_field(payload_str, "transaction"),
456 })
457}
458
459pub fn parse_debezium_log<P: AsRef<Path>>(path: P) -> Result<CdcLog> {
461 let file = std::fs::File::open(path.as_ref()).map_err(|e| IoError::Io(e))?;
462 let reader = BufReader::new(file);
463 let mut log = CdcLog::new();
464 let mut lsn: u64 = 0;
465
466 for (line_no, line_res) in reader.lines().enumerate() {
467 let line = line_res.map_err(|e| IoError::Io(e))?;
468 let trimmed = line.trim();
469 if trimmed.is_empty() {
470 continue;
471 }
472 let ev = debezium_json_parser(trimmed, lsn)
473 .map_err(|e| IoError::ParseError(format!("Debezium line {}: {}", line_no + 1, e)))?;
474 lsn = ev.lsn + 1;
475 log.push(ev);
476 }
477 Ok(log)
478}
479
480fn cdc_value_to_json(v: &CdcValue) -> String {
485 match v {
486 CdcValue::Null => "null".to_string(),
487 CdcValue::Bool(b) => b.to_string(),
488 CdcValue::Int(i) => i.to_string(),
489 CdcValue::Float(f) => {
490 if f.is_nan() || f.is_infinite() {
491 "null".to_string()
492 } else {
493 f.to_string()
494 }
495 }
496 CdcValue::Text(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
497 CdcValue::Bytes(b) => {
498 let hex: String = b.iter().map(|byte| format!("{:02x}", byte)).collect();
500 format!("\"\\u0000hex:{hex}\"")
501 }
502 }
503}
504
505fn row_image_to_json(row: &RowImage) -> String {
506 let mut pairs: Vec<String> = row
507 .iter()
508 .map(|(k, v)| format!("\"{}\":{}", k, cdc_value_to_json(v)))
509 .collect();
510 pairs.sort(); format!("{{{}}}", pairs.join(","))
512}
513
514fn event_to_ndjson(ev: &CdcEvent) -> String {
515 let op = match ev.operation {
516 CdcOperation::Insert => "c",
517 CdcOperation::Update => "u",
518 CdcOperation::Delete => "d",
519 CdcOperation::Read => "r",
520 CdcOperation::SchemaChange => "s",
521 };
522 let before_str = ev
523 .before
524 .as_ref()
525 .map(row_image_to_json)
526 .unwrap_or_else(|| "null".to_string());
527 let after_str = ev
528 .after
529 .as_ref()
530 .map(row_image_to_json)
531 .unwrap_or_else(|| "null".to_string());
532 let tx = ev
533 .transaction_id
534 .as_deref()
535 .map(|t| format!("\"{}\"", t))
536 .unwrap_or_else(|| "null".to_string());
537
538 format!(
539 "{{\"lsn\":{},\"ts_ms\":{},\"table\":\"{}\",\"op\":\"{}\",\"before\":{},\"after\":{},\"tx\":{}}}",
540 ev.lsn, ev.source_ts_ms, ev.table, op, before_str, after_str, tx
541 )
542}
543
544fn ndjson_to_event(s: &str) -> std::result::Result<CdcEvent, String> {
545 let lsn = extract_u64_field(s, "lsn").ok_or_else(|| "missing 'lsn'".to_string())?;
546 let ts_ms = extract_i64_field(s, "ts_ms").unwrap_or(0);
547 let table = extract_string_field(s, "table").ok_or_else(|| "missing 'table'".to_string())?;
548 let op_str = extract_string_field(s, "op").ok_or_else(|| "missing 'op'".to_string())?;
549 let operation =
550 CdcOperation::from_str(&op_str).ok_or_else(|| format!("unknown op '{}'", op_str))?;
551 let before = extract_row_object(s, "before");
552 let after = extract_row_object(s, "after");
553 let transaction_id = extract_string_field(s, "tx");
554
555 Ok(CdcEvent {
556 lsn,
557 source_ts_ms: ts_ms,
558 table,
559 operation,
560 before,
561 after,
562 transaction_id,
563 })
564}
565
566fn extract_payload_str(s: &str) -> &str {
572 if let Some(start) = find_key_value_start(s, "payload") {
573 if let Some(obj_start) = s[start..].find('{') {
575 let abs = start + obj_start;
576 if let Some(end) = matching_brace(s, abs) {
577 return &s[abs..=end];
578 }
579 }
580 }
581 s
582}
583
584fn extract_string_field(s: &str, key: &str) -> Option<String> {
585 let pattern = format!("\"{}\"", key);
586 let pos = s.find(&pattern)?;
587 let after_key = &s[pos + pattern.len()..];
588 let colon_pos = after_key.find(':')? + 1;
589 let val_str = after_key[colon_pos..].trim_start();
590 if let Some(inner) = val_str.strip_prefix('"') {
591 let mut result = String::new();
593 let mut chars = inner.chars();
594 loop {
595 match chars.next() {
596 None => break,
597 Some('"') => break,
598 Some('\\') => match chars.next() {
599 Some('"') => result.push('"'),
600 Some('\\') => result.push('\\'),
601 Some('n') => result.push('\n'),
602 Some('r') => result.push('\r'),
603 Some('t') => result.push('\t'),
604 Some(c) => {
605 result.push('\\');
606 result.push(c);
607 }
608 None => break,
609 },
610 Some(c) => result.push(c),
611 }
612 }
613 Some(result)
614 } else {
615 None
616 }
617}
618
619fn extract_i64_field(s: &str, key: &str) -> Option<i64> {
620 let pattern = format!("\"{}\"", key);
621 let pos = s.find(&pattern)?;
622 let after_key = &s[pos + pattern.len()..];
623 let colon_pos = after_key.find(':')? + 1;
624 let val_str = after_key[colon_pos..].trim_start();
625 let end = val_str
627 .find(|c: char| !c.is_ascii_digit() && c != '-')
628 .unwrap_or(val_str.len());
629 val_str[..end].parse::<i64>().ok()
630}
631
632fn extract_u64_field(s: &str, key: &str) -> Option<u64> {
633 let pattern = format!("\"{}\"", key);
634 let pos = s.find(&pattern)?;
635 let after_key = &s[pos + pattern.len()..];
636 let colon_pos = after_key.find(':')? + 1;
637 let val_str = after_key[colon_pos..].trim_start();
638 let end = val_str
639 .find(|c: char| !c.is_ascii_digit())
640 .unwrap_or(val_str.len());
641 val_str[..end].parse::<u64>().ok()
642}
643
644fn extract_source_table(s: &str) -> Option<String> {
645 let source_pos = find_key_value_start(s, "source")?;
647 let source_str = &s[source_pos..];
648 let obj_start = source_str.find('{')?;
649 let abs = source_pos + obj_start;
650 let end = matching_brace(s, abs)?;
651 let source_obj = &s[abs..=end];
652 extract_string_field(source_obj, "table")
653}
654
655fn extract_row_object(s: &str, key: &str) -> Option<RowImage> {
656 let pattern = format!("\"{}\"", key);
657 let pos = s.find(&pattern)?;
658 let after_key = &s[pos + pattern.len()..];
659 let colon_pos = after_key.find(':')? + 1;
660 let val_str = after_key[colon_pos..].trim_start();
661
662 if val_str.starts_with("null") || val_str.is_empty() {
663 return None;
664 }
665
666 if !val_str.starts_with('{') {
667 return None;
668 }
669
670 let abs_start =
672 s.len() - after_key.len() + colon_pos + (after_key[colon_pos..].len() - val_str.len());
673 let abs_end = matching_brace(s, abs_start)?;
674 let obj_str = &s[abs_start..=abs_end];
675
676 parse_flat_json_object(obj_str)
677}
678
679fn parse_flat_json_object(s: &str) -> Option<RowImage> {
681 let inner = s
682 .strip_prefix('{')
683 .and_then(|t| t.strip_suffix('}'))
684 .unwrap_or(s);
685
686 let mut map = HashMap::new();
687 let mut rest = inner.trim();
688
689 while !rest.is_empty() {
690 rest = rest.trim_start_matches(',').trim();
692 if rest.is_empty() {
693 break;
694 }
695 if !rest.starts_with('"') {
696 break;
697 }
698 let (key, after_key) = parse_json_string(rest)?;
699 rest = after_key.trim_start();
700 if !rest.starts_with(':') {
701 break;
702 }
703 rest = rest[1..].trim_start();
704
705 let (val, after_val) = parse_json_value(rest)?;
707 map.insert(key, val);
708 rest = after_val.trim_start();
709 }
710
711 Some(map)
712}
713
714fn parse_json_string(s: &str) -> Option<(String, &str)> {
715 if !s.starts_with('"') {
716 return None;
717 }
718 let mut result = String::new();
719 let mut chars = s[1..].char_indices();
720 loop {
721 match chars.next() {
722 None => return None,
723 Some((i, '"')) => {
724 return Some((result, &s[i + 2..]));
726 }
727 Some((_, '\\')) => match chars.next() {
728 Some((_, '"')) => result.push('"'),
729 Some((_, '\\')) => result.push('\\'),
730 Some((_, 'n')) => result.push('\n'),
731 Some((_, 'r')) => result.push('\r'),
732 Some((_, 't')) => result.push('\t'),
733 Some((_, c)) => {
734 result.push('\\');
735 result.push(c);
736 }
737 None => return None,
738 },
739 Some((_, c)) => result.push(c),
740 }
741 }
742}
743
744fn parse_json_value(s: &str) -> Option<(CdcValue, &str)> {
745 if let Some(rest) = s.strip_prefix("null") {
746 return Some((CdcValue::Null, rest));
747 }
748 if let Some(rest) = s.strip_prefix("true") {
749 return Some((CdcValue::Bool(true), rest));
750 }
751 if let Some(rest) = s.strip_prefix("false") {
752 return Some((CdcValue::Bool(false), rest));
753 }
754 if s.starts_with('"') {
755 let (string, rest) = parse_json_string(s)?;
756 return Some((CdcValue::Text(string), rest));
757 }
758 let end = s
760 .find(|c: char| {
761 !c.is_ascii_digit() && c != '-' && c != '.' && c != 'e' && c != 'E' && c != '+'
762 })
763 .unwrap_or(s.len());
764 let num_str = &s[..end];
765 if num_str.is_empty() {
766 return None;
767 }
768 if num_str.contains('.') || num_str.contains('e') || num_str.contains('E') {
769 let f = num_str.parse::<f64>().ok()?;
770 Some((CdcValue::Float(f), &s[end..]))
771 } else {
772 let i = num_str.parse::<i64>().ok()?;
773 Some((CdcValue::Int(i), &s[end..]))
774 }
775}
776
777fn find_key_value_start(s: &str, key: &str) -> Option<usize> {
779 let pattern = format!("\"{}\"", key);
780 let pos = s.find(&pattern)?;
781 Some(pos)
782}
783
784fn matching_brace(s: &str, start: usize) -> Option<usize> {
786 let bytes = s.as_bytes();
787 if bytes.get(start) != Some(&b'{') {
788 return None;
789 }
790 let mut depth = 0usize;
791 let mut in_str = false;
792 let mut escape = false;
793 for (i, &b) in bytes[start..].iter().enumerate() {
794 if escape {
795 escape = false;
796 continue;
797 }
798 if b == b'\\' && in_str {
799 escape = true;
800 continue;
801 }
802 if b == b'"' {
803 in_str = !in_str;
804 continue;
805 }
806 if in_str {
807 continue;
808 }
809 match b {
810 b'{' => depth += 1,
811 b'}' => {
812 depth -= 1;
813 if depth == 0 {
814 return Some(start + i);
815 }
816 }
817 _ => {}
818 }
819 }
820 None
821}
822
823#[cfg(test)]
828mod tests {
829 use super::*;
830
831 fn make_row(pairs: &[(&str, CdcValue)]) -> RowImage {
832 pairs
833 .iter()
834 .map(|(k, v)| (k.to_string(), v.clone()))
835 .collect()
836 }
837
838 #[test]
839 fn test_cdc_log_basic() {
840 let mut log = CdcLog::new();
841 let after = make_row(&[
842 ("id", CdcValue::Int(1)),
843 ("name", CdcValue::Text("Alice".into())),
844 ]);
845 let lsn = log.insert(1_000, "users", after);
846 assert_eq!(lsn, 0);
847 assert_eq!(log.len(), 1);
848 }
849
850 #[test]
851 fn test_replay_insert_delete() {
852 let mut log = CdcLog::new();
853
854 let row1 = make_row(&[
855 ("id", CdcValue::Int(1)),
856 ("val", CdcValue::Text("a".into())),
857 ]);
858 let row2 = make_row(&[
859 ("id", CdcValue::Int(2)),
860 ("val", CdcValue::Text("b".into())),
861 ]);
862
863 log.insert(0, "t", row1.clone());
864 log.insert(0, "t", row2.clone());
865 log.delete(0, "t", row1.clone());
866
867 let state = replay_cdc(&log, "t", &["id"]).expect("replay failed");
868 assert_eq!(state.len(), 1);
869 assert!(state.contains_key("2"));
870 }
871
872 #[test]
873 fn test_replay_update() {
874 let mut log = CdcLog::new();
875
876 let before = make_row(&[
877 ("id", CdcValue::Int(1)),
878 ("val", CdcValue::Text("old".into())),
879 ]);
880 let after = make_row(&[
881 ("id", CdcValue::Int(1)),
882 ("val", CdcValue::Text("new".into())),
883 ]);
884
885 log.insert(0, "t", before.clone());
886 log.update(0, "t", before, after);
887
888 let state = replay_cdc(&log, "t", &["id"]).expect("replay failed");
889 let row = state.get("1").expect("row must exist");
890 assert_eq!(row.get("val"), Some(&CdcValue::Text("new".into())));
891 }
892
893 #[test]
894 fn test_ndjson_roundtrip() {
895 let mut log = CdcLog::new();
896 let row = make_row(&[
897 ("id", CdcValue::Int(42)),
898 ("x", CdcValue::Float(std::f64::consts::PI)),
899 ]);
900 log.insert(9999, "metrics", row);
901
902 let mut buf = Vec::new();
903 log.write_ndjson(&mut buf).expect("write failed");
904
905 let log2 = CdcLog::read_ndjson(std::io::BufReader::new(&buf[..])).expect("read failed");
906 assert_eq!(log2.len(), 1);
907 let ev = &log2.events()[0];
908 assert_eq!(ev.operation, CdcOperation::Insert);
909 assert_eq!(ev.table, "metrics");
910 }
911
912 #[test]
913 fn test_debezium_parser() {
914 let json = r#"{
915 "payload": {
916 "op": "c",
917 "ts_ms": 1711234567890,
918 "source": {"table": "orders", "db": "mydb"},
919 "before": null,
920 "after": {"id": 99, "amount": 10}
921 }
922 }"#;
923 let ev = debezium_json_parser(json, 7).expect("parse failed");
924 assert_eq!(ev.operation, CdcOperation::Insert);
925 assert_eq!(ev.table, "orders");
926 assert_eq!(ev.lsn, 7);
927 assert!(ev.before.is_none());
928 assert!(ev.after.is_some());
929 }
930
931 #[test]
932 fn test_cdc_to_parquet() {
933 let mut log = CdcLog::new();
934 for i in 0..5i64 {
935 let row = make_row(&[
936 ("id", CdcValue::Int(i)),
937 ("v", CdcValue::Float(i as f64 * 1.5)),
938 ]);
939 log.insert(0, "data", row);
940 }
941
942 let snap =
943 cdc_to_parquet(&log, "data", &["id"], Some(&["id", "v"])).expect("materialise failed");
944 assert_eq!(snap.row_count, 5);
945 assert_eq!(snap.columns.len(), 2);
946 }
947}