use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use delta_kernel_derive::internal_api;
use itertools::Itertools;
use url::Url;
use uuid::Uuid;
use super::state::TransactionBuilderState;
use super::TransactionBuilder;
use crate::actions::{DomainMetadata, Metadata, Protocol};
use crate::clustering::{create_clustering_domain_metadata, validate_clustering_columns};
use crate::committer::Committer;
use crate::expressions::ColumnName;
use crate::schema::validation::validate_schema;
use crate::schema::variant_utils::schema_contains_variant_type;
use crate::schema::{
normalize_column_names_to_schema_casing, schema_contains_non_null_fields, DataType, SchemaRef,
StructType,
};
use crate::table_configuration::TableConfiguration;
use crate::table_features::{
add_feature_to_lists, assign_column_mapping_metadata, auto_enable_property_driven_features,
ensure_domain_metadata_dependency, find_max_column_id_in_schema,
get_any_level_column_physical_name, get_column_mapping_mode_from_properties,
schema_contains_timestamp_ntz, strip_stray_column_mapping_metadata,
validate_iceberg_compat_if_needed, ColumnMappingMode, IcebergCompatValidationContext,
TableFeature, SET_TABLE_FEATURE_SUPPORTED_PREFIX, SET_TABLE_FEATURE_SUPPORTED_VALUE,
V2_VALIDATOR,
};
use crate::table_properties::{
strip_check_constraint_prefix, CheckpointPolicy, TableProperties, APPEND_ONLY,
CHECKPOINT_INTERVAL, CHECKPOINT_POLICY, CHECKPOINT_WRITE_STATS_AS_JSON,
CHECKPOINT_WRITE_STATS_AS_STRUCT, COLUMN_MAPPING_MAX_COLUMN_ID, COLUMN_MAPPING_MODE,
DATA_SKIPPING_NUM_INDEXED_COLS, DATA_SKIPPING_STATS_COLUMNS, DELETED_FILE_RETENTION_DURATION,
DELTA_PROPERTY_PREFIX, ENABLE_CHANGE_DATA_FEED, ENABLE_DELETION_VECTORS,
ENABLE_EXPIRED_LOG_CLEANUP, ENABLE_ICEBERG_COMPAT_V1, ENABLE_ICEBERG_COMPAT_V2,
ENABLE_ICEBERG_COMPAT_V3, ENABLE_IN_COMMIT_TIMESTAMPS, ENABLE_ROW_TRACKING,
ENABLE_TYPE_WIDENING, LOG_RETENTION_DURATION, MATERIALIZED_ROW_COMMIT_VERSION_COLUMN_NAME,
MATERIALIZED_ROW_ID_COLUMN_NAME, PARQUET_FORMAT_VERSION, ROW_TRACKING_SUSPENDED,
SET_TRANSACTION_RETENTION_DURATION,
};
use crate::transaction::create_table::CreateTableTransaction;
use crate::transaction::data_layout::DataLayout;
use crate::transaction::{CommitOperation, CreateTable, ExecutionMode, Transaction};
use crate::utils::{current_time_ms, try_parse_uri};
use crate::{Engine, KernelError, KernelResult, StorageHandler};
const ALLOWED_DELTA_FEATURES: &[TableFeature] = &[
TableFeature::DomainMetadata,
TableFeature::ColumnMapping,
TableFeature::InCommitTimestamp,
TableFeature::VacuumProtocolCheck,
TableFeature::CatalogManaged,
TableFeature::DeletionVectors,
TableFeature::V2Checkpoint,
TableFeature::AppendOnly,
TableFeature::ChangeDataFeed,
TableFeature::TypeWidening,
TableFeature::RowTracking,
TableFeature::VariantType,
TableFeature::VariantShredding,
TableFeature::Invariants,
TableFeature::MaterializePartitionColumns,
TableFeature::IcebergCompatV2,
TableFeature::IcebergCompatV3,
];
const ALLOWED_DELTA_PROPERTIES: &[&str] = &[
COLUMN_MAPPING_MODE,
ENABLE_IN_COMMIT_TIMESTAMPS,
CHECKPOINT_WRITE_STATS_AS_JSON,
CHECKPOINT_WRITE_STATS_AS_STRUCT,
DATA_SKIPPING_NUM_INDEXED_COLS,
DATA_SKIPPING_STATS_COLUMNS,
ENABLE_DELETION_VECTORS,
ENABLE_CHANGE_DATA_FEED,
ENABLE_TYPE_WIDENING,
APPEND_ONLY,
ENABLE_ROW_TRACKING,
SET_TRANSACTION_RETENTION_DURATION,
PARQUET_FORMAT_VERSION,
ENABLE_ICEBERG_COMPAT_V2,
ENABLE_ICEBERG_COMPAT_V3,
LOG_RETENTION_DURATION,
DELETED_FILE_RETENTION_DURATION,
ENABLE_EXPIRED_LOG_CLEANUP,
CHECKPOINT_INTERVAL,
CHECKPOINT_POLICY,
];
fn ensure_table_does_not_exist(
storage: &dyn StorageHandler,
delta_log_url: &Url,
table_path: &str,
) -> KernelResult<()> {
match storage.list_from(delta_log_url) {
Ok(mut files) => {
match files.next() {
Some(Ok(_)) => Err(KernelError::TableAlreadyExistsAtPath(
table_path.to_string(),
)),
Some(Err(KernelError::FileNotFound(_))) | None => {
Ok(())
}
Some(Err(e)) => {
Err(e)
}
}
}
Err(KernelError::FileNotFound(_)) => {
Ok(())
}
Err(e) => {
Err(e)
}
}
}
struct ValidatedTableProperties {
properties: HashMap<String, String>,
reader_features: Vec<TableFeature>,
writer_features: Vec<TableFeature>,
}
impl ValidatedTableProperties {
fn is_property_true(&self, key: &str) -> bool {
self.properties.get(key).map(String::as_str) == Some("true")
}
}
#[cfg(test)]
fn validate_clustering_and_make_domain_metadata(
logical_schema: &SchemaRef,
logical_columns: &[ColumnName],
reader_features: &mut Vec<TableFeature>,
writer_features: &mut Vec<TableFeature>,
) -> KernelResult<DomainMetadata> {
validate_clustering_columns(logical_schema, logical_columns)?;
add_feature_to_lists(
TableFeature::ClusteredTable,
reader_features,
writer_features,
);
ensure_domain_metadata_dependency(reader_features, writer_features);
Ok(create_clustering_domain_metadata(logical_columns))
}
#[derive(Debug, Default)]
struct DataLayoutResult {
system_domain_metadata: Vec<DomainMetadata>,
clustering_columns: Option<Vec<ColumnName>>,
partition_columns: Option<Vec<ColumnName>>,
}
fn validate_partition_columns(
schema: &StructType,
partition_columns: &[ColumnName],
) -> KernelResult<()> {
if partition_columns.is_empty() {
return Err(KernelError::generic(
"Partitioning requires at least one column",
));
}
if partition_columns.len() >= schema.fields().len() {
return Err(KernelError::generic(
"Table must have at least one non-partition column",
));
}
let mut seen = HashSet::new();
for col in partition_columns {
let path = col.path();
if path.len() != 1 {
return Err(KernelError::generic(format!(
"Partition column '{}' must be a top-level column (nested paths are not supported)",
col
)));
}
if !seen.insert(col) {
return Err(KernelError::generic(format!(
"Duplicate partition column: '{col}'"
)));
}
let col_name = &path[0];
let field = schema.field(col_name).ok_or_else(|| {
KernelError::generic(format!("Partition column '{col}' not found in schema"))
})?;
let DataType::Primitive(_) = field.data_type() else {
return Err(KernelError::generic(format!(
"Partition column '{col}' has non-primitive type '{}'. \
Partition columns must have primitive types.",
field.data_type()
)));
};
}
Ok(())
}
fn apply_data_layout(
data_layout: &DataLayout,
effective_schema: &SchemaRef,
column_mapping_mode: ColumnMappingMode,
validated: &mut ValidatedTableProperties,
) -> KernelResult<DataLayoutResult> {
match data_layout {
DataLayout::None => Ok(DataLayoutResult::default()),
DataLayout::Clustered { columns } => {
let normalized = normalize_column_names_to_schema_casing(effective_schema, columns);
validate_clustering_columns(effective_schema, &normalized)?;
let physical_columns: Vec<ColumnName> = normalized
.iter()
.map(|c| {
get_any_level_column_physical_name(effective_schema, c, column_mapping_mode)
})
.try_collect()?;
add_feature_to_lists(
TableFeature::ClusteredTable,
&mut validated.reader_features,
&mut validated.writer_features,
);
let dm = create_clustering_domain_metadata(&physical_columns);
Ok(DataLayoutResult {
system_domain_metadata: vec![dm],
clustering_columns: Some(physical_columns),
partition_columns: None,
})
}
DataLayout::Partitioned { columns } => {
let normalized = normalize_column_names_to_schema_casing(effective_schema, columns);
validate_partition_columns(effective_schema, &normalized)?;
Ok(DataLayoutResult {
system_domain_metadata: vec![],
clustering_columns: None,
partition_columns: Some(normalized),
})
}
}
}
fn maybe_enable_variant_type(schema: &SchemaRef, validated: &mut ValidatedTableProperties) {
if schema_contains_variant_type(schema) {
add_feature_to_lists(
TableFeature::VariantType,
&mut validated.reader_features,
&mut validated.writer_features,
);
}
}
fn maybe_enable_timestamp_ntz(schema: &SchemaRef, validated: &mut ValidatedTableProperties) {
if schema_contains_timestamp_ntz(schema) {
add_feature_to_lists(
TableFeature::TimestampWithoutTimezone,
&mut validated.reader_features,
&mut validated.writer_features,
);
}
}
fn maybe_enable_invariants(schema: &SchemaRef, validated: &mut ValidatedTableProperties) {
if schema_contains_non_null_fields(schema) {
add_feature_to_lists(
TableFeature::Invariants,
&mut validated.reader_features,
&mut validated.writer_features,
);
}
}
fn maybe_auto_enable_property_driven_features(validated: &mut ValidatedTableProperties) {
let table_properties = TableProperties::from(validated.properties.iter());
auto_enable_property_driven_features(
ALLOWED_DELTA_FEATURES,
&table_properties,
&mut validated.reader_features,
&mut validated.writer_features,
);
}
fn maybe_set_materialized_row_tracking_column_name_properties(
validated: &mut ValidatedTableProperties,
) {
if validated
.properties
.get(ENABLE_ROW_TRACKING)
.is_none_or(|v| v != "true")
{
return;
}
validated.properties.insert(
MATERIALIZED_ROW_ID_COLUMN_NAME.to_string(),
format!("_row-id-col-{}", Uuid::new_v4()),
);
validated.properties.insert(
MATERIALIZED_ROW_COMMIT_VERSION_COLUMN_NAME.to_string(),
format!("_row-commit-version-col-{}", Uuid::new_v4()),
);
}
fn maybe_enable_ict_for_catalog_managed(
validated: &mut ValidatedTableProperties,
) -> KernelResult<()> {
let has_catalog_managed = validated
.writer_features
.contains(&TableFeature::CatalogManaged);
if has_catalog_managed {
if validated
.properties
.get(ENABLE_IN_COMMIT_TIMESTAMPS)
.is_some_and(|v| v != "true")
{
return Err(KernelError::generic(format!(
"Catalog-managed tables require '{ENABLE_IN_COMMIT_TIMESTAMPS}=true', \
but it was explicitly set to '{}'",
validated.properties[ENABLE_IN_COMMIT_TIMESTAMPS]
)));
}
add_feature_to_lists(
TableFeature::InCommitTimestamp,
&mut validated.reader_features,
&mut validated.writer_features,
);
validated
.properties
.entry(ENABLE_IN_COMMIT_TIMESTAMPS.to_string())
.or_insert_with(|| "true".to_string());
}
Ok(())
}
fn maybe_enable_v2_checkpoint_for_policy(validated: &mut ValidatedTableProperties) {
let is_v2_policy = validated
.properties
.get(CHECKPOINT_POLICY)
.and_then(|v| CheckpointPolicy::try_from(v.as_str()).ok())
== Some(CheckpointPolicy::V2);
if is_v2_policy {
add_feature_to_lists(
TableFeature::V2Checkpoint,
&mut validated.reader_features,
&mut validated.writer_features,
);
}
}
fn require_iceberg_compat_column_mapping(
validated: &mut ValidatedTableProperties,
feature_name: &str,
) -> KernelResult<()> {
match validated
.properties
.get(COLUMN_MAPPING_MODE)
.map(String::as_str)
{
None => {
validated
.properties
.insert(COLUMN_MAPPING_MODE.to_string(), "name".to_string());
}
Some("name") | Some("id") => {}
Some(other) => {
return Err(KernelError::generic(format!(
"{feature_name} requires '{COLUMN_MAPPING_MODE}' to be 'name' or 'id', got \
'{other}'"
)));
}
}
Ok(())
}
fn maybe_enable_iceberg_compat_v2_dependencies(
validated: &mut ValidatedTableProperties,
) -> KernelResult<()> {
let enabled = validated.is_property_true(ENABLE_ICEBERG_COMPAT_V2);
if !enabled
&& !validated
.writer_features
.contains(&TableFeature::IcebergCompatV2)
{
return Ok(());
}
add_feature_to_lists(
TableFeature::ColumnMapping,
&mut validated.reader_features,
&mut validated.writer_features,
);
if !enabled {
return Ok(());
}
require_iceberg_compat_column_mapping(validated, "IcebergCompatV2")?;
for key in [
ENABLE_ICEBERG_COMPAT_V1,
ENABLE_ICEBERG_COMPAT_V3,
ENABLE_DELETION_VECTORS,
] {
if validated.is_property_true(key) {
return Err(KernelError::generic(format!(
"IcebergCompatV2 cannot be enabled together with '{key}'"
)));
}
}
Ok(())
}
fn maybe_enable_iceberg_compat_v3_dependencies(
validated: &mut ValidatedTableProperties,
) -> KernelResult<()> {
if !validated.is_property_true(ENABLE_ICEBERG_COMPAT_V3) {
return Ok(());
}
require_iceberg_compat_column_mapping(validated, "IcebergCompatV3")?;
if validated.is_property_true(ROW_TRACKING_SUSPENDED) {
return Err(KernelError::generic(format!(
"IcebergCompatV3 cannot be enabled while '{ROW_TRACKING_SUSPENDED}' is 'true'"
)));
}
match validated
.properties
.get(ENABLE_ROW_TRACKING)
.map(String::as_str)
{
None => {
validated
.properties
.insert(ENABLE_ROW_TRACKING.to_string(), "true".to_string());
}
Some("true") => {}
Some(other) => {
return Err(KernelError::generic(format!(
"IcebergCompatV3 requires '{ENABLE_ROW_TRACKING}' to be 'true', got '{other}'"
)));
}
}
for key in [ENABLE_ICEBERG_COMPAT_V1, ENABLE_ICEBERG_COMPAT_V2] {
if validated.is_property_true(key) {
return Err(KernelError::generic(format!(
"IcebergCompatV3 cannot be enabled together with '{key}'"
)));
}
}
Ok(())
}
fn maybe_apply_column_mapping_for_table_create(
schema: &SchemaRef,
validated: &mut ValidatedTableProperties,
) -> KernelResult<(SchemaRef, ColumnMappingMode)> {
let column_mapping_mode = get_column_mapping_mode_from_properties(&validated.properties)?;
let effective_schema = match column_mapping_mode {
ColumnMappingMode::Name | ColumnMappingMode::Id => {
add_feature_to_lists(
TableFeature::ColumnMapping,
&mut validated.reader_features,
&mut validated.writer_features,
);
let assign_nested_field_ids = validated.is_property_true(ENABLE_ICEBERG_COMPAT_V2)
|| validated.is_property_true(ENABLE_ICEBERG_COMPAT_V3);
let mut max_id = find_max_column_id_in_schema(schema).unwrap_or(0);
let transformed_schema =
assign_column_mapping_metadata(schema, &mut max_id, assign_nested_field_ids)?;
validated
.properties
.insert(COLUMN_MAPPING_MAX_COLUMN_ID.to_string(), max_id.to_string());
Arc::new(transformed_schema)
}
ColumnMappingMode::None => schema.clone(),
};
Ok((effective_schema, column_mapping_mode))
}
fn validate_extract_table_features_and_properties(
properties: HashMap<String, String>,
) -> KernelResult<ValidatedTableProperties> {
let mut reader_features = Vec::new();
let mut writer_features = Vec::new();
let (feature_signals, properties): (HashMap<_, _>, HashMap<_, _>) = properties
.into_iter()
.partition(|(k, _)| k.starts_with(SET_TABLE_FEATURE_SUPPORTED_PREFIX));
for (key, value) in &feature_signals {
let Some(feature_name) = key.strip_prefix(SET_TABLE_FEATURE_SUPPORTED_PREFIX) else {
continue;
};
if value != SET_TABLE_FEATURE_SUPPORTED_VALUE {
return Err(KernelError::generic(format!(
"Invalid value '{value}' for '{key}'. Only '{SET_TABLE_FEATURE_SUPPORTED_VALUE}' is allowed."
)));
}
let feature: TableFeature = feature_name
.parse()
.unwrap_or_else(|_| TableFeature::Unknown(feature_name.to_string()));
if !ALLOWED_DELTA_FEATURES.contains(&feature) {
return Err(KernelError::generic(format!(
"Enabling feature '{feature_name}' via '{key}' is not supported during CREATE TABLE"
)));
}
if feature == TableFeature::VariantShredding {
add_feature_to_lists(
TableFeature::VariantType,
&mut reader_features,
&mut writer_features,
);
}
add_feature_to_lists(feature, &mut reader_features, &mut writer_features);
}
for key in properties.keys() {
if strip_check_constraint_prefix(key).is_some() {
return Err(KernelError::generic(format!(
"Setting CHECK constraint property '{key}' is not supported during CREATE TABLE"
)));
}
if key.starts_with(DELTA_PROPERTY_PREFIX)
&& !ALLOWED_DELTA_PROPERTIES.contains(&key.as_str())
{
return Err(KernelError::generic(format!(
"Setting delta property '{key}' is not supported during CREATE TABLE"
)));
}
}
Ok(ValidatedTableProperties {
properties,
reader_features,
writer_features,
})
}
pub type CreateTableTransactionBuilder = TransactionBuilder<CreateTable>;
#[doc(hidden)]
pub struct CreateTableBuilderState {
path: String,
schema: SchemaRef,
table_properties: HashMap<String, String>,
data_layout: DataLayout,
}
impl TransactionBuilder<CreateTable> {
#[internal_api]
pub(crate) fn new(
path: impl AsRef<str>,
schema: SchemaRef,
engine_info: impl Into<String>,
data_change: bool,
) -> Self {
Self {
common_state: TransactionBuilderState::new(engine_info.into(), data_change),
local_state: CreateTableBuilderState {
path: path.as_ref().to_string(),
schema,
table_properties: HashMap::new(),
data_layout: DataLayout::None,
},
}
}
#[internal_api]
pub(crate) fn with_table_properties<I, K, V>(mut self, properties: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.local_state
.table_properties
.extend(properties.into_iter().map(|(k, v)| (k.into(), v.into())));
self
}
#[internal_api]
pub(crate) fn with_data_layout(mut self, layout: DataLayout) -> Self {
self.local_state.data_layout = layout;
self
}
#[internal_api]
pub(crate) fn build(
self,
engine: &dyn Engine,
committer: Box<dyn Committer>,
) -> KernelResult<CreateTableTransaction> {
self.common_state.validate()?;
let table_url = try_parse_uri(&self.local_state.path)?;
let delta_log_url = table_url.join("_delta_log/")?;
let storage = engine.storage_handler();
ensure_table_does_not_exist(storage.as_ref(), &delta_log_url, &self.local_state.path)?;
let mut validated =
validate_extract_table_features_and_properties(self.local_state.table_properties)?;
maybe_enable_iceberg_compat_v2_dependencies(&mut validated)?;
maybe_enable_iceberg_compat_v3_dependencies(&mut validated)?;
let (mut effective_schema, column_mapping_mode) =
maybe_apply_column_mapping_for_table_create(&self.local_state.schema, &mut validated)?;
validate_schema(
&effective_schema,
column_mapping_mode,
validated.is_property_true(ENABLE_CHANGE_DATA_FEED),
)?;
if column_mapping_mode == ColumnMappingMode::None {
if let Some(stripped) = strip_stray_column_mapping_metadata(false, &effective_schema) {
effective_schema = Arc::new(stripped);
}
}
let data_layout_result = apply_data_layout(
&self.local_state.data_layout,
&effective_schema,
column_mapping_mode,
&mut validated,
)?;
maybe_enable_variant_type(&effective_schema, &mut validated);
maybe_enable_timestamp_ntz(&effective_schema, &mut validated);
maybe_enable_invariants(&effective_schema, &mut validated);
maybe_auto_enable_property_driven_features(&mut validated);
maybe_enable_ict_for_catalog_managed(&mut validated)?;
maybe_enable_v2_checkpoint_for_policy(&mut validated);
maybe_set_materialized_row_tracking_column_name_properties(&mut validated);
ensure_domain_metadata_dependency(
&mut validated.reader_features,
&mut validated.writer_features,
);
let protocol =
Protocol::try_new_modern(validated.reader_features, validated.writer_features)?;
let partition_columns: Vec<String> = data_layout_result
.partition_columns
.map(|cols| cols.into_iter().map(|c| c.into_inner().remove(0)).collect())
.unwrap_or_default();
let metadata = Metadata::try_new(
None, None, effective_schema.clone(),
partition_columns,
current_time_ms()?,
validated.properties,
)?;
let table_configuration = TableConfiguration::try_new(metadata, protocol, table_url, 0)?;
let transaction = try_new_create_table(
table_configuration,
committer,
data_layout_result.system_domain_metadata,
data_layout_result.clustering_columns,
self.common_state,
)?;
transaction.validate_domain_metadata_operations()?;
Ok(transaction)
}
}
fn try_new_create_table<MODE: ExecutionMode>(
effective_table_config: TableConfiguration,
committer: Box<dyn Committer>,
system_domain_metadata: Vec<DomainMetadata>,
clustering_columns: Option<Vec<ColumnName>>,
common_state: TransactionBuilderState,
) -> KernelResult<Transaction<CreateTable, MODE>> {
validate_iceberg_compat_if_needed(
&effective_table_config,
&V2_VALIDATOR,
IcebergCompatValidationContext::Write,
)?;
let span = tracing::info_span!(
"txn",
path = %effective_table_config.table_root(),
operation = "CREATE",
);
let commit_timestamp = current_time_ms()?;
Ok(Transaction {
should_emit_protocol: true,
should_emit_metadata: true,
system_domain_metadata_additions: system_domain_metadata,
physical_clustering_columns: clustering_columns,
..Transaction::new(
common_state,
effective_table_config,
committer,
CommitOperation::CreateTable,
span,
commit_timestamp,
)
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use rstest::rstest;
use super::*;
use crate::arrow::array::StringArray;
use crate::arrow::datatypes::Schema as ArrowSchema;
use crate::arrow::record_batch::RecordBatch;
use crate::committer::FileSystemCommitter;
use crate::engine::arrow_conversion::TryIntoArrow;
use crate::engine::arrow_data::ArrowEngineData;
use crate::engine::sync::SyncEngine;
use crate::expressions::{column_name, ColumnName};
use crate::metrics::MetricEvent;
use crate::scan::data_skipping::stats_schema::StripFieldMetadataTransform;
use crate::schema::{
schema, schema_ref, try_schema, ColumnMetadataKey, DataType, MetadataValue, StructField,
};
use crate::table_features::FeatureType;
use crate::table_properties::{
COLUMN_MAPPING_MAX_COLUMN_ID, ENABLE_ICEBERG_COMPAT_V1, ENABLE_ICEBERG_COMPAT_V2,
ENABLE_ICEBERG_COMPAT_V3, PARQUET_FORMAT_VERSION,
};
use crate::transforms::SchemaTransform;
use crate::unit_test_utils::{
assert_result_error_with_message, build_complex_nested_kernel_schema,
install_thread_local_metrics_reporter, CapturingReporter,
};
fn test_schema() -> SchemaRef {
schema_ref! { not_null "id": INTEGER }
}
#[test]
fn test_basic_builder_creation() {
let schema = test_schema();
let builder = CreateTableTransactionBuilder::new(
"/path/to/table",
schema.clone(),
"TestApp/1.0",
true,
);
assert_eq!(builder.local_state.path, "/path/to/table");
assert_eq!(builder.common_state.engine_info, "TestApp/1.0");
assert!(builder.local_state.table_properties.is_empty());
}
#[rstest]
fn common_builder_state_preserves_data_change_through_empty_commit(
#[values(false, true)] expected_data_change: bool,
) -> KernelResult<()> {
let tempdir = tempfile::tempdir()?;
let table_path = tempdir.path().join("table");
std::fs::create_dir(&table_path)?;
let engine = SyncEngine::new();
let reporter = Arc::new(CapturingReporter::default());
let _guard = install_thread_local_metrics_reporter(reporter.clone());
let builder = CreateTableTransactionBuilder::new(
table_path.to_string_lossy(),
test_schema(),
"test-engine",
expected_data_change,
)
.with_correlation_id("test-correlation")
.with_operation_parameters([("mode", Some("Create"))])
.with_transaction_id("app", 7);
let transaction = builder.build(&engine, Box::new(FileSystemCommitter::new()))?;
assert_eq!(transaction.engine_info, "test-engine");
assert_eq!(
transaction.correlation_id.as_deref(),
Some("test-correlation")
);
assert_eq!(
transaction.operation_parameters.as_ref().unwrap()["mode"].as_deref(),
Some("Create")
);
assert_eq!(transaction.set_transactions[0].app_id, "app");
assert_eq!(transaction.set_transactions[0].version, 7);
assert_eq!(
transaction.set_transactions[0].last_updated,
Some(transaction.commit_timestamp)
);
assert_eq!(transaction.data_change, expected_data_change);
let committed = transaction.commit(&engine)?.unwrap_committed();
assert_eq!(committed.commit_version(), 0);
let metrics = reporter
.events()
.into_iter()
.find_map(|event| match event {
MetricEvent::TransactionCommitSuccess(metrics) => Some(metrics),
_ => None,
})
.expect("create-table commit metrics");
assert_eq!(metrics.data_change, expected_data_change);
assert_eq!(metrics.num_add_files, 0);
assert_eq!(metrics.num_remove_files, 0);
Ok(())
}
#[rstest]
#[case(0)]
#[case(2)]
fn builder_rejects_non_single_row_commit_info(#[case] row_count: usize) -> KernelResult<()> {
let tempdir = tempfile::tempdir()?;
let table_path = tempdir.path().join("table");
std::fs::create_dir(&table_path)?;
let commit_info_schema = schema_ref! { nullable "tag": STRING };
let arrow_schema: ArrowSchema = commit_info_schema.as_ref().try_into_arrow()?;
let commit_info = Box::new(ArrowEngineData::new(RecordBatch::try_new(
Arc::new(arrow_schema),
vec![Arc::new(StringArray::from(vec![Some("value"); row_count]))],
)?));
let error = CreateTableTransactionBuilder::new(
table_path.to_string_lossy(),
test_schema(),
"test-engine",
true,
)
.with_commit_info(commit_info, commit_info_schema)
.build(&SyncEngine::new(), Box::new(FileSystemCommitter::new()))
.unwrap_err();
assert!(
error
.to_string()
.contains("Connector commit info must contain exactly one row"),
"{error}"
);
Ok(())
}
#[test]
fn duplicate_transaction_ids_are_rejected() -> KernelResult<()> {
let tempdir = tempfile::tempdir()?;
let table_path = tempdir.path().join("table");
std::fs::create_dir(&table_path)?;
let error = CreateTableTransactionBuilder::new(
table_path.to_string_lossy(),
test_schema(),
"test-engine",
true,
)
.with_transaction_id("app", 1)
.with_transaction_id("app", 2)
.build(&SyncEngine::new(), Box::new(FileSystemCommitter::new()))
.unwrap_err();
assert!(
error
.to_string()
.contains("app_id app appears more than once"),
"{error}"
);
Ok(())
}
#[rstest]
#[case::missing_feature("app.config", None, "domainMetadata")]
#[case::reserved_domain(
"delta.custom",
Some(("delta.feature.domainMetadata", "supported")),
"system controlled"
)]
fn invalid_domain_metadata_is_rejected_during_build(
#[case] domain: &str,
#[case] property: Option<(&str, &str)>,
#[case] expected: &str,
) -> KernelResult<()> {
let tempdir = tempfile::tempdir()?;
let table_path = tempdir.path().join("table");
std::fs::create_dir(&table_path)?;
let mut builder = CreateTableTransactionBuilder::new(
table_path.to_string_lossy(),
test_schema(),
"test-engine",
true,
)
.with_domain_metadata(domain, "{}");
if let Some(property) = property {
builder = builder.with_table_properties([property]);
}
assert_result_error_with_message(
builder.build(&SyncEngine::new(), Box::new(FileSystemCommitter::new())),
expected,
);
Ok(())
}
#[test]
fn user_domain_cannot_conflict_with_generated_clustering_domain() -> KernelResult<()> {
let tempdir = tempfile::tempdir()?;
let table_path = tempdir.path().join("table");
std::fs::create_dir(&table_path)?;
let error = CreateTableTransactionBuilder::new(
table_path.to_string_lossy(),
test_schema(),
"test-engine",
true,
)
.with_data_layout(DataLayout::clustered(["id"]))
.with_domain_metadata("delta.clustering", "{}")
.build(&SyncEngine::new(), Box::new(FileSystemCommitter::new()))
.unwrap_err();
assert!(error.to_string().contains("system controlled"), "{error}");
Ok(())
}
#[test]
fn test_nested_path_builder_creation() {
let schema = test_schema();
let builder = CreateTableTransactionBuilder::new(
"/path/to/table/nested",
schema.clone(),
"TestApp/1.0",
true,
);
assert_eq!(builder.local_state.path, "/path/to/table/nested");
}
#[test]
fn test_with_table_properties() {
let schema = test_schema();
let builder =
CreateTableTransactionBuilder::new("/path/to/table", schema, "TestApp/1.0", true)
.with_table_properties([("key1", "value1")]);
assert_eq!(
builder.local_state.table_properties.get("key1"),
Some(&"value1".to_string())
);
}
#[test]
fn test_with_multiple_table_properties() {
let schema = test_schema();
let builder =
CreateTableTransactionBuilder::new("/path/to/table", schema, "TestApp/1.0", true)
.with_table_properties([("key1", "value1")])
.with_table_properties([("key2", "value2")]);
assert_eq!(
builder.local_state.table_properties.get("key1"),
Some(&"value1".to_string())
);
assert_eq!(
builder.local_state.table_properties.get("key2"),
Some(&"value2".to_string())
);
}
#[rstest]
#[case::lowercase_prefix("delta.constraints.positive")]
#[case::bare_prefix("delta.constraints.")]
fn create_table_rejects_check_constraint_properties(#[case] key: &str) {
let properties = HashMap::from([(key.to_string(), "amount > 0".to_string())]);
let result = validate_extract_table_features_and_properties(properties);
assert_result_error_with_message(result, "Setting CHECK constraint property");
}
#[test]
fn test_validate_supported_properties() {
let properties = HashMap::new();
let result = validate_extract_table_features_and_properties(properties);
assert!(result.is_ok());
let validated = result.unwrap();
assert!(validated.properties.is_empty());
assert!(validated.reader_features.is_empty());
assert!(validated.writer_features.is_empty());
let mut properties = HashMap::new();
properties.insert("myapp.version".to_string(), "1.0".to_string());
properties.insert("custom.setting".to_string(), "value".to_string());
let result = validate_extract_table_features_and_properties(properties);
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(validated.properties.len(), 2);
assert_eq!(
validated.properties.get("myapp.version"),
Some(&"1.0".to_string())
);
assert_eq!(
validated.properties.get("custom.setting"),
Some(&"value".to_string())
);
}
#[test]
fn test_parquet_format_version_accepted() {
let properties =
HashMap::from([(PARQUET_FORMAT_VERSION.to_string(), "2.12.0".to_string())]);
let validated = validate_extract_table_features_and_properties(properties).unwrap();
assert_eq!(
validated.properties.get(PARQUET_FORMAT_VERSION),
Some(&"2.12.0".to_string()),
);
assert!(validated.reader_features.is_empty());
assert!(validated.writer_features.is_empty());
}
#[rstest::rstest]
#[case::num_indexed_cols_cap(DATA_SKIPPING_NUM_INDEXED_COLS, "16")]
#[case::num_indexed_cols_all(DATA_SKIPPING_NUM_INDEXED_COLS, "-1")]
#[case::stats_columns_multi(DATA_SKIPPING_STATS_COLUMNS, "a,b,c")]
#[case::stats_columns_single(DATA_SKIPPING_STATS_COLUMNS, "a")]
fn test_data_skipping_properties_accepted(#[case] key: &str, #[case] value: &str) {
let properties = HashMap::from([(key.to_string(), value.to_string())]);
let validated = validate_extract_table_features_and_properties(properties).unwrap();
assert_eq!(validated.properties.get(key), Some(&value.to_string()));
assert!(validated.reader_features.is_empty());
assert!(validated.writer_features.is_empty());
}
#[test]
fn test_metadata_maintenance_properties_accepted() {
let properties = HashMap::from([
(
LOG_RETENTION_DURATION.to_string(),
"interval 30 days".to_string(),
),
(
DELETED_FILE_RETENTION_DURATION.to_string(),
"interval 7 days".to_string(),
),
(ENABLE_EXPIRED_LOG_CLEANUP.to_string(), "true".to_string()),
(CHECKPOINT_INTERVAL.to_string(), "10".to_string()),
]);
let validated = validate_extract_table_features_and_properties(properties).unwrap();
assert_eq!(
validated.properties.get(LOG_RETENTION_DURATION),
Some(&"interval 30 days".to_string()),
);
assert_eq!(
validated.properties.get(DELETED_FILE_RETENTION_DURATION),
Some(&"interval 7 days".to_string()),
);
assert_eq!(
validated.properties.get(ENABLE_EXPIRED_LOG_CLEANUP),
Some(&"true".to_string()),
);
assert_eq!(
validated.properties.get(CHECKPOINT_INTERVAL),
Some(&"10".to_string()),
);
assert!(validated.reader_features.is_empty());
assert!(validated.writer_features.is_empty());
}
#[rstest::rstest]
#[case::v2(&[(CHECKPOINT_POLICY, "v2")], true)]
#[case::classic(&[(CHECKPOINT_POLICY, "classic")], false)]
fn test_checkpoint_policy_feature_enablement(
#[case] properties: &[(&str, &str)],
#[case] expect_v2_checkpoint_feature: bool,
) {
let properties: HashMap<String, String> = properties
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let mut validated = validate_extract_table_features_and_properties(properties).unwrap();
maybe_enable_v2_checkpoint_for_policy(&mut validated);
assert_eq!(
validated
.reader_features
.contains(&TableFeature::V2Checkpoint),
expect_v2_checkpoint_feature,
);
assert_eq!(
validated
.writer_features
.contains(&TableFeature::V2Checkpoint),
expect_v2_checkpoint_feature,
);
assert!(validated.properties.contains_key(CHECKPOINT_POLICY));
}
#[test]
fn test_validate_unsupported_properties() {
let mut properties = HashMap::new();
properties.insert(ENABLE_ICEBERG_COMPAT_V1.to_string(), "true".to_string());
assert_result_error_with_message(
validate_extract_table_features_and_properties(properties),
"Setting delta property 'delta.enableIcebergCompatV1' is not supported",
);
let properties = HashMap::from([(
"delta.feature.identityColumns".to_string(),
"supported".to_string(),
)]);
assert_result_error_with_message(
validate_extract_table_features_and_properties(properties),
"Enabling feature 'identityColumns' via 'delta.feature.identityColumns' is not supported",
);
let properties = HashMap::from([(
"delta.feature.clustering".to_string(),
"supported".to_string(),
)]);
assert_result_error_with_message(
validate_extract_table_features_and_properties(properties),
"Enabling feature 'clustering' via 'delta.feature.clustering' is not supported",
);
let mut properties = HashMap::new();
properties.insert("myapp.version".to_string(), "1.0".to_string());
properties.insert(ENABLE_ICEBERG_COMPAT_V1.to_string(), "true".to_string());
assert_result_error_with_message(
validate_extract_table_features_and_properties(properties),
"Setting delta property 'delta.enableIcebergCompatV1' is not supported",
);
}
#[test]
fn test_clustering_support_valid() {
use crate::clustering::CLUSTERING_DOMAIN_NAME;
use crate::expressions::column_name;
let schema = schema_ref! {
not_null "id": INTEGER,
nullable "name": STRING,
};
let mut reader_features = vec![];
let mut writer_features = vec![];
let dm = validate_clustering_and_make_domain_metadata(
&schema,
&[column_name!("id")],
&mut reader_features,
&mut writer_features,
)
.unwrap();
assert_eq!(dm.domain(), CLUSTERING_DOMAIN_NAME);
assert!(writer_features.contains(&TableFeature::DomainMetadata));
assert!(writer_features.contains(&TableFeature::ClusteredTable));
assert!(reader_features.is_empty());
}
#[test]
fn test_clustering_support_multiple_columns() {
use crate::expressions::column_name;
let schema = schema_ref! {
not_null "id": INTEGER,
nullable "date": STRING,
nullable "region": STRING,
};
let mut reader_features = vec![];
let mut writer_features = vec![];
let dm = validate_clustering_and_make_domain_metadata(
&schema,
&[column_name!("id"), column_name!("date")],
&mut reader_features,
&mut writer_features,
)
.unwrap();
let config: serde_json::Value = serde_json::from_str(dm.configuration()).unwrap();
let clustering_cols = config["clusteringColumns"].as_array().unwrap();
assert_eq!(clustering_cols.len(), 2);
assert_eq!(clustering_cols[0], serde_json::json!(["id"]));
assert_eq!(clustering_cols[1], serde_json::json!(["date"]));
}
#[test]
fn test_clustering_column_not_in_schema() {
use crate::expressions::column_name;
let schema = schema_ref! { not_null "id": INTEGER };
let mut reader_features = vec![];
let mut writer_features = vec![];
let result = validate_clustering_and_make_domain_metadata(
&schema,
&[column_name!("nonexistent")],
&mut reader_features,
&mut writer_features,
);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not found in schema"));
}
#[test]
fn test_clustering_nested_column_accepted() {
use crate::clustering::CLUSTERING_DOMAIN_NAME;
use crate::expressions::column_name;
let schema = schema_ref! {
not_null "id": INTEGER,
nullable "address": {
nullable "city": STRING,
nullable "zip": STRING,
},
};
let mut reader_features = vec![];
let mut writer_features = vec![];
let nested_col = column_name!("address.city");
let dm = validate_clustering_and_make_domain_metadata(
&schema,
&[nested_col],
&mut reader_features,
&mut writer_features,
)
.unwrap();
assert_eq!(dm.domain(), CLUSTERING_DOMAIN_NAME);
assert!(writer_features.contains(&TableFeature::ClusteredTable));
}
#[rstest::rstest]
#[case::clustered(DataLayout::clustered(["id"]), true, false)]
#[case::partitioned(DataLayout::partitioned(["id"]), false, true)]
#[case::none(DataLayout::default(), false, false)]
fn test_with_data_layout(
#[case] layout: DataLayout,
#[case] expect_clustered: bool,
#[case] expect_partitioned: bool,
) {
let schema = test_schema();
let builder =
CreateTableTransactionBuilder::new("/path/to/table", schema, "TestApp/1.0", true)
.with_data_layout(layout);
assert_eq!(
builder.local_state.data_layout.is_clustered(),
expect_clustered
);
assert_eq!(
builder.local_state.data_layout.is_partitioned(),
expect_partitioned
);
}
#[rstest::rstest]
#[case::variant_top_level(
schema_ref! { not_null "id": INTEGER, nullable "v": (DataType::unshredded_variant()) },
&[TableFeature::VariantType],
)]
#[case::variant_nested(
schema_ref! {
not_null "id": INTEGER,
nullable "nested": { nullable "inner_v": (DataType::unshredded_variant()) },
},
&[TableFeature::VariantType],
)]
#[case::ntz_top_level(
schema_ref! { not_null "id": INTEGER, nullable "ts": TIMESTAMP_NTZ },
&[TableFeature::TimestampWithoutTimezone],
)]
#[case::ntz_nested(
schema_ref! {
not_null "id": INTEGER,
nullable "nested": { nullable "inner_ts": TIMESTAMP_NTZ },
},
&[TableFeature::TimestampWithoutTimezone],
)]
#[case::both_variant_and_ntz(
schema_ref! {
not_null "id": INTEGER,
nullable "v": unshredded_variant(),
nullable "ts": TIMESTAMP_NTZ,
},
&[TableFeature::VariantType, TableFeature::TimestampWithoutTimezone],
)]
#[case::no_special_types(
test_schema(),
&[],
)]
fn test_schema_driven_feature_auto_enablement(
#[case] schema: SchemaRef,
#[case] expected_features: &[TableFeature],
) {
let mut validated = ValidatedTableProperties {
properties: HashMap::new(),
reader_features: vec![],
writer_features: vec![],
};
maybe_enable_variant_type(&schema, &mut validated);
maybe_enable_timestamp_ntz(&schema, &mut validated);
for feature in expected_features {
assert!(
validated.reader_features.contains(feature),
"Expected {feature:?} in reader_features"
);
assert!(
validated.writer_features.contains(feature),
"Expected {feature:?} in writer_features"
);
}
assert_eq!(
validated.reader_features.len(),
expected_features.len(),
"Unexpected extra reader features: {:?}",
validated.reader_features
);
assert_eq!(
validated.writer_features.len(),
expected_features.len(),
"Unexpected extra writer features: {:?}",
validated.writer_features
);
}
#[rstest::rstest]
#[case::all_nullable(
schema_ref! { nullable "id": INTEGER, nullable "name": STRING },
false,
)]
#[case::top_level_non_null(
schema_ref! { not_null "id": INTEGER, nullable "name": STRING },
true,
)]
#[case::nested_non_null(
schema_ref! { nullable "parent": { not_null "child": INTEGER } },
true,
)]
#[case::variant_only(
schema_ref! { nullable "id": INTEGER, nullable "v": (DataType::unshredded_variant()) },
false,
)]
fn test_maybe_enable_invariants(#[case] schema: SchemaRef, #[case] expect_invariants: bool) {
let mut validated = ValidatedTableProperties {
properties: HashMap::new(),
reader_features: vec![],
writer_features: vec![],
};
maybe_enable_invariants(&schema, &mut validated);
assert_eq!(
validated
.writer_features
.contains(&TableFeature::Invariants),
expect_invariants,
"Expected Invariants feature presence = {expect_invariants}"
);
assert!(
validated.reader_features.is_empty(),
"Invariants is writer-only; reader_features should be empty"
);
}
#[rstest::rstest]
#[case::property_true(&[("delta.enableInCommitTimestamps", "true")], true, true)]
#[case::property_false(&[("delta.enableInCommitTimestamps", "false")], false, true)]
#[case::property_absent(&[], false, false)]
#[case::feature_signal(&[("delta.feature.inCommitTimestamp", "supported")], true, false)]
fn test_ict_support_and_enablement(
#[case] properties: &[(&str, &str)],
#[case] expect_in_writer_features: bool,
#[case] expect_property_preserved: bool,
) {
let properties: HashMap<String, String> = properties
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let mut validated = validate_extract_table_features_and_properties(properties).unwrap();
maybe_auto_enable_property_driven_features(&mut validated);
assert_eq!(
validated
.writer_features
.contains(&TableFeature::InCommitTimestamp),
expect_in_writer_features,
);
assert_eq!(
validated
.properties
.contains_key(ENABLE_IN_COMMIT_TIMESTAMPS),
expect_property_preserved,
);
assert!(
validated.reader_features.is_empty(),
"InCommitTimestamp is writer-only, reader_features should always be empty"
);
}
#[rstest::rstest]
#[case::vacuum_protocol_check(TableFeature::VacuumProtocolCheck, "vacuumProtocolCheck")]
#[case::domain_metadata(TableFeature::DomainMetadata, "domainMetadata")]
#[case::column_mapping(TableFeature::ColumnMapping, "columnMapping")]
#[case::in_commit_timestamp(TableFeature::InCommitTimestamp, "inCommitTimestamp")]
#[case::deletion_vectors(TableFeature::DeletionVectors, "deletionVectors")]
#[case::v2_checkpoint(TableFeature::V2Checkpoint, "v2Checkpoint")]
#[case::append_only(TableFeature::AppendOnly, "appendOnly")]
#[case::change_data_feed(TableFeature::ChangeDataFeed, "changeDataFeed")]
#[case::type_widening(TableFeature::TypeWidening, "typeWidening")]
#[case::variant_type(TableFeature::VariantType, "variantType")]
#[case::variant_shredding(TableFeature::VariantShredding, "variantShredding")]
#[case::catalog_managed(TableFeature::CatalogManaged, "catalogManaged")]
#[case::invariants(TableFeature::Invariants, "invariants")]
fn test_feature_signal_accepted(#[case] feature: TableFeature, #[case] feature_name: &str) {
let key = format!("delta.feature.{feature_name}");
let properties = HashMap::from([(key, "supported".to_string())]);
let validated = validate_extract_table_features_and_properties(properties).unwrap();
assert!(
validated.properties.is_empty(),
"Feature signal should be removed from properties"
);
assert!(
validated.writer_features.contains(&feature),
"{feature:?} should be in writer_features"
);
match feature.feature_type() {
FeatureType::ReaderWriter => assert!(
validated.reader_features.contains(&feature),
"{feature:?} is ReaderWriter but missing from reader_features"
),
_ => assert!(
validated.reader_features.is_empty(),
"{feature:?} is WriterOnly but reader_features is not empty"
),
}
}
#[test]
fn test_variant_shredding_feature_signal_adds_variant_type_dependency() {
let properties = HashMap::from([(
"delta.feature.variantShredding".to_string(),
"supported".to_string(),
)]);
let validated = validate_extract_table_features_and_properties(properties).unwrap();
assert!(validated
.reader_features
.contains(&TableFeature::VariantType));
assert!(validated
.reader_features
.contains(&TableFeature::VariantShredding));
assert!(validated
.writer_features
.contains(&TableFeature::VariantType));
assert!(validated
.writer_features
.contains(&TableFeature::VariantShredding));
}
fn multi_column_schema() -> SchemaRef {
schema_ref! {
not_null "id": INTEGER,
nullable "name": STRING,
nullable "date": DATE,
}
}
struct DataLayoutExpectation {
layout: DataLayout,
has_domain_metadata: bool,
has_clustering_columns: bool,
expected_partition_columns: Option<Vec<ColumnName>>,
expected_writer_features: Vec<TableFeature>,
}
#[rstest::rstest]
#[case::none(DataLayoutExpectation {
layout: DataLayout::default(),
has_domain_metadata: false,
has_clustering_columns: false,
expected_partition_columns: None,
expected_writer_features: vec![],
})]
#[case::clustered(DataLayoutExpectation {
layout: DataLayout::clustered(["id"]),
has_domain_metadata: true,
has_clustering_columns: true,
expected_partition_columns: None,
expected_writer_features: vec![TableFeature::ClusteredTable],
})]
#[case::partitioned_single(DataLayoutExpectation {
layout: DataLayout::partitioned(["date"]),
has_domain_metadata: false,
has_clustering_columns: false,
expected_partition_columns: Some(vec![column_name!("date")]),
expected_writer_features: vec![],
})]
#[case::partitioned_multiple(DataLayoutExpectation {
layout: DataLayout::partitioned(["id", "date"]),
has_domain_metadata: false,
has_clustering_columns: false,
expected_partition_columns: Some(vec![column_name!("id"), column_name!("date")]),
expected_writer_features: vec![],
})]
fn test_apply_data_layout(#[case] expectation: DataLayoutExpectation) {
let schema = multi_column_schema();
let mut validated = ValidatedTableProperties {
properties: HashMap::new(),
reader_features: vec![],
writer_features: vec![],
};
let result = apply_data_layout(
&expectation.layout,
&schema,
ColumnMappingMode::None,
&mut validated,
)
.unwrap();
assert_eq!(
!result.system_domain_metadata.is_empty(),
expectation.has_domain_metadata
);
assert_eq!(
result.clustering_columns.is_some(),
expectation.has_clustering_columns
);
assert_eq!(
result.partition_columns,
expectation.expected_partition_columns
);
for feature in &expectation.expected_writer_features {
assert!(
validated.writer_features.contains(feature),
"Expected {feature:?} in writer_features"
);
}
}
#[rstest::rstest]
#[case::clustered_invalid_col(DataLayout::clustered(["nonexistent"]), "not found in schema")]
#[case::partitioned_invalid_col(DataLayout::partitioned(["nonexistent"]), "not found in schema")]
#[case::partitioned_duplicate(DataLayout::partitioned(["id", "id"]), "Duplicate partition column")]
#[case::partitioned_empty(DataLayout::Partitioned { columns: vec![] }, "at least one column")]
#[case::partitioned_all_columns(DataLayout::partitioned(["id", "name", "date"]), "at least one non-partition column")]
fn test_apply_data_layout_validation_errors(
#[case] layout: DataLayout,
#[case] expected_error: &str,
) {
let schema = multi_column_schema();
let mut validated = ValidatedTableProperties {
properties: HashMap::new(),
reader_features: vec![],
writer_features: vec![],
};
let result = apply_data_layout(&layout, &schema, ColumnMappingMode::None, &mut validated);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains(expected_error),
"Expected error containing '{expected_error}'"
);
}
#[test]
fn test_validate_partition_columns_nested_rejected() {
let schema = schema! {
not_null "id": INTEGER,
nullable "address": { nullable "city": STRING },
};
let columns = vec![column_name!("address.city")];
let result = validate_partition_columns(&schema, &columns);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must be a top-level column"));
}
#[rstest::rstest]
#[case::struct_type(
"struct_col",
DataType::from(schema! { not_null "inner": STRING }),
)]
#[case::array_type(
"array_col",
DataType::from(crate::schema::ArrayType::new(DataType::INTEGER, false))
)]
#[case::map_type(
"map_col",
DataType::from(crate::schema::MapType::new(DataType::STRING, DataType::INTEGER, false))
)]
fn test_validate_partition_columns_complex_types_rejected(
#[case] col_name: &str,
#[case] data_type: DataType,
) {
let schema = schema! {
not_null "id": INTEGER,
not_null col_name: (data_type),
};
let columns = vec![ColumnName::new([col_name])];
let result = validate_partition_columns(&schema, &columns);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("non-primitive type"));
}
#[rstest::rstest]
fn test_validate_partition_columns_nested_interval_types_rejected(
#[values(DataType::INTERVAL_YEAR_MONTH, DataType::INTERVAL_DAY_TIME)] data_type: DataType,
) {
let schema = schema! {
not_null "id": INTEGER,
not_null "nested": { not_null "col": (data_type) },
};
let error = validate_partition_columns(&schema, &[column_name!("nested.col")])
.expect_err("nested partition columns must be rejected")
.to_string();
assert!(error.contains("must be a top-level column"));
}
#[rstest::rstest]
#[case::integer(DataType::INTEGER)]
#[case::string(DataType::STRING)]
#[case::date(DataType::DATE)]
#[case::timestamp(DataType::TIMESTAMP)]
#[case::boolean(DataType::BOOLEAN)]
#[case::long(DataType::LONG)]
#[case::interval_year_month(DataType::INTERVAL_YEAR_MONTH)]
#[case::interval_day_time(DataType::INTERVAL_DAY_TIME)]
fn test_validate_partition_columns_primitive_types_accepted(#[case] data_type: DataType) {
let schema = schema! {
not_null "id": INTEGER,
not_null "col": (data_type),
};
let columns = vec![column_name!("col")];
assert!(validate_partition_columns(&schema, &columns).is_ok());
}
#[test]
fn test_catalog_managed_auto_enables_ict() {
let properties = HashMap::from([(
"delta.feature.catalogManaged".to_string(),
"supported".to_string(),
)]);
let mut validated = validate_extract_table_features_and_properties(properties).unwrap();
maybe_auto_enable_property_driven_features(&mut validated);
maybe_enable_ict_for_catalog_managed(&mut validated).unwrap();
assert!(
validated
.writer_features
.contains(&TableFeature::InCommitTimestamp),
"ICT should be auto-added to writer_features"
);
assert_eq!(
validated.properties.get(ENABLE_IN_COMMIT_TIMESTAMPS),
Some(&"true".to_string()),
"delta.enableInCommitTimestamps should be set to true"
);
}
#[test]
fn test_catalog_managed_with_ict_true_succeeds() {
let properties = HashMap::from([
(
"delta.feature.catalogManaged".to_string(),
"supported".to_string(),
),
(
"delta.enableInCommitTimestamps".to_string(),
"true".to_string(),
),
]);
let mut validated = validate_extract_table_features_and_properties(properties).unwrap();
maybe_auto_enable_property_driven_features(&mut validated);
maybe_enable_ict_for_catalog_managed(&mut validated).unwrap();
assert!(validated
.writer_features
.contains(&TableFeature::InCommitTimestamp));
assert_eq!(
validated.properties.get(ENABLE_IN_COMMIT_TIMESTAMPS),
Some(&"true".to_string()),
);
}
#[rstest::rstest]
#[case::enablement_property(
HashMap::from([(ENABLE_ROW_TRACKING.to_string(), "true".to_string())]),
true, // enablement property is set
)]
#[case::feature_signal(
HashMap::from([("delta.feature.rowTracking".to_string(), "supported".to_string())]),
false, // enablement property is NOT set
)]
fn test_row_tracking_activation_selects_feature(
#[case] properties: HashMap<String, String>,
#[case] expect_enablement_property: bool,
) {
let mut validated = validate_extract_table_features_and_properties(properties).unwrap();
maybe_auto_enable_property_driven_features(&mut validated);
assert!(
validated
.writer_features
.contains(&TableFeature::RowTracking),
"Expected RowTracking in writer_features"
);
assert!(
!validated
.writer_features
.contains(&TableFeature::DomainMetadata),
"Feature selection must leave domain dependency resolution to build"
);
assert_eq!(
validated.properties.contains_key(ENABLE_ROW_TRACKING),
expect_enablement_property,
"delta.enableRowTracking presence mismatch"
);
}
#[test]
fn test_catalog_managed_with_ict_false_fails() {
let properties = HashMap::from([
(
"delta.feature.catalogManaged".to_string(),
"supported".to_string(),
),
(
"delta.enableInCommitTimestamps".to_string(),
"false".to_string(),
),
]);
let mut validated = validate_extract_table_features_and_properties(properties).unwrap();
maybe_auto_enable_property_driven_features(&mut validated);
let err = maybe_enable_ict_for_catalog_managed(&mut validated).unwrap_err();
assert!(
err.to_string().contains("enableInCommitTimestamps"),
"expected ICT conflict error, got: {err}"
);
}
fn build_iceberg_compat_test_schema() -> SchemaRef {
let with_metadata =
build_complex_nested_kernel_schema(ColumnMetadataKey::ColumnMappingNestedIds.as_ref());
let complex = StripFieldMetadataTransform
.transform_struct(&with_metadata)
.into_owned();
Arc::new(
try_schema! {
..(complex.fields()),
nullable "region": STRING,
}
.unwrap(),
)
}
#[rstest]
#[case::v3_only(
/* extra_props */ &[],
/* expected_features */ &[
TableFeature::IcebergCompatV3,
TableFeature::ColumnMapping,
TableFeature::RowTracking,
],
)]
#[case::v3_with_cross_features(
/* extra_props */ &[
(ENABLE_DELETION_VECTORS, "true"),
(ENABLE_IN_COMMIT_TIMESTAMPS, "true"),
(ENABLE_TYPE_WIDENING, "true"),
(APPEND_ONLY, "true"),
(ENABLE_CHANGE_DATA_FEED, "true"),
],
&[
TableFeature::IcebergCompatV3,
TableFeature::ColumnMapping,
TableFeature::RowTracking,
TableFeature::DeletionVectors,
TableFeature::InCommitTimestamp,
TableFeature::TypeWidening,
TableFeature::AppendOnly,
TableFeature::ChangeDataFeed,
],
)]
fn test_create_table_iceberg_compat_v3(
#[case] extra_props: &[(&str, &str)],
#[case] expected_features: &[TableFeature],
) {
let schema = build_iceberg_compat_test_schema();
let mut props: HashMap<String, String> =
HashMap::from([(ENABLE_ICEBERG_COMPAT_V3.to_string(), "true".to_string())]);
for (k, v) in extra_props {
props.insert(k.to_string(), v.to_string());
}
let mut validated = validate_extract_table_features_and_properties(props).unwrap();
maybe_enable_iceberg_compat_v3_dependencies(&mut validated).unwrap();
assert_eq!(
validated
.properties
.get(COLUMN_MAPPING_MODE)
.map(String::as_str),
Some("name"),
);
assert_eq!(
validated
.properties
.get(ENABLE_ROW_TRACKING)
.map(String::as_str),
Some("true"),
);
let (effective_schema, mode) =
maybe_apply_column_mapping_for_table_create(&schema, &mut validated).unwrap();
assert_eq!(mode, ColumnMappingMode::Name);
let top = effective_schema.field("top").expect("missing top");
let top_physical = expect_field_id_and_physical_name(top, 1);
assert_eq!(
extract_nested_ids(top),
serde_json::json!({
format!("{top_physical}.key"): 4,
format!("{top_physical}.key.element"): 5,
format!("{top_physical}.value"): 6,
}),
"top's nested-ids JSON",
);
let DataType::Map(top_map) = top.data_type() else {
panic!("top must be a map");
};
let DataType::Struct(value_struct) = top_map.value_type() else {
panic!("top's value must be a struct");
};
let inner = value_struct.fields().next().expect("missing inner");
let inner_physical = expect_field_id_and_physical_name(inner, 2);
assert_eq!(
extract_nested_ids(inner),
serde_json::json!({
format!("{inner_physical}.key"): 7,
format!("{inner_physical}.value"): 8,
format!("{inner_physical}.value.element"): 9,
}),
"inner's nested-ids JSON",
);
let region = effective_schema.field("region").expect("missing region");
let _ = expect_field_id_and_physical_name(region, 3);
assert!(!region
.metadata
.contains_key(ColumnMetadataKey::ColumnMappingNestedIds.as_ref()));
assert_eq!(
validated
.properties
.get(COLUMN_MAPPING_MAX_COLUMN_ID)
.map(String::as_str),
Some("9"),
);
maybe_enable_invariants(&effective_schema, &mut validated);
maybe_auto_enable_property_driven_features(&mut validated);
for f in expected_features {
assert!(
validated.writer_features.contains(f) || validated.reader_features.contains(f),
"expected {f:?} in protocol features (writer={:?}, reader={:?})",
validated.writer_features,
validated.reader_features,
);
}
assert!(
!validated
.writer_features
.contains(&TableFeature::MaterializePartitionColumns),
"MaterializePartitionColumns flag should not be auto-added by V3",
);
}
#[rstest]
#[case::cm_mode_none(
&[(COLUMN_MAPPING_MODE, "none")],
"delta.columnMapping.mode",
)]
#[case::row_tracking_false(
&[(ENABLE_ROW_TRACKING, "false")],
"delta.enableRowTracking",
)]
#[case::row_tracking_suspended(
&[("delta.rowTrackingSuspended", "true")],
"delta.rowTrackingSuspended",
)]
#[case::v1_concurrent(
&[(ENABLE_ICEBERG_COMPAT_V1, "true")],
"delta.enableIcebergCompatV1",
)]
#[case::v2_concurrent(
&[("delta.enableIcebergCompatV2", "true")],
"delta.enableIcebergCompatV2",
)]
fn test_v3_dependencies_rejects_invalid_combinations(
#[case] extra_props: &[(&str, &str)],
#[case] expected_substring: &str,
) {
let mut properties: HashMap<String, String> =
HashMap::from([(ENABLE_ICEBERG_COMPAT_V3.to_string(), "true".to_string())]);
for (k, v) in extra_props {
properties.insert(k.to_string(), v.to_string());
}
let mut validated = ValidatedTableProperties {
properties,
reader_features: Vec::new(),
writer_features: Vec::new(),
};
let err = maybe_enable_iceberg_compat_v3_dependencies(&mut validated).unwrap_err();
assert!(
err.to_string().contains(expected_substring),
"expected error mentioning '{expected_substring}', got: {err}",
);
}
#[rstest]
#[case::canonical_key(ColumnMetadataKey::ColumnMappingNestedIds.as_ref())]
#[case::legacy_key(ColumnMetadataKey::ParquetFieldNestedIds.as_ref())]
fn test_create_table_v3_rejects_pre_existing_nested_ids(#[case] nested_ids_meta_key: &str) {
let schema = Arc::new(build_complex_nested_kernel_schema(nested_ids_meta_key));
let mut validated = validate_extract_table_features_and_properties(HashMap::from([(
ENABLE_ICEBERG_COMPAT_V3.to_string(),
"true".to_string(),
)]))
.unwrap();
maybe_enable_iceberg_compat_v3_dependencies(&mut validated).unwrap();
let err = maybe_apply_column_mapping_for_table_create(&schema, &mut validated).unwrap_err();
assert!(
err.to_string().contains("has pre-populated"),
"expected pre-populated metadata error, got: {err}",
);
}
#[test]
fn test_create_table_v3_supported_but_not_enabled_skips_nested_ids() {
let schema = build_iceberg_compat_test_schema();
let mut validated = validate_extract_table_features_and_properties(HashMap::from([
(
"delta.feature.icebergCompatV3".to_string(),
"supported".to_string(),
),
(COLUMN_MAPPING_MODE.to_string(), "name".to_string()),
]))
.unwrap();
assert!(
validated
.writer_features
.contains(&TableFeature::IcebergCompatV3),
"V3 must be in writerFeatures (supported) for this test to be meaningful",
);
maybe_enable_iceberg_compat_v3_dependencies(&mut validated).unwrap();
let (effective_schema, mode) =
maybe_apply_column_mapping_for_table_create(&schema, &mut validated).unwrap();
assert_eq!(mode, ColumnMappingMode::Name);
let top = effective_schema.field("top").expect("missing field top");
assert!(
!top.metadata()
.contains_key(ColumnMetadataKey::ColumnMappingNestedIds.as_ref()),
"top should not carry delta.columnMapping.nested.ids when V3 is not enabled",
);
assert!(
!top.metadata()
.contains_key(ColumnMetadataKey::ParquetFieldNestedIds.as_ref()),
"top should not carry parquet.field.nested.ids when V3 is not enabled",
);
}
#[test]
fn test_create_table_iceberg_compat_v2() {
let schema = build_iceberg_compat_test_schema();
let props: HashMap<String, String> =
HashMap::from([(ENABLE_ICEBERG_COMPAT_V2.to_string(), "true".to_string())]);
let mut validated = validate_extract_table_features_and_properties(props).unwrap();
maybe_enable_iceberg_compat_v2_dependencies(&mut validated).unwrap();
assert_eq!(
validated
.properties
.get(COLUMN_MAPPING_MODE)
.map(String::as_str),
Some("name"),
);
assert_eq!(validated.properties.get(ENABLE_ROW_TRACKING), None);
let (effective_schema, mode) =
maybe_apply_column_mapping_for_table_create(&schema, &mut validated).unwrap();
assert_eq!(mode, ColumnMappingMode::Name);
let top = effective_schema.field("top").expect("missing top");
let top_physical = expect_field_id_and_physical_name(top, 1);
assert_eq!(
extract_nested_ids(top),
serde_json::json!({
format!("{top_physical}.key"): 4,
format!("{top_physical}.key.element"): 5,
format!("{top_physical}.value"): 6,
}),
"top's nested-ids JSON",
);
let DataType::Map(top_map) = top.data_type() else {
panic!("top must be a map");
};
let DataType::Struct(value_struct) = top_map.value_type() else {
panic!("top's value must be a struct");
};
let inner = value_struct.fields().next().expect("missing inner");
let inner_physical = expect_field_id_and_physical_name(inner, 2);
assert_eq!(
extract_nested_ids(inner),
serde_json::json!({
format!("{inner_physical}.key"): 7,
format!("{inner_physical}.value"): 8,
format!("{inner_physical}.value.element"): 9,
}),
"inner's nested-ids JSON",
);
maybe_enable_invariants(&effective_schema, &mut validated);
maybe_auto_enable_property_driven_features(&mut validated);
for f in [TableFeature::IcebergCompatV2, TableFeature::ColumnMapping] {
assert!(
validated.writer_features.contains(&f) || validated.reader_features.contains(&f),
"expected {f:?} in protocol features (writer={:?}, reader={:?})",
validated.writer_features,
validated.reader_features,
);
}
assert!(
!validated
.writer_features
.contains(&TableFeature::RowTracking),
"V2 must not auto-add RowTracking",
);
}
#[rstest]
#[case::default_to_name(&[], Some("name"))]
#[case::name_ok(&[(COLUMN_MAPPING_MODE, "name")], Some("name"))]
#[case::id_ok(&[(COLUMN_MAPPING_MODE, "id")], Some("id"))]
fn test_v2_dependencies_sets_column_mapping(
#[case] extra_props: &[(&str, &str)],
#[case] expected_cm: Option<&str>,
) {
let mut properties: HashMap<String, String> =
HashMap::from([(ENABLE_ICEBERG_COMPAT_V2.to_string(), "true".to_string())]);
for (k, v) in extra_props {
properties.insert(k.to_string(), v.to_string());
}
let mut validated = ValidatedTableProperties {
properties,
reader_features: Vec::new(),
writer_features: Vec::new(),
};
maybe_enable_iceberg_compat_v2_dependencies(&mut validated).unwrap();
assert_eq!(
validated
.properties
.get(COLUMN_MAPPING_MODE)
.map(String::as_str),
expected_cm,
);
assert!(
validated
.reader_features
.contains(&TableFeature::ColumnMapping)
&& validated
.writer_features
.contains(&TableFeature::ColumnMapping)
);
}
#[test]
fn test_v2_support_adds_column_mapping_support_without_enabling_mode() {
let mut validated = ValidatedTableProperties {
properties: HashMap::new(),
reader_features: Vec::new(),
writer_features: vec![TableFeature::IcebergCompatV2],
};
maybe_enable_iceberg_compat_v2_dependencies(&mut validated).unwrap();
assert!(
validated
.reader_features
.contains(&TableFeature::ColumnMapping)
&& validated
.writer_features
.contains(&TableFeature::ColumnMapping)
);
assert!(!validated.properties.contains_key(COLUMN_MAPPING_MODE));
}
#[rstest]
#[case::cm_mode_none(&[(COLUMN_MAPPING_MODE, "none")], "delta.columnMapping.mode")]
#[case::v1_concurrent(&[(ENABLE_ICEBERG_COMPAT_V1, "true")], "delta.enableIcebergCompatV1")]
#[case::v3_concurrent(&[(ENABLE_ICEBERG_COMPAT_V3, "true")], "delta.enableIcebergCompatV3")]
#[case::deletion_vectors_enabled(
&[(ENABLE_DELETION_VECTORS, "true")],
"delta.enableDeletionVectors",
)]
fn test_v2_dependencies_rejects_invalid_combinations(
#[case] extra_props: &[(&str, &str)],
#[case] expected_substring: &str,
) {
let mut properties: HashMap<String, String> =
HashMap::from([(ENABLE_ICEBERG_COMPAT_V2.to_string(), "true".to_string())]);
for (k, v) in extra_props {
properties.insert(k.to_string(), v.to_string());
}
let mut validated = ValidatedTableProperties {
properties,
reader_features: Vec::new(),
writer_features: Vec::new(),
};
let err = maybe_enable_iceberg_compat_v2_dependencies(&mut validated).unwrap_err();
assert!(
err.to_string().contains(expected_substring),
"expected error mentioning '{expected_substring}', got: {err}",
);
}
fn expect_field_id_and_physical_name(field: &StructField, expected_id: i64) -> String {
assert_eq!(
field
.metadata
.get(ColumnMetadataKey::ColumnMappingId.as_ref()),
Some(&MetadataValue::Number(expected_id)),
"field '{}' CM id mismatch",
field.name(),
);
let MetadataValue::String(physical_name) = field
.metadata
.get(ColumnMetadataKey::ColumnMappingPhysicalName.as_ref())
.unwrap_or_else(|| panic!("field '{}' missing physicalName", field.name()))
else {
panic!("field '{}' physicalName must be a String", field.name());
};
assert!(
physical_name.starts_with("col-"),
"field '{}' physicalName '{physical_name}' must start with 'col-'",
field.name(),
);
physical_name.clone()
}
fn extract_nested_ids(field: &StructField) -> serde_json::Value {
let MetadataValue::Other(value) = field
.metadata
.get(ColumnMetadataKey::ColumnMappingNestedIds.as_ref())
.unwrap_or_else(|| panic!("field '{}' missing nested-ids", field.name()))
else {
panic!("field '{}' nested-ids must be a JSON object", field.name());
};
value.clone()
}
}