@@ -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