Skip to content

Commit 7ef58b4

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 6ef9012 commit 7ef58b4

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

0 commit comments

Comments
 (0)