Skip to content

Commit e45b286

Browse files
committed
Add ALTER TABLE ADD COLUMN integration tests for preserve/fill column mapping
The 3-way rewrite of try_assign_field_column_mapping flows straight through apply_schema_operations, so the same preserve/fill/assign matrix applies to ALTER as to CREATE. These tests pin that contract end-to-end through the alter -> snapshot read roundtrip: - both id + physicalName preserved when fully supplied (in name and id modes) - id allocated when only physicalName is supplied - physicalName filled when only id is supplied - supplied id less than the existing maxColumnId is accepted as long as it doesn't collide. This matches Spark and diverges from the Java Kernel proposal in delta-io/delta#4520. - supplied id that collides with an existing field is rejected Refs #2377.
1 parent c815ac8 commit e45b286

1 file changed

Lines changed: 265 additions & 0 deletions

File tree

kernel/tests/integration/features/alter_table.rs

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,3 +759,268 @@ async fn alter_blocked_when_iceberg_compat_v3_enabled() -> Result<(), Box<dyn st
759759

760760
Ok(())
761761
}
762+
763+
// ============================================================================
764+
// ALTER TABLE ADD COLUMN preserves / fills pre-populated column mapping metadata
765+
// (delta-spark parity per `DeltaColumnMapping.assignColumnIdAndPhysicalName`).
766+
// See https://github.com/delta-io/delta-kernel-rs/issues/2377.
767+
// ============================================================================
768+
769+
fn cm_id_for_field(field: &StructField) -> i64 {
770+
field
771+
.column_mapping_id()
772+
.expect("field must have a column mapping id")
773+
}
774+
775+
fn physical_name_for_field(field: &StructField) -> &str {
776+
match field.get_config_value(&ColumnMetadataKey::ColumnMappingPhysicalName) {
777+
Some(MetadataValue::String(s)) => s.as_str(),
778+
other => panic!("expected physicalName string, got {other:?}"),
779+
}
780+
}
781+
782+
/// ADD COLUMN with both `delta.columnMapping.id` and `delta.columnMapping.physicalName`
783+
/// pre-populated: the connector-supplied metadata is preserved verbatim. `maxColumnId`
784+
/// advances to the supplied id when it exceeds the existing max.
785+
#[rstest]
786+
#[tokio::test]
787+
async fn add_column_preserves_complete_cm_metadata(
788+
#[values("name", "id")] cm_mode: &str,
789+
) -> DeltaResult<()> {
790+
let (_temp_dir, table_path, engine) = test_table_setup()?;
791+
let snapshot = create_table_and_load_snapshot(
792+
&table_path,
793+
simple_schema(),
794+
engine.as_ref(),
795+
&[("delta.columnMapping.mode", cm_mode)],
796+
)?;
797+
let original_max = max_column_id(&snapshot);
798+
799+
// Supplied id is well above the table's max so we can verify maxColumnId follows it.
800+
let supplied_id = original_max + 100;
801+
let mut field = StructField::nullable("preserved", DataType::STRING);
802+
field.metadata.insert(
803+
ColumnMetadataKey::ColumnMappingId.as_ref().to_string(),
804+
MetadataValue::Number(supplied_id),
805+
);
806+
field.metadata.insert(
807+
ColumnMetadataKey::ColumnMappingPhysicalName
808+
.as_ref()
809+
.to_string(),
810+
MetadataValue::String("user-supplied-physical".to_string()),
811+
);
812+
813+
snapshot
814+
.alter_table()
815+
.add_column(field)
816+
.build(engine.as_ref(), committer())?
817+
.commit(engine.as_ref())?
818+
.unwrap_committed();
819+
820+
let reloaded = Snapshot::builder_for(&table_path).build(engine.as_ref())?;
821+
let schema = reloaded.schema();
822+
let added = schema.field("preserved").unwrap();
823+
assert_eq!(cm_id_for_field(added), supplied_id);
824+
assert_eq!(physical_name_for_field(added), "user-supplied-physical");
825+
assert_eq!(max_column_id(&reloaded), supplied_id);
826+
Ok(())
827+
}
828+
829+
/// ADD COLUMN with only `delta.columnMapping.physicalName` supplied: kernel allocates
830+
/// `id = old maxColumnId + 1`, preserves the user-provided physical name, and bumps
831+
/// `maxColumnId` to the new id.
832+
#[rstest]
833+
#[tokio::test]
834+
async fn add_column_with_only_physical_name_allocates_id(
835+
#[values("name", "id")] cm_mode: &str,
836+
) -> DeltaResult<()> {
837+
let (_temp_dir, table_path, engine) = test_table_setup()?;
838+
let snapshot = create_table_and_load_snapshot(
839+
&table_path,
840+
simple_schema(),
841+
engine.as_ref(),
842+
&[("delta.columnMapping.mode", cm_mode)],
843+
)?;
844+
let original_max = max_column_id(&snapshot);
845+
846+
let mut field = StructField::nullable("named_only", DataType::STRING);
847+
field.metadata.insert(
848+
ColumnMetadataKey::ColumnMappingPhysicalName
849+
.as_ref()
850+
.to_string(),
851+
MetadataValue::String("phys-named-only".to_string()),
852+
);
853+
854+
snapshot
855+
.alter_table()
856+
.add_column(field)
857+
.build(engine.as_ref(), committer())?
858+
.commit(engine.as_ref())?
859+
.unwrap_committed();
860+
861+
let reloaded = Snapshot::builder_for(&table_path).build(engine.as_ref())?;
862+
let schema = reloaded.schema();
863+
let added = schema.field("named_only").unwrap();
864+
assert_eq!(cm_id_for_field(added), original_max + 1);
865+
assert_eq!(physical_name_for_field(added), "phys-named-only");
866+
assert_eq!(max_column_id(&reloaded), original_max + 1);
867+
Ok(())
868+
}
869+
870+
/// ADD COLUMN with only `delta.columnMapping.id` supplied: id is preserved, missing
871+
/// `physicalName` is filled with `col-<uuid>`.
872+
#[rstest]
873+
#[tokio::test]
874+
async fn add_column_with_only_id_fills_physical_name(
875+
#[values("name", "id")] cm_mode: &str,
876+
) -> DeltaResult<()> {
877+
let (_temp_dir, table_path, engine) = test_table_setup()?;
878+
let snapshot = create_table_and_load_snapshot(
879+
&table_path,
880+
simple_schema(),
881+
engine.as_ref(),
882+
&[("delta.columnMapping.mode", cm_mode)],
883+
)?;
884+
let original_max = max_column_id(&snapshot);
885+
let supplied_id = original_max + 7;
886+
887+
let mut field = StructField::nullable("id_only", DataType::STRING);
888+
field.metadata.insert(
889+
ColumnMetadataKey::ColumnMappingId.as_ref().to_string(),
890+
MetadataValue::Number(supplied_id),
891+
);
892+
893+
snapshot
894+
.alter_table()
895+
.add_column(field)
896+
.build(engine.as_ref(), committer())?
897+
.commit(engine.as_ref())?
898+
.unwrap_committed();
899+
900+
let reloaded = Snapshot::builder_for(&table_path).build(engine.as_ref())?;
901+
let schema = reloaded.schema();
902+
let added = schema.field("id_only").unwrap();
903+
assert_eq!(cm_id_for_field(added), supplied_id);
904+
assert!(
905+
physical_name_for_field(added).starts_with("col-"),
906+
"physical name should be filled with col-<uuid>, got {}",
907+
physical_name_for_field(added)
908+
);
909+
assert_eq!(max_column_id(&reloaded), supplied_id);
910+
Ok(())
911+
}
912+
913+
/// ADD COLUMN where the supplied `id` is *less than* the existing `maxColumnId` but does
914+
/// not collide with any existing field's id: succeeds, with the supplied id preserved
915+
/// verbatim and `maxColumnId` unchanged. Matches delta-spark; diverges from the Java Kernel
916+
/// proposal in https://github.com/delta-io/delta/pull/4520, which would reject this.
917+
#[tokio::test]
918+
async fn add_column_with_id_below_max_column_id_succeeds() -> DeltaResult<()> {
919+
let (_temp_dir, table_path, engine) = test_table_setup()?;
920+
921+
// Pre-populate the table with sparse ids (1, 100) using the create-table preserve path.
922+
let schema = Arc::new(StructType::try_new(vec![
923+
StructField::nullable("a", DataType::INTEGER).with_metadata([
924+
(
925+
ColumnMetadataKey::ColumnMappingId.as_ref(),
926+
MetadataValue::Number(1),
927+
),
928+
(
929+
ColumnMetadataKey::ColumnMappingPhysicalName.as_ref(),
930+
MetadataValue::String("phys-a".to_string()),
931+
),
932+
]),
933+
StructField::nullable("b", DataType::STRING).with_metadata([
934+
(
935+
ColumnMetadataKey::ColumnMappingId.as_ref(),
936+
MetadataValue::Number(100),
937+
),
938+
(
939+
ColumnMetadataKey::ColumnMappingPhysicalName.as_ref(),
940+
MetadataValue::String("phys-b".to_string()),
941+
),
942+
]),
943+
])?);
944+
let snapshot = create_table_and_load_snapshot(
945+
&table_path,
946+
schema,
947+
engine.as_ref(),
948+
&[("delta.columnMapping.mode", "name")],
949+
)?;
950+
assert_eq!(max_column_id(&snapshot), 100);
951+
952+
// Now add a new column with id=50, which is well below maxColumnId=100 and not used.
953+
let mut field = StructField::nullable("inserted_below_max", DataType::STRING);
954+
field.metadata.insert(
955+
ColumnMetadataKey::ColumnMappingId.as_ref().to_string(),
956+
MetadataValue::Number(50),
957+
);
958+
field.metadata.insert(
959+
ColumnMetadataKey::ColumnMappingPhysicalName
960+
.as_ref()
961+
.to_string(),
962+
MetadataValue::String("phys-inserted".to_string()),
963+
);
964+
965+
snapshot
966+
.alter_table()
967+
.add_column(field)
968+
.build(engine.as_ref(), committer())?
969+
.commit(engine.as_ref())?
970+
.unwrap_committed();
971+
972+
let reloaded = Snapshot::builder_for(&table_path).build(engine.as_ref())?;
973+
let schema = reloaded.schema();
974+
let added = schema.field("inserted_below_max").unwrap();
975+
assert_eq!(cm_id_for_field(added), 50);
976+
assert_eq!(physical_name_for_field(added), "phys-inserted");
977+
// maxColumnId stays at 100 because the supplied id (50) didn't exceed it.
978+
assert_eq!(max_column_id(&reloaded), 100);
979+
Ok(())
980+
}
981+
982+
/// ADD COLUMN where the supplied `id` collides with an existing field's id: fails. The
983+
/// duplicate-id check happens when the alter builder constructs the new
984+
/// `TableConfiguration` via `make_physical`.
985+
#[tokio::test]
986+
async fn add_column_with_id_colliding_existing_field_is_rejected() -> DeltaResult<()> {
987+
let (_temp_dir, table_path, engine) = test_table_setup()?;
988+
let snapshot = create_table_and_load_snapshot(
989+
&table_path,
990+
simple_schema(),
991+
engine.as_ref(),
992+
&[("delta.columnMapping.mode", "name")],
993+
)?;
994+
995+
// Pick an id that already exists in the simple_schema (1, 2 typically).
996+
let existing_id = snapshot
997+
.schema()
998+
.field("id")
999+
.unwrap()
1000+
.column_mapping_id()
1001+
.expect("simple_schema 'id' must have a CM id under name mode");
1002+
1003+
let mut field = StructField::nullable("colliding", DataType::STRING);
1004+
field.metadata.insert(
1005+
ColumnMetadataKey::ColumnMappingId.as_ref().to_string(),
1006+
MetadataValue::Number(existing_id),
1007+
);
1008+
field.metadata.insert(
1009+
ColumnMetadataKey::ColumnMappingPhysicalName
1010+
.as_ref()
1011+
.to_string(),
1012+
MetadataValue::String("phys-colliding".to_string()),
1013+
);
1014+
1015+
let err = snapshot
1016+
.alter_table()
1017+
.add_column(field)
1018+
.build(engine.as_ref(), committer())
1019+
.unwrap_err()
1020+
.to_string();
1021+
assert!(
1022+
err.contains("Duplicate column mapping ID") && err.contains(&existing_id.to_string()),
1023+
"expected duplicate-id error naming id {existing_id}, got: {err}"
1024+
);
1025+
Ok(())
1026+
}

0 commit comments

Comments
 (0)