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 69239ba78 fix: match NotNaN on rows of files written before the column 
was added (#2219)
69239ba78 is described below

commit 69239ba788cfe335377227425bebce3e21dc8c54
Author: Revanth <[email protected]>
AuthorDate: Mon Oct 19 15:07:51 2026 -0500

    fix: match NotNaN on rows of files written before the column was added 
(#2219)
---
 table/scanner_internal_test.go | 35 +++++++++++++++++++++++++++++++++++
 visitors.go                    | 28 ++++++++++++++++------------
 visitors_test.go               | 40 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 91 insertions(+), 12 deletions(-)

diff --git a/table/scanner_internal_test.go b/table/scanner_internal_test.go
index 322ae013f..fa90c0d11 100644
--- a/table/scanner_internal_test.go
+++ b/table/scanner_internal_test.go
@@ -2455,6 +2455,41 @@ func TestArrowScanFiltersMissingColumnInitialDefault(t 
*testing.T) {
        })
 }
 
+// TestArrowScanFiltersMissingColumnWithoutDefault checks that rows of a file
+// written before an optional column was added read the column as null, so a
+// filter matches them as it would a stored null.
+func TestArrowScanFiltersMissingColumnWithoutDefault(t *testing.T) {
+       tbl := buildV3TableWithRows(t, 
`[{"id":1,"data":"a"},{"id":2,"data":"b"}]`)
+
+       txn := tbl.NewTransaction()
+       require.NoError(t, txn.UpdateSchema(true, false).
+               AddColumn([]string{"x"}, iceberg.PrimitiveTypes.Float64, "", 
false, nil).
+               Commit())
+       var err error
+       tbl, err = txn.Commit(t.Context())
+       require.NoError(t, err)
+
+       x := iceberg.Reference("x")
+       for _, tt := range []struct {
+               name     string
+               filter   iceberg.BooleanExpression
+               expected int64
+       }{
+               {"is null", iceberg.IsNull(x), 2},
+               {"not null", iceberg.NotNull(x), 0},
+               {"is nan", iceberg.IsNaN(x), 0},
+               {"not nan", iceberg.NotNaN(x), 2},
+               {"not is nan", iceberg.NewNot(iceberg.IsNaN(x)), 2},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       result, err := 
tbl.Scan(WithRowFilter(tt.filter)).ToArrowTable(t.Context())
+                       require.NoError(t, err)
+                       defer result.Release()
+                       assert.Equal(t, tt.expected, result.NumRows())
+               })
+       }
+}
+
 func TestPlanDataManifestTasksHandlesInaccurateCounts(t *testing.T) {
        for _, counts := range [][]int32{{5, 5, 5}, {0, 2, 1}, {-1, -1, -1}} {
                scan, schema, manifests, posIndex, dvIndex, eqIndex := 
newPlanTasksBenchmarkFixture(t, 3, 3)
diff --git a/visitors.go b/visitors.go
index da3c266d9..4deb926d7 100644
--- a/visitors.go
+++ b/visitors.go
@@ -714,6 +714,18 @@ func (*scanTranslator) VisitUnbound(pred UnboundPredicate) 
BooleanExpression {
        panic(fmt.Errorf("%w: expected bound predicate, got: %s", 
ErrInvalidArgument, pred.Term()))
 }
 
+// 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.
+func nullColumnResult(op Operation) BooleanExpression {
+       switch op {
+       case OpIsNull, OpNotNan:
+               return AlwaysTrue{}
+       default:
+               return AlwaysFalse{}
+       }
+}
+
 func (t *scanTranslator) extractRef(ext BoundExtract) Reference {
        key := ext.String()
        idx, ok := t.byKey[key]
@@ -802,14 +814,10 @@ func (t *scanTranslator) VisitBound(pred BoundPredicate) 
BooleanExpression {
        var ref Reference
        if ext, ok := pred.Term().(BoundExtract); ok {
                // If the variant column is absent from this file, the 
extracted path is null
-               // for every row: IsNull matches, everything else cannot. 
Handle it here rather
-               // than fabricating an all-null derived column downstream.
+               // for every row. Handle it here rather than fabricating an 
all-null derived
+               // column downstream.
                if _, found := 
t.fileSchema.FindColumnName(ext.Ref().Field().ID); !found {
-                       if pred.Op() == OpIsNull {
-                               return AlwaysTrue{}
-                       }
-
-                       return AlwaysFalse{}
+                       return nullColumnResult(pred.Op())
                }
                ref = t.extractRef(ext)
        } else {
@@ -822,11 +830,7 @@ func (t *scanTranslator) VisitBound(pred BoundPredicate) 
BooleanExpression {
                        // its default is not a file-wide constant. Preserve 
the existing
                        // missing-column behavior until translation has 
row-level parent state.
                        if field.InitialDefault == nil || 
len(pred.Ref().PosPath()) > 1 {
-                               if pred.Op() == OpIsNull {
-                                       return AlwaysTrue{}
-                               }
-
-                               return AlwaysFalse{}
+                               return nullColumnResult(pred.Op())
                        }
                        // The spec requires geo defaults to be null, but fail 
open for metadata
                        // written by non-conforming V3 writers rather than 
aborting the scan.
diff --git a/visitors_test.go b/visitors_test.go
index 7379adb99..e7d11044e 100644
--- a/visitors_test.go
+++ b/visitors_test.go
@@ -604,6 +604,46 @@ func 
TestTranslateColumnNamesNestedInitialDefaultDoesNotAssumeParentPresent(t *t
        }
 }
 
+// TestTranslateColumnNamesMissingColumnIsNull checks that a predicate over a
+// column absent from the file, with no initial default, folds as it would for
+// a stored null: IsNull and NotNaN match, every other predicate does not.
+func TestTranslateColumnNamesMissingColumnIsNull(t *testing.T) {
+       currentSchema := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+               iceberg.NestedField{ID: 2, Name: "x", Type: 
iceberg.PrimitiveTypes.Float64},
+               iceberg.NestedField{ID: 3, Name: "payload", Type: 
iceberg.VariantType{}},
+       )
+       fileSchema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+       )
+       x := iceberg.Reference("x")
+       extracted := iceberg.Extract("payload", "$.v", 
iceberg.PrimitiveTypes.Float64)
+
+       for _, tt := range []struct {
+               name     string
+               expr     iceberg.BooleanExpression
+               expected iceberg.BooleanExpression
+       }{
+               {"is null", iceberg.IsNull(x), iceberg.AlwaysTrue{}},
+               {"not null", iceberg.NotNull(x), iceberg.AlwaysFalse{}},
+               {"is nan", iceberg.IsNaN(x), iceberg.AlwaysFalse{}},
+               {"not nan", iceberg.NotNaN(x), iceberg.AlwaysTrue{}},
+               {"equal", iceberg.EqualTo(x, 1.0), iceberg.AlwaysFalse{}},
+               {"variant is null", iceberg.IsNull(extracted), 
iceberg.AlwaysTrue{}},
+               {"variant is nan", iceberg.IsNaN(extracted), 
iceberg.AlwaysFalse{}},
+               {"variant not nan", iceberg.NotNaN(extracted), 
iceberg.AlwaysTrue{}},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       bound, err := iceberg.BindExpr(currentSchema, tt.expr, 
true)
+                       require.NoError(t, err)
+
+                       translated, err := iceberg.TranslateColumnNames(bound, 
fileSchema)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.expected, translated)
+               })
+       }
+}
+
 func TestBoundBoolExprVisitor(t *testing.T) {
        tests := []struct {
                expr     iceberg.BooleanExpression

Reply via email to