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