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 2c5bc2359 fix: keep NaN rows when RewriteNotExpr pushes a NOT down 
(#2355)
2c5bc2359 is described below

commit 2c5bc23598aed1e160a8285f8af5c8c1bf33f507
Author: Revanth <[email protected]>
AuthorDate: Wed Nov 4 11:10:19 2026 -0600

    fix: keep NaN rows when RewriteNotExpr pushes a NOT down (#2355)
    
    * fix: keep NaN rows when RewriteNotExpr pushes a NOT down
    
    * docs: link the follow-up issues and note the float comparison change
---
 exprs.go                                |  40 ++++++
 exprs_test.go                           |  27 ++--
 literals.go                             |   9 +-
 table/delete_classification_nan_test.go | 105 ++++++++++++++++
 table/not_nan_pruning_test.go           | 210 ++++++++++++++++++++++++++++++++
 table/transaction_delete.go             |   5 +-
 visitors.go                             |  52 +++++++-
 visitors_test.go                        |  59 +++++++++
 website/src/releases.md                 |   1 +
 9 files changed, 493 insertions(+), 15 deletions(-)

diff --git a/exprs.go b/exprs.go
index b133c9dd9..cb427a761 100644
--- a/exprs.go
+++ b/exprs.go
@@ -769,6 +769,30 @@ func (ul *unboundLiteralPredicate) Bind(schema *Schema, 
caseSensitive bool) (Boo
                return nil, err
        }
 
+       // A finite literal beyond the range of a floating-point term lies 
between
+       // the largest finite value and infinity, so an ordering comparison 
against
+       // it is the same as one against the largest finite value. Folding it to
+       // AlwaysTrue or AlwaysFalse instead would get NaN, the infinities and 
NULL
+       // wrong: x < 1e100 on a float32 column is false for NaN and +Inf and 
NULL
+       // for NULL. Java still folds them, see
+       // https://github.com/apache/iceberg/issues/18700.
+       if isFloatingPoint(bound.Type()) && isOrderingOp(ul.op) {
+               switch lit.(type) {
+               case AboveMaxLiteral:
+                       if ul.op == OpLT || ul.op == OpLTEQ {
+                               return createBoundLiteralPredicate(OpLTEQ, 
bound, maxFiniteLiteral(bound.Type()))
+                       }
+
+                       return createBoundLiteralPredicate(OpGT, bound, 
maxFiniteLiteral(bound.Type()))
+               case BelowMinLiteral:
+                       if ul.op == OpGT || ul.op == OpGTEQ {
+                               return createBoundLiteralPredicate(OpGTEQ, 
bound, minFiniteLiteral(bound.Type()))
+                       }
+
+                       return createBoundLiteralPredicate(OpLT, bound, 
minFiniteLiteral(bound.Type()))
+               }
+       }
+
        switch lit.(type) {
        case AboveMaxLiteral:
                switch ul.op {
@@ -1437,3 +1461,19 @@ func (b *boundBBoxPredicate) Equals(other 
BooleanExpression) bool {
 
        return b.op == rhs.op && b.term.Equals(rhs.term) && 
b.bbox.Equals(rhs.bbox)
 }
+
+func maxFiniteLiteral(typ Type) Literal {
+       if _, ok := typ.(Float32Type); ok {
+               return Float32Literal(math.MaxFloat32)
+       }
+
+       return Float64Literal(math.MaxFloat64)
+}
+
+func minFiniteLiteral(typ Type) Literal {
+       if _, ok := typ.(Float32Type); ok {
+               return Float32Literal(-math.MaxFloat32)
+       }
+
+       return Float64Literal(-math.MaxFloat64)
+}
diff --git a/exprs_test.go b/exprs_test.go
index 5286b0f8a..dff135bb7 100644
--- a/exprs_test.go
+++ b/exprs_test.go
@@ -983,6 +983,12 @@ func TestBindAboveBelowIntMax(t *testing.T) {
        ref, ref2 := iceberg.Reference("a"), iceberg.Reference("b")
        above, below := int64(math.MaxInt32)+1, int64(math.MinInt32)-1
        above2, below2 := float64(math.MaxFloat32)+1e37, 
float64(-math.MaxFloat32)-1e37
+       boundFloat32 := func(pred iceberg.UnboundPredicate) 
iceberg.BooleanExpression {
+               bound, err := pred.Bind(sc, true)
+               require.NoError(t, err)
+
+               return bound
+       }
 
        tests := []struct {
                pred iceberg.UnboundPredicate
@@ -1005,14 +1011,19 @@ func TestBindAboveBelowIntMax(t *testing.T) {
                {iceberg.EqualTo(ref2, below2), iceberg.AlwaysFalse{}},
                {iceberg.NotEqualTo(ref2, above2), iceberg.AlwaysTrue{}},
                {iceberg.NotEqualTo(ref2, below2), iceberg.AlwaysTrue{}},
-               {iceberg.LessThan(ref2, above2), iceberg.AlwaysTrue{}},
-               {iceberg.LessThan(ref2, below2), iceberg.AlwaysFalse{}},
-               {iceberg.LessThanEqual(ref2, above2), iceberg.AlwaysTrue{}},
-               {iceberg.LessThanEqual(ref2, below2), iceberg.AlwaysFalse{}},
-               {iceberg.GreaterThan(ref2, above2), iceberg.AlwaysFalse{}},
-               {iceberg.GreaterThan(ref2, below2), iceberg.AlwaysTrue{}},
-               {iceberg.GreaterThanEqual(ref2, above2), iceberg.AlwaysFalse{}},
-               {iceberg.GreaterThanEqual(ref2, below2), iceberg.AlwaysTrue{}},
+               // a float comparison keeps NaN, the infinities and NULL, so it 
moves
+               // to the largest finite value instead of folding
+               {iceberg.LessThan(ref2, above2), 
boundFloat32(iceberg.LessThanEqual(ref2, float32(math.MaxFloat32)))},
+               {iceberg.LessThan(ref2, below2), 
boundFloat32(iceberg.LessThan(ref2, float32(-math.MaxFloat32)))},
+               {iceberg.LessThanEqual(ref2, above2), 
boundFloat32(iceberg.LessThanEqual(ref2, float32(math.MaxFloat32)))},
+               {iceberg.LessThanEqual(ref2, below2), 
boundFloat32(iceberg.LessThan(ref2, float32(-math.MaxFloat32)))},
+               {iceberg.GreaterThan(ref2, above2), 
boundFloat32(iceberg.GreaterThan(ref2, float32(math.MaxFloat32)))},
+               {iceberg.GreaterThan(ref2, below2), 
boundFloat32(iceberg.GreaterThanEqual(ref2, float32(-math.MaxFloat32)))},
+               {iceberg.GreaterThanEqual(ref2, above2), 
boundFloat32(iceberg.GreaterThan(ref2, float32(math.MaxFloat32)))},
+               {iceberg.GreaterThanEqual(ref2, below2), 
boundFloat32(iceberg.GreaterThanEqual(ref2, float32(-math.MaxFloat32)))},
+               // infinity is a float32 value, so it doesn't fold
+               {iceberg.LessThanEqual(ref2, math.Inf(1)), 
boundFloat32(iceberg.LessThanEqual(ref2, float32(math.Inf(1))))},
+               {iceberg.EqualTo(ref2, math.Inf(-1)), 
boundFloat32(iceberg.EqualTo(ref2, float32(math.Inf(-1))))},
        }
 
        for _, tt := range tests {
diff --git a/literals.go b/literals.go
index c1f0b933b..1b65bdb81 100644
--- a/literals.go
+++ b/literals.go
@@ -680,9 +680,14 @@ func (f Float64Literal) String() string                { 
return strconv.FormatFl
 func (f Float64Literal) To(t Type) (Literal, error) {
        switch t := t.(type) {
        case Float32Type:
-               if math.MaxFloat32 < f {
+               switch {
+               case math.IsInf(float64(f), 0):
+                       // infinity is a float32 value, unlike a finite value 
beyond
+                       // the float32 range, and x <= +Inf holds for +Inf
+                       return Float32Literal(f), nil
+               case math.MaxFloat32 < f:
                        return Float32AboveMaxLiteral(), nil
-               } else if -math.MaxFloat32 > f {
+               case -math.MaxFloat32 > f:
                        return Float32BelowMinLiteral(), nil
                }
 
diff --git a/table/delete_classification_nan_test.go 
b/table/delete_classification_nan_test.go
new file mode 100644
index 000000000..51292039e
--- /dev/null
+++ b/table/delete_classification_nan_test.go
@@ -0,0 +1,105 @@
+// 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 table
+
+import (
+       "math"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// A delete drops a file whole when the strict evaluator says every row
+// matches. That must agree with a scan's row filter for files of only NaN,
+// only NULL or only +Inf, also when binding moves a literal beyond the float32
+// range. iceberg-go doesn't write NaN counts, but other engines write complete
+// ones.
+func TestDeleteWholeFileDecisionMatchesNaNRows(t *testing.T) {
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "x", Type: 
iceberg.PrimitiveTypes.Float32},
+       )
+       x := iceberg.Reference("x")
+       above, below := 1e100, -1e100
+
+       posInf, err := 
iceberg.Float32Literal(float32(math.Inf(1))).MarshalBinary()
+       require.NoError(t, err)
+
+       newFile := func(nulls, nans int64, bound []byte) iceberg.DataFile {
+               builder, err := iceberg.NewDataFileBuilder(
+                       *iceberg.UnpartitionedSpec, iceberg.EntryContentData, 
"file.parquet", iceberg.ParquetFile,
+                       nil, nil, nil, 2, 100,
+               )
+               require.NoError(t, err)
+               builder.
+                       ValueCounts(map[int]int64{1: 2}).
+                       NullValueCounts(map[int]int64{1: nulls}).
+                       NaNValueCounts(map[int]int64{1: nans})
+               if bound != nil {
+                       builder.
+                               LowerBoundValues(map[int][]byte{1: bound}).
+                               UpperBoundValues(map[int][]byte{1: bound})
+               }
+
+               return builder.Build()
+       }
+       allNaN, allNull, allPosInf := newFile(0, 2, nil), newFile(2, 0, nil), 
newFile(0, 0, posInf)
+
+       // whether a scan's row filter matches a NaN row and a +Inf row; it 
never
+       // matches a NULL row, since every filter here is NULL for it
+       cases := []struct {
+               filter        iceberg.BooleanExpression
+               nan, infinity bool
+       }{
+               {iceberg.LessThan(x, above), false, false},
+               {iceberg.NewNot(iceberg.LessThan(x, above)), true, true},
+               {iceberg.GreaterThan(x, above), false, true},
+               {iceberg.NewNot(iceberg.GreaterThan(x, above)), true, false},
+               {iceberg.GreaterThan(x, below), false, true},
+               {iceberg.NewNot(iceberg.GreaterThan(x, below)), true, false},
+               {iceberg.NewNot(iceberg.NewAnd(iceberg.LessThan(x, above), 
iceberg.GreaterThan(x, below))), true, true},
+               {iceberg.NewNot(iceberg.NewOr(iceberg.GreaterThan(x, above), 
iceberg.LessThan(x, below))), true, false},
+               {iceberg.LessThan(x, 5.0), false, false},
+               {iceberg.NewNot(iceberg.LessThan(x, 5.0)), true, true},
+               {iceberg.IsNaN(x), true, false},
+       }
+
+       for _, c := range cases {
+               t.Run(c.filter.String(), func(t *testing.T) {
+                       notTrue, err := isNotTrueExpr(c.filter)
+                       require.NoError(t, err)
+                       strict, err := newStrictMetricsEvaluator(schema, 
iceberg.NewNot(notTrue), true, false)
+                       require.NoError(t, err)
+
+                       for _, f := range []struct {
+                               name    string
+                               file    iceberg.DataFile
+                               matches bool
+                       }{
+                               {"all NaN", allNaN, c.nan},
+                               {"all NULL", allNull, false},
+                               {"all +Inf", allPosInf, c.infinity},
+                       } {
+                               deleteWhole, err := strict(f.file)
+                               require.NoError(t, err)
+                               assert.Equal(t, f.matches, deleteWhole, f.name)
+                       }
+               })
+       }
+}
diff --git a/table/not_nan_pruning_test.go b/table/not_nan_pruning_test.go
new file mode 100644
index 000000000..c8fb89eeb
--- /dev/null
+++ b/table/not_nan_pruning_test.go
@@ -0,0 +1,210 @@
+// 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 table_test
+
+import (
+       "context"
+       "math"
+       "path/filepath"
+       "slices"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "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/stretchr/testify/require"
+)
+
+// NOT(x < 5) is true for NaN, so the NaN row must match whatever the other
+// values in its file are. Min/max leave NaN out, and pruning on x >= 5 alone
+// skipped a file whose other values are all below 5. Scans and both delete
+// modes must agree.
+// https://github.com/apache/iceberg-go/issues/2131
+func TestNotComparisonMatchesNaNRegardlessOfFileBounds(t *testing.T) {
+       x := iceberg.Reference("x")
+       notLessThan5 := iceberg.NewNot(iceberg.LessThan(x, 5.0))
+
+       cases := []struct {
+               name        string
+               isFloat32   bool
+               partitioned bool
+               values      []float64
+               filter      iceberg.BooleanExpression
+               want        []int64
+       }{
+               {name: "bounds above", values: []float64{10, math.NaN(), 20}, 
filter: notLessThan5, want: []int64{1, 2, 3}},
+               {name: "bounds below", values: []float64{1, math.NaN(), 2}, 
filter: notLessThan5, want: []int64{2}},
+               {
+                       name: "not and", values: []float64{1, math.NaN(), 2},
+                       filter: 
iceberg.NewNot(iceberg.NewAnd(iceberg.GreaterThan(x, 0.0), iceberg.LessThan(x, 
5.0))),
+                       want:   []int64{2},
+               },
+               // each file is pruned by evaluating the projected filter on its
+               // partition value, NaN for the second row
+               {name: "identity partition", partitioned: true, values: 
[]float64{1, math.NaN(), 2}, filter: notLessThan5, want: []int64{2}},
+               // binding folds a literal outside the float32 range, and NaN 
still
+               // fails the comparison
+               {name: "float32 out of range", isFloat32: true, values: 
[]float64{1, math.NaN(), 2}, filter: iceberg.NewNot(iceberg.LessThan(x, 
1e100)), want: []int64{2}},
+               {name: "float32 out of range without not", isFloat32: true, 
values: []float64{1, math.NaN(), 2}, filter: iceberg.LessThan(x, 1e100), want: 
[]int64{1, 3}},
+       }
+
+       for _, mode := range []string{table.WriteModeCopyOnWrite, 
table.WriteModeMergeOnRead} {
+               for _, c := range cases {
+                       t.Run(mode+"/"+c.name, func(t *testing.T) {
+                               tbl := newNaNTable(t, mode, c.isFloat32, 
c.partitioned, c.values, nil)
+
+                               requireScanAndDeleteMatch(t, tbl, c.filter, 
c.want, len(c.values))
+                       })
+               }
+       }
+}
+
+// An ordering comparison against a literal beyond the float32 range binds to
+// one against the largest finite float32, so NaN, the infinities and NULL
+// compare as they do against the literal itself, also under NOT.
+func TestFloat32OutOfRangeComparisonMatchesIEEE(t *testing.T) {
+       x := iceberg.Reference("x")
+       above, below, inf := 1e100, -1e100, math.Inf(1)
+
+       // ids 1 to 6
+       values := []float64{1, math.NaN(), inf, -inf, 0, 2}
+       valid := []bool{true, true, true, true, false, true}
+
+       cases := []struct {
+               filter iceberg.BooleanExpression
+               want   []int64
+       }{
+               {iceberg.LessThan(x, above), []int64{1, 4, 6}},
+               {iceberg.NewNot(iceberg.LessThan(x, above)), []int64{2, 3}},
+               {iceberg.LessThanEqual(x, above), []int64{1, 4, 6}},
+               {iceberg.NewNot(iceberg.LessThanEqual(x, above)), []int64{2, 
3}},
+               {iceberg.GreaterThan(x, above), []int64{3}},
+               {iceberg.NewNot(iceberg.GreaterThan(x, above)), []int64{1, 2, 
4, 6}},
+               {iceberg.GreaterThanEqual(x, above), []int64{3}},
+               {iceberg.GreaterThan(x, below), []int64{1, 3, 6}},
+               {iceberg.NewNot(iceberg.GreaterThan(x, below)), []int64{2, 4}},
+               {iceberg.LessThan(x, below), []int64{4}},
+               {iceberg.NewNot(iceberg.LessThan(x, below)), []int64{1, 2, 3, 
6}},
+               {iceberg.NewNot(iceberg.NewAnd(iceberg.LessThan(x, above), 
iceberg.GreaterThan(x, below))), []int64{2, 3, 4}},
+               {iceberg.NewNot(iceberg.NewOr(iceberg.GreaterThan(x, above), 
iceberg.LessThan(x, below))), []int64{1, 2, 6}},
+               {iceberg.LessThan(x, inf), []int64{1, 4, 6}},
+               {iceberg.LessThanEqual(x, inf), []int64{1, 3, 4, 6}},
+               {iceberg.GreaterThan(x, inf), []int64{}},
+               {iceberg.EqualTo(x, inf), []int64{3}},
+               {iceberg.NewNot(iceberg.LessThanEqual(x, inf)), []int64{2}},
+       }
+
+       for _, mode := range []string{table.WriteModeCopyOnWrite, 
table.WriteModeMergeOnRead} {
+               for _, c := range cases {
+                       t.Run(mode+"/"+c.filter.String(), func(t *testing.T) {
+                               tbl := newNaNTable(t, mode, true, false, 
values, valid)
+                               requireScanAndDeleteMatch(t, tbl, c.filter, 
c.want, len(values))
+                       })
+               }
+       }
+}
+
+// requireScanAndDeleteMatch checks that a scan with filter returns want, and
+// that a delete with it removes exactly those rows.
+func requireScanAndDeleteMatch(t *testing.T, tbl *table.Table, filter 
iceberg.BooleanExpression, want []int64, total int) {
+       t.Helper()
+       ctx := context.Background()
+
+       matched := scanIDsMatching(t, tbl, filter)
+       slices.Sort(matched)
+       require.Equal(t, want, matched)
+
+       txn := tbl.NewTransaction()
+       require.NoError(t, txn.Delete(ctx, filter, nil))
+       tbl, err := txn.Commit(ctx)
+       require.NoError(t, err)
+
+       remaining := scanIDs(t, tbl)
+       for _, id := range remaining {
+               require.NotContains(t, want, id)
+       }
+       require.Len(t, remaining, total-len(want))
+}
+
+// newNaNTable writes values to one data file per partition, with ids from 1. A
+// value is NULL where valid is false; a nil valid has no NULL.
+func newNaNTable(t *testing.T, mode string, isFloat32, partitioned bool, 
values []float64, valid []bool) *table.Table {
+       t.Helper()
+
+       icebergType, arrowType := iceberg.Type(iceberg.PrimitiveTypes.Float64), 
arrow.DataType(arrow.PrimitiveTypes.Float64)
+       if isFloat32 {
+               icebergType, arrowType = iceberg.PrimitiveTypes.Float32, 
arrow.PrimitiveTypes.Float32
+       }
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+               iceberg.NestedField{ID: 2, Name: "x", Type: icebergType, 
Required: false},
+       )
+       spec := iceberg.UnpartitionedSpec
+       if partitioned {
+               identity := iceberg.NewPartitionSpec(iceberg.PartitionField{
+                       SourceIDs: []int{2}, FieldID: 1000, Name: "x", 
Transform: iceberg.IdentityTransform{},
+               })
+               spec = &identity
+       }
+
+       location := filepath.ToSlash(t.TempDir())
+       meta, err := table.NewMetadata(schema, spec, table.UnsortedSortOrder, 
location,
+               iceberg.Properties{
+                       table.PropertyFormatVersion: "2",
+                       table.WriteDeleteModeKey:    mode,
+               })
+       require.NoError(t, err)
+
+       metaLoc := location + "/metadata/v1.metadata.json"
+       fsF := func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }
+       cat := &concurrentTestCatalog{metadata: meta, location: metaLoc, fsF: 
fsF}
+       tbl := table.New(table.Identifier{"db", "nan_rows"}, meta, metaLoc, 
fsF, cat)
+
+       bldr := array.NewRecordBuilder(memory.DefaultAllocator, 
arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64},
+               {Name: "x", Type: arrowType, Nullable: true},
+       }, nil))
+       defer bldr.Release()
+       for i, v := range values {
+               bldr.Field(0).(*array.Int64Builder).Append(int64(i + 1))
+               if valid != nil && !valid[i] {
+                       bldr.Field(1).AppendNull()
+
+                       continue
+               }
+               if isFloat32 {
+                       bldr.Field(1).(*array.Float32Builder).Append(float32(v))
+               } else {
+                       bldr.Field(1).(*array.Float64Builder).Append(v)
+               }
+       }
+       rec := bldr.NewRecordBatch()
+       defer rec.Release()
+
+       rdr, err := array.NewRecordReader(rec.Schema(), 
[]arrow.RecordBatch{rec})
+       require.NoError(t, err)
+       defer rdr.Release()
+
+       tbl, err = tbl.Append(context.Background(), rdr, nil)
+       require.NoError(t, err)
+
+       return tbl
+}
diff --git a/table/transaction_delete.go b/table/transaction_delete.go
index aae3c0af1..f0a0cd053 100644
--- a/table/transaction_delete.go
+++ b/table/transaction_delete.go
@@ -628,9 +628,8 @@ func (t *Transaction) rewriteFilesWithFilter(ctx 
context.Context, fs io.IO, upda
 // enough: under three-valued logic `v = 5` is NULL when v is NULL, and so is
 // NOT(v = 5), so a copy-on-write rewrite would drop rows that never matched.
 //
-// The result contains no NOT. Row-group pruning pushes a NOT down with
-// RewriteNotExpr, which turns NOT(x < 5) into x >= 5, and that is false for
-// NaN. Negating the predicates here keeps NaN rows in both places.
+// The result contains no NOT. A negated ordering comparison gets an is_nan
+// branch, as RewriteNotExpr adds one, since NaN fails both x < 5 and x >= 5.
 func isNotTrueExpr(filter iceberg.BooleanExpression) 
(iceberg.BooleanExpression, error) {
        res, err := iceberg.VisitExpr(filter, isNotTrueVisitor{})
        if err != nil {
diff --git a/visitors.go b/visitors.go
index 6161c686c..ea4cd2943 100644
--- a/visitors.go
+++ b/visitors.go
@@ -564,7 +564,13 @@ var _ BoundGeospatialExprVisitor[bool] = 
(*exprEvaluator)(nil)
 // RewriteNotExpr rewrites a boolean expression to remove "Not" nodes from the 
expression
 // tree. This is because Projections assume there are no "not" nodes.
 //
-// Not nodes will be replaced with simply calling `Negate` on the child in the 
tree.
+// Not nodes are replaced by negating the child in the tree. A negated ordering
+// comparison also matches NaN, so NOT(x < 5) becomes x >= 5 OR is_nan(x): NaN
+// fails both x < 5 and x >= 5, and the result must hold for the same rows as
+// the NOT it replaces. That follows IEEE 754 comparison, as a scan's row
+// filter does. ExpressionEvaluator orders NaN below every number instead, so 
on
+// a NaN value it can disagree with the expression it evaluates before 
rewriting;
+// see https://github.com/apache/iceberg-go/issues/2359.
 func RewriteNotExpr(expr BooleanExpression) (BooleanExpression, error) {
        return VisitExpr(expr, rewriteNotVisitor{})
 }
@@ -574,7 +580,49 @@ type rewriteNotVisitor struct{}
 func (rewriteNotVisitor) VisitTrue() BooleanExpression  { return AlwaysTrue{} }
 func (rewriteNotVisitor) VisitFalse() BooleanExpression { return AlwaysFalse{} 
}
 func (rewriteNotVisitor) VisitNot(child BooleanExpression) BooleanExpression {
-       return child.Negate()
+       return negateKeepingNaN(child)
+}
+
+// negateKeepingNaN negates expr, which contains no Not nodes, adding an is_nan
+// branch to every negated ordering comparison on a floating-point term. And
+// and Or recurse here rather than through Negate, which would drop the branch
+// for their children.
+func negateKeepingNaN(expr BooleanExpression) BooleanExpression {
+       switch e := expr.(type) {
+       case AndExpr:
+               return NewOr(negateKeepingNaN(e.left), 
negateKeepingNaN(e.right))
+       case OrExpr:
+               return NewAnd(negateKeepingNaN(e.left), 
negateKeepingNaN(e.right))
+       case UnboundPredicate:
+               if isOrderingOp(e.Op()) {
+                       // IsNaN binds to AlwaysFalse for a term that isn't 
floating point.
+                       return NewOr(e.Negate(), IsNaN(e.Term()))
+               }
+       case BoundPredicate:
+               if isOrderingOp(e.Op()) && isFloatingPoint(e.Term().Type()) {
+                       return NewOr(e.Negate(), 
createBoundUnaryPredicate(OpIsNan, e.Term()))
+               }
+       }
+
+       return expr.Negate()
+}
+
+func isOrderingOp(op Operation) bool {
+       switch op {
+       case OpLT, OpLTEQ, OpGT, OpGTEQ:
+               return true
+       }
+
+       return false
+}
+
+func isFloatingPoint(typ Type) bool {
+       switch typ.(type) {
+       case Float32Type, Float64Type:
+               return true
+       }
+
+       return false
 }
 
 func (rewriteNotVisitor) VisitAnd(left, right BooleanExpression) 
BooleanExpression {
diff --git a/visitors_test.go b/visitors_test.go
index ce5cb82ba..884b3d3f0 100644
--- a/visitors_test.go
+++ b/visitors_test.go
@@ -1243,6 +1243,65 @@ func TestRewriteNot(t *testing.T) {
        }
 }
 
+// NaN fails both x < 5 and x >= 5, so a negated ordering comparison must keep
+// matching NaN: https://github.com/apache/iceberg-go/issues/2131
+func TestRewriteNotKeepsNaN(t *testing.T) {
+       schema := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "x", Type: 
iceberg.PrimitiveTypes.Float64},
+               iceberg.NestedField{ID: 2, Name: "f", Type: 
iceberg.PrimitiveTypes.Float32},
+               iceberg.NestedField{ID: 3, Name: "i", Type: 
iceberg.PrimitiveTypes.Int32},
+       )
+       x, f, i := iceberg.Reference("x"), iceberg.Reference("f"), 
iceberg.Reference("i")
+
+       tests := []struct {
+               name           string
+               expr, expected iceberg.BooleanExpression
+       }{
+               {"less than", iceberg.NewNot(iceberg.LessThan(x, 5.0)), 
iceberg.NewOr(iceberg.GreaterThanEqual(x, 5.0), iceberg.IsNaN(x))},
+               {"less than or equal", iceberg.NewNot(iceberg.LessThanEqual(x, 
5.0)), iceberg.NewOr(iceberg.GreaterThan(x, 5.0), iceberg.IsNaN(x))},
+               {"greater than", iceberg.NewNot(iceberg.GreaterThan(f, 
float32(5))), iceberg.NewOr(iceberg.LessThanEqual(f, float32(5)), 
iceberg.IsNaN(f))},
+               {"greater than or equal", 
iceberg.NewNot(iceberg.GreaterThanEqual(x, 5.0)), 
iceberg.NewOr(iceberg.LessThan(x, 5.0), iceberg.IsNaN(x))},
+               {"equal", iceberg.NewNot(iceberg.EqualTo(x, 5.0)), 
iceberg.NotEqualTo(x, 5.0)},
+               {"is nan", iceberg.NewNot(iceberg.IsNaN(x)), iceberg.NotNaN(x)},
+               {"integer", iceberg.NewNot(iceberg.LessThan(i, int32(5))), 
iceberg.GreaterThanEqual(i, int32(5))},
+               {
+                       "not and",
+                       iceberg.NewNot(iceberg.NewAnd(iceberg.GreaterThan(x, 
0.0), iceberg.LessThan(i, int32(5)))),
+                       iceberg.NewOr(iceberg.NewOr(iceberg.LessThanEqual(x, 
0.0), iceberg.IsNaN(x)), iceberg.GreaterThanEqual(i, int32(5))),
+               },
+               {
+                       "not or",
+                       iceberg.NewNot(iceberg.NewOr(iceberg.LessThan(x, 0.0), 
iceberg.GreaterThan(x, 5.0))),
+                       iceberg.NewAnd(
+                               iceberg.NewOr(iceberg.GreaterThanEqual(x, 0.0), 
iceberg.IsNaN(x)),
+                               iceberg.NewOr(iceberg.LessThanEqual(x, 5.0), 
iceberg.IsNaN(x))),
+               },
+       }
+
+       for _, tt := range tests {
+               expected, err := iceberg.BindExpr(schema, tt.expected, true)
+               require.NoError(t, err)
+
+               t.Run("unbound/"+tt.name, func(t *testing.T) {
+                       // the unbound rewrite has no type, so an integer term 
gets an
+                       // is_nan branch that binding then drops
+                       out, err := iceberg.RewriteNotExpr(tt.expr)
+                       require.NoError(t, err)
+                       bound, err := iceberg.BindExpr(schema, out, true)
+                       require.NoError(t, err)
+                       assert.True(t, bound.Equals(expected), "got %s", bound)
+               })
+
+               t.Run("bound/"+tt.name, func(t *testing.T) {
+                       bound, err := iceberg.BindExpr(schema, tt.expr, true)
+                       require.NoError(t, err)
+                       out, err := iceberg.RewriteNotExpr(bound)
+                       require.NoError(t, err)
+                       assert.True(t, out.Equals(expected), "got %s", out)
+               })
+       }
+}
+
 func TestSanitizeExpression(t *testing.T) {
        // A literal predicate must keep its column and operation but never 
leak the
        // literal a user scanned with.
diff --git a/website/src/releases.md b/website/src/releases.md
index 75490333d..92c9c6327 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -100,6 +100,7 @@ Per-release notes (highlights, breaking changes, 
contributors) are published on
 - **File writes and directory walks now take an operation context.** Update 
`WriteFileIO` and `ListableIO` implementations and calls to `Create`, 
`WriteFile`, and `WalkDir` to pass `context.Context`; cloud writes and listings 
use it for cancellation.
 - **In and NotIn follow SQL semantics for NULL.** A multi-valued `NotIn`, 
`NOT(In(...))` and `NOT(NotIn(...))` no longer match rows where the column is 
NULL, consistent with `NotEqual`. A predicate under NOT on a column missing 
from an older data file no longer matches every row of that file; those rows 
match as a stored NULL would. Java's `Evaluator` still matches NULL for `notEq` 
and `notIn`, see 
[apache/iceberg#18701](https://github.com/apache/iceberg/issues/18701).
 - **Commit rejections now match `table.ErrCommitRejected`.** SQL, Glue, Hive 
and Hadoop failures while preparing metadata, plus REST commit responses 400, 
401, 403 and 404, leave transactions committable (after a REST 404, the next 
`Commit` fails when it reloads the table). Existing `errors.Is` matches keep 
working, but SQL, Glue, Hive and Hadoop rejection messages now start with 
`commit rejected: `, so code that matches error text may need updating.
+- **Float comparisons against literals beyond the float32 range follow IEEE 
754.** On a float32 column, `x < 1e100` no longer returns NaN, `+Inf` or NULL 
rows, and `NOT(x > 1e100)` no longer returns NULL rows. An infinite literal now 
binds to a float32 infinity, so `x = +Inf` matches `+Inf` rows. Java still 
folds these comparisons to always true or false, see 
[apache/iceberg#18700](https://github.com/apache/iceberg/issues/18700).
 
 ## Verifying and producing releases
 

Reply via email to