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 5bd08d6d7 fix: make In and NotIn NULL for a NULL value in the row 
filter (#2356)
5bd08d6d7 is described below

commit 5bd08d6d700f4a0c8dfafb8335326cf3dd88c183
Author: Revanth <[email protected]>
AuthorDate: Fri Oct 30 13:50:16 2026 -0500

    fix: make In and NotIn NULL for a NULL value in the row filter (#2356)
    
    * fix: make In and NotIn NULL for a NULL value in the row filter
    
    * docs: link the Java issue and note the NULL semantics change
    
    * docs: name NOT(NotIn) in the NULL semantics release note
---
 table/in_null_rows_test.go        | 211 ++++++++++++++++++++++++++++++++++++++
 table/substrait/substrait.go      |  22 ++--
 table/substrait/substrait_test.go |   6 +-
 table/transaction_delete.go       |   7 +-
 visitors.go                       |  50 ++++++++-
 website/src/releases.md           |   1 +
 6 files changed, 278 insertions(+), 19 deletions(-)

diff --git a/table/in_null_rows_test.go b/table/in_null_rows_test.go
new file mode 100644
index 000000000..625451b76
--- /dev/null
+++ b/table/in_null_rows_test.go
@@ -0,0 +1,211 @@
+// 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"
+       "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"
+)
+
+// In and NotIn are NULL for a NULL value, as Equal and NotEqual are, so a
+// NULL row matches neither, also under NOT and whatever the number of values.
+// Scans and both delete modes must agree.
+// https://github.com/apache/iceberg-go/issues/2132
+func TestInAndNotInNeverMatchNull(t *testing.T) {
+       age := iceberg.Reference("age")
+
+       // ids 1 to 4
+       ages := []int64{25, 30, 0, 40}
+       valid := []bool{true, true, false, true}
+
+       cases := []struct {
+               name   string
+               filter iceberg.BooleanExpression
+               want   []int64
+       }{
+               {"not in", iceberg.NotIn(age, int64(30), int64(40)), 
[]int64{1}},
+               // binding reduces the set to one value and the predicate to 
NotEqual
+               {"not in with duplicate values", iceberg.NotIn(age, int64(30), 
int64(30)), []int64{1, 4}},
+               {"not equal", iceberg.NotEqualTo(age, int64(30)), []int64{1, 
4}},
+               {"in", iceberg.IsIn(age, int64(30), int64(40)), []int64{2, 4}},
+               {"not of in", iceberg.NewNot(iceberg.IsIn(age, int64(30), 
int64(40))), []int64{1}},
+               {"not of not in", iceberg.NewNot(iceberg.NotIn(age, int64(30), 
int64(40))), []int64{2, 4}},
+               {"not of and", iceberg.NewNot(iceberg.NewAnd(iceberg.IsIn(age, 
int64(30), int64(40)), iceberg.GreaterThan(iceberg.Reference("id"), 
int64(0)))), []int64{1}},
+               {"is null or not in", iceberg.NewOr(iceberg.IsNull(age), 
iceberg.NotIn(age, int64(30), int64(40))), []int64{1, 3}},
+       }
+
+       for _, partitioned := range []bool{false, true} {
+               for _, mode := range []string{table.WriteModeCopyOnWrite, 
table.WriteModeMergeOnRead} {
+                       for _, c := range cases {
+                               name := mode + "/" + c.name
+                               if partitioned {
+                                       name = "partitioned/" + name
+                               }
+                               t.Run(name, func(t *testing.T) {
+                                       tbl := newNullAgeTable(t, mode, 
partitioned, ages, valid)
+                                       requireNullRowsScanAndDelete(t, tbl, 
c.filter, c.want, len(ages))
+                               })
+                       }
+               }
+       }
+}
+
+// A column added after a file was written reads as NULL for that file's rows,
+// and a filter must treat them like a stored NULL, also under NOT. Ids 1 to 4
+// are in a file without x, id 5 has a stored NULL and id 6 has x = 5.
+func TestMissingColumnMatchesLikeStoredNull(t *testing.T) {
+       x, id := iceberg.Reference("x"), iceberg.Reference("id")
+
+       cases := []struct {
+               name   string
+               filter iceberg.BooleanExpression
+               want   []int64
+       }{
+               {"not of in", iceberg.NewNot(iceberg.IsIn(x, int64(5), 
int64(6))), []int64{}},
+               {"not of not in", iceberg.NewNot(iceberg.NotIn(x, int64(5), 
int64(6))), []int64{6}},
+               {"not in", iceberg.NotIn(x, int64(7), int64(8)), []int64{6}},
+               {"not of equal", iceberg.NewNot(iceberg.EqualTo(x, int64(7))), 
[]int64{6}},
+               {"not of less than", iceberg.NewNot(iceberg.LessThan(x, 
int64(1))), []int64{6}},
+               {"not of and", iceberg.NewNot(iceberg.NewAnd(iceberg.IsIn(x, 
int64(5), int64(6)), iceberg.GreaterThan(id, int64(0)))), []int64{}},
+               {"not of or", iceberg.NewNot(iceberg.NewOr(iceberg.EqualTo(x, 
int64(7)), iceberg.LessThan(id, int64(3)))), []int64{6}},
+               {"is null", iceberg.IsNull(x), []int64{1, 2, 3, 4, 5}},
+               {"not of is null", iceberg.NewNot(iceberg.IsNull(x)), 
[]int64{6}},
+               {"not of not null", iceberg.NewNot(iceberg.NotNull(x)), 
[]int64{1, 2, 3, 4, 5}},
+       }
+
+       for _, mode := range []string{table.WriteModeCopyOnWrite, 
table.WriteModeMergeOnRead} {
+               for _, c := range cases {
+                       t.Run(mode+"/"+c.name, func(t *testing.T) {
+                               ctx := context.Background()
+                               // ids 1 to 4, written before x exists
+                               tbl := newCopyOnWriteNullRowsTable(t, "2")
+
+                               txn := tbl.NewTransaction()
+                               require.NoError(t, 
txn.SetProperties(iceberg.Properties{table.WriteDeleteModeKey: mode}))
+                               require.NoError(t, txn.UpdateSchema(true, 
false).
+                                       AddColumn([]string{"x"}, 
iceberg.PrimitiveTypes.Int64, "", 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.Int64Builder).AppendValues([]int64{0, 5}, []bool{false, 
true})
+                               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)
+
+                               requireNullRowsScanAndDelete(t, tbl, c.filter, 
c.want, 6)
+                       })
+               }
+       }
+}
+
+// newNullAgeTable writes ages with ids from 1, NULL where valid is false. A
+// partitioned table has an identity partition on age, so one file per value.
+func newNullAgeTable(t *testing.T, mode string, partitioned bool, ages 
[]int64, valid []bool) *table.Table {
+       t.Helper()
+
+       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},
+       )
+       spec := iceberg.UnpartitionedSpec
+       if partitioned {
+               identity := iceberg.NewPartitionSpec(iceberg.PartitionField{
+                       SourceIDs: []int{2}, FieldID: 1000, Name: "age", 
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", "null_age"}, meta, metaLoc, 
fsF, cat)
+
+       bldr := array.NewRecordBuilder(memory.DefaultAllocator, 
arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64},
+               {Name: "age", Type: arrow.PrimitiveTypes.Int64, Nullable: true},
+       }, nil))
+       defer bldr.Release()
+       for i := range ages {
+               bldr.Field(0).(*array.Int64Builder).Append(int64(i + 1))
+       }
+       bldr.Field(1).(*array.Int64Builder).AppendValues(ages, 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
+}
+
+// requireNullRowsScanAndDelete checks that a scan with filter returns want, 
and
+// that a delete with it removes exactly those rows.
+func requireNullRowsScanAndDelete(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))
+}
diff --git a/table/substrait/substrait.go b/table/substrait/substrait.go
index e781f47a3..81aac851f 100644
--- a/table/substrait/substrait.go
+++ b/table/substrait/substrait.go
@@ -405,17 +405,25 @@ func (t *toSubstraitExpr) makeSetFunc(id extensions.ID, 
term iceberg.BoundTerm,
                t.bldr.Literal(expr.NewNestedLiteral(val, false)))
 }
 
