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
