use std::sync::Arc;
use delta_kernel::arrow::array::{
Array, BinaryArray, BooleanArray, Int32Array, RecordBatch, StringArray, StructArray,
};
use delta_kernel::arrow::compute::filter_record_batch;
use delta_kernel::engine::arrow_data::ArrowEngineData;
use delta_kernel::expressions::{col, lit, ColumnName, Predicate};
use delta_kernel::object_store::local::LocalFileSystem;
use delta_kernel::object_store::DynObjectStore;
use delta_kernel::scan::state::ScanFile;
use delta_kernel::scan::PartitionValuesOptions;
use delta_kernel::table_features::{get_any_level_column_physical_name, ColumnMappingMode};
use delta_kernel::Snapshot;
use rstest::rstest;
use test_utils::delta_kernel_default_engine::DefaultEngineBuilder;
use test_utils::table_builder::{partitioned, version_latest, FeatureSet, LogState, TableConfig};
use test_utils::{
add_commit, create_default_engine_mt_executor, get_column,
install_thread_local_metrics_reporter, test_context, CountingReporter,
};
use url::Url;
#[rstest]
fn scan_metadata_emits_partition_values_parsed_across_column_mapping(
#[values(
ColumnMappingMode::None,
ColumnMappingMode::Id,
ColumnMappingMode::Name
)]
cm_mode: ColumnMappingMode,
#[values(false, true)] native_checkpoint: bool,
) {
let cm_str = match cm_mode {
ColumnMappingMode::None => "none",
ColumnMappingMode::Id => "id",
ColumnMappingMode::Name => "name",
};
let log_state = if native_checkpoint {
LogState::with_latest_version(1).with_checkpoint_at([1])
} else {
LogState::with_latest_version(1)
};
let table_config = if native_checkpoint {
TableConfig::new().write_stats_as_struct(true)
} else {
TableConfig::new()
};
let (engine, snapshot, _table) = test_context!(
log_state,
FeatureSet::empty().column_mapping(cm_str),
partitioned(),
table_config,
version_latest(),
);
let schema = snapshot.schema();
let scan = snapshot
.scan_builder()
.with_partition_values(PartitionValuesOptions::with_struct())
.build()
.unwrap();
let physical_name = |logical: &str| -> String {
get_any_level_column_physical_name(schema.as_ref(), &ColumnName::new([logical]), cm_mode)
.unwrap()
.into_inner()
.into_iter()
.next()
.unwrap()
};
let scan_metadata_results: Vec<_> = scan
.scan_metadata(&engine)
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
!scan_metadata_results.is_empty(),
"Should have scan metadata"
);
let mut file_count = 0;
for scan_metadata in scan_metadata_results {
let (underlying_data, selection_vector) = scan_metadata.scan_files.into_parts();
let batch: RecordBatch = ArrowEngineData::try_from_engine_data(underlying_data)
.unwrap()
.into();
let filtered_batch =
filter_record_batch(&batch, &BooleanArray::from(selection_vector)).unwrap();
if filtered_batch.num_rows() == 0 {
continue;
}
let pv_parsed = get_column!(filtered_batch, "partitionValues_parsed", StructArray);
assert_eq!(
pv_parsed.num_columns(),
13,
"expected one parsed field per partition column (cm={cm_str}, native_checkpoint={native_checkpoint})"
);
for field in pv_parsed.columns() {
assert_eq!(
field.null_count(),
0,
"partition value unexpectedly null (cm={cm_str}, native_checkpoint={native_checkpoint})"
);
}
for logical in ["part_int", "part_string"] {
let phys = physical_name(logical);
assert!(
pv_parsed.column_by_name(&phys).is_some(),
"partitionValues_parsed should key {logical} by physical name {phys} \
(cm={cm_str}, native_checkpoint={native_checkpoint})"
);
}
file_count += filtered_batch.num_rows();
}
assert_eq!(file_count, 1, "Should have processed exactly one file");
}
async fn write_foreign_partition_table(
table_path: &std::path::Path,
add_actions: &[String],
) -> Url {
std::fs::create_dir_all(table_path).unwrap();
let url = Url::from_directory_path(table_path).unwrap();
let table_root = url.to_string();
let store: Arc<DynObjectStore> = Arc::new(LocalFileSystem::new());
let schema_string = serde_json::json!({
"type": "struct",
"fields": [
{"name": "p_str", "type": "string", "nullable": true, "metadata": {}},
{"name": "p_bin", "type": "binary", "nullable": true, "metadata": {}},
{"name": "p_int", "type": "integer", "nullable": true, "metadata": {}},
{"name": "value", "type": "integer", "nullable": true, "metadata": {}},
],
})
.to_string();
let protocol = r#"{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}"#;
let metadata = serde_json::json!({
"metaData": {
"id": "00000000-0000-0000-0000-000000000000",
"format": {"provider": "parquet", "options": {}},
"schemaString": schema_string,
"partitionColumns": ["p_str", "p_bin", "p_int"],
"configuration": {"delta.checkpoint.writeStatsAsStruct": "true"},
"createdTime": 1700000000000_i64,
},
})
.to_string();
add_commit(
&table_root,
store.as_ref(),
0,
format!("{protocol}\n{metadata}"),
)
.await
.unwrap();
add_commit(&table_root, store.as_ref(), 1, add_actions.join("\n"))
.await
.unwrap();
url
}
fn add_action(path: &str, p_str: &str, p_bin: &str, p_int: &str) -> String {
serde_json::json!({
"add": {
"path": path,
"partitionValues": {"p_str": p_str, "p_bin": p_bin, "p_int": p_int},
"size": 100,
"modificationTime": 1700000000000_i64,
"dataChange": true,
"stats": "{\"numRecords\":1}",
},
})
.to_string()
}
#[rstest]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn parsed_partition_values_read_foreign_empty_string(
#[values(false, true)] native_checkpoint: bool,
) {
let temp_dir = tempfile::tempdir().unwrap();
let table_path = temp_dir.path().join("foreign-empty-string");
let url = write_foreign_partition_table(
&table_path,
&[add_action(
"p_str=/p_bin=/p_int=/part-0.parquet",
"",
"",
"",
)],
)
.await;
let engine = create_default_engine_mt_executor(&url).unwrap();
if native_checkpoint {
let snapshot = Snapshot::builder_for(url.clone())
.build(engine.as_ref())
.unwrap();
snapshot.checkpoint(engine.as_ref(), None).unwrap();
}
let reporter = Arc::new(CountingReporter::new());
let _guard = install_thread_local_metrics_reporter(reporter.clone());
let snapshot = Snapshot::builder_for(url.clone())
.build(engine.as_ref())
.unwrap();
assert_eq!(
reporter.checkpoint_files.get(),
u64::from(native_checkpoint),
"log segment checkpoint parts must match native_checkpoint={native_checkpoint}"
);
let scan = snapshot
.scan_builder()
.with_partition_values(PartitionValuesOptions::with_struct())
.build()
.unwrap();
let mut asserted_rows = 0;
for scan_metadata in scan.scan_metadata(engine.as_ref()).unwrap() {
let (data, selection) = scan_metadata.unwrap().scan_files.into_parts();
let batch: RecordBatch = ArrowEngineData::try_from_engine_data(data).unwrap().into();
let batch = filter_record_batch(&batch, &BooleanArray::from(selection)).unwrap();
let pv = get_column!(batch, "partitionValues_parsed", StructArray);
let p_str = pv.column_by_name("p_str").unwrap();
let p_str = p_str.as_any().downcast_ref::<StringArray>().unwrap();
let p_bin = pv.column_by_name("p_bin").unwrap();
let p_bin = p_bin.as_any().downcast_ref::<BinaryArray>().unwrap();
let p_int = pv.column_by_name("p_int").unwrap();
let p_int = p_int.as_any().downcast_ref::<Int32Array>().unwrap();
assert!(!p_str.is_null(0), "string \"\" reconstructs as \"\"");
assert_eq!(p_str.value(0), "");
assert!(!p_bin.is_null(0), "binary \"\" reconstructs as empty bytes");
assert_eq!(p_bin.value(0), b"");
assert!(p_int.is_null(0), "non-string \"\" must be null");
asserted_rows += batch.num_rows();
}
assert_eq!(
asserted_rows, 1,
"expected exactly one file (native_checkpoint={native_checkpoint})"
);
}
fn collect_path(paths: &mut Vec<String>, scan_file: ScanFile) {
paths.push(scan_file.path);
}
#[rstest]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_string_partition_pruning(#[values(false, true)] native_checkpoint: bool) {
let temp_dir = tempfile::tempdir().unwrap();
let table_path = temp_dir.path().join("empty-string-pruning");
let url = write_foreign_partition_table(
&table_path,
&[
add_action("p_str=/empty.parquet", "", "", ""),
add_action("p_str=other/other.parquet", "other", "other", "7"),
],
)
.await;
let engine = create_default_engine_mt_executor(&url).unwrap();
if native_checkpoint {
let snapshot = Snapshot::builder_for(url.clone())
.build(engine.as_ref())
.unwrap();
snapshot.checkpoint(engine.as_ref(), None).unwrap();
}
let surviving = |predicate: Predicate| -> Vec<String> {
let snapshot = Snapshot::builder_for(url.clone())
.build(engine.as_ref())
.unwrap();
let scan = snapshot
.scan_builder()
.with_predicate(Arc::new(predicate))
.build()
.unwrap();
let mut paths = Vec::new();
for scan_metadata in scan.scan_metadata(engine.as_ref()).unwrap() {
paths = scan_metadata
.unwrap()
.visit_scan_files(paths, collect_path)
.unwrap();
}
paths.sort();
paths
};
let empty = "p_str=/empty.parquet".to_string();
let other = "p_str=other/other.parquet".to_string();
let both = vec![empty.clone(), other.clone()];
assert_eq!(
surviving(Predicate::eq(col!("p_str"), lit(""))),
vec![empty.clone()],
"empty-string file must be kept under p_str = ''"
);
assert_eq!(
surviving(Predicate::eq(col!("p_str"), lit("other"))),
vec![other.clone()],
"empty-string file must be pruned under p_str = 'other'"
);
assert!(
surviving(Predicate::is_null(col!("p_str"))).is_empty(),
"no file has a null p_str, so IS NULL prunes both (the empty file's value is \"\", not null)"
);
assert_eq!(
surviving(Predicate::is_not_null(col!("p_str"))),
both,
"both files must be kept under p_str IS NOT NULL"
);
assert_eq!(
surviving(Predicate::eq(col!("p_bin"), lit(b"other".as_slice()))),
vec![other.clone()],
"empty-bytes file must be pruned under p_bin = X'6f74686572'"
);
}