This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-4083-945c8db4c82b4fb1c2decc29109bf9630515b0d2
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git

commit 41006bf4c5740d84545c096a71cdb3edef0419ef
Author: Sung Yun <[email protected]>
AuthorDate: Sat Oct 24 22:37:53 2026 +0000

    Bug Fix: handle column projection edge case with null identity partition 
(#4083)
    
    * fix column projection edge case with null identity partition
    
    * Cover filter-only null identity partitions and simplify test
    
    Co-authored-by: Copilot App <[email protected]>
    
    ---------
    
    Co-authored-by: Kevin Liu <[email protected]>
    Co-authored-by: Copilot App <[email protected]>
---
 pyiceberg/io/pyarrow.py  |  8 +++-----
 tests/io/test_pyarrow.py | 38 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 41 insertions(+), 5 deletions(-)

diff --git a/pyiceberg/io/pyarrow.py b/pyiceberg/io/pyarrow.py
index 70dd37146..0cab26309 100644
--- a/pyiceberg/io/pyarrow.py
+++ b/pyiceberg/io/pyarrow.py
@@ -1686,9 +1686,7 @@ def _get_column_projection_values(
     for field_id in missing_field_ids:
         for partition_field in partition_spec.fields_by_source_id(field_id):
             if isinstance(partition_field.transform, IdentityTransform):
-                partition_value = 
accessors[partition_field.field_id].get(file.partition)
-                if partition_value is not None:
-                    projected_missing_fields[field_id] = partition_value
+                projected_missing_fields[field_id] = 
accessors[partition_field.field_id].get(file.partition)
 
     return projected_missing_fields
 
@@ -2130,8 +2128,8 @@ class 
ArrowProjectionVisitor(SchemaWithPartnerVisitor[pa.Array, pa.Array | None]
             elif field.optional or field.initial_default is not None:
                 # When an optional field is added, or when a required field 
with a non-null initial default is added
                 arrow_type = schema_to_pyarrow(field.field_type, 
include_field_ids=self._include_field_ids)
-                projected_value = 
self._projected_missing_fields.get(field.field_id)
-                if projected_value is not None:
+                if field.field_id in self._projected_missing_fields:
+                    projected_value = 
self._projected_missing_fields[field.field_id]
                     field_arrays.append(pa.repeat(pa.scalar(projected_value, 
type=arrow_type), len(struct_array)))
                 elif field.initial_default is None:
                     field_arrays.append(pa.nulls(len(struct_array), 
type=arrow_type))
diff --git a/tests/io/test_pyarrow.py b/tests/io/test_pyarrow.py
index d665df9a7..0ba8117ef 100644
--- a/tests/io/test_pyarrow.py
+++ b/tests/io/test_pyarrow.py
@@ -63,7 +63,9 @@ from pyiceberg.expressions import (
     BoundNotStartsWith,
     BoundReference,
     BoundStartsWith,
+    EqualTo,
     GreaterThan,
+    IsNull,
     Not,
     Or,
 )
@@ -1495,6 +1497,42 @@ def 
test_identity_transform_column_projection_with_falsy_value(
     )
 
 
+def 
test_identity_transform_null_partition_value_takes_precedence_over_initial_default(tmpdir:
 str) -> None:
+    """A null identity partition value must project as null, not as the 
field's initial-default."""
+    table_schema = Schema(
+        NestedField(1, "other_field", StringType(), required=False),
+        NestedField(2, "partition_col", IntegerType(), required=False, 
initial_default=7),
+    )
+    partition_spec = PartitionSpec(PartitionField(2, 1000, 
IdentityTransform(), "partition_col"))
+    table_metadata = TableMetadataV3(
+        location=str(tmpdir), last_column_id=2, schemas=[table_schema], 
partition_specs=[partition_spec], next_row_id=0
+    )
+
+    arrow_schema = pa.schema([pa.field("other_field", pa.string(), 
metadata={PYARROW_PARQUET_FIELD_ID_KEY: "1"})])
+    file_path = _write_table_to_file(
+        f"{tmpdir}/null_partition.parquet", arrow_schema, 
pa.table({"other_field": ["foo", "bar"]}, schema=arrow_schema)
+    )
+    data_file = DataFile.from_args(
+        content=DataFileContent.DATA,
+        file_path=file_path,
+        file_format=FileFormat.PARQUET,
+        partition=Record(None),
+        record_count=2,
+        file_size_in_bytes=os.path.getsize(file_path),
+        spec_id=partition_spec.spec_id,
+    )
+
+    def scan(projected_schema: Schema, row_filter: BooleanExpression) -> 
pa.Table:
+        return ArrowScan(table_metadata, PyArrowFileIO(), projected_schema, 
row_filter).to_table([FileScanTask(data_file)])
+
+    assert scan(table_schema, AlwaysTrue()).to_pydict() == {"other_field": 
["foo", "bar"], "partition_col": [None, None]}
+
+    # Filters on the partition column see null whether or not the column is 
projected
+    for projected_schema in (table_schema, Schema(table_schema.fields[0])):
+        assert scan(projected_schema, 
IsNull("partition_col"))["other_field"].to_pylist() == ["foo", "bar"]
+        assert scan(projected_schema, EqualTo("partition_col", 7)).num_rows == 0
+
+
 @pytest.mark.parametrize("with_field_ids", [False, True], ids=["name-mapping", 
"field-ids"])
 def test_identity_transform_columns_projection(tmp_path: str, catalog: 
InMemoryCatalog, with_field_ids: bool) -> None:
     # Test by adding a non-partitioned data file to a multi-partitioned table, 
verifying partition value

Reply via email to