This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git
The following commit(s) were added to refs/heads/main by this push:
new d98c04cc fix(parquet): read null fixed-size-list parents (#1099)
d98c04cc is described below
commit d98c04cc2246228f94a3cee43c88c23fed82e402
Author: Minh Vu <[email protected]>
AuthorDate: Mon Aug 10 20:42:12 2026 +0200
fix(parquet): read null fixed-size-list parents (#1099)
### Rationale for this change
Parquet does not store child values for a null fixed-size-list parent,
while Arrow requires list_size child slots for every parent. The
existing reader assumes every parent consumes exactly list_size values
and does not validate the final span.
### What changes are included in this PR?
Reconstruct children from valid and null parent runs:
- validate that each valid parent consumes exactly list_size values
- validate that null parents consume no stored child values
- materialize list_size null children for each null parent
- validate the final parent span
When there are no null parents, validate the spans and reuse the decoded
child array directly.
### Are these changes tested?
- `go test ./parquet/pqarrow -run 'TestParquetArrowIO/TestFixedSizeList'
-count=1`
### Are there any user-facing changes?
No API changes. This corrects the reported behavior while preserving the
existing ownership and compatibility contracts.
---
parquet/pqarrow/column_readers.go | 114 ++++++++++++++++++++++++++-------
parquet/pqarrow/column_readers_test.go | 110 +++++++++++++++++++++++++++++++
parquet/pqarrow/encode_arrow_test.go | 33 ++++++++++
3 files changed, 234 insertions(+), 23 deletions(-)
diff --git a/parquet/pqarrow/column_readers.go
b/parquet/pqarrow/column_readers.go
index 9064d018..5aedeb47 100644
--- a/parquet/pqarrow/column_readers.go
+++ b/parquet/pqarrow/column_readers.go
@@ -453,6 +453,10 @@ func (lr *listReader) LoadBatch(nrecords int64) error {
}
func (lr *listReader) BuildArray(lenBound int64) (*arrow.Chunked, error) {
+ return lr.buildArray(lenBound)
+}
+
+func (lr *listReader) buildArray(lenBound int64) (*arrow.Chunked, error) {
var (
defLevels []int16
repLevels []int16
@@ -523,6 +527,14 @@ func (lr *listReader) BuildArray(lenBound int64)
(*arrow.Chunked, error) {
return nil, err
}
defer item.Release()
+ itemArr := array.MakeFromData(item)
+ defer itemArr.Release()
+
+ if lr.field.Type.ID() == arrow.FIXED_SIZE_LIST {
+ offsetData :=
arrow.Int32Traits.CastFromBytes(offsetsBuffer.Bytes())
+ return lr.buildFixedSizeListArray(int(validityIO.Read),
offsetData, validityBuffer,
+ validityIO.NullCount, itemArr)
+ }
buffers := []*memory.Buffer{nil, offsetsBuffer}
if validityIO.NullCount > 0 {
@@ -531,38 +543,94 @@ func (lr *listReader) BuildArray(lenBound int64)
(*arrow.Chunked, error) {
data := array.NewData(lr.field.Type, int(validityIO.Read), buffers,
[]arrow.ArrayData{item}, int(validityIO.NullCount), 0)
defer data.Release()
- if lr.field.Type.ID() == arrow.FIXED_SIZE_LIST {
- defer data.Buffers()[1].Release()
- offsetData :=
arrow.Int32Traits.CastFromBytes(offsetsBuffer.Bytes())
- listSize := lr.field.Type.(*arrow.FixedSizeListType).Len()
- for x := 1; x < data.Len(); x++ {
- size := offsetData[x] - offsetData[x-1]
- if size != listSize {
- return nil, fmt.Errorf("expected all lists to
be of size=%d, but index %d had size=%d", listSize, x, size)
+ out := array.MakeFromData(data)
+ defer out.Release()
+ return arrow.NewChunked(lr.field.Type, []arrow.Array{out}), nil
+}
+
+func (lr *listReader) buildFixedSizeListArray(length int, offsets []int32,
validityBuffer *memory.Buffer,
+ nullCount int64, item arrow.Array) (*arrow.Chunked, error) {
+ listType := lr.field.Type.(*arrow.FixedSizeListType)
+ listSize := int(listType.Len())
+
+ if offsets[0] != 0 {
+ return nil, fmt.Errorf("fixed-size list first offset must be
zero, got %d", offsets[0])
+ }
+ if int64(offsets[length]) != int64(item.Len()) {
+ return nil, fmt.Errorf("fixed-size list final offset %d does
not match decoded child length %d",
+ offsets[length], item.Len())
+ }
+
+ if nullCount == 0 {
+ for idx := 0; idx < length; idx++ {
+ if size := offsets[idx+1] - offsets[idx]; size !=
int32(listSize) {
+ return nil, fmt.Errorf("expected all lists to
be of size=%d, but index %d had size=%d", listSize, idx, size)
+ }
+ }
+ data := array.NewData(lr.field.Type, length,
[]*memory.Buffer{nil},
+ []arrow.ArrayData{item.Data()}, 0, 0)
+ defer data.Release()
+ out := array.MakeFromData(data)
+ defer out.Release()
+ return arrow.NewChunked(lr.field.Type, []arrow.Array{out}), nil
+ }
+
+ // Each validity run becomes one piece. Alternating valid and null
parents
+ // create O(length) temporary arrays before concatenation.
+ pieces := make([]arrow.Array, 0, length)
+ defer func() { releaseArrays(pieces) }()
+
+ for i := 0; i < length; {
+ valid := !lr.field.Nullable ||
bitutil.BitIsSet(validityBuffer.Bytes(), i)
+ end := i + 1
+ for end < length {
+ nextValid := !lr.field.Nullable ||
bitutil.BitIsSet(validityBuffer.Bytes(), end)
+ if nextValid != valid {
+ break
+ }
+ end++
+ }
+
+ if valid {
+ for idx := i; idx < end; idx++ {
+ if size := offsets[idx+1] - offsets[idx]; size
!= int32(listSize) {
+ return nil, fmt.Errorf("expected all
lists to be of size=%d, but index %d had size=%d", listSize, idx, size)
+ }
}
+ pieces = append(pieces, array.NewSlice(item,
int64(offsets[i]), int64(offsets[end])))
+ } else {
+ for idx := i; idx < end; idx++ {
+ if size := offsets[idx+1] - offsets[idx]; size
!= 0 {
+ return nil, fmt.Errorf("null fixed-size
list at index %d consumed %d child values", idx, size)
+ }
+ }
+ pieces = append(pieces,
array.MakeArrayOfNull(lr.rctx.mem, listType.Elem(), (end-i)*listSize))
}
- data.Buffers()[1] = nil
+ i = end
+ }
+
+ if len(pieces) == 0 {
+ pieces = append(pieces, array.MakeArrayOfNull(lr.rctx.mem,
listType.Elem(), 0))
+ }
+ child, err := array.Concatenate(pieces, lr.rctx.mem)
+ if err != nil {
+ return nil, err
+ }
+ defer child.Release()
+
+ buffers := []*memory.Buffer{nil}
+ if nullCount > 0 {
+ buffers[0] = validityBuffer
}
+ data := array.NewData(lr.field.Type, length, buffers,
[]arrow.ArrayData{child.Data()}, int(nullCount), 0)
+ defer data.Release()
out := array.MakeFromData(data)
defer out.Release()
return arrow.NewChunked(lr.field.Type, []arrow.Array{out}), nil
}
-// column reader logic for fixed size lists instead of variable length ones.
-type fixedSizeListReader struct {
- *listReader
-}
-
func newFixedSizeListReader(rctx *readerCtx, field *arrow.Field, info
file.LevelInfo, childRdr *ColumnReader, props ArrowReadProperties)
*ColumnReader {
- childRdr.Retain()
- lr := listReader{rctx: rctx, field: field, info: info, itemRdr:
childRdr, props: props}
- lr.refCount.Add(1)
-
- return &ColumnReader{
- &fixedSizeListReader{
- &lr,
- },
- }
+ return newListReader(rctx, field, info, childRdr, props)
}
// helper function to combine chunks into a single array.
diff --git a/parquet/pqarrow/column_readers_test.go
b/parquet/pqarrow/column_readers_test.go
index 6a86b3f9..1371d6f9 100644
--- a/parquet/pqarrow/column_readers_test.go
+++ b/parquet/pqarrow/column_readers_test.go
@@ -24,6 +24,7 @@ import (
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/extensions"
"github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/parquet"
"github.com/apache/arrow-go/v18/parquet/compress"
@@ -156,6 +157,115 @@ func TestChunksToSingle(t *testing.T) {
})
}
+func TestBuildFixedSizeListArrayRequiresExactChildSpan(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ tests := []struct {
+ name string
+ length int
+ offsets []int32
+ nullCount int64
+ validity byte
+ itemLen int
+ errContains string
+ }{
+ {name: "nonzero first offset", length: 1, offsets: []int32{1,
3}, itemLen: 3,
+ errContains: "first offset must be zero"},
+ {name: "trailing child values", length: 1, offsets: []int32{0,
2}, itemLen: 3,
+ errContains: "final offset 2 does not match decoded
child length 3"},
+ {name: "final offset beyond child values", length: 1, offsets:
[]int32{0, 3}, itemLen: 2,
+ errContains: "final offset 3 does not match decoded
child length 2"},
+ {name: "nullable trailing child values", length: 2, offsets:
[]int32{0, 2, 2}, nullCount: 1, validity: 0x01, itemLen: 3,
+ errContains: "final offset 2 does not match decoded
child length 3"},
+ {name: "valid parent with wrong child span", length: 2,
offsets: []int32{0, 1, 2}, itemLen: 2,
+ errContains: "index 0 had size=1"},
+ {name: "null parent consumes child values", length: 2, offsets:
[]int32{0, 1, 1}, nullCount: 2, itemLen: 1,
+ errContains: "null fixed-size list at index 0 consumed
1 child values"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ listType := arrow.FixedSizeListOf(2,
arrow.PrimitiveTypes.Int32)
+ field := arrow.Field{Type: listType, Nullable: true}
+ lr := &listReader{rctx: &readerCtx{mem: mem}, field:
&field}
+
+ builder := array.NewInt32Builder(mem)
+ builder.AppendValues(make([]int32, tc.itemLen), nil)
+ item := builder.NewArray()
+ defer item.Release()
+ builder.Release()
+
+ var validity *memory.Buffer
+ if tc.nullCount > 0 {
+ validity =
memory.NewBufferBytes([]byte{tc.validity})
+ defer validity.Release()
+ }
+
+ out, err := lr.buildFixedSizeListArray(tc.length,
tc.offsets, validity, tc.nullCount, item)
+ if out != nil {
+ out.Release()
+ }
+ require.ErrorContains(t, err, tc.errContains)
+ })
+ }
+}
+
+func TestBuildFixedSizeListArrayConcatenatesSpecializedChildren(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(t, 0)
+
+ build := func(t *testing.T, item arrow.Array) arrow.Array {
+ t.Helper()
+ listType := arrow.FixedSizeListOf(2, item.DataType())
+ field := arrow.Field{Type: listType, Nullable: true}
+ lr := &listReader{rctx: &readerCtx{mem: mem}, field: &field}
+ validity := memory.NewBufferBytes([]byte{0x01})
+ defer validity.Release()
+
+ out, err := lr.buildFixedSizeListArray(2, []int32{0, 2, 2},
validity, 1, item)
+ require.NoError(t, err)
+ t.Cleanup(out.Release)
+
+ list := out.Chunk(0).(*array.FixedSizeList)
+ assert.False(t, list.IsNull(0))
+ assert.True(t, list.IsNull(1))
+ require.Equal(t, 4, list.ListValues().Len())
+ return list.ListValues()
+ }
+
+ t.Run("dictionary", func(t *testing.T) {
+ dictType := &arrow.DictionaryType{IndexType:
arrow.PrimitiveTypes.Int8, ValueType: arrow.BinaryTypes.String}
+ item, err := array.DictArrayFromJSON(mem, dictType, `[0, 1]`,
`["one", "two"]`)
+ require.NoError(t, err)
+ defer item.Release()
+
+ values := build(t, item).(*array.Dictionary)
+ assert.Equal(t, "one", values.ValueStr(0))
+ assert.Equal(t, "two", values.ValueStr(1))
+ assert.True(t, values.IsNull(2))
+ assert.True(t, values.IsNull(3))
+ })
+
+ t.Run("extension", func(t *testing.T) {
+ extType, err := extensions.NewJSONType(arrow.BinaryTypes.String)
+ require.NoError(t, err)
+ builder := array.NewExtensionBuilder(mem, extType)
+ defer builder.Release()
+
builder.StorageBuilder().(*array.StringBuilder).AppendValues([]string{`{"one":
1}`, `{"two": 2}`}, nil)
+ item := builder.NewArray()
+ defer item.Release()
+
+ values := build(t, item).(array.ExtensionArray)
+ assert.True(t, arrow.TypeEqual(extType, values.DataType()))
+ storage := values.Storage().(*array.String)
+ assert.Equal(t, `{"one": 1}`, storage.Value(0))
+ assert.Equal(t, `{"two": 2}`, storage.Value(1))
+ assert.True(t, storage.IsNull(2))
+ assert.True(t, storage.IsNull(3))
+ })
+}
+
func TestChunkedTableRoundTrip(t *testing.T) {
mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
defer mem.AssertSize(t, 0)
diff --git a/parquet/pqarrow/encode_arrow_test.go
b/parquet/pqarrow/encode_arrow_test.go
index 6c9e979d..7a4fdd01 100644
--- a/parquet/pqarrow/encode_arrow_test.go
+++ b/parquet/pqarrow/encode_arrow_test.go
@@ -1967,6 +1967,39 @@ func (ps *ParquetIOTestSuite) TestFixedSizeList() {
ps.roundTripTable(mem, tbl, true)
}
+func (ps *ParquetIOTestSuite) TestFixedSizeListNullableValues() {
+ mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+ defer mem.AssertSize(ps.T(), 0)
+
+ bldr := array.NewFixedSizeListBuilder(mem, 3,
arrow.PrimitiveTypes.Int16)
+ defer bldr.Release()
+ vb := bldr.ValueBuilder().(*array.Int16Builder)
+
+ bldr.AppendNull()
+ bldr.Append(true)
+ vb.AppendValues([]int16{1, 2, 3}, nil)
+ bldr.AppendNull()
+
+ data := bldr.NewArray()
+ defer data.Release()
+ field := arrow.Field{Name: "root", Type: data.DataType(), Nullable:
true}
+ tbl := array.NewTableFromSlice(arrow.NewSchema([]arrow.Field{field},
nil), [][]arrow.Array{{data}})
+ defer tbl.Release()
+
+ ps.roundTripTable(mem, tbl, true)
+
+ allNullBuilder := array.NewFixedSizeListBuilder(mem, 3,
arrow.PrimitiveTypes.Int16)
+ defer allNullBuilder.Release()
+ allNullBuilder.AppendNulls(2)
+ allNull := allNullBuilder.NewArray()
+ defer allNull.Release()
+ allNullField := arrow.Field{Name: "root", Type: allNull.DataType(),
Nullable: true}
+ allNullTable :=
array.NewTableFromSlice(arrow.NewSchema([]arrow.Field{allNullField}, nil),
[][]arrow.Array{{allNull}})
+ defer allNullTable.Release()
+
+ ps.roundTripTable(mem, allNullTable, true)
+}
+
// TestFixedSizeListNullableElements verifies that FixedSizeList with nullable
// element type correctly round-trips non-null values through Parquet.
// This is a regression test for https://github.com/apache/arrow-go/issues/584