use std::collections::HashMap;
use std::sync::Arc;
use delta_kernel::arrow::array::{Int32Array, RecordBatch, StringArray};
use delta_kernel::arrow::datatypes::{
DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema,
};
use delta_kernel::object_store::path::Path as ObjectStorePath;
use delta_kernel::object_store::{DynObjectStore, ObjectStoreExt as _};
use delta_kernel::schema::{schema, DataType};
use delta_kernel::table_features::TableFeature;
use delta_kernel::Snapshot;
use rstest::rstest;
use serde_json::json;
use test_utils::column_mapping_fixtures::cm_field;
use test_utils::{add_commit, engine_store_setup, read_scan, record_batch_to_bytes};
const ID_LOGICAL: &str = "id";
const ID_PHYSICAL: &str = "col-9f1c2b7e-0000-4000-8000-000000000000";
const ID_FIELD_ID: i64 = 1;
const PAYLOAD_LOGICAL: &str = "payload_renamed";
const PAYLOAD_PHYSICAL: &str = "col-9f1c2b7e-0000-4000-8000-000000000001";
const PAYLOAD_FIELD_ID: i64 = 2;
fn metadata_action(mode: &str) -> serde_json::Value {
let schema = schema! {
(cm_field(ID_LOGICAL, ID_FIELD_ID, ID_PHYSICAL, DataType::INTEGER)),
(cm_field(
PAYLOAD_LOGICAL,
PAYLOAD_FIELD_ID,
PAYLOAD_PHYSICAL,
DataType::STRING,
)),
};
json!({
"metaData": {
"id": "cm-reader-features-test-table",
"format": { "provider": "parquet", "options": {} },
"schemaString": serde_json::to_string(&schema).unwrap(),
"partitionColumns": [],
"configuration": {
"delta.columnMapping.mode": mode,
"delta.columnMapping.maxColumnId": "2"
},
"createdTime": 1_700_000_000_000i64
}
})
}
fn protocol_action(reader_features: &[&str], writer_features: &[&str]) -> serde_json::Value {
json!({
"protocol": {
"minReaderVersion": 3,
"minWriterVersion": 7,
"readerFeatures": reader_features,
"writerFeatures": writer_features,
}
})
}
async fn setup_table(
store: &DynObjectStore,
table_root: &str,
reader_features: &[&str],
writer_features: &[&str],
) -> Result<(), Box<dyn std::error::Error>> {
let commit = format!(
"{}\n{}\n",
protocol_action(reader_features, writer_features),
metadata_action("name")
);
add_commit(table_root, store, 0, commit).await?;
Ok(())
}
fn field_names(snapshot: &Snapshot) -> Vec<String> {
snapshot
.schema()
.fields()
.map(|f| f.name().to_string())
.collect()
}
#[rstest]
#[case::both_lists(&["columnMapping"], &["columnMapping"], true)]
#[case::orphaned_writer_only(&[], &["columnMapping"], true)]
#[case::absent_from_both(&[], &["appendOnly"], false)]
#[tokio::test]
async fn column_mapping_support_matrix(
#[case] reader_features: &[&str],
#[case] writer_features: &[&str],
#[case] expected_supported: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let scenario = format!(
"cm_support_{}_{}",
reader_features.len(),
writer_features.len()
);
let (store, engine, table_url) = engine_store_setup(&scenario, None);
setup_table(
store.as_ref(),
table_url.as_str(),
reader_features,
writer_features,
)
.await?;
let snapshot = Snapshot::builder_for(table_url).build(&engine)?;
let table_config = snapshot.table_configuration();
assert_eq!(
table_config.is_feature_supported(&TableFeature::ColumnMapping),
expected_supported,
);
assert_eq!(
table_config.is_feature_enabled(&TableFeature::ColumnMapping),
expected_supported,
);
assert_eq!(field_names(&snapshot), vec![ID_LOGICAL, PAYLOAD_LOGICAL]);
Ok(())
}
#[rstest]
#[case::name_mode("name")]
#[case::id_mode("id")]
#[tokio::test]
async fn orphaned_column_mapping_resolves_data_columns(
#[case] mode: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let scenario = format!("cm_orphan_read_{mode}");
let (store, engine, table_url) = engine_store_setup(&scenario, None);
let commit = format!(
"{}\n{}\n",
protocol_action(&[], &["columnMapping"]),
metadata_action(mode)
);
add_commit(table_url.as_str(), store.as_ref(), 0, commit).await?;
let (id_field, payload_field) = match mode {
"name" => (
ArrowField::new(ID_PHYSICAL, ArrowDataType::Int32, true),
ArrowField::new(PAYLOAD_PHYSICAL, ArrowDataType::Utf8, true),
),
"id" => (
ArrowField::new("c0", ArrowDataType::Int32, true).with_metadata(HashMap::from([(
"PARQUET:field_id".to_string(),
ID_FIELD_ID.to_string(),
)])),
ArrowField::new("c1", ArrowDataType::Utf8, true).with_metadata(HashMap::from([(
"PARQUET:field_id".to_string(),
PAYLOAD_FIELD_ID.to_string(),
)])),
),
other => panic!("unexpected mode {other}"),
};
let batch = RecordBatch::try_new(
Arc::new(ArrowSchema::new(vec![id_field, payload_field])),
vec![
Arc::new(Int32Array::from(vec![1, 2])),
Arc::new(StringArray::from(vec!["alpha", "beta"])),
],
)?;
let parquet_bytes = record_batch_to_bytes(&batch);
let parquet_len = parquet_bytes.len();
let data_path = "data_file.parquet";
let data_url = table_url.join(data_path)?;
store
.put(
&ObjectStorePath::from_url_path(data_url.path())?,
parquet_bytes.into(),
)
.await?;
let add = json!({
"add": {
"path": data_path,
"partitionValues": {},
"size": parquet_len,
"modificationTime": 1,
"dataChange": true,
}
});
add_commit(table_url.as_str(), store.as_ref(), 1, add.to_string()).await?;
let snapshot = Snapshot::builder_for(table_url).build(&engine)?;
let scan = snapshot.scan_builder().build()?;
let batches = read_scan(&scan, Arc::new(engine))?;
let batch = &batches[0];
assert_eq!(batch.num_rows(), 2);
assert_eq!(batch.schema().field(0).name(), ID_LOGICAL);
assert_eq!(batch.schema().field(1).name(), PAYLOAD_LOGICAL);
let id = batch
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.expect("id column is an int32 array");
assert_eq!(id.value(0), 1);
assert_eq!(id.value(1), 2);
let payload = batch
.column(1)
.as_any()
.downcast_ref::<StringArray>()
.expect("payload column is a string array");
assert_eq!(payload.value(0), "alpha");
assert_eq!(payload.value(1), "beta");
Ok(())
}
async fn load_error(
scenario: &str,
reader_features: &[&str],
writer_features: &[&str],
) -> delta_kernel::Error {
let (store, engine, table_url) = engine_store_setup(scenario, None);
setup_table(
store.as_ref(),
table_url.as_str(),
reader_features,
writer_features,
)
.await
.unwrap();
Snapshot::builder_for(table_url)
.build(&engine)
.expect_err(&format!("{scenario}: expected snapshot load to fail"))
}
#[rstest]
#[case::non_legacy_writer_only(&[], &["columnMapping", "deletionVectors"])]
#[case::reader_only(&["columnMapping"], &["appendOnly"])]
#[tokio::test]
async fn invalid_protocol_shapes_are_rejected(
#[case] reader_features: &[&str],
#[case] writer_features: &[&str],
) {
let scenario = format!(
"cm_reject_{}_{}",
reader_features.len(),
writer_features.len()
);
let err = load_error(&scenario, reader_features, writer_features).await;
assert!(
matches!(err, delta_kernel::Error::InvalidProtocol(_)),
"{err}"
);
}
#[tokio::test]
async fn reader_v3_without_reader_features_is_rejected() {
let (store, engine, table_url) = engine_store_setup("cm_absent_reader_list", None);
let protocol = json!({
"protocol": {
"minReaderVersion": 3,
"minWriterVersion": 7,
"writerFeatures": ["columnMapping"],
}
});
let commit = format!("{}\n{}\n", protocol, metadata_action("name"));
add_commit(table_url.as_str(), store.as_ref(), 0, commit)
.await
.unwrap();
let err = Snapshot::builder_for(table_url)
.build(&engine)
.expect_err("expected snapshot load to fail");
assert!(
matches!(err, delta_kernel::Error::InvalidProtocol(_)),
"{err}"
);
}