Skip to content

Commit 69af7ae

Browse files
committed
Fix null list of structs being read and written as an empty list
from_arrays() takes the offsets buffer alone, which cannot express a null list, so rebuilding the array without a mask collapsed every null list<struct<...>> into an empty one. On write that is unrecoverable: the Parquet file itself then holds the empty list. The rebuild still works around apache/arrow#38809 and stays; only the mask is added. That restores the assertion test_null_list_and_map has carried commented out since #252. Closes #3833
1 parent 7539661 commit 69af7ae

3 files changed

Lines changed: 42 additions & 5 deletions

File tree

pyiceberg/io/pyarrow.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2075,7 +2075,10 @@ def list(self, list_type: ListType, list_array: pa.Array | None, value_array: pa
20752075
if isinstance(value_array, pa.StructArray):
20762076
# This can be removed once this has been fixed:
20772077
# https://github.com/apache/arrow/issues/38809
2078-
list_array = pa.LargeListArray.from_arrays(list_array.offsets, value_array)
2078+
# The mask must be carried over explicitly: from_arrays() takes the offsets
2079+
# buffer alone, which cannot express a null list, so without it every null
2080+
# list is rebuilt as an empty one.
2081+
list_array = pa.LargeListArray.from_arrays(list_array.offsets, value_array, mask=list_array.is_null())
20792082
value_array = self._cast_if_needed(list_type.element_field, value_array)
20802083
arrow_field = list_initializer(self._construct_field(list_type.element_field, value_array.type))
20812084
return list_array.cast(arrow_field)

tests/integration/test_reads.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -994,10 +994,7 @@ def test_null_list_and_map(catalog: Catalog) -> None:
994994
arrow_table = table_test_empty_list_and_map.scan().to_arrow()
995995
assert arrow_table["col_list"].to_pylist() == [None, []]
996996
assert arrow_table["col_map"].to_pylist() == [None, []]
997-
# This should be:
998-
# assert arrow_table["col_list_with_struct"].to_pylist() == [None, [{'test': 1}]]
999-
# Once https://github.com/apache/arrow/issues/38809 has been fixed
1000-
assert arrow_table["col_list_with_struct"].to_pylist() == [[], [{"test": 1}]]
997+
assert arrow_table["col_list_with_struct"].to_pylist() == [None, [{"test": 1}]]
1001998

1002999

10031000
@pytest.mark.integration

tests/io/test_pyarrow.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3210,6 +3210,43 @@ def test__to_requested_schema_float_promotion(
32103210
assert result.column(0).to_pylist() == [1.5, 2.25, 3.0, None]
32113211

32123212

3213+
def test__to_requested_schema_null_list_of_structs() -> None:
3214+
"""Test that a null list survives the write path when its element is a struct."""
3215+
requested_schema = Schema(
3216+
NestedField(
3217+
1,
3218+
"col_list_with_struct",
3219+
ListType(11, StructType(NestedField(111, "test", IntegerType(), required=False)), element_required=False),
3220+
required=False,
3221+
),
3222+
NestedField(2, "col_list", ListType(21, IntegerType(), element_required=False), required=False),
3223+
)
3224+
file_schema = requested_schema
3225+
3226+
arrow_schema = pa.schema(
3227+
[
3228+
pa.field("col_list_with_struct", pa.list_(pa.struct([pa.field("test", pa.int32())]))),
3229+
pa.field("col_list", pa.list_(pa.int32())),
3230+
]
3231+
)
3232+
batch = pa.RecordBatch.from_arrays(
3233+
[
3234+
pa.array([[{"test": 1}], [], None], type=arrow_schema.field(0).type),
3235+
pa.array([[1], [], None], type=arrow_schema.field(1).type),
3236+
],
3237+
schema=arrow_schema,
3238+
)
3239+
3240+
result = _to_requested_schema(
3241+
requested_schema, file_schema, batch, downcast_ns_timestamp_to_us=False, include_field_ids=False
3242+
)
3243+
3244+
# A null list and an empty list are different values, and only the struct-element
3245+
# case ever collapsed the former into the latter.
3246+
assert result.column(0).to_pylist() == [[{"test": 1}], [], None]
3247+
assert result.column(1).to_pylist() == [[1], [], None]
3248+
3249+
32133250
def test_pyarrow_file_io_fs_by_scheme_cache() -> None:
32143251
# It's better to set up multi-region minio servers for an integration test once `endpoint_url` argument
32153252
# becomes available for `resolve_s3_region`

0 commit comments

Comments
 (0)