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 b95d4fc80 fix!: reject NaN literals when binding predicates (#2432)
b95d4fc80 is described below

commit b95d4fc8018ed6a6def40a97e4926401577af005
Author: Revanth <[email protected]>
AuthorDate: Wed Nov 4 12:29:14 2026 -0600

    fix!: reject NaN literals when binding predicates (#2432)
---
 bound_set_literal_view_internal_test.go |  8 +---
 exprs.go                                | 44 ++++++++++++++++++-
 exprs_test.go                           | 62 +++++++++++++++++++++++++++
 table/conflict_validation.go            | 32 +++++++++++++-
 table/partition_conflict_test.go        | 75 +++++++++++++++++++++++++++++++--
 table/partition_residual_null_test.go   |  2 -
 website/src/releases.md                 |  1 +
 website/src/row-filter-syntax.md        |  2 +-
 8 files changed, 208 insertions(+), 18 deletions(-)

diff --git a/bound_set_literal_view_internal_test.go 
b/bound_set_literal_view_internal_test.go
index 87ec273ad..9d21b893b 100644
--- a/bound_set_literal_view_internal_test.go
+++ b/bound_set_literal_view_internal_test.go
@@ -102,9 +102,6 @@ func 
TestBoundSetPredicateLiteralsPreserveFloatingPointMembers(t *testing.T) {
                        name: "float32",
                        typ:  PrimitiveTypes.Float32,
                        values: []Literal{
-                               NewLiteral(math.Float32frombits(0x7fc00001)),
-                               NewLiteral(math.Float32frombits(0x7fc00001)),
-                               NewLiteral(math.Float32frombits(0x7fc00002)),
                                NewLiteral(float32(0)),
                                NewLiteral(math.Float32frombits(0x80000000)),
                                NewLiteral(float32(math.Inf(-1))),
@@ -119,9 +116,6 @@ func 
TestBoundSetPredicateLiteralsPreserveFloatingPointMembers(t *testing.T) {
                        name: "float64",
                        typ:  PrimitiveTypes.Float64,
                        values: []Literal{
-                               
NewLiteral(math.Float64frombits(0x7ff8000000000001)),
-                               
NewLiteral(math.Float64frombits(0x7ff8000000000001)),
-                               
NewLiteral(math.Float64frombits(0x7ff8000000000002)),
                                NewLiteral(float64(0)),
                                
NewLiteral(math.Float64frombits(0x8000000000000000)),
                                NewLiteral(math.Inf(-1)),
@@ -143,7 +137,7 @@ func 
TestBoundSetPredicateLiteralsPreserveFloatingPointMembers(t *testing.T) {
                                require.NoError(t, err)
                                original := 
bound.(boundSetLiteralRef).boundSetLiteralsRef()
                                cloned := bound.(BoundSetPredicate).Literals()
-                               require.Equal(t, 7, original.Len())
+                               require.Equal(t, 4, original.Len())
                                require.Equal(t, original.Len(), cloned.Len())
 
                                counts := func(lits Set[Literal]) 
map[uint64]int {
diff --git a/exprs.go b/exprs.go
index cb427a761..2bc5d5c1d 100644
--- a/exprs.go
+++ b/exprs.go
@@ -712,7 +712,8 @@ func (bp *boundUnaryPredicate[T]) String() string {
 // a single literal argument, such as LessThan or StartsWith.
 //
 // Panics if the operation provided is not a valid Literal operation,
-// if the term is nil or if the literal is nil.
+// if the term is nil or if the literal is nil. Binding the predicate fails
+// with ErrInvalidArgument for a NaN literal; use IsNaN or NotNaN instead.
 func LiteralPredicate(op Operation, t UnboundTerm, lit Literal) 
UnboundPredicate {
        switch {
        case op < OpLT || op > OpNotStartsWith:
@@ -729,6 +730,32 @@ func LiteralPredicate(op Operation, t UnboundTerm, lit 
Literal) UnboundPredicate
        return &unboundLiteralPredicate{op: op, term: t, lit: lit}
 }
 
+// isNaNLiteral reports whether lit is a floating-point NaN.
+func isNaNLiteral(lit Literal) bool {
+       switch l := lit.(type) {
+       case Float32Literal:
+               return math.IsNaN(float64(l))
+       case Float64Literal:
+               return math.IsNaN(float64(l))
+       }
+
+       return false
+}
+
+// checkNotNaN rejects a NaN literal. x = NaN is never true under IEEE 754, so
+// NaN must be matched with IsNaN or NotNaN, as in Java. Bind checks the
+// literal both before and after converting it to the term's type: a NaN
+// converted to a decimal becomes zero, and the string "NaN" converted to a
+// float becomes NaN.
+func checkNotNaN(op Operation, term BoundTerm, lit Literal) error {
+       if isNaNLiteral(lit) {
+               return fmt.Errorf("%w: cannot bind %s on %q with a NaN literal, 
use IsNaN or NotNaN instead",
+                       ErrInvalidArgument, op, term.Ref().Field().Name)
+       }
+
+       return nil
+}
+
 type unboundLiteralPredicate struct {
        op   Operation
        term UnboundTerm
@@ -764,10 +791,16 @@ func (ul *unboundLiteralPredicate) Bind(schema *Schema, 
caseSensitive bool) (Boo
                        ErrType, bound.Type())
        }
 
+       if err := checkNotNaN(ul.op, bound, ul.lit); err != nil {
+               return nil, err
+       }
        lit, err := ul.lit.To(bound.Type())
        if err != nil {
                return nil, err
        }
+       if err := checkNotNaN(ul.op, bound, lit); err != nil {
+               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
@@ -903,7 +936,8 @@ func (blp *boundLiteralPredicate[T]) AsUnbound(r Reference, 
l Literal) UnboundPr
 // of literals as the argument, like In or NotIn. Duplicate literals will be 
folded
 // into a set, only maintaining the unique literals.
 //
-// Will panic if op is not a valid Set operation
+// Will panic if op is not a valid Set operation. Binding the predicate fails
+// with ErrInvalidArgument if any literal is NaN; use IsNaN or NotNaN instead.
 func SetPredicate(op Operation, t UnboundTerm, lits []Literal) 
BooleanExpression {
        if op < OpIn || op > OpNotIn {
                panic(fmt.Errorf("%w: invalid operation for SetPredicate: %s",
@@ -983,10 +1017,16 @@ func createBoundSetPredicate(op Operation, term 
BoundTerm, lits Set[Literal]) (B
 
        typedSet := make(literalSet, lits.Len())
        for _, v := range lits.Members() {
+               if err := checkNotNaN(op, term, v); err != nil {
+                       return nil, err
+               }
                casted, err := v.To(boundType)
                if err != nil {
                        return nil, err
                }
+               if err := checkNotNaN(op, term, casted); err != nil {
+                       return nil, err
+               }
                switch casted.(type) {
                case AboveMaxLiteral, BelowMinLiteral:
                        continue
diff --git a/exprs_test.go b/exprs_test.go
index dff135bb7..d381b015b 100644
--- a/exprs_test.go
+++ b/exprs_test.go
@@ -1063,6 +1063,68 @@ func TestBindAboveBelowIntMax(t *testing.T) {
        })
 }
 
+func TestBindRejectsNaNLiterals(t *testing.T) {
+       dec, err := iceberg.DecimalTypeOf(9, 2)
+       require.NoError(t, err)
+       schema := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "f32", Type: 
iceberg.PrimitiveTypes.Float32},
+               iceberg.NestedField{ID: 2, Name: "f64", Type: 
iceberg.PrimitiveTypes.Float64},
+               iceberg.NestedField{ID: 3, Name: "dec", Type: dec},
+               iceberg.NestedField{ID: 4, Name: "i", Type: 
iceberg.PrimitiveTypes.Int32},
+       )
+       f32NaN := iceberg.NewLiteral(float32(math.NaN()))
+       f64NaN := iceberg.NewLiteral(math.NaN())
+       one := iceberg.NewLiteral(1.0)
+
+       var exprs []iceberg.BooleanExpression
+       for _, col := range []string{"f32", "f64"} {
+               ref := iceberg.Reference(col)
+               for _, op := range []iceberg.Operation{
+                       iceberg.OpEQ, iceberg.OpNEQ, iceberg.OpLT, 
iceberg.OpLTEQ, iceberg.OpGT, iceberg.OpGTEQ,
+               } {
+                       exprs = append(exprs,
+                               iceberg.LiteralPredicate(op, ref, f32NaN),
+                               iceberg.LiteralPredicate(op, ref, f64NaN))
+               }
+               for _, op := range []iceberg.Operation{iceberg.OpIn, 
iceberg.OpNotIn} {
+                       exprs = append(exprs,
+                               iceberg.SetPredicate(op, ref, 
[]iceberg.Literal{one, f64NaN}),
+                               iceberg.SetPredicate(op, ref, 
[]iceberg.Literal{one, f32NaN}))
+               }
+               // "NaN" parses to a NaN float only when the literal is 
converted.
+               exprs = append(exprs,
+                       iceberg.EqualTo(ref, "NaN"),
+                       iceberg.IsIn(ref, "1", "NaN"),
+                       iceberg.NewNot(iceberg.EqualTo(ref, math.NaN())))
+       }
+       // A NaN converted to a decimal would otherwise bind as zero, and to an
+       // int it would fail with a cast error that gives no hint.
+       exprs = append(exprs,
+               iceberg.EqualTo(iceberg.Reference("dec"), math.NaN()),
+               iceberg.IsIn(iceberg.Reference("dec"), 1.0, math.NaN()),
+               iceberg.EqualTo(iceberg.Reference("i"), math.NaN()))
+
+       for _, expr := range exprs {
+               t.Run(expr.String(), func(t *testing.T) {
+                       _, err := iceberg.BindExpr(schema, expr, true)
+                       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+                       assert.ErrorContains(t, err, "use IsNaN or NotNaN 
instead")
+               })
+       }
+
+       t.Run("IsNaN, NotNaN and infinities still bind", func(t *testing.T) {
+               for _, expr := range []iceberg.BooleanExpression{
+                       iceberg.IsNaN(iceberg.Reference("f64")),
+                       iceberg.NotNaN(iceberg.Reference("f32")),
+                       iceberg.EqualTo(iceberg.Reference("f64"), math.Inf(1)),
+                       iceberg.IsIn(iceberg.Reference("f32"), 
float32(math.Inf(-1)), float32(1)),
+               } {
+                       _, err := iceberg.BindExpr(schema, expr, true)
+                       assert.NoError(t, err, expr.String())
+               }
+       })
+}
+
 func TestBindOutOfRangeDate(t *testing.T) {
        sc := iceberg.NewSchema(1,
                iceberg.NestedField{ID: 1, Name: "d", Type: 
iceberg.PrimitiveTypes.Date},
diff --git a/table/conflict_validation.go b/table/conflict_validation.go
index 2343a3f7b..207b645ff 100644
--- a/table/conflict_validation.go
+++ b/table/conflict_validation.go
@@ -1057,8 +1057,18 @@ func eqDeletePartitionsToFilter(files 
[]iceberg.DataFile, meta Metadata) (iceber
                                return nil, fmt.Errorf("partition field %q: 
%w", sourceField.name, err)
                        }
 
-                       // Keep values needing a cast on the equality path, 
including
-                       // strings that can cast to NaN floats during binding.
+                       // x = NaN is never true and Bind rejects a NaN 
literal, so a NaN
+                       // partition value matches through IsNaN, as in
+                       // internal.BuildPartitionMatchPredicate. The value is 
checked after
+                       // conversion to the source type, which also catches a 
string that
+                       // casts to NaN.
+                       if isNaNPartitionLiteral(lit, sourceField.fieldType) {
+                               conjuncts = append(conjuncts, 
iceberg.IsNaN(iceberg.Reference(sourceField.name)))
+
+                               continue
+                       }
+
+                       // Keep values needing a cast on the equality path.
                        if len(fieldIDs) == 1 && 
equalityDeleteLiteralCanUseSet(lit, sourceField.fieldType) {
                                key := equalityDeleteIdentityGroupKey{specID: 
specID, fieldID: partFieldID}
                                group := identityGroups[key]
@@ -1287,6 +1297,24 @@ var filePathFieldID = func() int {
        return f.ID
 }()
 
+// isNaNPartitionLiteral reports whether a partition literal is a 
floating-point
+// NaN once converted to the source field's type. A literal that cannot be
+// converted is left for Bind to reject.
+func isNaNPartitionLiteral(lit iceberg.Literal, fieldType iceberg.Type) bool {
+       converted, err := lit.To(fieldType)
+       if err != nil {
+               return false
+       }
+       switch v := converted.(type) {
+       case iceberg.Float32Literal:
+               return math.IsNaN(float64(v))
+       case iceberg.Float64Literal:
+               return math.IsNaN(float64(v))
+       }
+
+       return false
+}
+
 // canonicalPartitionKey returns a deterministic, injective string key for a
 // (specID, partition) tuple. Partition values are compared post-transform
 // (direct equality), matching Java's DeleteFileIndex — no transform-aware
diff --git a/table/partition_conflict_test.go b/table/partition_conflict_test.go
index 3b344cb18..62919cf7d 100644
--- a/table/partition_conflict_test.go
+++ b/table/partition_conflict_test.go
@@ -445,6 +445,73 @@ func TestRowDeltaValidate_NullIdentityPartitionRejected(t 
*testing.T) {
        assert.ErrorIs(t, err, ErrConflictingDataFiles)
 }
 
+// ---------------------------------------------------------------------------
+// NaN identity partition: Bind rejects x = NaN, so the filter uses IsNaN
+// ---------------------------------------------------------------------------
+
+func TestEqDeletePartitionsToFilterUsesIsNaNForNaNPartition(t *testing.T) {
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+               iceberg.NestedField{ID: 2, Name: "score", Type: 
iceberg.PrimitiveTypes.Float64},
+       )
+       meta := partitionedConflictMeta(t, schema, 2, nil)
+       spec := meta.PartitionSpec()
+       fieldID := spec.Field(0).FieldID
+
+       filter, err := eqDeletePartitionsToFilter([]iceberg.DataFile{
+               equalityDeleteTestFile(t, spec, "eq-nan.parquet", 
map[int]any{fieldID: math.NaN()}),
+       }, meta)
+       require.NoError(t, err)
+
+       expected := iceberg.IsNaN(iceberg.Reference("score"))
+       assert.True(t, filter.Equals(expected), "got %s, want %s", filter, 
expected)
+       _, err = iceberg.BindExpr(schema, filter, true)
+       require.NoError(t, err)
+}
+
+func checkNaNPartitionEqDeleteConflict(t *testing.T, concurrentValue float64) 
error {
+       t.Helper()
+       dir := filepath.ToSlash(t.TempDir())
+
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+               iceberg.NestedField{ID: 2, Name: "score", Type: 
iceberg.PrimitiveTypes.Float64},
+       )
+
+       writerBaseID := int64(259)
+       baseMeta := partitionedConflictMeta(t, schema, 2, &writerBaseID)
+       concSnapshotID := int64(260)
+
+       spec := baseMeta.PartitionSpec()
+       fieldID := spec.Field(0).FieldID
+
+       mf := writeTestManifest(t, dir, spec, schema, concSnapshotID,
+               map[int]any{fieldID: concurrentValue}, 
dir+"/score-data.parquet")
+       listPath := writeTestManifestList(t, dir, concSnapshotID, 
[]iceberg.ManifestFile{mf})
+
+       ctx := buildPartitionedContext(t, baseMeta, listPath, writerBaseID, 
concSnapshotID)
+       require.Len(t, ctx.concurrent, 1)
+
+       eqDf, err := iceberg.NewDataFileBuilder(
+               spec, iceberg.EntryContentEqDeletes,
+               dir+"/nan-score-eq-del.parquet", iceberg.ParquetFile,
+               map[int]any{fieldID: math.NaN()}, nil, nil, 1, 1024,
+       )
+       require.NoError(t, err)
+
+       return validateNoConflictingDataFilesInPartitions(ctx, 
[]iceberg.DataFile{eqDf.Build()}, IsolationSerializable)
+}
+
+func TestRowDeltaValidate_NaNIdentityPartitionRejected(t *testing.T) {
+       err := checkNaNPartitionEqDeleteConflict(t, math.NaN())
+       require.Error(t, err)
+       assert.ErrorIs(t, err, ErrConflictingDataFiles)
+}
+
+func TestRowDeltaValidate_NaNIdentityPartitionDifferentAllowed(t *testing.T) {
+       require.NoError(t, checkNaNPartitionEqDeleteConflict(t, 1.5))
+}
+
 // ---------------------------------------------------------------------------
 // UUID partition column: same UUID rejected, different UUID allowed
 // ---------------------------------------------------------------------------
@@ -898,8 +965,8 @@ func 
TestEqDeleteConflictLargeSparseSetPreservesMetricsPruning(t *testing.T) {
        }
 }
 
-// NaN equality must remain an equality predicate: Go map lookup cannot find
-// a NaN key, so placing it in an IN set can miss a conflicting partition.
+// A NaN partition must stay out of the IN set, because Go map lookup cannot
+// find a NaN key, and match through IsNaN, because Bind rejects x = NaN.
 func TestEqDeleteConflictNaNPartition(t *testing.T) {
        schema := iceberg.NewSchema(0, iceberg.NestedField{ID: 1, Name: 
"value", Type: iceberg.PrimitiveTypes.Float64})
        meta := partitionedConflictMeta(t, schema, 1, nil)
@@ -977,9 +1044,9 @@ func 
TestEqDeleteConflictCastValuePreservesEqualityPredicate(t *testing.T) {
                iceberg.SetPredicate(iceberg.OpIn, iceberg.Reference("value"), 
[]iceberg.Literal{
                        iceberg.Float64Literal(1), iceberg.Float64Literal(2),
                }),
-               iceberg.LiteralPredicate(iceberg.OpEQ, 
iceberg.Reference("value"), iceberg.StringLiteral("NaN")),
+               iceberg.IsNaN(iceberg.Reference("value")),
        )
-       require.True(t, filter.Equals(want), "cast values must retain 
individual equality predicates")
+       require.True(t, filter.Equals(want), "got %s: cast values must stay out 
of the IN set, and a cast to NaN must use IsNaN", filter)
        _, err = iceberg.BindExpr(meta.CurrentSchema(), filter, true)
        require.NoError(t, err)
 }
diff --git a/table/partition_residual_null_test.go 
b/table/partition_residual_null_test.go
index 42003c9c7..aa5feb0ac 100644
--- a/table/partition_residual_null_test.go
+++ b/table/partition_residual_null_test.go
@@ -41,8 +41,6 @@ func TestPartitionResidualPreservesArrowNaNFiltering(t 
*testing.T) {
        }{
                {"null", nil, "null", iceberg.Float64Literal(5)},
                {"nan partition", math.NaN(), `"NaN"`, 
iceberg.Float64Literal(5)},
-               {"nan literal", float64(5), "5", 
iceberg.Float64Literal(math.NaN())},
-               {"nan partition and literal", math.NaN(), `"NaN"`, 
iceberg.Float64Literal(math.NaN())},
        } {
                t.Run(tt.name, func(t *testing.T) {
                        checkPartitionResidualArrowFiltering(t, 
iceberg.PrimitiveTypes.Float64, tt.value, tt.json,
diff --git a/website/src/releases.md b/website/src/releases.md
index f7837145c..5e1518b31 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -42,6 +42,7 @@ Per-release notes (highlights, breaking changes, 
contributors) are published on
 
 ### Unreleased compatibility notes
 
+- **Predicates reject NaN literals when bound.** `EqualTo`, `NotEqualTo`, 
`LessThan`, `LessThanEqual`, `GreaterThan`, `GreaterThanEqual`, `IsIn`, `NotIn` 
and `LiteralPredicate`/`SetPredicate` with a NaN float literal now fail in 
`Bind` with `ErrInvalidArgument`, as Java does. That includes the string 
`"NaN"` on a float column and a NaN on a decimal column, which used to bind as 
zero. Scans, `ExpressionEvaluator` and the metrics evaluators all bind first, 
so they return the error too. Use [...]
 - **SQL view APIs align with `catalog.ViewCatalog`.** `CreateView` now takes a 
`*view.Version`, schema and options, and returns `*view.View`. `LoadView` now 
returns `*view.View`; use `Metadata()` to access its metadata. If the caller 
leaves `DefaultCatalog` empty, SQL still sets it to the catalog name.
 - **`LocationProvider.NewDataLocation` takes the partition path.** The method 
is now `NewDataLocation(dataFileName, partitionPath string)`, matching Iceberg 
Java and PyIceberg, so custom `LocationProvider` implementations must add the 
argument. Callers pass the partition path there, or `""` for an unpartitioned 
file, instead of prefixing the file name.
 - **Partitioned data files follow the table's location provider.** With 
`write.object-storage.enabled=true` and the default 
`write.object-storage.partitioned-paths=true`, new partitioned files land under 
`data/<hash>/<partition>/` instead of `data/<partition>/<hash>/`, which can 
affect prefix-based lifecycle rules and bucket policies. 
`write.object-storage.partitioned-paths=false` and `write.data.path` now apply 
to partitioned writes. Partitioned paths also no longer percent-encode the t 
[...]
diff --git a/website/src/row-filter-syntax.md b/website/src/row-filter-syntax.md
index 83857ce98..6e0fc6b64 100644
--- a/website/src/row-filter-syntax.md
+++ b/website/src/row-filter-syntax.md
@@ -77,7 +77,7 @@ iceberg.IsNaN(iceberg.Reference("ratio"))
 iceberg.NotNaN(iceberg.Reference("ratio"))
 ```
 
-Operators `OpIsNan` and `OpNotNan`. Use these instead of `EqualTo(..., 
math.NaN())` - NaN is never equal to itself.
+Operators `OpIsNan` and `OpNotNan`. Use these instead of `EqualTo(..., 
math.NaN())` - NaN is never equal to itself, so a comparison or `In`/`NotIn` 
with a NaN literal fails to bind with `ErrInvalidArgument`.
 
 ## String prefix
 

Reply via email to