slachiewicz opened a new issue, #3833:
URL: https://github.com/apache/iceberg-python/issues/3833

   ### Apache Iceberg version
   
   main (development)
   
   ### Please describe the bug 🐞
   
   A null `list<struct<...>>` is silently rebuilt as an **empty** list. This is 
known on
   the read path — `tests/integration/test_reads.py::test_null_list_and_map` 
currently
   asserts the corrupted value, with the correct assertion commented out pending
   apache/arrow#38809:
   
   ```python
   # This should be:
   # assert arrow_table["col_list_with_struct"].to_pylist() == [None, [{'test': 
1}]]
   # Once https://github.com/apache/arrow/issues/38809 has been fixed
   assert arrow_table["col_list_with_struct"].to_pylist() == [[], [{"test": 1}]]
   ```
   
   Two things seem worth reporting on top of that.
   
   **It also affects the write path**, where the consequence is worse. The 
Parquet file
   pyiceberg writes contains an empty list, so the null is gone at rest and no 
reader —
   pyiceberg, Spark, Trino — can recover it. On read the file is at least still 
correct.
   
   **It does not depend on the upstream Arrow fix.** 
`pa.LargeListArray.from_arrays`
   takes a `mask` argument — since well before pyiceberg's `pyarrow>=18.0.0` 
floor — so
   this particular null loss can be fixed independently of apache/arrow#38809, 
which is
   still open.
   
   This is the `array<struct<>>` case from #251. That issue was closed in March 
2025 on
   the strength of this test existing, but the assertion it makes is the 
corrupted one;
   the `array<int>` case in the issue body was genuinely fixed by #252, while 
the
   `array<struct<test:int>>` case in the issue *title* — which @HonahX flagged 
as
   remaining broken in
   https://github.com/apache/iceberg-python/pull/252#discussion_r1467065763 — 
was not.
   
   #### Reproduction (write path)
   
   pyiceberg 0.11.1, pyarrow 25.0.1:
   
   ```python
   import os, shutil, glob
   import pyarrow as pa, pyarrow.parquet as pq
   from pyiceberg.catalog.sql import SqlCatalog
   
   WH = "/tmp/wh"; shutil.rmtree(WH, ignore_errors=True); os.makedirs(WH)
   
   sch = pa.schema([
       pa.field("id", pa.int32(), nullable=False),
       pa.field("l_struct", pa.list_(pa.field("element", 
pa.struct([pa.field("x", pa.int32())]), nullable=True)), nullable=True),
       pa.field("l_int",    pa.list_(pa.field("element", pa.int32(), 
nullable=True)), nullable=True),
   ])
   tbl = pa.table({"id": [1, 2, 3, 4],
                   "l_struct": [[{"x": 1}], [], None, [{"x": 3}]],
                   "l_int":    [[1],        [], None, [3]]}, schema=sch)
   
   cat = SqlCatalog("r", uri=f"sqlite:///{WH}/c.db", warehouse=f"file://{WH}")
   cat.create_namespace("ns")
   it = cat.create_table("ns.t", schema=tbl.schema)
   it.append(tbl)
   
   out = it.scan().to_arrow()
   for c in ("l_struct", "l_int"):
       print(f"{c:9s} in={tbl.column(c).to_pylist()!s:35s} 
out={out.column(c).to_pylist()}")
   
   # the loss is already in the file on disk, not in the read path
   f = glob.glob(f"{WH}/**/*.parquet", recursive=True)[0]
   print("raw parquet:", pq.read_table(f).column("l_struct").to_pylist())
   ```
   
   Output:
   
   ```
   l_struct  in=[[{'x': 1}], [], None, [{'x': 3}]]  out=[[{'x': 1}], [], [], 
[{'x': 3}]]
   l_int     in=[[1], [], None, [3]]                out=[[1], [], None, [3]]
   raw parquet: [[{'x': 1}], [], [], [{'x': 3}]]
   ```
   
   `l_int` round-trips correctly, and writing the same `pa.Table` with 
`pq.write_table`
   preserves the null, so the loss is not pyarrow's.
   
   #### Cause
   
   `ArrowProjectionVisitor.list` rebuilds the array when the element is a struct
   (`pyiceberg/io/pyarrow.py:2078` on `main` @ 7539661):
   
   ```python
   if isinstance(value_array, pa.StructArray):
       # This can be removed once this has been fixed:
       # https://github.com/apache/arrow/issues/38809
       list_array = pa.LargeListArray.from_arrays(list_array.offsets, 
value_array)
   ```
   
   `from_arrays` receives the offsets buffer alone, which cannot express a null 
list, and
   no mask, so the validity bitmap is dropped. That is also why only this one 
shape is
   affected: the `struct` visitor passes `mask=struct_array.is_null()`, the 
`map` visitor
   does not rebuild at all, and a list whose element is a primitive never 
enters this
   branch. The visitor runs on both paths, which is why the same root cause 
shows up as
   the read-side assertion above and as the write-side corruption here.
   
   #### Fix
   
   Carrying the mask over is enough:
   
   ```python
   list_array = pa.LargeListArray.from_arrays(list_array.offsets, value_array, 
mask=list_array.is_null())
   ```
   
   With that change the reproduction above returns `None` for both columns, and
   `test_null_list_and_map` passes with its commented-out assertion restored. I 
have not
   looked at int32-offset or sliced-array handling of this call, which the 
existing line
   already relies on; that appears independent of the mask.
   
   I have this on a branch with a unit test covering the write path and the 
integration
   assertion un-commented, and can open a PR.
   
   Found while testing a third-party Iceberg writer against pyiceberg as a 
reader.
   
   ### Willingness to contribute
   
   - [x] I can contribute a fix for this bug independently
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to