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/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 0e6b8d2d4 feat(table): record NaN value counts for float columns on 
write (#2420)
0e6b8d2d4 is described below

commit 0e6b8d2d4fa387d2aaeb1cf43a2cc72a0466512d
Author: Revanth <[email protected]>
AuthorDate: Wed Nov 4 11:21:22 2026 -0600

    feat(table): record NaN value counts for float columns on write (#2420)
---
 table/internal/parquet_files.go                 | 128 +++++++++++++++++
 table/internal/parquet_files_nan_counts_test.go | 178 ++++++++++++++++++++++++
 2 files changed, 306 insertions(+)

diff --git a/table/internal/parquet_files.go b/table/internal/parquet_files.go
index c1cc61c37..29bf5609a 100644
--- a/table/internal/parquet_files.go
+++ b/table/internal/parquet_files.go
@@ -517,6 +517,8 @@ type ParquetFileWriter struct {
        geoCols          []geoColumn
        geoNormalizeCols []int
        geoAccs          map[int]*geoBoundsAccumulator
+       nanCols          []nanColumn
+       nanCounts        map[int]int64
        arrowSchema      *arrow.Schema
        rowGroupBytes    int64
 }
@@ -671,6 +673,12 @@ func (p parquetFormat) NewFileWriter(ctx context.Context, 
fs iceio.WriteFileIO,
                geoAccs[gc.fieldID] = newGeoBoundsAccumulator(isGeog)
        }
 
+       nanCols := collectNaNColumns(arrowSchema, colMapping, info.StatsCols)
+       nanCounts := make(map[int]int64, len(nanCols))
+       for _, nc := range nanCols {
+               nanCounts[nc.fieldID] = 0
+       }
+
        return &ParquetFileWriter{
                pqWriter:         writer,
                counter:          counter,
@@ -685,6 +693,8 @@ func (p parquetFormat) NewFileWriter(ctx context.Context, 
fs iceio.WriteFileIO,
                geoCols:          geoCols,
                geoNormalizeCols: geoNormalizeCols,
                geoAccs:          geoAccs,
+               nanCols:          nanCols,
+               nanCounts:        nanCounts,
                arrowSchema:      arrowSchema,
                rowGroupBytes:    rowGroupTargetSizeBytes,
        }, nil
@@ -856,6 +866,10 @@ func (w *ParquetFileWriter) Write(batch arrow.RecordBatch) 
error {
                return err
        }
 
+       if err := w.countNaNs(writeBatch); err != nil {
+               return err
+       }
+
        // Rotate before writing the next non-empty batch. Rotating immediately 
after
        // crossing the target would leave an empty trailing row group when the 
file
        // is closed without another write.
@@ -1306,6 +1320,118 @@ func accumulateWKBValues(acc *geoBoundsAccumulator, arr 
arrow.Array, lo, hi int)
        return nil
 }
 
+// nanColumn locates a float or double leaf whose NaN values are counted during
+// the write: the position of its top-level column in the Arrow record batch,
+// the struct child index taken at each struct on the way down, and the Iceberg
+// field ID the count is recorded under.
+type nanColumn struct {
+       colIdx  int
+       path    []int
+       fieldID int
+}
+
+// collectNaNColumns finds the float and double leaves that get a NaN value
+// count. Parquet column statistics carry no NaN count, so it comes from the
+// written values. Like Java's MetricsUtil.createNanValueCounts, this skips
+// leaves inside a list or map and columns whose metrics mode is none.
+func collectNaNColumns(sc *arrow.Schema, colMapping map[string]int, statsCols 
map[int]StatisticsCollector) []nanColumn {
+       var result []nanColumn
+       for i, f := range sc.Fields() {
+               result = appendNaNColumns(result, f.Type, f.Name, i, nil, 
colMapping, statsCols)
+       }
+
+       return result
+}
+
+// appendNaNColumns walks dt through structs only and appends a nanColumn for
+// each float or double leaf. name is the Parquet column path used as the
+// colMapping key.
+func appendNaNColumns(result []nanColumn, dt arrow.DataType, name string, 
colIdx int, path []int, colMapping map[string]int, statsCols 
map[int]StatisticsCollector) []nanColumn {
+       switch t := dt.(type) {
+       case *arrow.Float32Type, *arrow.Float64Type:
+               fieldID, ok := colMapping[name]
+               if !ok {
+                       return result
+               }
+               if sc, ok := statsCols[fieldID]; !ok || sc.Mode.Typ == 
MetricModeNone {
+                       return result
+               }
+               result = append(result, nanColumn{colIdx: colIdx, path: 
slices.Clone(path), fieldID: fieldID})
+       case *arrow.StructType:
+               for i, f := range t.Fields() {
+                       result = appendNaNColumns(result, f.Type, 
name+"."+f.Name, colIdx, append(path, i), colMapping, statsCols)
+               }
+       }
+
+       return result
+}
+
+// countNaNs adds the NaN values in this batch to the per-field counts. A value
+// under a null struct is not counted, since the row has no value there.
+func (w *ParquetFileWriter) countNaNs(batch arrow.RecordBatch) error {
+       for _, nc := range w.nanCols {
+               if nc.colIdx >= int(batch.NumCols()) {
+                       return fmt.Errorf("float field %d: column %d missing 
from batch with %d columns",
+                               nc.fieldID, nc.colIdx, batch.NumCols())
+               }
+
+               col := batch.Column(nc.colIdx)
+               n, err := countNaNValues(col, nc.path, 0, col.Len())
+               if err != nil {
+                       return fmt.Errorf("float field %d: %w", nc.fieldID, err)
+               }
+               w.nanCounts[nc.fieldID] += n
+       }
+
+       return nil
+}
+
+func countNaNValues(arr arrow.Array, path []int, lo, hi int) (int64, error) {
+       var n int64
+       switch arr := arr.(type) {
+       case *array.Struct:
+               if len(path) == 0 || path[0] >= arr.NumField() {
+                       return 0, fmt.Errorf("struct array %s does not match 
the float column path", arr.DataType())
+               }
+               child := arr.Field(path[0])
+               if arr.NullN() == 0 {
+                       return countNaNValues(child, path[1:], lo, hi)
+               }
+               for i := lo; i < hi; i++ {
+                       if arr.IsNull(i) {
+                               continue
+                       }
+                       c, err := countNaNValues(child, path[1:], i, i+1)
+                       if err != nil {
+                               return 0, err
+                       }
+                       n += c
+               }
+       case *array.Float32:
+               if len(path) != 0 {
+                       return 0, fmt.Errorf("%s array does not match the float 
column path", arr.DataType())
+               }
+               for i := lo; i < hi; i++ {
+                       if arr.IsValid(i) && math.IsNaN(float64(arr.Value(i))) {
+                               n++
+                       }
+               }
+       case *array.Float64:
+               if len(path) != 0 {
+                       return 0, fmt.Errorf("%s array does not match the float 
column path", arr.DataType())
+               }
+               for i := lo; i < hi; i++ {
+                       if arr.IsValid(i) && math.IsNaN(arr.Value(i)) {
+                               n++
+                       }
+               }
+       default:
+               return 0, fmt.Errorf("unexpected %s array for a float column", 
arr.DataType())
+       }
+
+       return n, nil
+}
+
 // BytesWritten returns the number of bytes flushed to the output so far.
 func (w *ParquetFileWriter) BytesWritten() int64 {
        return w.counter.Count
@@ -1332,6 +1458,8 @@ func (w *ParquetFileWriter) Close() (_ iceberg.DataFile, 
err error) {
                return nil, err
        }
 
+       maps.Copy(stats.NanValueCounts, w.nanCounts)
+
        opts := DataFileOpts{
                Schema:          w.info.FileSchema,
                Spec:            w.info.Spec,
diff --git a/table/internal/parquet_files_nan_counts_test.go 
b/table/internal/parquet_files_nan_counts_test.go
new file mode 100644
index 000000000..f089c02ab
--- /dev/null
+++ b/table/internal/parquet_files_nan_counts_test.go
@@ -0,0 +1,178 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package internal_test
+
+import (
+       "context"
+       "math"
+       "slices"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/bitutil"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/apache/iceberg-go/table/internal"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// TestWriteDataFileRecordsNaNValueCounts writes float and double columns
+// through the full ParquetFileWriter path. Parquet statistics carry no NaN
+// count, so the writer counts NaN values itself; like Java, it records a 
count,
+// zero included, for each float leaf outside a list or map whose metrics mode
+// isn't none.
+func TestWriteDataFileRecordsNaNValueCounts(t *testing.T) {
+       iceSchema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "d", Type: 
iceberg.PrimitiveTypes.Float64},
+               iceberg.NestedField{ID: 2, Name: "f", Type: 
iceberg.PrimitiveTypes.Float32},
+               iceberg.NestedField{ID: 3, Name: "s", Type: 
&iceberg.StructType{FieldList: []iceberg.NestedField{
+                       {ID: 4, Name: "x", Type: 
iceberg.PrimitiveTypes.Float64},
+               }}},
+               iceberg.NestedField{ID: 5, Name: "l", Type: &iceberg.ListType{
+                       ElementID: 6, Element: iceberg.PrimitiveTypes.Float64,
+               }},
+               iceberg.NestedField{ID: 7, Name: "n", Type: 
iceberg.PrimitiveTypes.Float64},
+               iceberg.NestedField{ID: 8, Name: "z", Type: 
iceberg.PrimitiveTypes.Float64},
+               iceberg.NestedField{ID: 9, Name: "i", Type: 
iceberg.PrimitiveTypes.Int32},
+       )
+       arrowSchema, err := table.SchemaToArrowSchema(iceSchema, nil, true, 
false)
+       require.NoError(t, err)
+
+       nan := math.NaN()
+       type row struct {
+               d, x, n, z float64
+               f          float32
+               dNull      bool
+               sNull      bool
+               list       []float64
+       }
+       batch := func(rows []row) arrow.RecordBatch {
+               bldr := array.NewRecordBuilder(memory.DefaultAllocator, 
arrowSchema)
+               defer bldr.Release()
+
+               for _, r := range rows {
+                       if r.dNull {
+                               bldr.Field(0).AppendNull()
+                       } else {
+                               
bldr.Field(0).(*array.Float64Builder).Append(r.d)
+                       }
+                       bldr.Field(1).(*array.Float32Builder).Append(r.f)
+                       sb := bldr.Field(2).(*array.StructBuilder)
+                       sb.Append(true)
+                       sb.FieldBuilder(0).(*array.Float64Builder).Append(r.x)
+                       lb := bldr.Field(3).(*array.ListBuilder)
+                       lb.Append(true)
+                       for _, v := range r.list {
+                               
lb.ValueBuilder().(*array.Float64Builder).Append(v)
+                       }
+                       bldr.Field(4).(*array.Float64Builder).Append(r.n)
+                       bldr.Field(5).(*array.Float64Builder).Append(r.z)
+                       bldr.Field(6).(*array.Int32Builder).Append(1)
+               }
+
+               rec := bldr.NewRecordBatch()
+               defer rec.Release()
+
+               // StructBuilder.Append(false) appends a null child too, so 
null struct
+               // rows are set afterwards over the built child: a NaN then 
sits under a
+               // null struct row without belonging to it.
+               valid := make([]byte, bitutil.BytesForBits(int64(len(rows))))
+               nulls := 0
+               for i, r := range rows {
+                       if r.sNull {
+                               nulls++
+                       } else {
+                               bitutil.SetBit(valid, i)
+                       }
+               }
+               structData := rec.Column(2).Data()
+               withNulls := array.NewData(structData.DataType(), 
structData.Len(),
+                       []*memory.Buffer{memory.NewBufferBytes(valid)}, 
structData.Children(), nulls, 0)
+               defer withNulls.Release()
+               structArr := array.MakeFromData(withNulls)
+               defer structArr.Release()
+
+               cols := slices.Clone(rec.Columns())
+               cols[2] = structArr
+
+               return array.NewRecordBatch(arrowSchema, cols, rec.NumRows())
+       }
+
+       first := batch([]row{
+               {d: 1, f: float32(nan), x: nan, list: []float64{nan}, n: nan, 
z: 1},
+               {d: nan, f: 2, x: nan, sNull: true, list: []float64{nan, 1}, n: 
nan, z: 2},
+       })
+       defer first.Release()
+       second := batch([]row{
+               {dNull: true, f: 3, x: 1, list: []float64{nan}, n: nan, z: 3},
+               {d: nan, f: 4, x: nan, n: nan, z: 4},
+       })
+       defer second.Release()
+
+       // The NaN under the null struct row must really be in the child array,
+       // or the s.x count below would pass without the null check.
+       structCol := first.Column(2).(*array.Struct)
+       require.True(t, structCol.IsNull(1))
+       require.True(t, 
math.IsNaN(structCol.Field(0).(*array.Float64).Value(1)))
+
+       full := internal.MetricsMode{Typ: internal.MetricModeFull}
+       counts := internal.MetricsMode{Typ: internal.MetricModeCounts}
+       statsCols := map[int]internal.StatisticsCollector{
+               1: {FieldID: 1, IcebergTyp: iceberg.PrimitiveTypes.Float64, 
ColName: "d", Mode: full},
+               2: {FieldID: 2, IcebergTyp: iceberg.PrimitiveTypes.Float32, 
ColName: "f", Mode: counts},
+               4: {FieldID: 4, IcebergTyp: iceberg.PrimitiveTypes.Float64, 
ColName: "s.x", Mode: counts},
+               6: {FieldID: 6, IcebergTyp: iceberg.PrimitiveTypes.Float64, 
ColName: "l.element", Mode: counts},
+               7: {FieldID: 7, IcebergTyp: iceberg.PrimitiveTypes.Float64, 
ColName: "n", Mode: internal.MetricsMode{Typ: internal.MetricModeNone}},
+               8: {FieldID: 8, IcebergTyp: iceberg.PrimitiveTypes.Float64, 
ColName: "z", Mode: full},
+               9: {FieldID: 9, IcebergTyp: iceberg.PrimitiveTypes.Int32, 
ColName: "i", Mode: full},
+       }
+
+       fm := internal.GetFileFormat(iceberg.ParquetFile)
+       df, err := fm.WriteDataFile(context.Background(), iceio.NewMemFS(), 
nil, internal.WriteFileInfo{
+               FileSchema: iceSchema,
+               Spec:       *iceberg.UnpartitionedSpec,
+               FileName:   "nan.parquet",
+               StatsCols:  statsCols,
+               WriteProps: fm.GetWriteProperties(iceberg.Properties{}),
+               Content:    iceberg.EntryContentData,
+       }, []arrow.RecordBatch{first, second})
+       require.NoError(t, err)
+
+       // d: NaN in both batches, and the NULL row is not a NaN. f: counts mode
+       // still records NaN counts. s.x: three NaN child values, one of them
+       // under the null struct row. z: no NaN still records a zero, which is
+       // what lets a metrics evaluator rule NaN out. l.element is in a list 
and
+       // n has metrics mode none, so neither is recorded, nor is the int 
column.
+       assert.Equal(t, map[int]int64{1: 2, 2: 1, 4: 2, 8: 0}, 
df.NaNValueCounts())
+
+       // Parquet already leaves NaN out of float min/max, so the bounds hold
+       // only the finite values.
+       lower, upper := df.LowerBoundValues(), df.UpperBoundValues()
+       d := func(b []byte) float64 {
+               lit, err := 
iceberg.LiteralFromBytes(iceberg.PrimitiveTypes.Float64, b)
+               require.NoError(t, err)
+
+               return float64(lit.(iceberg.Float64Literal))
+       }
+       assert.Equal(t, 1.0, d(lower[1]))
+       assert.Equal(t, 1.0, d(upper[1]))
+}

Reply via email to