@@ -593,10 +593,127 @@ def dynamic_partition_overwrite(
593593 )
594594
595595 partitions_to_overwrite = {data_file .partition for data_file in data_files }
596- delete_filter = self ._build_partition_predicate (
597- partition_records = partitions_to_overwrite , spec = self .table_metadata .spec (), schema = self .table_metadata .schema ()
596+ current_spec = self .table_metadata .spec ()
597+ all_specs = self .table_metadata .specs ()
598+ schema = self .table_metadata .schema ()
599+
600+ # Keep the existing dynamic overwrite behavior for non-evolution tables.
601+ # We only need per-spec predicate handling when some historical specs are
602+ # missing current partition source IDs.
603+ current_source_ids = {field .source_id for field in current_spec .fields }
604+ has_missing_partition_fields_in_history = any (
605+ current_source_ids - {field .source_id for field in historical_spec .fields } for historical_spec in all_specs .values ()
598606 )
599- self .delete (delete_filter = delete_filter , snapshot_properties = snapshot_properties , branch = branch )
607+
608+ if not has_missing_partition_fields_in_history :
609+ delete_filter = self ._build_partition_predicate (
610+ partition_records = partitions_to_overwrite ,
611+ spec = current_spec ,
612+ schema = schema ,
613+ )
614+ self .delete (delete_filter = delete_filter , snapshot_properties = snapshot_properties , branch = branch )
615+ else :
616+ # Build per-spec delete predicates to handle partition spec evolution correctly.
617+ #
618+ # When a partition field was added via spec evolution, data files written under
619+ # older specs carry NULL for that field (because it was absent from the schema at
620+ # write time). A single "category=A AND region=us" predicate would never match
621+ # those files because the strict-metrics evaluator sees region=NULL != "us".
622+ #
623+ # To fix this, we compute a per-spec predicate for every historical spec:
624+ # - For specs that include all current partition fields -> use exact-match predicate.
625+ # - For specs that are missing some current partition fields -> also accept NULL
626+ # for the missing fields.
627+ #
628+ # These per-spec predicates are stored on the delete snapshot producer so that
629+ # _compute_deletes uses the right predicate when evaluating each manifest file.
630+ source_id_to_pos = {field .source_id : pos for pos , field in enumerate (current_spec .fields )}
631+ source_id_to_col = {field .source_id : schema .find_field (field .source_id ).name for field in current_spec .fields }
632+ exact_delete_filter = self ._build_partition_predicate (
633+ partition_records = partitions_to_overwrite ,
634+ spec = current_spec ,
635+ schema = schema ,
636+ )
637+
638+ per_spec_predicates : dict [int , BooleanExpression ] = {}
639+ for spec_id , hist_spec in all_specs .items ():
640+ hist_source_ids = {field .source_id for field in hist_spec .fields }
641+ missing_source_ids = current_source_ids - hist_source_ids
642+ has_overlap_with_current = bool (hist_source_ids & current_source_ids )
643+
644+ per_record_exprs : list [BooleanExpression ] = []
645+ for partition_record in partitions_to_overwrite :
646+ predicates : list [BooleanExpression ] = []
647+ for source_id , col_name in source_id_to_col .items ():
648+ value = partition_record [source_id_to_pos [source_id ]]
649+ if value is not None :
650+ field_pred : BooleanExpression = EqualTo (Reference (col_name ), value )
651+ if source_id in missing_source_ids and has_overlap_with_current :
652+ field_pred = Or (field_pred , IsNull (Reference (col_name )))
653+ else :
654+ field_pred = IsNull (Reference (col_name ))
655+ predicates .append (field_pred )
656+
657+ per_record_exprs .append (And (* predicates ) if len (predicates ) > 1 else predicates [0 ])
658+
659+ per_spec_predicates [spec_id ] = Or (* per_record_exprs ) if len (per_record_exprs ) > 1 else per_record_exprs [0 ]
660+
661+ # Open the delete snapshot and set per-spec predicates before committing.
662+ # This mirrors Transaction.delete() but injects per_spec_predicates so that
663+ # _compute_deletes uses the right predicate for each historical spec.
664+ from pyiceberg .io .pyarrow import ArrowScan , _dataframe_to_data_files , _expression_to_complementary_pyarrow
665+
666+ with self .update_snapshot (snapshot_properties = snapshot_properties , branch = branch ).delete () as delete_snapshot :
667+ delete_snapshot ._per_spec_predicates = per_spec_predicates
668+ delete_snapshot .delete_by_predicate (exact_delete_filter )
669+
670+ # Handle partial-match files that need to be rewritten (copy-on-write).
671+ if delete_snapshot .rewrites_needed is True :
672+ bound_delete_filter = bind (self .table_metadata .schema (), exact_delete_filter , case_sensitive = True )
673+ preserve_row_filter = _expression_to_complementary_pyarrow (bound_delete_filter , self .table_metadata .schema ())
674+
675+ file_scan = self ._scan (row_filter = exact_delete_filter )
676+ if branch is not None :
677+ file_scan = file_scan .use_ref (branch )
678+
679+ rewrite_uuid = uuid .uuid4 ()
680+ rewrite_counter = itertools .count (0 )
681+ replaced_files : list [tuple [DataFile , list [DataFile ]]] = []
682+ for original_file in file_scan .plan_files ():
683+ df_orig = ArrowScan (
684+ table_metadata = self .table_metadata ,
685+ io = self ._table .io ,
686+ projected_schema = self .table_metadata .schema (),
687+ row_filter = AlwaysTrue (),
688+ ).to_table (tasks = [original_file ])
689+ filtered_df = df_orig .filter (preserve_row_filter )
690+ if len (filtered_df ) == 0 :
691+ replaced_files .append ((original_file .file , []))
692+ elif len (df_orig ) != len (filtered_df ):
693+ replaced_files .append (
694+ (
695+ original_file .file ,
696+ list (
697+ _dataframe_to_data_files (
698+ io = self ._table .io ,
699+ df = filtered_df ,
700+ table_metadata = self .table_metadata ,
701+ write_uuid = rewrite_uuid ,
702+ counter = rewrite_counter ,
703+ )
704+ ),
705+ )
706+ )
707+
708+ if replaced_files :
709+ with self .update_snapshot (
710+ snapshot_properties = snapshot_properties , branch = branch
711+ ).overwrite () as overwrite_snapshot :
712+ overwrite_snapshot .commit_uuid = rewrite_uuid
713+ for original_data_file , replacement_data_files in replaced_files :
714+ overwrite_snapshot .delete_data_file (original_data_file )
715+ for replacement_data_file in replacement_data_files :
716+ overwrite_snapshot .append_data_file (replacement_data_file )
600717
601718 with self ._append_snapshot_producer (snapshot_properties , branch = branch ) as append_files :
602719 append_files .commit_uuid = append_snapshot_commit_uuid
0 commit comments