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 50fb0781 fix(parquet/metadata): count non-null values from the
validity bitmap in UpdateSpaced (#1367)
50fb0781 is described below
commit 50fb07811d3626f175e66ceba020c775594768b7
Author: Animesh Kumar <[email protected]>
AuthorDate: Tue Oct 13 19:41:54 2026 +0530
fix(parquet/metadata): count non-null values from the validity bitmap in
UpdateSpaced (#1367)
### Rationale for this change
Fixes #1366. Writing a `list<timestamp>` column whose only row is a null
list with statistics enabled produced `min = MaxInt64` and `max =
MinInt64` with `HasMinMax() == true`, so DuckDB failed to read the file.
`UpdateSpaced` computed the non-null count as `len(values) - numNull`.
For a nested column the spaced values only cover the leaf slots, but
`numNull` also counts the null list, so the count was `0 - 1 = -1`. That
skipped the `notnull == 0` early return and set min/max from no values.
The same miscount also hid real values: a null list next to `[5]` gave
`1 - 1 = 0`, so the `5` never reached min/max.
### What changes are included in this PR?
`UpdateSpaced` counts the non-null values with `bitutil.CountSetBits`
over `validBits`, the bitmap it already uses for `getMinMaxSpaced`. The
change is in `statistics_types.gen.go.tmpl`, and
`statistics_types.gen.go` is regenerated with `go generate`.
### Are these changes tested?
Yes. `TestWriteArrowNestedNullListStatistics` writes a column with only
a null list and one with a null list followed by `[5]`, on both data
page versions. It checks the null count, the number of values and that
min/max are absent or `5`. All four cases fail on main and pass with the
fix.
`go test ./parquet/... ./arrow/compute/...` passes with the testing
submodules, and golangci-lint reports no issues.
### Are there any user-facing changes?
Nested columns with null lists no longer get invalid min/max statistics,
and their non-null values are included in min/max.
---
parquet/metadata/statistics_types.gen.go | 18 ++++-----
parquet/metadata/statistics_types.gen.go.tmpl | 3 +-
parquet/pqarrow/encode_arrow_test.go | 54 +++++++++++++++++++++++++++
3 files changed, 65 insertions(+), 10 deletions(-)
diff --git a/parquet/metadata/statistics_types.gen.go
b/parquet/metadata/statistics_types.gen.go
index aa4099a5..f6e93ef4 100644
--- a/parquet/metadata/statistics_types.gen.go
+++ b/parquet/metadata/statistics_types.gen.go
@@ -242,7 +242,7 @@ func (s *Int32Statistics) Update(values []int32, numNull
int64) {
// and skip null values.
func (s *Int32Statistics) UpdateSpaced(values []int32, validBits []byte,
validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -546,7 +546,7 @@ func (s *Int64Statistics) Update(values []int64, numNull
int64) {
// and skip null values.
func (s *Int64Statistics) UpdateSpaced(values []int64, validBits []byte,
validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -835,7 +835,7 @@ func (s *Int96Statistics) Update(values []parquet.Int96,
numNull int64) {
// and skip null values.
func (s *Int96Statistics) UpdateSpaced(values []parquet.Int96, validBits
[]byte, validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -1124,7 +1124,7 @@ func (s *Float32Statistics) Update(values []float32,
numNull int64) {
// and skip null values.
func (s *Float32Statistics) UpdateSpaced(values []float32, validBits []byte,
validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -1420,7 +1420,7 @@ func (s *Float64Statistics) Update(values []float64,
numNull int64) {
// and skip null values.
func (s *Float64Statistics) UpdateSpaced(values []float64, validBits []byte,
validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -1709,7 +1709,7 @@ func (s *BooleanStatistics) Update(values []bool, numNull
int64) {
// and skip null values.
func (s *BooleanStatistics) UpdateSpaced(values []bool, validBits []byte,
validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -2084,7 +2084,7 @@ func (s *ByteArrayStatistics) Update(values
[]parquet.ByteArray, numNull int64)
// and skip null values.
func (s *ByteArrayStatistics) UpdateSpaced(values []parquet.ByteArray,
validBits []byte, validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -2418,7 +2418,7 @@ func (s *FixedLenByteArrayStatistics) Update(values
[]parquet.FixedLenByteArray,
// and skip null values.
func (s *FixedLenByteArrayStatistics) UpdateSpaced(values
[]parquet.FixedLenByteArray, validBits []byte, validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
@@ -2761,7 +2761,7 @@ func (s *Float16Statistics) Update(values
[]parquet.FixedLenByteArray, numNull i
// and skip null values.
func (s *Float16Statistics) UpdateSpaced(values []parquet.FixedLenByteArray,
validBits []byte, validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
diff --git a/parquet/metadata/statistics_types.gen.go.tmpl
b/parquet/metadata/statistics_types.gen.go.tmpl
index 0938d4ed..eef1a050 100644
--- a/parquet/metadata/statistics_types.gen.go.tmpl
+++ b/parquet/metadata/statistics_types.gen.go.tmpl
@@ -20,6 +20,7 @@ import (
"fmt"
"github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/bitutil"
"github.com/apache/arrow-go/v18/parquet"
"github.com/apache/arrow-go/v18/parquet/schema"
"github.com/apache/arrow-go/v18/parquet/internal/utils"
@@ -364,7 +365,7 @@ func (s *{{.Name}}Statistics) Update(values []{{.name}},
numNull int64) {
// and skip null values.
func (s *{{.Name}}Statistics) UpdateSpaced(values []{{.name}}, validBits
[]byte, validBitsOffset, numNull int64) {
s.IncNulls(numNull)
- notnull := int64(len(values)) - numNull
+ notnull := int64(bitutil.CountSetBits(validBits, int(validBitsOffset),
len(values)))
s.nvalues += notnull
if notnull == 0 {
diff --git a/parquet/pqarrow/encode_arrow_test.go
b/parquet/pqarrow/encode_arrow_test.go
index 7f49aeff..ab8d0797 100644
--- a/parquet/pqarrow/encode_arrow_test.go
+++ b/parquet/pqarrow/encode_arrow_test.go
@@ -43,6 +43,7 @@ import (
"github.com/apache/arrow-go/v18/parquet/file"
"github.com/apache/arrow-go/v18/parquet/internal/encoding"
"github.com/apache/arrow-go/v18/parquet/internal/testutils"
+ "github.com/apache/arrow-go/v18/parquet/metadata"
"github.com/apache/arrow-go/v18/parquet/pqarrow"
"github.com/apache/arrow-go/v18/parquet/schema"
"github.com/google/uuid"
@@ -3082,3 +3083,56 @@ func
TestByteArrayStatisticsStreamingReleaseBetweenBatches(t *testing.T) {
assert.Equal(t, "aaa", string(stats.EncodeMin()), "min corrupted by
released batch buffer")
assert.Equal(t, "zzz", string(stats.EncodeMax()), "max corrupted by
released batch buffer")
}
+
+func TestWriteArrowNestedNullListStatistics(t *testing.T) {
+ tests := []struct {
+ name string
+ values []int64
+ hasMinMax bool
+ }{
+ {name: "null-list-only"},
+ {name: "null-list-and-value", values: []int64{5}, hasMinMax:
true},
+ }
+ for _, pageVersion := range
[]parquet.DataPageVersion{parquet.DataPageV1, parquet.DataPageV2} {
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("page-%d/%s", pageVersion, tt.name),
func(t *testing.T) {
+ builder :=
array.NewListBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
arrow.Millisecond})
+ defer builder.Release()
+ values :=
builder.ValueBuilder().(*array.TimestampBuilder)
+ builder.AppendNull()
+ if len(tt.values) > 0 {
+ builder.Append(true)
+ for _, v := range tt.values {
+
values.Append(arrow.Timestamp(v))
+ }
+ }
+ arr := builder.NewListArray()
+ defer arr.Release()
+ field := arrow.Field{Name: "events", Type:
arr.DataType(), Nullable: true}
+ column := arrow.NewColumnFromArr(field, arr)
+ defer column.Release()
+ tbl :=
array.NewTable(arrow.NewSchema([]arrow.Field{field}, nil),
[]arrow.Column{column}, int64(arr.Len()))
+ defer tbl.Release()
+
+ props :=
parquet.NewWriterProperties(parquet.WithStats(true),
parquet.WithDataPageVersion(pageVersion))
+ data := writeParquetTable(t, tbl,
tbl.NumRows(), props)
+ reader, err :=
file.NewParquetReader(bytes.NewReader(data))
+ require.NoError(t, err)
+ defer reader.Close()
+ chunk, err :=
reader.MetaData().RowGroup(0).ColumnChunk(0)
+ require.NoError(t, err)
+ stats, err := chunk.Statistics()
+ require.NoError(t, err)
+
+ require.Equal(t, int64(1), stats.NullCount())
+ require.Equal(t, int64(len(tt.values)),
stats.NumValues())
+ require.Equal(t, tt.hasMinMax,
stats.HasMinMax())
+ if tt.hasMinMax {
+ typed :=
stats.(*metadata.Int64Statistics)
+ require.Equal(t, int64(5), typed.Min())
+ require.Equal(t, int64(5), typed.Max())
+ }
+ })
+ }
+ }
+}