+// VisitIn is NULL for a NULL term, as SQL IN and Equal are. Arrow's is_in is
+// false for it, so it is or-ed with (is_null(x) AND NULL), which is false for
+// any other value. NotIn negates the result, so it never matches NULL either.
+// Java's Evaluator matches NULL for notIn instead, see
+// https://github.com/apache/iceberg/issues/18701.
 func (t *toSubstraitExpr) VisitIn(term iceberg.BoundTerm, lits 
iceberg.Set[iceberg.Literal]) expr.Builder {
-       return t.makeSetFunc(isInID, term, lits)
+       nullIfNull := t.bldr.ScalarFunc(andID).Args(
+               t.makeRefFunc(isNullID, term).(expr.FuncArgBuilder),
+               t.bldr.Wrap(expr.NewNullLiteral(&types.BooleanType{Nullability: 
types.NullabilityNullable}), nil),
+       )
+
+       return t.bldr.ScalarFunc(orID).Args(
+               t.makeSetFunc(isInID, term, lits).(expr.FuncArgBuilder),
+               nullIfNull,
+       )
 }
 
 func (t *toSubstraitExpr) VisitNotIn(term iceberg.BoundTerm, lits 
iceberg.Set[iceberg.Literal]) expr.Builder {
-       notIn := t.bldr.ScalarFunc(notID).Args(t.makeSetFunc(isInID, term, 
lits).(expr.FuncArgBuilder))
-
-       return t.bldr.ScalarFunc(andID).Args(
-               t.makeRefFunc(isNotNullID, term).(expr.FuncArgBuilder),
-               notIn,
-       )
+       return t.bldr.ScalarFunc(notID).Args(t.VisitIn(term, 
lits).(expr.FuncArgBuilder))
 }
 
 func (t *toSubstraitExpr) makeRefFunc(id extensions.ID, term 
iceberg.BoundTerm) expr.Builder {
diff --git a/table/substrait/substrait_test.go 
b/table/substrait/substrait_test.go
index fbc771eea..3152ddf2e 100644
--- a/table/substrait/substrait_test.go
+++ b/table/substrait/substrait_test.go
@@ -167,7 +167,7 @@ func TestExprs(t *testing.T) {
                {iceberg.NotStartsWith(ref, "he"), tableSchemaSimple, 
"not(starts_with(.field(0) => string?, string(he)) => boolean?) => boolean?"},
                {
                        iceberg.NotIn(ref, "hello", "world"), tableSchemaSimple,
-                       "and(is_not_null(.field(0) => string?) => boolean, 
not(is_in(.field(0) => string?, list<string>([string(hello) string(world)])) => 
boolean?) => boolean?) => boolean?",
+                       "not(or(is_in(.field(0) => string?, 
list<string>([string(hello) string(world)])) => boolean?, and(is_null(.field(0) 
=> string?) => boolean, null(boolean?)) => boolean?) => boolean?) => boolean?",
                },
                {
                        iceberg.NewAnd(iceberg.EqualTo(ref, "hello"), 
iceberg.IsNull(ref)), tableSchemaSimple,
@@ -239,13 +239,13 @@ func TestNanosecondTimestampLiterals(t *testing.T) {
                        name:      "timestamp in",
                        fieldType: iceberg.PrimitiveTypes.TimestampNs,
                        predicate: iceberg.IsIn(iceberg.Reference("ts"), 
iceberg.TimestampNano(1), iceberg.TimestampNano(1001)),
-                       wantExpr:  "is_in(.field(0) => precision_timestamp?<9>, 
list<precision_timestamp<9>>([precision_timestamp<9>(1970-01-01 
00:00:00.000000001) precision_timestamp<9>(1970-01-01 00:00:00.000001001)])) => 
boolean?",
+                       wantExpr:  "or(is_in(.field(0) => 
precision_timestamp?<9>, 
list<precision_timestamp<9>>([precision_timestamp<9>(1970-01-01 
00:00:00.000000001) precision_timestamp<9>(1970-01-01 00:00:00.000001001)])) => 
boolean?, and(is_null(.field(0) => precision_timestamp?<9>) => boolean, 
null(boolean?)) => boolean?) => boolean?",
                },
                {
                        name:      "timestamp with timezone not in",
                        fieldType: iceberg.PrimitiveTypes.TimestampTzNs,
                        predicate: iceberg.NotIn(iceberg.Reference("ts"), 
iceberg.TimestampNano(-1), iceberg.TimestampNano(1001)),
-                       wantExpr:  "and(is_not_null(.field(0) => 
precision_timestamp_tz?<9>) => boolean, not(is_in(.field(0) => 
precision_timestamp_tz?<9>, 
list<precision_timestamp_tz<9>>([precision_timestamp_tz<9>(1969-12-31T23:59:59.999999999Z)
 precision_timestamp_tz<9>(1970-01-01T00:00:00.000001001Z)])) => boolean?) => 
