use std::sync::Arc;
use arrow_array::{Int64Array, RecordBatch, StringArray, StructArray};
use arrow_schema::{DataType, Field, Schema};
use super::*;
fn wide(rows: &[(i64, &str, &str, &str)]) -> RecordBatch {
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, true),
Field::new("name", DataType::Utf8, true),
Field::new("country", DataType::Utf8, true),
Field::new(meta::CHANGE_TYPE, DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(
rows.iter().map(|r| r.0).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.1).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.2).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.3).collect::<Vec<_>>(),
)),
],
)
.unwrap()
}
fn triples(b: &RecordBatch) -> Vec<(String, Option<i64>, Option<i64>)> {
let op = b
.column_by_name(col::OP)
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
let side = |name: &str| -> Vec<Option<i64>> {
let s = b
.column_by_name(name)
.unwrap()
.as_any()
.downcast_ref::<StructArray>()
.unwrap();
let ids = s
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
(0..s.len())
.map(|i| {
if s.is_null(i) {
None
} else {
Some(ids.value(i))
}
})
.collect()
};
let before = side(col::BEFORE);
let after = side(col::AFTER);
(0..b.num_rows())
.map(|i| (op.value(i).to_string(), before[i], after[i]))
.collect()
}
#[test]
fn parses_both_engine_dialects() {
assert_eq!(ChangeOp::parse("INSERT"), Some(ChangeOp::Insert));
assert_eq!(ChangeOp::parse("DELETE"), Some(ChangeOp::Delete));
assert_eq!(
ChangeOp::parse("UPDATE_BEFORE"),
Some(ChangeOp::UpdateBefore)
);
assert_eq!(ChangeOp::parse("UPDATE_AFTER"), Some(ChangeOp::UpdateAfter));
assert_eq!(ChangeOp::parse("insert"), Some(ChangeOp::Insert));
assert_eq!(ChangeOp::parse("delete"), Some(ChangeOp::Delete));
assert_eq!(
ChangeOp::parse("update_preimage"),
Some(ChangeOp::UpdateBefore)
);
assert_eq!(
ChangeOp::parse("update_postimage"),
Some(ChangeOp::UpdateAfter)
);
assert_eq!(ChangeOp::parse("upsert"), None);
assert_eq!(ChangeOp::parse(""), None);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::parses_both_engine_dialects",
true,
"Iceberg UPDATE_BEFORE/AFTER and Spark 4.2 update_preimage/postimage both parse",
);
}
#[test]
fn renderable_schema_is_the_published_shape() {
let s = change_row_schema(wide(&[(1, "a", "SE", "INSERT")]).schema_ref());
let names: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(
names,
vec![col::OP, col::BEFORE, col::AFTER, col::SNAPSHOT, col::TS]
);
assert!(!s.field(0).is_nullable(), "`op` is never null");
let (DataType::Struct(b), DataType::Struct(a)) = (
s.field(1).data_type().clone(),
s.field(2).data_type().clone(),
) else {
panic!("before/after must be structs");
};
assert_eq!(b, a, "before and after must be field-identical");
let inner: Vec<&str> = b.iter().map(|f| f.name().as_str()).collect();
assert_eq!(inner, vec!["id", "name", "country"]);
assert_eq!(
s.field(4).data_type(),
&DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, Some("UTC".into()))
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::renderable_schema_is_the_published_shape",
true,
"(op, before, after, snapshot, ts) with field-identical images, no metadata leakage",
);
}
#[test]
fn encodes_insert_update_delete_in_order_with_the_right_side_filled() {
let batch = wide(&[
(1, "a", "SE", "INSERT"),
(2, "old", "NO", "UPDATE_BEFORE"),
(2, "new", "NO", "UPDATE_AFTER"),
(3, "c", "DK", "DELETE"),
]);
let out = encode_change_rows(&batch, Some(77)).unwrap();
assert_eq!(out.num_rows(), 4, "one output row per change event");
assert_eq!(
triples(&out),
vec![
("insert".into(), None, Some(1)),
("update_before".into(), Some(2), None),
("update_after".into(), None, Some(2)),
("delete".into(), Some(3), None),
],
);
let snap = out
.column_by_name(col::SNAPSHOT)
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert!((0..4).all(|i| snap.value(i) == 77));
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::encodes_insert_update_delete_in_order_with_the_right_side_filled",
true,
"insert/update_before/update_after/delete encoded in order, before/after filled correctly",
);
}
#[test]
fn refuses_to_drop_an_untypable_change_row() {
let bad = wide(&[(1, "a", "SE", "UPSERT")]);
let err = encode_change_rows(&bad, None).unwrap_err().to_string();
assert!(err.contains("UPSERT"), "{err}");
assert!(err.contains("refusing to drop"), "{err}");
let no_col = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
vec![Arc::new(Int64Array::from(vec![1i64]))],
)
.unwrap();
assert!(
encode_change_rows(&no_col, None).is_err(),
"no _change_type → error"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::refuses_to_drop_an_untypable_change_row",
true,
"unknown/absent _change_type errors instead of silently dropping the row",
);
}
#[test]
fn spark_native_metadata_lands_in_snapshot_and_ts() {
use arrow_array::TimestampMicrosecondArray;
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, true),
Field::new(meta::CHANGE_TYPE, DataType::Utf8, false),
Field::new(meta::COMMIT_VERSION, DataType::Int64, true),
Field::new(
meta::COMMIT_TIMESTAMP,
DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, Some("UTC".into())),
true,
),
])),
vec![
Arc::new(Int64Array::from(vec![1i64, 2])),
Arc::new(StringArray::from(vec!["insert", "delete"])),
Arc::new(Int64Array::from(vec![11i64, 12])),
Arc::new(
TimestampMicrosecondArray::from(vec![1_000_000i64, 2_000_000]).with_timezone("UTC"),
),
],
)
.unwrap();
let out = encode_change_rows(&batch, Some(999)).unwrap();
let snap = out
.column_by_name(col::SNAPSHOT)
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert_eq!(
(snap.value(0), snap.value(1)),
(11, 12),
"_commit_version wins"
);
let ts = out
.column_by_name(col::TS)
.unwrap()
.as_any()
.downcast_ref::<TimestampMicrosecondArray>()
.unwrap();
assert_eq!((ts.value(0), ts.value(1)), (1_000_000, 2_000_000));
assert_eq!(
triples(&out),
vec![
("insert".into(), None, Some(1)),
("delete".into(), Some(2), None),
],
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::spark_native_metadata_lands_in_snapshot_and_ts",
true,
"_commit_version → snapshot and _commit_timestamp → ts, overriding the default cursor",
);
}
#[test]
fn folds_update_pairs_into_one_row_with_both_images() {
let batch = wide(&[
(1, "a", "SE", "INSERT"),
(2, "old", "NO", "UPDATE_BEFORE"),
(2, "new", "NO", "UPDATE_AFTER"),
(3, "c", "DK", "DELETE"),
]);
let enc = encode_change_rows(&batch, Some(5)).unwrap();
let folded = fold_updates(&enc, &["id".to_string()]).unwrap();
assert_eq!(folded.num_rows(), 3, "the update pair became one row");
assert_eq!(
triples(&folded),
vec![
("insert".into(), None, Some(1)),
("update".into(), Some(2), Some(2)),
("delete".into(), Some(3), None),
],
);
let names = |name: &str| -> String {
let s = folded
.column_by_name(name)
.unwrap()
.as_any()
.downcast_ref::<StructArray>()
.unwrap();
s.column_by_name("name")
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.value(1)
.to_string()
};
assert_eq!(names(col::BEFORE), "old");
assert_eq!(names(col::AFTER), "new");
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::folds_update_pairs_into_one_row_with_both_images",
true,
"update_before+update_after → one `update` row with before=old, after=new",
);
}
#[test]
fn folding_without_identity_is_a_no_op() {
let enc = encode_change_rows(&wide(&[(2, "o", "NO", "UPDATE_BEFORE")]), None).unwrap();
let same = fold_updates(&enc, &[]).unwrap();
assert_eq!(same.num_rows(), enc.num_rows());
assert_eq!(triples(&same), triples(&enc));
}
#[test]
fn folding_keeps_an_unmatched_preimage() {
let batch = wide(&[(2, "old", "NO", "UPDATE_BEFORE"), (9, "z", "FI", "INSERT")]);
let enc = encode_change_rows(&batch, Some(1)).unwrap();
let folded = fold_updates(&enc, &["id".to_string()]).unwrap();
assert_eq!(
triples(&folded),
vec![
("update_before".into(), Some(2), None),
("insert".into(), None, Some(9)),
],
);
}
#[test]
fn post_filter_prunes_but_never_loses_a_delete() {
use crate::mapping::RowFilter;
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, true),
Field::new("country", DataType::Utf8, true),
Field::new(meta::CHANGE_TYPE, DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1i64, 2, 3])),
Arc::new(StringArray::from(vec![Some("SE"), Some("NO"), None])),
Arc::new(StringArray::from(vec!["INSERT", "INSERT", "DELETE"])),
],
)
.unwrap();
let kept = post_filter(&batch, &RowFilter::eq("country", "SE")).unwrap();
assert_eq!(kept.num_rows(), 2, "SE insert + the null-country DELETE");
let enc = encode_change_rows(&kept, Some(1)).unwrap();
assert_eq!(
triples(&enc),
vec![
("insert".into(), None, Some(1)),
("delete".into(), Some(3), None)
],
);
assert!(post_filter(&batch, &RowFilter::eq("missing", "x")).is_err());
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::post_filter_prunes_but_never_loses_a_delete",
true,
"post-filter drops non-matching inserts, keeps a null-column DELETE, errors on a missing column",
);
}
#[test]
fn projected_encode_equals_full_width_on_the_shared_columns() {
let batch = wide(&[
(1, "a", "SE", "INSERT"),
(2, "old", "NO", "UPDATE_BEFORE"),
(2, "new", "NO", "UPDATE_AFTER"),
(3, "c", "DK", "DELETE"),
]);
let full = encode_change_rows(&batch, Some(7)).unwrap();
let projected_wide = batch
.project(&[
batch.schema().index_of("id").unwrap(),
batch.schema().index_of(meta::CHANGE_TYPE).unwrap(),
])
.unwrap();
let projected = encode_change_rows(&projected_wide, Some(7)).unwrap();
assert_eq!(projected.num_rows(), full.num_rows(), "same row count");
assert_eq!(
triples(&projected),
triples(&full),
"same ops, same ids, same order"
);
for side in [col::BEFORE, col::AFTER] {
let get = |b: &RecordBatch| {
b.column_by_name(side)
.unwrap()
.as_any()
.downcast_ref::<StructArray>()
.unwrap()
.column_by_name("id")
.unwrap()
.clone()
};
assert_eq!(
get(&projected).to_data(),
get(&full).to_data(),
"{side}.id must be byte-identical between the projected and full-width reads",
);
}
let width = |b: &RecordBatch| match b.schema().field(1).data_type() {
DataType::Struct(f) => f.len(),
_ => unreachable!(),
};
assert_eq!((width(&projected), width(&full)), (1, 3));
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::projected_encode_equals_full_width_on_the_shared_columns",
true,
"projected CDC rows == full-width CDC rows (ops, order, byte-identical id images)",
);
}
#[test]
fn capability_json_says_which_path_ran_and_whether_it_fell_back() {
let cap = CdcCapability {
chosen: CdcKind::IcebergChangelogView,
server_version: Some("4.2.0".into()),
server_major_minor: Some((4, 2)),
native_expected: true,
native_usable: false,
fell_back: true,
reason: "CHANGES did not resolve for `lake.a.b`".into(),
};
let j = cap.to_json();
assert_eq!(j["cdc_backend"], "iceberg_changelog_view");
assert_eq!(j["server_version"], "4.2.0");
assert_eq!(j["server_major_minor"], "4.2");
assert_eq!(j["native_expected"], true);
assert_eq!(j["native_usable"], false);
assert_eq!(j["fell_back"], true);
assert!(j["reason"].as_str().unwrap().contains("did not resolve"));
let back: CdcCapability = serde_json::from_str(&serde_json::to_string(&cap).unwrap()).unwrap();
assert_eq!(back, cap);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::capability_json_says_which_path_ran_and_whether_it_fell_back",
true,
"state_json carries backend + version + fell_back + reason, and round-trips",
);
}
#[test]
fn parses_the_version_strings_spark_really_sends() {
assert_eq!(parse_major_minor("4.2.0"), Some((4, 2)));
assert_eq!(parse_major_minor("4.1.2"), Some((4, 1)));
assert_eq!(parse_major_minor("4.2.0-SNAPSHOT"), Some((4, 2)));
assert_eq!(parse_major_minor("3.5.7"), Some((3, 5)));
assert_eq!(parse_major_minor("garbage"), None);
assert_eq!(parse_major_minor(""), None);
}
#[tokio::test]
async fn change_stream_delivers_batch_by_batch() {
let batch = encode_change_rows(&wide(&[(1, "a", "SE", "INSERT")]), Some(1)).unwrap();
let schema = batch.schema();
let (sink, mut stream) = ChangeStream::channel(schema.clone(), CdcCapability::skade(), 1);
let producer = tokio::spawn(async move {
for _ in 0..3 {
if !sink.send(Ok(batch.clone())).await {
break;
}
}
});
let mut seen = 0;
while let Some(b) = stream.next().await {
assert_eq!(b.unwrap().num_rows(), 1);
seen += 1;
}
producer.await.unwrap();
assert_eq!(seen, 3, "every batch arrived");
assert_eq!(stream.capability.chosen, CdcKind::SkadeDelta);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::change_stream_delivers_batch_by_batch",
seen == 3,
"ChangeStream delivered 3 batches incrementally over a 1-slot channel",
);
}
#[tokio::test]
async fn empty_feed_still_has_the_renderable_schema() {
let s = change_row_schema(&Schema::empty());
let stream = ChangeStream::ready(s, CdcCapability::skade(), Vec::new());
let names: Vec<String> = stream
.schema
.fields()
.iter()
.map(|f| f.name().clone())
.collect();
assert_eq!(
names,
vec![col::OP, col::BEFORE, col::AFTER, col::SNAPSHOT, col::TS]
);
assert!(stream.collect().await.unwrap().is_empty());
}
#[test]
fn post_filter_keeps_unjudgeable_rows_where_the_scan_planner_would_drop_them() {
use crate::mapping::{FilterNulls, RowFilter, apply_row_filter_with};
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, true),
Field::new("country", DataType::Utf8, true),
Field::new(meta::CHANGE_TYPE, DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1i64, 2, 3, 4])),
Arc::new(StringArray::from(vec![
Some("SE"),
Some("NO"),
None, Some("SE"),
])),
Arc::new(StringArray::from(vec![
"INSERT", "INSERT", "DELETE", "INSERT",
])),
],
)
.unwrap();
let f = RowFilter::eq("country", "SE");
let ids = |b: &RecordBatch| -> Vec<i64> {
let c = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
(0..b.num_rows()).map(|i| c.value(i)).collect()
};
let dropped =
apply_row_filter_with(std::slice::from_ref(&batch), &f, FilterNulls::Drop).unwrap();
assert_eq!(
ids(&dropped[0]),
vec![1, 4],
"Drop loses the delete — by design"
);
let kept = post_filter(&batch, &f).unwrap();
assert_eq!(
ids(&kept),
vec![1, 3, 4],
"post_filter must keep the unjudgeable DELETE row (id=3)",
);
let ct = kept
.column_by_name(meta::CHANGE_TYPE)
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(ct.value(1), "DELETE");
assert!(ids(&kept).windows(2).all(|w| w[0] < w[1]));
assert_eq!(
ids(&dropped[0]),
ids(&kept)
.into_iter()
.filter(|i| *i != 3)
.collect::<Vec<_>>(),
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"cdc::post_filter_keeps_unjudgeable_rows_where_the_scan_planner_would_drop_them",
true,
"FilterNulls::Drop loses the null-column DELETE; cdc::post_filter (Keep) does not",
);
}
#[test]
fn an_uncomparable_literal_is_refused_not_coerced() {
use crate::mapping::RowFilter;
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, true),
Field::new(meta::CHANGE_TYPE, DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1i64, 2])),
Arc::new(StringArray::from(vec!["INSERT", "INSERT"])),
],
)
.unwrap();
let err = post_filter(&batch, &RowFilter::eq("id", "not-a-number"))
.unwrap_err()
.to_string();
assert!(err.contains("refusing to coerce"), "{err}");
assert!(post_filter(&batch, &RowFilter::eq("missing", 1i64)).is_err());
}