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 0b9fa13f0 fix(table): keep rows where the filter is NULL in 
copy-on-write deletes (#2111)
0b9fa13f0 is described below

commit 0b9fa13f031febfb76712a7b2ba3412f3981588b
Author: Enes Keles <[email protected]>
AuthorDate: Tue Oct 13 14:08:31 2026 +0300

    fix(table): keep rows where the filter is NULL in copy-on-write deletes 
(#2111)
    
    * fix(table): keep rows where the filter is NULL in copy-on-write deletes
    
    Signed-off-by: Enes Keles <[email protected]>
    
    * fix(table): keep NaN rows when row-group pruning the copy-on-write 
complement
    
    Signed-off-by: Enes Keles <[email protected]>
    
    * fix(table): keep NULL rows in whole-file deletes and IsNaN deletes on old 
files
    
    Signed-off-by: Enes Keles <[email protected]>
    
    * test(table): pin the rewrite path and NotNaN on files without the column
    
    Signed-off-by: Enes Keles <[email protected]>
    
    ---------
    
    Signed-off-by: Enes Keles <[email protected]>
---
 table/copy_on_write_null_rows_test.go | 360 ++++++++++++++++++++++++++++++++++
 table/table.go                        |   3 +
 table/transaction.go                  | 104 +++++++++-
 3 files changed, 465 insertions(+), 2 deletions(-)

diff --git a/table/copy_on_write_null_rows_test.go 
b/table/copy_on_write_null_rows_test.go
new file mode 100644
index 000000000..b3ea3a860
--- /dev/null
+++ b/table/copy_on_write_null_rows_test.go
@@ -0,0 +1,360 @@
+// 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"
+)
+
+// A copy-on-write delete must remove only the rows the filter is true for.
+// Rows where the filter is NULL (because a referenced column is NULL) don't
+// match it and must survive the rewrite.
+func TestCopyOnWriteDeleteKeepsRowsWhereFilterIsNull(t *testing.T) {
+       age := iceberg.Reference("age")
+       name := iceberg.Reference("name")
+
+       // rows: id=1 {25, "a"}, id=2 {30, NULL}, id=3 {NULL, "c"}, id=4 {40, 
"d"}
+       cases := []struct {
+               name   string
+               filter iceberg.BooleanExpression
+               want   []int64
+       }{
+               {"equal", iceberg.EqualTo(age, int64(30)), []int64{1, 3, 4}},
+               {"greater than", iceberg.GreaterThan(age, int64(35)), 
[]int64{1, 2, 3}},
+               {"in", iceberg.IsIn(age, int64(30), int64(40)), []int64{1, 3}},
+               // binding reduces the set to one value and the predicate to 
Equal
+               {"in with duplicate values", iceberg.IsIn(age, int64(30), 
int64(30)), []int64{1, 3, 4}},
+               {"not in", iceberg.NotIn(age, int64(30), int64(40)), []int64{2, 
3, 4}},
+               {"not in with duplicate values", iceberg.NotIn(age, int64(30), 
int64(30)), []int64{2, 3}},
+               {"not", iceberg.NewNot(iceberg.EqualTo(age, int64(30))), 
[]int64{2, 3}},
+               {"is null", iceberg.IsNull(age), []int64{1, 2, 4}},
+               {"and", iceberg.NewAnd(iceberg.EqualTo(age, int64(30)), 
iceberg.EqualTo(name, "c")), []int64{1, 2, 3, 4}},
+               {"or", iceberg.NewOr(iceberg.EqualTo(age, int64(25)), 
iceberg.EqualTo(name, "c")), []int64{2, 4}},
+               {"not and", iceberg.NewNot(iceberg.NewAnd(iceberg.EqualTo(age, 
int64(30)), iceberg.EqualTo(name, "c"))), []int64{2, 3}},
+               // the literal is outside the file's bounds, so every non-NULL 
row
+               // matches and only the NULL row keeps the file from being 
deleted whole
+               {"not equal outside bounds", iceberg.NotEqualTo(age, 
int64(100)), []int64{3}},
+               {"not outside bounds", iceberg.NewNot(iceberg.EqualTo(age, 
int64(100))), []int64{3}},
+               {"not in outside bounds", iceberg.NotIn(age, int64(100), 
int64(200)), []int64{3}},
+       }
+
+       for _, version := range []string{"2", "3"} {
+               for _, c := range cases {
+                       t.Run("v"+version+"/"+c.name, func(t *testing.T) {
+                               ctx := context.Background()
+                               tbl := newCopyOnWriteNullRowsTable(t, version)
+
+                               txn := tbl.NewTransaction()
+                               require.NoError(t, txn.Delete(ctx, c.filter, 
nil))
+                               tbl, err := txn.Commit(ctx)
+                               require.NoError(t, err)
+
+                               ids := scanIDs(t, tbl)
+                               slices.Sort(ids)
+                               require.Equal(t, c.want, ids)
+
+                               // the NULL row keeps the file from being 
deleted whole, so
+                               // the file must have been rewritten rather 
than dropped
+                               summary := 
tbl.CurrentSnapshot().Summary.Properties
+                               require.Equal(t, "1", 
summary["deleted-data-files"])
+                               require.Equal(t, "1", 
summary["added-data-files"])
+                       })
+               }
+       }
+}
+
+// A filtered overwrite rewrites matched files through the same path.
+func TestCopyOnWriteOverwriteKeepsRowsWhereFilterIsNull(t *testing.T) {
+       ctx := context.Background()
+       tbl := newCopyOnWriteNullRowsTable(t, "2")
+
+       arrowSchema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id":5,"age":30,"name":"e"}]`,
+       })
+       require.NoError(t, err)
+       defer data.Release()
+
+       txn := tbl.NewTransaction()
+       require.NoError(t, txn.OverwriteTable(ctx, data, 1024, nil,
+               
table.WithOverwriteFilter(iceberg.EqualTo(iceberg.Reference("age"), 
int64(30)))))
+       tbl, err = txn.Commit(ctx)
+       require.NoError(t, err)
+
+       ids := scanIDs(t, tbl)
+       slices.Sort(ids)
+       require.Equal(t, []int64{1, 3, 4, 5}, ids)
+}
+
+// For floating-point columns, NaN compares false rather than NULL, so a delete
+// must remove exactly the rows a scan with the same filter returns.
+func TestCopyOnWriteDeleteMatchesScanWithNaNAndNull(t *testing.T) {
+       x := iceberg.Reference("x")
+
+       cases := []struct {
+               name   string
+               filter iceberg.BooleanExpression
+       }{
+               {"less than", iceberg.LessThan(x, 5.0)},
+               {"greater than or equal", iceberg.GreaterThanEqual(x, 5.0)},
+               {"not less than", iceberg.NewNot(iceberg.LessThan(x, 5.0))},
+               {"not equal", iceberg.NotEqualTo(x, 1.0)},
+               {"is nan", iceberg.IsNaN(x)},
+               {"not nan", iceberg.NotNaN(x)},
+               {"not is nan", iceberg.NewNot(iceberg.IsNaN(x))},
+       }
+
+       for _, version := range []string{"2", "3"} {
+               for _, c := range cases {
+                       t.Run("v"+version+"/"+c.name, func(t *testing.T) {
+                               tbl := newCopyOnWriteNaNTable(t, version,
+                                       []float64{1, math.NaN(), 10, 0}, 
[]bool{true, true, true, false})
+                               requireDeleteMatchesScan(t, tbl, c.filter)
+                       })
+               }
+       }
+}
+
+// Row-group pruning on the rows to keep must not skip a NaN row. Parquet
+// min/max leave NaN out, so with every other value on one side of 5 a pruning
+// filter of x >= 5 for NOT(x < 5) would drop the whole row group.
+func TestCopyOnWriteDeleteKeepsNaNRowsInPrunedRowGroups(t *testing.T) {
+       x := iceberg.Reference("x")
+
+       cases := []struct {
+               name   string
+               values []float64
+               filter iceberg.BooleanExpression
+       }{
+               {"less than", []float64{1, math.NaN(), 2}, iceberg.LessThan(x, 
5.0)},
+               {"less than or equal", []float64{1, math.NaN(), 2}, 
iceberg.LessThanEqual(x, 5.0)},
+               {"greater than", []float64{10, math.NaN(), 20}, 
iceberg.GreaterThan(x, 5.0)},
+               {"greater than or equal", []float64{10, math.NaN(), 20}, 
iceberg.GreaterThanEqual(x, 5.0)},
+               {"or", []float64{1, math.NaN(), 20}, 
iceberg.NewOr(iceberg.LessThan(x, 5.0), iceberg.GreaterThan(x, 10.0))},
+               {"and", []float64{1, math.NaN(), 2}, 
iceberg.NewAnd(iceberg.GreaterThan(x, 0.0), iceberg.LessThan(x, 5.0))},
+       }
+
+       for _, version := range []string{"2", "3"} {
+               for _, c := range cases {
+                       t.Run("v"+version+"/"+c.name, func(t *testing.T) {
+                               tbl := newCopyOnWriteNaNTable(t, version, 
c.values, nil)
+                               requireDeleteMatchesScan(t, tbl, c.filter)
+                       })
+               }
+       }
+}
+
+// Rows of a file written before the filtered column was added read as NULL
+// and must survive, also for IsNaN, which is false rather than NULL for them.
+// NotNaN is true for a NULL, like in Java's Evaluator, so those rows match it
+// and are deleted along with every other non-NaN row.
+func TestCopyOnWriteDeleteKeepsRowsOfFilesWithoutTheColumn(t *testing.T) {
+       x := iceberg.Reference("x")
+
+       cases := []struct {
+               name   string
+               filter iceberg.BooleanExpression
+               want   []int64
+       }{
+               {"is nan", iceberg.IsNaN(x), []int64{1, 2, 3, 4, 5}},
+               {"is nan and", iceberg.NewAnd(iceberg.IsNaN(x), 
iceberg.GreaterThan(iceberg.Reference("id"), int64(0))), []int64{1, 2, 3, 4, 
5}},
+               {"not nan", iceberg.NotNaN(x), []int64{6}},
+               {"not is nan", iceberg.NewNot(iceberg.IsNaN(x)), []int64{6}},
+               {"less than", iceberg.LessThan(x, 5.0), []int64{1, 2, 3, 4, 6}},
+               {"is null", iceberg.IsNull(x), []int64{5, 6}},
+       }
+
+       for _, version := range []string{"2", "3"} {
+               for _, c := range cases {
+                       t.Run("v"+version+"/"+c.name, func(t *testing.T) {
+                               ctx := context.Background()
+                               // ids 1 to 4 are written before x exists
+                               tbl := newCopyOnWriteNullRowsTable(t, version)
+
+                               txn := tbl.NewTransaction()
+                               require.NoError(t, txn.UpdateSchema(true, 
false).
+                                       AddColumn([]string{"x"}, 
iceberg.PrimitiveTypes.Float64, "", false, nil).Commit())
+                               tbl, err := txn.Commit(ctx)
+                               require.NoError(t, err)
+
+                               arrowSchema, err := 
table.SchemaToArrowSchema(tbl.Schema(), nil, false, false)
+                               require.NoError(t, err)
+                               bldr := 
array.NewRecordBuilder(memory.DefaultAllocator, arrowSchema)
+                               defer bldr.Release()
+                               
bldr.Field(0).(*array.Int64Builder).AppendValues([]int64{5, 6}, nil)
+                               
bldr.Field(1).(*array.Int64Builder).AppendValues([]int64{50, 60}, nil)
+                               
bldr.Field(2).(*array.StringBuilder).AppendValues([]string{"e", "f"}, nil)
+                               
bldr.Field(3).(*array.Float64Builder).AppendValues([]float64{1, math.NaN()}, 
nil)
+                               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(ctx, rdr, nil)
+                               require.NoError(t, err)
+
+                               txn = tbl.NewTransaction()
+                               require.NoError(t, txn.Delete(ctx, c.filter, 
nil))
+                               tbl, err = txn.Commit(ctx)
+                               require.NoError(t, err)
+
+                               ids := scanIDs(t, tbl)
+                               slices.Sort(ids)
+                               require.Equal(t, c.want, ids)
+                       })
+               }
+       }
+}
+
+// requireDeleteMatchesScan deletes filter from tbl and checks that exactly the
+// rows a scan with the same filter returns were removed.
+func requireDeleteMatchesScan(t *testing.T, tbl *table.Table, filter 
iceberg.BooleanExpression) {
+       t.Helper()
+       ctx := context.Background()
+
+       before := scanIDs(t, tbl)
+       require.NotEmpty(t, before)
+       matched := scanIDsMatching(t, tbl, filter)
+       want := slices.DeleteFunc(before, func(id int64) bool {
+               return slices.Contains(matched, id)
+       })
+       slices.Sort(want)
+
+       txn := tbl.NewTransaction()
+       require.NoError(t, txn.Delete(ctx, filter, nil))
+       tbl, err := txn.Commit(ctx)
+       require.NoError(t, err)
+
+       ids := scanIDs(t, tbl)
+       slices.Sort(ids)
+       require.Equal(t, want, ids, "scan with the filter matched %v", matched)
+}
+
+func newCopyOnWriteNullRowsTable(t *testing.T, version string) *table.Table {
+       t.Helper()
+
+       location := 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: "age", Type: 
iceberg.PrimitiveTypes.Int64, Required: false},
+               iceberg.NestedField{ID: 3, Name: "name", Type: 
iceberg.PrimitiveTypes.String, Required: false},
+       )
+       meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, 
table.UnsortedSortOrder, location,
+               iceberg.Properties{
+                       table.PropertyFormatVersion: version,
+                       table.WriteDeleteModeKey:    table.WriteModeCopyOnWrite,
+               })
+       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", "cow_null_rows"}, meta, 
metaLoc, fsF, cat)
+
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64},
+               {Name: "age", Type: arrow.PrimitiveTypes.Int64, Nullable: true},
+               {Name: "name", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id":1,"age":25,"name":"a"},{"id":2,"age":30,"name":null},` +
+                       
`{"id":3,"age":null,"name":"c"},{"id":4,"age":40,"name":"d"}]`,
+       })
+       require.NoError(t, err)
+       defer data.Release()
+
+       rdr := array.NewTableReader(data, -1)
+       defer rdr.Release()
+
+       tbl, err = tbl.Append(context.Background(), rdr, nil)
+       require.NoError(t, err)
+
+       return tbl
+}
+
+func newCopyOnWriteNaNTable(t *testing.T, version string, values []float64, 
valid []bool) *table.Table {
+       t.Helper()
+
+       location := 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: "x", Type: 
iceberg.PrimitiveTypes.Float64, Required: false},
+       )
+       meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, 
table.UnsortedSortOrder, location,
+               iceberg.Properties{
+                       table.PropertyFormatVersion: version,
+                       table.WriteDeleteModeKey:    table.WriteModeCopyOnWrite,
+               })
+       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", "cow_nan_rows"}, meta, metaLoc, 
fsF, cat)
+
+       bldr := array.NewRecordBuilder(memory.DefaultAllocator, 
arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64},
+               {Name: "x", Type: arrow.PrimitiveTypes.Float64, Nullable: true},
+       }, nil))
+       defer bldr.Release()
+       for i := range values {
+               bldr.Field(0).(*array.Int64Builder).Append(int64(i + 1))
+       }
+       bldr.Field(1).(*array.Float64Builder).AppendValues(values, valid)
+       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
+}
+
+func scanIDsMatching(t *testing.T, tbl *table.Table, filter 
iceberg.BooleanExpression) []int64 {
+       t.Helper()
+
+       out, err := 
tbl.Scan(table.WithRowFilter(filter)).ToArrowTable(t.Context())
+       require.NoError(t, err)
+       defer out.Release()
+
+       ids := []int64{}
+       for _, chunk := range out.Column(0).Data().Chunks() {
+               ids = append(ids, chunk.(*array.Int64).Int64Values()...)
+       }
+
+       return ids
+}
diff --git a/table/table.go b/table/table.go
index 9bb3df320..f21e8cba0 100644
--- a/table/table.go
+++ b/table/table.go
@@ -356,6 +356,9 @@ func (t Table) Overwrite(ctx context.Context, rdr 
array.RecordReader, snapshotPr
 //   - Files where some rows match and others don't (partial match) are 
rewritten to keep only non-matching rows
 //   - Files where no rows match the filter are kept unchanged
 //
+// A row matches only if the filter is true for it. Rows where the filter 
evaluates to NULL, for example
+// `age = 30` on a NULL age, do not match and are kept.
+//
 // The filter uses both inclusive and strict metrics evaluators on file 
statistics to classify files:
 //   - Inclusive evaluator identifies candidate files that may contain 
matching rows
 //   - Strict evaluator determines if all rows in a file must match the filter
diff --git a/table/transaction.go b/table/transaction.go
index 9516a3b92..3edadcf89 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -2606,6 +2606,9 @@ func WithDeleteCaseInsensitive() DeleteOption {
 //   - Files where some rows match and others don't (partial match) are 
rewritten to keep only non-matching rows
 //   - Files where no rows match the filter are kept unchanged
 //
+// A row matches only if the filter is true for it. Rows where the filter 
evaluates to NULL, for example
+// `age = 30` on a NULL age, do not match and are kept.
+//
 // The filter uses both inclusive and strict metrics evaluators on file 
statistics to classify files:
 //   - Inclusive evaluator identifies candidate files that may contain 
matching rows
 //   - Strict evaluator determines if all rows in a file must match the filter
@@ -2733,7 +2736,16 @@ func (t *Transaction) 
classifyFilesForFilteredDeletions(ctx context.Context, fs
                return nil, nil, nil, fmt.Errorf("failed to create inclusive 
metrics evaluator: %w", err)
        }
 
-       strictEvaluator, err := newStrictMetricsEvaluator(schema, filter, 
caseSensitive, false)
+       // A file is deleted as a whole only if the filter is true for all of 
its
+       // rows. The strict evaluator treats predicates as two-valued, so 
NotEqual
+       // and NotIn would also count rows where the filter is NULL. Evaluate 
the
+       // negation of the rows the rewrite keeps instead, so that both paths 
agree.
+       notTrue, err := isNotTrueExpr(filter)
+       if err != nil {
+               return nil, nil, nil, fmt.Errorf("failed to build negated 
filter: %w", err)
+       }
+
+       strictEvaluator, err := newStrictMetricsEvaluator(schema, 
iceberg.NewNot(notTrue), caseSensitive, false)
        if err != nil {
                return nil, nil, nil, fmt.Errorf("failed to create strict 
metrics evaluator: %w", err)
        }
@@ -2850,7 +2862,10 @@ func (t *Transaction) rewriteFilesWithFilter(ctx 
context.Context, fs io.IO, upda
                return err
        }
 
-       complementFilter := iceberg.NewNot(filter)
+       complementFilter, err := isNotTrueExpr(filter)
+       if err != nil {
+               return fmt.Errorf("failed to build complement filter: %w", err)
+       }
 
        // Bind + convert the complement filter once for the whole rewrite. The
        // per-batch filter function is reused across every record batch from
@@ -2888,6 +2903,91 @@ func (t *Transaction) rewriteFilesWithFilter(ctx 
context.Context, fs io.IO, upda
        return nil
 }
 
+// isNotTrueExpr returns an expression that holds exactly for the rows where
+// filter is not true, i.e. where it is false or NULL. Plain NOT(filter) is not
+// 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.
+func isNotTrueExpr(filter iceberg.BooleanExpression) 
(iceberg.BooleanExpression, error) {
+       res, err := iceberg.VisitExpr(filter, isNotTrueVisitor{})
+       if err != nil {
+               return nil, err
+       }
+
+       return res.notTrue, nil
+}
+
+// truthExprs holds two never-NULL expressions for a sub-expression e:
+// notTrue holds iff e is false or NULL, notFalse holds iff e is true or NULL.
+type truthExprs struct {
+       notTrue, notFalse iceberg.BooleanExpression
+}
+
+type isNotTrueVisitor struct{}
+
+func (isNotTrueVisitor) VisitTrue() truthExprs {
+       return truthExprs{notTrue: iceberg.AlwaysFalse{}, notFalse: 
iceberg.AlwaysTrue{}}
+}
+
+func (isNotTrueVisitor) VisitFalse() truthExprs {
+       return truthExprs{notTrue: iceberg.AlwaysTrue{}, notFalse: 
iceberg.AlwaysFalse{}}
+}
+
+func (isNotTrueVisitor) VisitNot(child truthExprs) truthExprs {
+       return truthExprs{notTrue: child.notFalse, notFalse: child.notTrue}
+}
+
+func (isNotTrueVisitor) VisitAnd(left, right truthExprs) truthExprs {
+       return truthExprs{
+               notTrue:  iceberg.NewOr(left.notTrue, right.notTrue),
+               notFalse: iceberg.NewAnd(left.notFalse, right.notFalse),
+       }
+}
+
+func (isNotTrueVisitor) VisitOr(left, right truthExprs) truthExprs {
+       return truthExprs{
+               notTrue:  iceberg.NewAnd(left.notTrue, right.notTrue),
+               notFalse: iceberg.NewOr(left.notFalse, right.notFalse),
+       }
+}
+
+func (isNotTrueVisitor) VisitUnbound(pred iceberg.UnboundPredicate) truthExprs 
{
+       negated := pred.Negate()
+       switch pred.Op() {
+       case iceberg.OpIsNull, iceberg.OpNotNull, iceberg.OpNotNan:
+               // never evaluates to NULL
+               return truthExprs{notTrue: negated, notFalse: pred}
+       case iceberg.OpIsNan:
+               // is_nan(NULL) is false, so NULL rows survive. The guard also 
covers
+               // files written before the column existed, where NotNaN on the 
missing
+               // column translates to AlwaysFalse.
+               return truthExprs{notTrue: iceberg.NewOr(negated, 
iceberg.IsNull(pred.Term())), notFalse: pred}
+       case iceberg.OpLT, iceberg.OpLTEQ, iceberg.OpGT, iceberg.OpGTEQ:
+               // NaN fails both x < 5 and x >= 5. IsNaN binds to AlwaysFalse 
for
+               // non-floating-point terms.
+               negated = iceberg.NewOr(negated, iceberg.IsNaN(pred.Term()))
+       }
+
+       // Any other predicate is NULL when its term is NULL. Guard In and NotIn
+       // too: binding turns a set that ends up with one value into Equal or
+       // NotEqual, which are NULL for a NULL term. A scan with a multi-valued
+       // NotIn still matches NULL rows, see
+       // https://github.com/apache/iceberg-go/issues/2132.
+       isNull := iceberg.IsNull(pred.Term())
+
+       return truthExprs{
+               notTrue:  iceberg.NewOr(negated, isNull),
+               notFalse: iceberg.NewOr(pred, isNull),
+       }
+}
+
+func (isNotTrueVisitor) VisitBound(pred iceberg.BoundPredicate) truthExprs {
+       panic(fmt.Errorf("%w: found already bound predicate: %s", 
iceberg.ErrInvalidArgument, pred))
+}
+
 // rewriteSingleFileArgs bundles the parameters for 
[Transaction.rewriteSingleFile]
 // — there are several same-typed fields (two filter values, plus a UUID and an
 // int) so positional ordering offers no compile-time order protection.

Reply via email to