boolean?) => boolean?",
+                       wantExpr:  "not(or(is_in(.field(0) => 
precision_timestamp_tz?<9>, 
list<precision_timestamp_tz<9>>([precision_timestamp_tz<9>(1969-12-31T23:59:59.999999999Z)
 precision_timestamp_tz<9>(1970-01-01T00:00:00.000001001Z)])) => boolean?, 
and(is_null(.field(0) => precision_timestamp_tz?<9>) => boolean, 
null(boolean?)) => boolean?) => boolean?) => boolean?",
                },
                {
                        name:      "timestamp greater than",
diff --git a/table/transaction_delete.go b/table/transaction_delete.go
index ccf383295..aae3c0af1 100644
--- a/table/transaction_delete.go
+++ b/table/transaction_delete.go
@@ -691,11 +691,8 @@ func (isNotTrueVisitor) VisitUnbound(pred 
iceberg.UnboundPredicate) truthExprs {
                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.
+       // Any other predicate is NULL when its term is NULL, In and NotIn
+       // included, as the row filter evaluates them.
        isNull := iceberg.IsNull(pred.Term())
 
        return truthExprs{
diff --git a/visitors.go b/visitors.go
index 4deb926d7..6161c686c 100644
--- a/visitors.go
+++ b/visitors.go
@@ -645,7 +645,8 @@ func (expressionFieldIDs) VisitBound(pred BoundPredicate) 
map[int]struct{} {
 
 // TranslateColumnNames converts the names of columns in an expression by 
looking up
 // the field IDs in the file schema. If columns don't exist they are replaced 
with
-// AlwaysFalse or AlwaysTrue depending on the operator.
+// AlwaysFalse or AlwaysTrue depending on the operator and, for a predicate 
that
+// is NULL for a null value, on whether it is under NOT.
 func TranslateColumnNames(expr BooleanExpression, fileSchema *Schema) 
(BooleanExpression, error) {
        res, extracts, err := TranslateColumnNamesForScan(expr, fileSchema)
        if err != nil {
@@ -679,7 +680,46 @@ func TranslateColumnNamesForScan(expr BooleanExpression, 
fileSchema *Schema) (Bo
                return nil, nil, err
        }
 
-       return res, tr.columns, nil
+       return resolveNullColumns(res, false), tr.columns, nil
+}
+
+// nullColumn stands for a predicate over a column that is null for every row
+// of a file, which is NULL rather than false. resolveNullColumns replaces it
+// before translation returns, and it must not escape 
TranslateColumnNamesForScan:
+// Op reports OpFalse, which is only right where no NOT applies.
+type nullColumn struct{}
+
+func (nullColumn) String() string            { return "NullColumn()" }
+func (nullColumn) Op() Operation             { return OpFalse }
+func (nullColumn) Negate() BooleanExpression { return nullColumn{} }
+func (nullColumn) Equals(other BooleanExpression) bool {
+       _, ok := other.(nullColumn)
+
+       return ok
+}
+
+// resolveNullColumns replaces each nullColumn so that the expression is true
+// for exactly the rows it is true for under three-valued logic, as the row
+// filter evaluates a stored null. AND and OR are monotone, so a NULL can be
+// read as false under an even number of NOTs and as true under an odd number:
+// NOT(x = 5) must stay NULL, not become true.
+func resolveNullColumns(expr BooleanExpression, negated bool) 
BooleanExpression {
+       switch e := expr.(type) {
+       case nullColumn:
+               if negated {
+                       return AlwaysTrue{}
+               }
+
+               return AlwaysFalse{}
+       case NotExpr:
+               return NewNot(resolveNullColumns(e.child, !negated))
+       case AndExpr:
+               return NewAnd(resolveNullColumns(e.left, negated), 
resolveNullColumns(e.right, negated))
+       case OrExpr:
+               return NewOr(resolveNullColumns(e.left, negated), 
resolveNullColumns(e.right, negated))
+       }
+
+       return expr
 }
 
 type scanTranslator struct {
@@ -716,13 +756,15 @@ func (*scanTranslator) VisitUnbound(pred 
UnboundPredicate) BooleanExpression {
 
 // nullColumnResult folds a predicate over a column that is null for every row
 // of a file. IsNull and NotNaN match a null, as they do for a stored null in
-// the row filter; every other predicate does not.
+// the row filter, and NotNull and IsNaN don't. Every other predicate is NULL.
 func nullColumnResult(op Operation) BooleanExpression {
        switch op {
        case OpIsNull, OpNotNan:
                return AlwaysTrue{}
-       default:
+       case OpNotNull, OpIsNan:
                return AlwaysFalse{}
+       default:
+               return nullColumn{}
        }
 }
 
diff --git a/website/src/releases.md b/website/src/releases.md
index 5041e88e4..598ff1136 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -93,6 +93,7 @@ Per-release notes (highlights, breaking changes, 
contributors) are published on
 
 - **Row-lineage column predicate renamed.** Use `iceberg.IsRowLineageColumn` 
instead of `iceberg.IsMetadataColumn` to identify `_row_id` and 
`_last_updated_sequence_number`. Use `iceberg.IsReservedFieldID` to check the 
full reserved metadata field ID range.
 - **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).
 
 ## Verifying and producing releases
 

Reply via email to