This is an automated email from the ASF dual-hosted git repository.

laskoviymishka 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 59a02254a feat(expr): bbox predicate evaluation for geo pruning (#1597)
59a02254a is described below

commit 59a02254a25d74e041a0b26b795cb58b9cd8fb29
Author: Tanmay Rauth <[email protected]>
AuthorDate: Wed Jul 29 14:32:22 2026 -0700

    feat(expr): bbox predicate evaluation for geo pruning (#1597)
    
    Summary
    
    Adds geospatial BBoxIntersects / BBoxNotIntersects predicates and wires
    them through the bound-expression visitor so data files whose geometry
    bounds cannot overlap a query bounding box are pruned during scan
    planning. Reuses the single-value geo bound codec from #993.
    
    
      What changed
    
    - New predicate (exprs.go) — BoundingBox (planar XY box with
    Valid/Equals) and the BBoxIntersects(term, bbox) constructor producing
    an UnboundPredicate. Binding rejects non-geo columns (ErrType) and
    transform terms; the constructor panics on a nil term or an invalid box
    (NaN / inverted min-max) to prevent silent mis-pruning. Adds
    OpBBoxIntersects / OpBBoxNotIntersects (kept after the boolean ops so
    existing op-group ranges are undisturbed) with negation and String()
    support.
    - Visitor interface (visitors.go) — adds VisitBBoxIntersects /
    VisitBBoxNotIntersects to BoundBooleanExprVisitor, dispatches them in
    VisitBoundPredicate, and collapses bbox predicates to AlwaysTrue in the
    sanitize visitor (they carry query-box coordinates, not user literals,
    and have no REST JSON form). The row-level exprEvaluator conservatively
    keeps every row — full ST_Intersects remains a query-engine
      concern.
    - Data-file pruning (table/evaluators.go) —
    inclusiveMetricsEval.VisitBBoxIntersects prunes files whose decoded
    geometry bounds are disjoint from the query box (all-null files also
    prune). Geography columns are never pruned (geodesic bounds may cross
    the antimeridian). BBoxNotIntersects, strict eval, manifest eval, and
    the bloom collector all conservatively keep files, since bounds alone
    can't answer them.
    - Codec helpers (table/internal/geo_codec.go) — GeoBoundsXY decodes
    lower/upper bounds to planar extents (fails safe on
    missing/malformed/NaN), and BBoxIntersectsXY does the closed-interval
    overlap test.
    - Serialization guards — bbox predicates return an error from
    MarshalJSON (no REST grammar) and panic in the substrait converter,
    since they exist only for local scan planning.
    
      Testing
    
    expr_bbox_test.go, table/evaluators_geo_test.go,
    table/internal/geo_codec_internal_test.go, and additions to
    visitors_test.go cover construction/validation, binding, pruning
    decisions (geometry vs geography, null-only, malformed bounds), and
    codec edge cases.
    
    Closes: #994
---
 errors.go                                 |   7 +
 expr_bbox_test.go                         | 229 ++++++++++++++++++++++++++
 expr_json.go                              |   7 +
 exprs.go                                  | 176 ++++++++++++++++++++
 operation_string.go                       |   6 +-
 table/evaluators.go                       |  81 ++++++++++
 table/evaluators_geo_test.go              | 259 ++++++++++++++++++++++++++++++
 table/internal/geo_codec.go               |  36 +++++
 table/internal/geo_codec_internal_test.go |  57 +++++++
 table/substrait/substrait.go              |  15 ++
 table/substrait/substrait_test.go         |  19 +++
 visitors.go                               |  91 +++++++++++
 visitors_test.go                          | 122 ++++++++++++++
 13 files changed, 1103 insertions(+), 2 deletions(-)

diff --git a/errors.go b/errors.go
index 44e64d967..45e03770e 100644
--- a/errors.go
+++ b/errors.go
@@ -35,4 +35,11 @@ var (
        ErrBadLiteral              = errors.New("invalid literal value")
        ErrInvalidBinSerialization = errors.New("invalid binary serialization")
        ErrResolve                 = errors.New("cannot resolve type")
+       // ErrBBoxNotSerializable is returned when marshaling a geospatial bbox
+       // predicate to REST expression JSON: bbox predicates exist only for 
local
+       // scan planning and have no representation in the REST expression 
grammar.
+       // It wraps ErrNotImplemented (not ErrInvalidArgument) because this is a
+       // contract/not-implemented condition, not a bad caller argument - 
matching
+       // how the substrait backstop surfaces the same predicate.
+       ErrBBoxNotSerializable = fmt.Errorf("%w: geospatial bbox predicates 
cannot be serialized to REST expression JSON", ErrNotImplemented)
 )
diff --git a/expr_bbox_test.go b/expr_bbox_test.go
new file mode 100644
index 000000000..cc18b4dd3
--- /dev/null
+++ b/expr_bbox_test.go
@@ -0,0 +1,229 @@
+// 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 iceberg_test
+
+import (
+       "encoding/json"
+       "math"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func geoTestSchema() *iceberg.Schema {
+       return iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "geom", Type: 
iceberg.GeometryType{}, Required: false},
+               iceberg.NestedField{ID: 2, Name: "geog", Type: 
iceberg.GeographyType{}, Required: false},
+               iceberg.NestedField{ID: 3, Name: "num", Type: 
iceberg.PrimitiveTypes.Int32, Required: false},
+       )
+}
+
+func TestBBoxIntersectsConstruction(t *testing.T) {
+       bbox := iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 10}
+       pred := iceberg.BBoxIntersects(iceberg.Reference("geom"), bbox)
+
+       assert.Equal(t, iceberg.OpBBoxIntersects, pred.Op())
+       assert.Equal(t, iceberg.Reference("geom"), pred.Term())
+       assert.Contains(t, pred.String(), "BBoxIntersects")
+
+       // Negation flips the operation and round-trips back.
+       neg := pred.Negate()
+       assert.Equal(t, iceberg.OpBBoxNotIntersects, neg.Op())
+       assert.True(t, pred.Equals(neg.Negate()))
+       assert.False(t, pred.Equals(neg))
+}
+
+func TestBBoxIntersectsNilTermPanics(t *testing.T) {
+       assert.Panics(t, func() {
+               iceberg.BBoxIntersects(nil, iceberg.BoundingBox{})
+       })
+}
+
+func TestBoundingBoxValid(t *testing.T) {
+       nan := math.NaN()
+       inf := math.Inf(1)
+       tests := []struct {
+               name string
+               bbox iceberg.BoundingBox
+               want bool
+       }{
+               {"zero", iceberg.BoundingBox{}, true},
+               {"normal", iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, 
MaxY: 10}, true},
+               {"open half-plane via inf", iceberg.BoundingBox{MinX: -inf, 
MinY: -inf, MaxX: inf, MaxY: inf}, true},
+               {"inverted x", iceberg.BoundingBox{MinX: 10, MinY: 0, MaxX: 0, 
MaxY: 10}, false},
+               {"inverted y", iceberg.BoundingBox{MinX: 0, MinY: 10, MaxX: 10, 
MaxY: 0}, false},
+               {"nan min", iceberg.BoundingBox{MinX: nan, MinY: 0, MaxX: 10, 
MaxY: 10}, false},
+               {"nan max", iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, 
MaxY: nan}, false},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       assert.Equal(t, tt.want, tt.bbox.Valid())
+               })
+       }
+}
+
+func TestBBoxIntersectsInvalidBoxPanics(t *testing.T) {
+       // An inverted or NaN box would silently mis-prune, so construction 
rejects it.
+       assert.Panics(t, func() {
+               iceberg.BBoxIntersects(iceberg.Reference("geom"),
+                       iceberg.BoundingBox{MinX: 10, MinY: 0, MaxX: 0, MaxY: 
10})
+       })
+       assert.Panics(t, func() {
+               iceberg.BBoxIntersects(iceberg.Reference("geom"),
+                       iceberg.BoundingBox{MinX: math.NaN(), MinY: 0, MaxX: 
10, MaxY: 10})
+       })
+}
+
+func TestBBoxIntersectsBind(t *testing.T) {
+       sc := geoTestSchema()
+       bbox := iceberg.BoundingBox{MinX: 1, MinY: 2, MaxX: 3, MaxY: 4}
+
+       for _, name := range []string{"geom", "geog"} {
+               t.Run(name, func(t *testing.T) {
+                       bound, err := 
iceberg.BBoxIntersects(iceberg.Reference(name), bbox).Bind(sc, true)
+                       require.NoError(t, err, "binding to %s", name)
+
+                       bp, ok := bound.(iceberg.BoundBBoxPredicate)
+                       require.True(t, ok)
+                       assert.Equal(t, iceberg.OpBBoxIntersects, bp.Op())
+                       assert.True(t, bbox.Equals(bp.BBox()))
+                       assert.Equal(t, name, bp.Ref().Field().Name)
+               })
+       }
+}
+
+func TestBBoxIntersectsBindNonGeoRejected(t *testing.T) {
+       sc := geoTestSchema()
+       _, err := iceberg.BBoxIntersects(iceberg.Reference("num"), 
iceberg.BoundingBox{}).Bind(sc, true)
+       require.Error(t, err)
+       assert.ErrorIs(t, err, iceberg.ErrType)
+}
+
+// TestBBoxIntersectsNotAUnaryPredicate locks in the invariant that a bound 
bbox
+// predicate must NOT satisfy BoundUnaryPredicate. If it did (e.g. by 
regaining an
+// AsUnbound(Reference) method), every type-switch on BoundUnaryPredicate -
+// column-name translation, transform projection - would silently match it and
+// rebuild it as a generic unary predicate, which reaches substrait (panic) or
+// drops rows on a missing column. Bbox is dispatched via BoundBBoxPredicate 
only.
+func TestBBoxIntersectsNotAUnaryPredicate(t *testing.T) {
+       sc := geoTestSchema()
+       bound, err := iceberg.BBoxIntersects(iceberg.Reference("geom"),
+               iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 
10}).Bind(sc, true)
+       require.NoError(t, err)
+
+       _, isBBox := bound.(iceberg.BoundBBoxPredicate)
+       require.True(t, isBBox)
+       _, isUnary := bound.(iceberg.BoundUnaryPredicate)
+       assert.False(t, isUnary, "a bound bbox predicate must not satisfy 
BoundUnaryPredicate")
+}
+
+func TestBBoxIntersectsBindUnknownField(t *testing.T) {
+       sc := geoTestSchema()
+       _, err := iceberg.BBoxIntersects(iceberg.Reference("missing"), 
iceberg.BoundingBox{}).Bind(sc, true)
+       require.Error(t, err)
+}
+
+// Bound negation preserves the box and can be rewritten through 
RewriteNotExpr.
+func TestBBoxNotIntersectsRewrite(t *testing.T) {
+       sc := geoTestSchema()
+       bbox := iceberg.BoundingBox{MinX: 1, MinY: 2, MaxX: 3, MaxY: 4}
+
+       bound, err := iceberg.BBoxIntersects(iceberg.Reference("geom"), 
bbox).Bind(sc, true)
+       require.NoError(t, err)
+
+       rewritten, err := iceberg.RewriteNotExpr(iceberg.NewNot(bound))
+       require.NoError(t, err)
+       assert.Equal(t, iceberg.OpBBoxNotIntersects, rewritten.Op())
+}
+
+// Geospatial predicates have no REST JSON representation and must surface an
+// error rather than silently marshalling to an empty object.
+func TestBBoxIntersectsNotJSONSerializable(t *testing.T) {
+       sc := geoTestSchema()
+       unbound := iceberg.BBoxIntersects(iceberg.Reference("geom"), 
iceberg.BoundingBox{})
+
+       _, err := json.Marshal(unbound)
+       require.Error(t, err)
+
+       bound, err := unbound.Bind(sc, true)
+       require.NoError(t, err)
+       _, err = json.Marshal(bound)
+       require.Error(t, err)
+}
+
+// TestBBoxTranslateColumnNames guards column-name translation, the single
+// function where a bbox predicate previously went wrong in two directions: 
with
+// the geo column present it survived as a BoundUnaryPredicate and later 
panicked
+// in substrait, and with the column absent (a file written before the geo 
column
+// existed) it collapsed to AlwaysFalse and silently dropped every row. Both 
must
+// resolve to AlwaysTrue so the record filter conservatively keeps every row -
+// bbox pruning is the metrics evaluator's job, not the record filter's.
+func TestBBoxTranslateColumnNames(t *testing.T) {
+       sc := geoTestSchema()
+       bound, err := iceberg.BBoxIntersects(iceberg.Reference("geom"),
+               iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 
10}).Bind(sc, true)
+       require.NoError(t, err)
+
+       t.Run("column present", func(t *testing.T) {
+               out, err := iceberg.TranslateColumnNames(bound, sc)
+               require.NoError(t, err)
+               assert.Equal(t, iceberg.AlwaysTrue{}, out)
+       })
+
+       t.Run("column absent (schema evolution)", func(t *testing.T) {
+               // A file schema that predates the geometry column.
+               fileSchema := iceberg.NewSchema(1,
+                       iceberg.NestedField{ID: 3, Name: "num", Type: 
iceberg.PrimitiveTypes.Int32})
+               out, err := iceberg.TranslateColumnNames(bound, fileSchema)
+               require.NoError(t, err)
+               assert.Equal(t, iceberg.AlwaysTrue{}, out,
+                       "a file missing the geo column must keep every row, not 
drop them")
+       })
+}
+
+// SanitizeExpression must not fail on a bbox predicate: it has no user 
literal to
+// mask and no REST JSON form, so it collapses to always-true while the rest of
+// the expression sanitizes normally. Covers both the unbound path (how a 
scan's
+// row filter is stored) and the bound path.
+func TestBBoxIntersectsSanitize(t *testing.T) {
+       sc := geoTestSchema()
+       bbox := iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 10}
+       unbound := iceberg.BBoxIntersects(iceberg.Reference("geom"), bbox)
+
+       // Unbound path: scan.rowFilter is stored unbound.
+       sanitized, err := iceberg.SanitizeExpression(unbound)
+       require.NoError(t, err)
+       assert.Equal(t, iceberg.AlwaysTrue{}, sanitized)
+
+       // Bound path.
+       bound, err := unbound.Bind(sc, true)
+       require.NoError(t, err)
+       sanitized, err = iceberg.SanitizeExpression(bound)
+       require.NoError(t, err)
+       assert.Equal(t, iceberg.AlwaysTrue{}, sanitized)
+
+       // Within a larger expression, the non-geo conjunct still sanitizes and 
the
+       // result serializes to Expression JSON.
+       combined, err := iceberg.SanitizeExpression(iceberg.NewAnd(unbound,
+               iceberg.EqualTo(iceberg.Reference("num"), int32(5))))
+       require.NoError(t, err)
+       _, err = json.Marshal(combined)
+       require.NoError(t, err)
+}
diff --git a/expr_json.go b/expr_json.go
index 5fd5f1bb1..a81b5f4bd 100644
--- a/expr_json.go
+++ b/expr_json.go
@@ -195,6 +195,13 @@ func (bsp *boundSetPredicate[T]) MarshalJSON() ([]byte, 
error) {
        return marshalSetPredicate(bsp.op, bsp.term, bsp.lits.Members())
 }
 
+// Geospatial bbox predicates have no representation in the REST expression 
JSON
+// grammar; they are used only for local scan planning. Report an error rather
+// than silently emitting an empty object. The sentinel lives in errors.go with
+// the other wrapped sentinels (see ErrBBoxNotSerializable).
+func (u *unboundBBoxPredicate) MarshalJSON() ([]byte, error) { return nil, 
ErrBBoxNotSerializable }
+func (b *boundBBoxPredicate) MarshalJSON() ([]byte, error)   { return nil, 
ErrBBoxNotSerializable }
+
 // predicateType returns the wire "type" string for a predicate operation.
 func predicateType(op Operation) (string, error) {
        s, ok := opToJSON[op]
diff --git a/exprs.go b/exprs.go
index 139044929..ed95848a6 100644
--- a/exprs.go
+++ b/exprs.go
@@ -19,6 +19,7 @@ package iceberg
 
 import (
        "fmt"
+       "math"
        "reflect"
 
        "github.com/apache/arrow-go/v18/parquet/variant"
@@ -59,6 +60,12 @@ const (
        OpNot // Not
        OpAnd // And
        OpOr  // Or
+       // geospatial ops. Kept after the boolean ops so the group ranges above
+       // (used for quick op-kind validation) are undisturbed. These have 
their own
+       // predicate constructor (BBoxIntersects) rather than belonging to the
+       // unary/literal/set groups.
+       OpBBoxIntersects    // BBoxIntersects
+       OpBBoxNotIntersects // BBoxNotIntersects
 )
 
 // Negate returns the inverse operation for a given op
@@ -92,6 +99,10 @@ func (op Operation) Negate() Operation {
                return OpNotStartsWith
        case OpNotStartsWith:
                return OpStartsWith
+       case OpBBoxIntersects:
+               return OpBBoxNotIntersects
+       case OpBBoxNotIntersects:
+               return OpBBoxIntersects
        default:
                panic("no negation for operation " + op.String())
        }
@@ -1132,3 +1143,168 @@ func rejectTransformTerm(term BoundTerm) error {
 
        return nil
 }
+
+// BoundingBox is a planar (XY) query bounding box used by the geospatial
+// BBoxIntersects predicate. MinX/MinY are the lower-left corner and MaxX/MaxY
+// the upper-right corner (X is longitude/easting, Y is latitude/northing).
+//
+// The box is two-dimensional: pruning only compares the X and Y extents of a
+// geometry column's bounds, which is all the Iceberg spec requires for
+// bbox-based data skipping. Z/M extents in a column's bounds are ignored.
+//
+// Z/M are intentionally omitted for now rather than reserved as fields: XY is
+// sufficient for spec-compliant pruning, and adding optional 
MinZ/MaxZ/MinM/MaxM
+// later is source-additive. The forward-compat contract is that Valid and 
Equals
+// are defined over whichever extents the box carries - today X and Y - so 
adding
+// higher dimensions later extends, rather than reinterprets, existing XY 
boxes.
+type BoundingBox struct {
+       MinX, MinY, MaxX, MaxY float64
+}
+
+func (b BoundingBox) String() string {
+       return fmt.Sprintf("BoundingBox(minX=%g, minY=%g, maxX=%g, maxY=%g)",
+               b.MinX, b.MinY, b.MaxX, b.MaxY)
+}
+
+// Equals reports whether two bounding boxes have identical extents.
+func (b BoundingBox) Equals(other BoundingBox) bool {
+       return b.MinX == other.MinX && b.MinY == other.MinY &&
+               b.MaxX == other.MaxX && b.MaxY == other.MaxY
+}
+
+// Valid reports whether the box is well-formed: no coordinate is NaN and the
+// minimum of each axis does not exceed its maximum. An infinite bound is 
allowed
+// (an open half-plane). An invalid box would silently mis-prune - a NaN makes
+// every intersection test false (pruning matching files), and an inverted box
+// (min > max) reports the wrong overlap - so BBoxIntersects rejects one.
+func (b BoundingBox) Valid() bool {
+       if math.IsNaN(b.MinX) || math.IsNaN(b.MinY) ||
+               math.IsNaN(b.MaxX) || math.IsNaN(b.MaxY) {
+               return false
+       }
+
+       return b.MinX <= b.MaxX && b.MinY <= b.MaxY
+}
+
+// isGeoType reports whether t is a geometry or geography type, the only types 
a
+// bbox predicate may bind to.
+func isGeoType(t Type) bool {
+       switch t.(type) {
+       case GeometryType, GeographyType:
+               return true
+       default:
+               return false
+       }
+}
+
+// BBoxIntersects constructs an unbound geospatial predicate that matches rows
+// whose geometry/geography value's bounding box intersects the query box. It 
is
+// used to prune data files whose stored geo bounds cannot overlap the query
+// region; the spec only requires bbox-based pruning, so full geometric 
predicate
+// evaluation (ST_Intersects/ST_Within) remains a query-engine concern.
+//
+// Panics if the term is nil or the bbox is not Valid (NaN coordinate or an
+// inverted min/max), either of which would cause silent mis-pruning. Panicking
+// on invalid construction is consistent with the other predicate constructors 
in
+// this package (UnaryPredicate, LiteralPredicate, SetPredicate, NewAnd, 
NewNot),
+// which all panic rather than return an error; a caller building a box from
+// untrusted input should validate it with BoundingBox.Valid first.
+func BBoxIntersects(t UnboundTerm, bbox BoundingBox) UnboundPredicate {
+       if t == nil {
+               panic(fmt.Errorf("%w: cannot create bbox predicate with nil 
term",
+                       ErrInvalidArgument))
+       }
+       if !bbox.Valid() {
+               panic(fmt.Errorf("%w: invalid bounding box %s (NaN coordinate 
or min > max)",
+                       ErrInvalidArgument, bbox))
+       }
+
+       return &unboundBBoxPredicate{op: OpBBoxIntersects, term: t, bbox: bbox}
+}
+
+type unboundBBoxPredicate struct {
+       op   Operation
+       term UnboundTerm
+       bbox BoundingBox
+}
+
+func (u *unboundBBoxPredicate) String() string {
+       return fmt.Sprintf("%s(term=%s, bbox=%s)", u.op, u.term, u.bbox)
+}
+
+func (u *unboundBBoxPredicate) Op() Operation     { return u.op }
+func (u *unboundBBoxPredicate) Term() UnboundTerm { return u.term }
+func (u *unboundBBoxPredicate) Negate() BooleanExpression {
+       return &unboundBBoxPredicate{op: u.op.Negate(), term: u.term, bbox: 
u.bbox}
+}
+
+func (u *unboundBBoxPredicate) Equals(other BooleanExpression) bool {
+       rhs, ok := other.(*unboundBBoxPredicate)
+       if !ok {
+               return false
+       }
+
+       return u.op == rhs.op && u.term.Equals(rhs.term) && 
u.bbox.Equals(rhs.bbox)
+}
+
+func (u *unboundBBoxPredicate) Bind(schema *Schema, caseSensitive bool) 
(BooleanExpression, error) {
+       bound, err := u.term.Bind(schema, caseSensitive)
+       if err != nil {
+               return nil, err
+       }
+       if err := rejectTransformTerm(bound); err != nil {
+               return nil, err
+       }
+
+       if !isGeoType(bound.Type()) {
+               return nil, fmt.Errorf("%w: BBoxIntersects must bind to a 
geometry or geography type, not %s",
+                       ErrType, bound.Type())
+       }
+
+       return &boundBBoxPredicate{op: u.op, term: bound, bbox: u.bbox}, nil
+}
+
+// BoundBBoxPredicate is a bound geospatial predicate that tests whether a
+// geometry/geography column's bounds intersect a query bounding box.
+type BoundBBoxPredicate interface {
+       BoundPredicate
+
+       BBox() BoundingBox
+}
+
+// boundBBoxPredicate carries a bound geo term plus the query box. Unlike the
+// other bound predicates it intentionally has no AsUnbound(Reference) method, 
so
+// it does NOT satisfy BoundUnaryPredicate: a bbox predicate must never be 
rebuilt
+// as a generic unary predicate (it would reach substrait and error, or be
+// dropped to AlwaysFalse when a column is absent). It has no record-filter or
+// REST-JSON form at all, so the two visitors that would otherwise rebuild a 
bound
+// predicate - columnNameTranslator.VisitBound and sanitizeVisitor.VisitBound -
+// special-case *boundBBoxPredicate and collapse it to AlwaysTrue. Data-file
+// pruning is done separately by inclusiveMetricsEval.
+type boundBBoxPredicate struct {
+       op   Operation
+       term BoundTerm
+       bbox BoundingBox
+}
+
+func (b *boundBBoxPredicate) String() string {
+       return fmt.Sprintf("Bound%s(term=%s, bbox=%s)", b.op, b.term, b.bbox)
+}
+
+func (b *boundBBoxPredicate) Op() Operation       { return b.op }
+func (b *boundBBoxPredicate) Term() BoundTerm     { return b.term }
+func (b *boundBBoxPredicate) Ref() BoundReference { return b.term.Ref() }
+func (b *boundBBoxPredicate) BBox() BoundingBox   { return b.bbox }
+
+func (b *boundBBoxPredicate) Negate() BooleanExpression {
+       return &boundBBoxPredicate{op: b.op.Negate(), term: b.term, bbox: 
b.bbox}
+}
+
+func (b *boundBBoxPredicate) Equals(other BooleanExpression) bool {
+       rhs, ok := other.(*boundBBoxPredicate)
+       if !ok {
+               return false
+       }
+
+       return b.op == rhs.op && b.term.Equals(rhs.term) && 
b.bbox.Equals(rhs.bbox)
+}
diff --git a/operation_string.go b/operation_string.go
index 3af65e3e0..25f6340b3 100644
--- a/operation_string.go
+++ b/operation_string.go
@@ -27,11 +27,13 @@ func _() {
        _ = x[OpNot-16]
        _ = x[OpAnd-17]
        _ = x[OpOr-18]
+       _ = x[OpBBoxIntersects-19]
+       _ = x[OpBBoxNotIntersects-20]
 }
 
-const _Operation_name = 
"TrueFalseIsNullNotNullIsNaNNotNaNLessThanLessThanEqualGreaterThanGreaterThanEqualEqualNotEqualStartsWithNotStartsWithInNotInNotAndOr"
+const _Operation_name = 
"TrueFalseIsNullNotNullIsNaNNotNaNLessThanLessThanEqualGreaterThanGreaterThanEqualEqualNotEqualStartsWithNotStartsWithInNotInNotAndOrBBoxIntersectsBBoxNotIntersects"
 
-var _Operation_index = [...]uint8{0, 4, 9, 15, 22, 27, 33, 41, 54, 65, 81, 86, 
94, 104, 117, 119, 124, 127, 130, 132}
+var _Operation_index = [...]uint8{0, 4, 9, 15, 22, 27, 33, 41, 54, 65, 81, 86, 
94, 104, 117, 119, 124, 127, 130, 132, 146, 163}
 
 func (i Operation) String() string {
        if i < 0 || i >= Operation(len(_Operation_index)-1) {
diff --git a/table/evaluators.go b/table/evaluators.go
index e914dde83..03a13dbb8 100644
--- a/table/evaluators.go
+++ b/table/evaluators.go
@@ -530,6 +530,31 @@ func (m *manifestEvalVisitor) VisitNotStartsWith(term 
iceberg.BoundTerm, lit ice
        return rowsMightMatch
 }
 
+// Manifest (partition-summary) pruning does not apply to geo predicates: no
+// partition transform accepts a geometry/geography source 
(Transform.CanTransform
+// rejects them), so a geo column is never a partition field and a bbox 
predicate
+// always projects to AlwaysTrue before reaching this evaluator. These methods
+// exist only to satisfy the visitor interface and conservatively keep every
+// manifest. Data-file pruning is handled by inclusiveMetricsEval.
+func (m *manifestEvalVisitor) VisitBBoxIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) bool {
+       return rowsMightMatch
+}
+
+func (m *manifestEvalVisitor) VisitBBoxNotIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) bool {
+       return rowsMightMatch
+}
+
+// These evaluators handle geospatial predicates, so they implement the 
optional
+// iceberg.BoundGeospatialExprVisitor. The assertions keep that wiring
+// compile-checked now that the geo methods live on that extension interface
+// rather than on BoundBooleanExprVisitor.
+var (
+       _ iceberg.BoundGeospatialExprVisitor[bool]                         = 
(*manifestEvalVisitor)(nil)
+       _ iceberg.BoundGeospatialExprVisitor[bool]                         = 
(*inclusiveMetricsEval)(nil)
+       _ iceberg.BoundGeospatialExprVisitor[bool]                         = 
(*strictMetricsEval)(nil)
+       _ iceberg.BoundGeospatialExprVisitor[[]internal.RowGroupBloomPred] = 
(*bloomPredicateCollector)(nil)
+)
+
 func (m *manifestEvalVisitor) VisitTrue() bool {
        return rowsMightMatch
 }
@@ -1209,6 +1234,43 @@ func (m *inclusiveMetricsEval) VisitNotStartsWith(t 
iceberg.BoundTerm, lit icebe
        return rowsMightMatch
 }
 
+func (m *inclusiveMetricsEval) VisitBBoxIntersects(t iceberg.BoundTerm, bbox 
iceberg.BoundingBox) bool {
+       fieldID := t.Ref().Field().ID
+
+       // If the column is entirely null, no geometry can intersect the query 
box.
+       if m.containsNullsOnly(fieldID) {
+               return rowsCannotMatch
+       }
+
+       // Only geometry bounds are safe to prune with a planar min/max compare.
+       // Geography bounds are geodesic and may cross the antimeridian 
(lower_x >
+       // upper_x; spec Appendix D), which scalar XY intersection would 
mis-handle
+       // and wrongly prune. iceberg-go emits no geography bounds, but files 
written
+       // by other engines can, so guard on the column type rather than on 
presence.
+       if _, isGeography := t.Ref().Field().Type.(iceberg.GeographyType); 
isGeography {
+               return rowsMightMatch
+       }
+
+       // Prune only when both geo bounds are present and decode cleanly. A 
missing
+       // or malformed bound leaves the file unprunable, which is always safe.
+       minX, minY, maxX, maxY, ok := 
internal.GeoBoundsXY(m.lowerBounds[fieldID], m.upperBounds[fieldID])
+       if !ok {
+               return rowsMightMatch
+       }
+
+       if internal.BBoxIntersectsXY(minX, minY, maxX, maxY, bbox.MinX, 
bbox.MinY, bbox.MaxX, bbox.MaxY) {
+               return rowsMightMatch
+       }
+
+       return rowsCannotMatch
+}
+
+func (m *inclusiveMetricsEval) VisitBBoxNotIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) bool {
+       // A file whose bounds intersect the query box may still hold only 
geometries
+       // outside it, so not-intersects cannot be answered from bounds alone.
+       return rowsMightMatch
+}
+
 func newStrictMetricsEvaluator(s *iceberg.Schema, expr 
iceberg.BooleanExpression,
        caseSensitive bool, includeEmptyFiles bool,
 ) (func(iceberg.DataFile) (bool, error), error) {
@@ -1560,6 +1622,17 @@ func (m *strictMetricsEval) 
VisitNotStartsWith(iceberg.BoundTerm, iceberg.Litera
        return rowsMightNotMatch
 }
 
+// Strict evaluation asks whether every row must match. Bounds can prove a bbox
+// predicate false for a file (disjoint bounds) but never that every geometry
+// intersects the query box, so neither variant can assert rowsMustMatch.
+func (m *strictMetricsEval) VisitBBoxIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) bool {
+       return rowsMightNotMatch
+}
+
+func (m *strictMetricsEval) VisitBBoxNotIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) bool {
+       return rowsMightNotMatch
+}
+
 func (m *strictMetricsEval) mayContainNulls(field iceberg.NestedField) bool {
        cnt, exists := m.nullCounts[field.ID]
        if !exists {
@@ -1756,6 +1829,14 @@ func (c *bloomPredicateCollector) VisitNotStartsWith(_ 
iceberg.BoundTerm, _ iceb
        return nil
 }
 
+func (c *bloomPredicateCollector) VisitBBoxIntersects(_ iceberg.BoundTerm, _ 
iceberg.BoundingBox) []internal.RowGroupBloomPred {
+       return nil
+}
+
+func (c *bloomPredicateCollector) VisitBBoxNotIntersects(_ iceberg.BoundTerm, 
_ iceberg.BoundingBox) []internal.RowGroupBloomPred {
+       return nil
+}
+
 // newBloomFilterPredicates walks expr and returns bloom-filter-checkable
 // predicates for row group pruning. Returns nil (no predicates) for
 // AlwaysTrue or any expression with no EqualTo/In conjuncts.
diff --git a/table/evaluators_geo_test.go b/table/evaluators_geo_test.go
new file mode 100644
index 000000000..e225c5150
--- /dev/null
+++ b/table/evaluators_geo_test.go
@@ -0,0 +1,259 @@
+// 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 (
+       "bytes"
+       "context"
+       "encoding/binary"
+       "math"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// geoBound2D encodes a single bound point in the Iceberg geospatial 
single-value
+// serialization: little-endian float64 X then Y (16 bytes).
+func geoBound2D(x, y float64) []byte {
+       b := make([]byte, 16)
+       binary.LittleEndian.PutUint64(b[0:], math.Float64bits(x))
+       binary.LittleEndian.PutUint64(b[8:], math.Float64bits(y))
+
+       return b
+}
+
+var geoMetricsSchema = iceberg.NewSchema(0,
+       iceberg.NestedField{ID: 1, Name: "geom", Type: iceberg.GeometryType{}, 
Required: false},
+       iceberg.NestedField{ID: 2, Name: "geog", Type: iceberg.GeographyType{}, 
Required: false},
+)
+
+// TestInclusiveMetricsBBoxIntersects verifies that a data file is dropped only
+// when its geometry bounds cannot intersect the query bbox.
+func TestInclusiveMetricsBBoxIntersects(t *testing.T) {
+       // A file whose geometry column spans the box [0,0]-[10,10].
+       lower, upper := geoBound2D(0, 0), geoBound2D(10, 10)
+       file := &mockDataFile{
+               count:       2,
+               valueCounts: map[int]int64{1: 2},
+               nullCounts:  map[int]int64{1: 0},
+               lowerBounds: map[int][]byte{1: lower},
+               upperBounds: map[int][]byte{1: upper},
+       }
+
+       tests := []struct {
+               name string
+               bbox iceberg.BoundingBox
+               want bool // true => might match (kept), false => pruned
+       }{
+               {"overlapping", iceberg.BoundingBox{MinX: 5, MinY: 5, MaxX: 15, 
MaxY: 15}, true},
+               {"contained", iceberg.BoundingBox{MinX: 2, MinY: 2, MaxX: 3, 
MaxY: 3}, true},
+               {"touching corner", iceberg.BoundingBox{MinX: 10, MinY: 10, 
MaxX: 20, MaxY: 20}, true},
+               {"disjoint right", iceberg.BoundingBox{MinX: 11, MinY: 0, MaxX: 
20, MaxY: 10}, false},
+               {"disjoint diagonal", iceberg.BoundingBox{MinX: 20, MinY: 20, 
MaxX: 30, MaxY: 30}, false},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       eval, err := newInclusiveMetricsEvaluator(
+                               geoMetricsSchema, 
iceberg.BBoxIntersects(iceberg.Reference("geom"), tt.bbox), true, true)
+                       require.NoError(t, err)
+
+                       got, err := eval(file)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.want, got)
+               })
+       }
+}
+
+// TestInclusiveMetricsBBoxIntersectsAllNull prunes a file whose geometry 
column
+// is entirely null: no geometry can intersect any query box.
+func TestInclusiveMetricsBBoxIntersectsAllNull(t *testing.T) {
+       file := &mockDataFile{
+               count:       3,
+               valueCounts: map[int]int64{1: 3},
+               nullCounts:  map[int]int64{1: 3},
+       }
+
+       eval, err := newInclusiveMetricsEvaluator(
+               geoMetricsSchema,
+               iceberg.BBoxIntersects(iceberg.Reference("geom"), 
iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 10}),
+               true, true)
+       require.NoError(t, err)
+
+       got, err := eval(file)
+       require.NoError(t, err)
+       assert.False(t, got, "all-null geometry column must be pruned")
+}
+
+// TestInclusiveMetricsBBoxIntersectsNoBounds keeps a file when the geometry
+// column has no usable bounds. Geography columns never emit bounds, so a
+// geography predicate can never prune - which is always safe.
+func TestInclusiveMetricsBBoxIntersectsNoBounds(t *testing.T) {
+       file := &mockDataFile{
+               count:       2,
+               valueCounts: map[int]int64{2: 2},
+               nullCounts:  map[int]int64{2: 0},
+       }
+
+       eval, err := newInclusiveMetricsEvaluator(
+               geoMetricsSchema,
+               iceberg.BBoxIntersects(iceberg.Reference("geog"), 
iceberg.BoundingBox{MinX: 100, MinY: 100, MaxX: 200, MaxY: 200}),
+               true, true)
+       require.NoError(t, err)
+
+       got, err := eval(file)
+       require.NoError(t, err)
+       assert.True(t, got, "geography column has no bounds, so the file cannot 
be pruned")
+}
+
+// TestInclusiveMetricsBBoxIntersectsGeographyWithBounds guards the 
antimeridian
+// hazard: a geography file written by another engine can carry bounds, and 
those
+// bounds may cross the antimeridian (lower_x > upper_x). A planar min/max 
compare
+// would mis-handle the wrapped box and wrongly prune, so geography columns 
must
+// never be pruned from bounds - even when the bounds look disjoint from the 
query.
+func TestInclusiveMetricsBBoxIntersectsGeographyWithBounds(t *testing.T) {
+       file := &mockDataFile{
+               count:       2,
+               valueCounts: map[int]int64{2: 2},
+               nullCounts:  map[int]int64{2: 0},
+               // Bounds that look disjoint from the query box under a planar 
compare.
+               lowerBounds: map[int][]byte{2: geoBound2D(0, 0)},
+               upperBounds: map[int][]byte{2: geoBound2D(10, 10)},
+       }
+
+       eval, err := newInclusiveMetricsEvaluator(
+               geoMetricsSchema,
+               iceberg.BBoxIntersects(iceberg.Reference("geog"), 
iceberg.BoundingBox{MinX: 100, MinY: 100, MaxX: 200, MaxY: 200}),
+               true, true)
+       require.NoError(t, err)
+
+       got, err := eval(file)
+       require.NoError(t, err)
+       assert.True(t, got, "geography must not be pruned from planar bounds 
(antimeridian hazard)")
+}
+
+// TestInclusiveMetricsBBoxNotIntersects never prunes: intersecting bounds 
don't
+// prove any geometry lies outside the query box.
+func TestInclusiveMetricsBBoxNotIntersects(t *testing.T) {
+       file := &mockDataFile{
+               count:       2,
+               valueCounts: map[int]int64{1: 2},
+               nullCounts:  map[int]int64{1: 0},
+               lowerBounds: map[int][]byte{1: geoBound2D(0, 0)},
+               upperBounds: map[int][]byte{1: geoBound2D(10, 10)},
+       }
+
+       pred := iceberg.BBoxIntersects(iceberg.Reference("geom"),
+               iceberg.BoundingBox{MinX: 20, MinY: 20, MaxX: 30, MaxY: 
30}).Negate()
+       eval, err := newInclusiveMetricsEvaluator(geoMetricsSchema, pred, true, 
true)
+       require.NoError(t, err)
+
+       got, err := eval(file)
+       require.NoError(t, err)
+       assert.True(t, got, "not-intersects cannot prune from bounds alone")
+}
+
+// TestScanPrunesDisjointGeometryFile drives a bbox filter through a real
+// table.Scan - binding, projection, and the manifest->metrics pruning path -
+// rather than calling the metrics evaluator directly. It proves the
+// metrics-pruning path end-to-end: a data file whose geometry bounds are
+// disjoint from the query box is pruned, while an overlapping one survives.
+//
+// PlanFiles does not run ReadTasks/arrowScan, so this does not exercise
+// column-name translation or the substrait conversion; those regressions (a
+// bbox predicate dropped to AlwaysFalse during translation, or panicking in
+// substrait) are pinned by TestBBoxTranslateColumnNames and
+// TestBBoxPredicateConvertsToTypedError, not here.
+func TestScanPrunesDisjointGeometryFile(t *testing.T) {
+       ctx := context.Background()
+       spec := iceberg.NewPartitionSpec() // geometry cannot be a partition 
source
+       memIO := iceio.NewMemFS()
+
+       const geoFieldID = 1
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: geoFieldID, Name: "geom", Type: 
iceberg.GeometryType{}, Required: false},
+       )
+
+       meta, err := NewMetadata(schema, &spec, UnsortedSortOrder, 
"mem://default/table",
+               iceberg.Properties{PropertyFormatVersion: "3"}) // geometry 
requires v3
+       require.NoError(t, err)
+
+       builder, err := MetadataBuilderFromBase(meta, "")
+       require.NoError(t, err)
+
+       snapshotID := int64(1)
+       newGeoEntry := func(path string, lower, upper []byte) 
iceberg.ManifestEntry {
+               df, err := iceberg.NewDataFileBuilder(spec, 
iceberg.EntryContentData, path,
+                       iceberg.ParquetFile, nil, nil, nil, 2, 1024)
+               require.NoError(t, err)
+
+               return iceberg.NewManifestEntry(iceberg.EntryStatusADDED, 
&snapshotID, nil, nil,
+                       df.LowerBoundValues(map[int][]byte{geoFieldID: lower}).
+                               UpperBoundValues(map[int][]byte{geoFieldID: 
upper}).
+                               Build())
+       }
+
+       const overlappingPath = "mem://default/table/data/overlapping.parquet"
+       // File [0,0]-[10,10] overlaps the query box; file [20,20]-[30,30] is 
disjoint.
+       entries := []iceberg.ManifestEntry{
+               newGeoEntry(overlappingPath, geoBound2D(0, 0), geoBound2D(10, 
10)),
+               newGeoEntry("mem://default/table/data/disjoint.parquet", 
geoBound2D(20, 20), geoBound2D(30, 30)),
+       }
+
+       manifestPath := "mem://default/table/metadata/manifest.avro"
+       var manifestBuf bytes.Buffer
+       manifest, err := iceberg.WriteManifest(manifestPath, &manifestBuf, 3, 
spec, schema, snapshotID, entries)
+       require.NoError(t, err)
+       require.NoError(t, memIO.WriteFile(manifestPath, manifestBuf.Bytes()))
+
+       manifestListPath := 
"mem://default/table/metadata/snap-1-manifest-list.avro"
+       var listBuf bytes.Buffer
+       seqNum := int64(1)
+       require.NoError(t, iceberg.WriteManifestList(3, &listBuf, snapshotID, 
nil, &seqNum, 0,
+               []iceberg.ManifestFile{manifest}))
+       require.NoError(t, memIO.WriteFile(manifestListPath, listBuf.Bytes()))
+
+       firstRowID, addedRows := int64(0), int64(4)
+       require.NoError(t, builder.AddSnapshot(&Snapshot{
+               SnapshotID:     snapshotID,
+               SequenceNumber: seqNum,
+               TimestampMs:    meta.LastUpdatedMillis() + 1,
+               ManifestList:   manifestListPath,
+               Summary:        &Summary{Operation: OpAppend},
+               FirstRowID:     &firstRowID, // v3 row-lineage bookkeeping
+               AddedRows:      &addedRows,
+       }))
+       require.NoError(t, builder.SetSnapshotRef(MainBranch, snapshotID, 
BranchRef))
+
+       built, err := builder.Build()
+       require.NoError(t, err)
+
+       tbl := New(Identifier{"db", "tbl"}, built, "metadata.json",
+               func(context.Context) (iceio.IO, error) { return memIO, nil }, 
nil)
+
+       // Query box [5,5]-[15,15]: overlaps the first file, disjoint from the 
second.
+       scan := 
tbl.Scan(WithRowFilter(iceberg.BBoxIntersects(iceberg.Reference("geom"),
+               iceberg.BoundingBox{MinX: 5, MinY: 5, MaxX: 15, MaxY: 15})))
+
+       tasks, err := scan.PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 1, "only the overlapping geometry file survives 
pruning")
+       assert.Equal(t, overlappingPath, tasks[0].File.FilePath())
+}
diff --git a/table/internal/geo_codec.go b/table/internal/geo_codec.go
index c36ae8ae7..fe905f502 100644
--- a/table/internal/geo_codec.go
+++ b/table/internal/geo_codec.go
@@ -315,6 +315,42 @@ func decodeGeoBound(data []byte) (vals 
[geoNumDims]float64, layout geom.Layout,
        return vals, layout, true
 }
 
+// GeoBoundsXY decodes a geometry column's lower and upper geo bounds (Iceberg
+// single-value serialization; see encodeGeoBound) into their planar XY 
extents.
+// ok is false when either bound is missing, malformed, or carries a NaN X/Y
+// coordinate - all cases where the bound is unusable for pruning.
+func GeoBoundsXY(lower, upper []byte) (minX, minY, maxX, maxY float64, ok 
bool) {
+       lo, _, okLo := decodeGeoBound(lower)
+       hi, _, okHi := decodeGeoBound(upper)
+       if !okLo || !okHi {
+               return 0, 0, 0, 0, false
+       }
+       if math.IsNaN(lo[geoDimX]) || math.IsNaN(lo[geoDimY]) ||
+               math.IsNaN(hi[geoDimX]) || math.IsNaN(hi[geoDimY]) {
+               return 0, 0, 0, 0, false
+       }
+       // Reject inverted bounds (lower > upper). iceberg-go's own accumulator 
never
+       // emits them, but this is a trust-on-read decode path: a bound written 
by
+       // another engine with lo > hi would make BBoxIntersectsXY always report
+       // no-overlap and prune a file that should be kept. Treating it as 
unusable
+       // leaves the file unprunable, which is always safe.
+       if lo[geoDimX] > hi[geoDimX] || lo[geoDimY] > hi[geoDimY] {
+               return 0, 0, 0, 0, false
+       }
+
+       return lo[geoDimX], lo[geoDimY], hi[geoDimX], hi[geoDimY], true
+}
+
+// BBoxIntersectsXY reports whether two planar (XY) bounding boxes intersect.
+// Boxes touching only at an edge or corner count as intersecting (closed
+// intervals), matching Iceberg's inclusive bbox pruning: a box that might
+// contain a matching value must not be pruned. This is the geometry (planar)
+// rule; geography columns emit no bounds (see Bounds), so the antimeridian
+// wrap-around case never reaches pruning.
+func BBoxIntersectsXY(aMinX, aMinY, aMaxX, aMaxY, bMinX, bMinY, bMaxX, bMaxY 
float64) bool {
+       return aMinX <= bMaxX && aMaxX >= bMinX && aMinY <= bMaxY && aMaxY >= 
bMinY
+}
+
 // layoutHasZM reports which optional dimensions a bound layout carries.
 func layoutHasZM(l geom.Layout) (hasZ, hasM bool) {
        switch l {
diff --git a/table/internal/geo_codec_internal_test.go 
b/table/internal/geo_codec_internal_test.go
index 24dbfcfc9..601f0ac25 100644
--- a/table/internal/geo_codec_internal_test.go
+++ b/table/internal/geo_codec_internal_test.go
@@ -423,3 +423,60 @@ func TestNewGeoBoundsAggregatorGeometry(t *testing.T) {
        assert.Equal(t, []float64{1, 5}, decodeBound(t, lower))
        assert.Equal(t, []float64{30, 20}, decodeBound(t, upper))
 }
+
+func TestGeoBoundsXY(t *testing.T) {
+       minX, minY, maxX, maxY, ok := GeoBoundsXY(encGeo(5, 10), encGeo(30, 40))
+       require.True(t, ok)
+       assert.Equal(t, [4]float64{5, 10, 30, 40}, [4]float64{minX, minY, maxX, 
maxY})
+
+       // Higher-dimension bounds still yield their XY extents.
+       minX, minY, maxX, maxY, ok = GeoBoundsXY(encGeoZ(1, 2, 3), encGeoZ(4, 
5, 6))
+       require.True(t, ok)
+       assert.Equal(t, [4]float64{1, 2, 4, 5}, [4]float64{minX, minY, maxX, 
maxY})
+
+       // Missing or malformed bounds are unusable for pruning.
+       _, _, _, _, ok = GeoBoundsXY(nil, encGeo(1, 2))
+       assert.False(t, ok, "missing lower bound")
+       _, _, _, _, ok = GeoBoundsXY(encGeo(1, 2), []byte{0x01})
+       assert.False(t, ok, "malformed upper bound")
+
+       // A NaN X/Y coordinate cannot bound anything.
+       var nan [geoNumDims]float64
+       nan[geoDimX], nan[geoDimY] = math.NaN(), 2
+       _, _, _, _, ok = GeoBoundsXY(encodeGeoBound(nan, geom.XY), encGeo(3, 4))
+       assert.False(t, ok, "NaN coordinate")
+
+       // Inverted bounds (lower > upper) from an untrusted writer are 
unusable:
+       // treating them as valid would make BBoxIntersectsXY always report 
no-overlap
+       // and prune a file that should be kept.
+       _, _, _, _, ok = GeoBoundsXY(encGeo(30, 10), encGeo(5, 40))
+       assert.False(t, ok, "inverted X bound must be rejected")
+       _, _, _, _, ok = GeoBoundsXY(encGeo(5, 40), encGeo(30, 10))
+       assert.False(t, ok, "inverted Y bound must be rejected")
+}
+
+func TestBBoxIntersectsXY(t *testing.T) {
+       // file box [0,0]-[10,10]
+       tests := []struct {
+               name                       string
+               qMinX, qMinY, qMaxX, qMaxY float64
+               want                       bool
+       }{
+               {"overlapping", 5, 5, 15, 15, true},
+               {"contained", 2, 2, 3, 3, true},
+               {"touching edge", 10, 0, 20, 10, true},
+               {"touching corner", 10, 10, 20, 20, true},
+               {"disjoint right", 11, 0, 20, 10, false},
+               {"disjoint above", 0, 11, 10, 20, false},
+               {"disjoint diagonal", 20, 20, 30, 30, false},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       assert.Equal(t, tt.want,
+                               BBoxIntersectsXY(0, 0, 10, 10, tt.qMinX, 
tt.qMinY, tt.qMaxX, tt.qMaxY))
+                       // intersection is symmetric
+                       assert.Equal(t, tt.want,
+                               BBoxIntersectsXY(tt.qMinX, tt.qMinY, tt.qMaxX, 
tt.qMaxY, 0, 0, 10, 10))
+               })
+       }
+}
diff --git a/table/substrait/substrait.go b/table/substrait/substrait.go
index c6c369570..be6e026a9 100644
--- a/table/substrait/substrait.go
+++ b/table/substrait/substrait.go
@@ -443,3 +443,18 @@ func (t *toSubstraitExpr) VisitNotStartsWith(term 
iceberg.BoundTerm, lit iceberg
                t.makeLitFunc(startsWithID, term, lit).(expr.FuncArgBuilder),
        )
 }
+
+// toSubstraitExpr implements the optional BoundGeospatialExprVisitor as a
+// backstop: bbox predicates are dropped to always-true during column-name
+// translation and never reach substrait, but if one ever did these panic with 
a
+// wrapped ErrNotImplemented (recovered into a typed, wrappable error by
+// iceberg.VisitExpr) rather than a bare string.
+var _ iceberg.BoundGeospatialExprVisitor[expr.Builder] = 
(*toSubstraitExpr)(nil)
+
+func (t *toSubstraitExpr) VisitBBoxIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) expr.Builder {
+       panic(fmt.Errorf("%w: geospatial bbox predicates cannot be converted to 
substrait", iceberg.ErrNotImplemented))
+}
+
+func (t *toSubstraitExpr) VisitBBoxNotIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) expr.Builder {
+       panic(fmt.Errorf("%w: geospatial bbox predicates cannot be converted to 
substrait", iceberg.ErrNotImplemented))
+}
diff --git a/table/substrait/substrait_test.go 
b/table/substrait/substrait_test.go
index e7882575f..e42ceeb1d 100644
--- a/table/substrait/substrait_test.go
+++ b/table/substrait/substrait_test.go
@@ -155,3 +155,22 @@ func TestVariantSchemaConversionDoesNotPanic(t *testing.T) 
{
        require.NoError(t, err)
        assert.NotNil(t, expr)
 }
+
+// TestBBoxPredicateConvertsToTypedError ensures a geospatial bbox predicate 
that
+// somehow reaches substrait surfaces a wrapped, typed ErrNotImplemented rather
+// than a bare panic string. In normal scans a bbox predicate is dropped to
+// AlwaysTrue during column-name translation and never reaches ConvertExpr; 
this
+// pins the backstop so a caller can errors.Is the failure instead of matching 
on
+// panic text.
+func TestBBoxPredicateConvertsToTypedError(t *testing.T) {
+       sc := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "geom", Type: 
iceberg.GeometryType{}},
+       )
+       bound, err := iceberg.BBoxIntersects(iceberg.Reference("geom"),
+               iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 
10}).Bind(sc, true)
+       require.NoError(t, err)
+
+       _, _, err = substrait.ConvertExpr(sc, bound, true)
+       require.Error(t, err)
+       assert.ErrorIs(t, err, iceberg.ErrNotImplemented)
+}
diff --git a/visitors.go b/visitors.go
index 47594abf1..19d7b8034 100644
--- a/visitors.go
+++ b/visitors.go
@@ -62,6 +62,21 @@ type BoundBooleanExprVisitor[T any] interface {
        VisitNotStartsWith(BoundTerm, Literal) T
 }
 
+// BoundGeospatialExprVisitor is an optional extension a 
BoundBooleanExprVisitor
+// may implement to handle the geospatial bbox predicates (BBoxIntersects /
+// BBoxNotIntersects). It is deliberately kept out of BoundBooleanExprVisitor:
+// adding methods to that interface would break every existing implementation -
+// including external ones (query-engine adapters, downstream visitors) - which
+// would then fail to compile until they grew geo methods they may not care
+// about. This mirrors Java, which dispatches geospatial predicates outside its
+// BoundExpressionVisitor. VisitBoundPredicate routes a bbox predicate here 
only
+// when the visitor implements this interface; a visitor that does not is 
treated
+// as not supporting geospatial predicates.
+type BoundGeospatialExprVisitor[T any] interface {
+       VisitBBoxIntersects(BoundTerm, BoundingBox) T
+       VisitBBoxNotIntersects(BoundTerm, BoundingBox) T
+}
+
 // VisitExpr is a convenience function to use a given visitor to visit all 
parts of
 // a boolean expression in-order. Values returned from the methods are passed 
to the
 // subsequent methods, effectively "bubbling up" the results.
@@ -110,6 +125,12 @@ func visitBoolExpr[T any](e BooleanExpression, visitor 
BooleanExprVisitor[T]) T
 // based on the type of operation in the predicate. This is a convenience 
function
 // for implementing the VisitBound method of a BoundBooleanExprVisitor by 
simply calling
 // iceberg.VisitBoundPredicate(pred, this).
+//
+// If the predicate is a geospatial bbox op 
(OpBBoxIntersects/OpBBoxNotIntersects)
+// and the visitor does not also implement BoundGeospatialExprVisitor[T], this
+// panics with an error wrapping ErrNotImplemented. When reached through 
VisitExpr
+// that panic is recovered into a returned error; a caller invoking this 
directly
+// must implement the extension or recover the panic itself.
 func VisitBoundPredicate[T any](e BoundPredicate, visitor 
BoundBooleanExprVisitor[T]) T {
        switch e.Op() {
        case OpIn:
@@ -140,6 +161,23 @@ func VisitBoundPredicate[T any](e BoundPredicate, visitor 
BoundBooleanExprVisito
                return visitor.VisitStartsWith(e.Term(), 
e.(BoundLiteralPredicate).Literal())
        case OpNotStartsWith:
                return visitor.VisitNotStartsWith(e.Term(), 
e.(BoundLiteralPredicate).Literal())
+       case OpBBoxIntersects, OpBBoxNotIntersects:
+               // Geospatial predicates are dispatched through the optional
+               // BoundGeospatialExprVisitor extension so visitors that 
predate (or don't
+               // care about) geo support are not forced to implement them. A 
visitor that
+               // doesn't implement it doesn't support geo predicates; panic 
here is
+               // recovered into an error by VisitExpr.
+               gv, ok := visitor.(BoundGeospatialExprVisitor[T])
+               if !ok {
+                       panic(fmt.Errorf("%w: visitor %T does not support 
geospatial bbox predicates",
+                               ErrNotImplemented, visitor))
+               }
+               bbox := e.(BoundBBoxPredicate).BBox()
+               if e.Op() == OpBBoxIntersects {
+                       return gv.VisitBBoxIntersects(e.Term(), bbox)
+               }
+
+               return gv.VisitBBoxNotIntersects(e.Term(), bbox)
        }
        panic(fmt.Errorf("%w: unhandled bound predicate type: %s", 
ErrNotImplemented, e))
 }
@@ -385,6 +423,29 @@ func (e *exprEvaluator) VisitNotStartsWith(term BoundTerm, 
lit Literal) bool {
        return !e.VisitStartsWith(term, lit)
 }
 
+// VisitBBoxIntersects evaluates a geospatial bbox predicate at the row level.
+// Row-level geometric evaluation (parsing each row's WKB and testing its
+// bounding box) is a query-engine concern - the Iceberg spec only requires
+// bbox-based data skipping, which happens in the metrics/manifest evaluators.
+// Here the predicate conservatively keeps every row so it never drops a row 
that
+// an engine's own ST_Intersects would retain.
+func (e *exprEvaluator) VisitBBoxIntersects(BoundTerm, BoundingBox) bool {
+       return true
+}
+
+func (e *exprEvaluator) VisitBBoxNotIntersects(BoundTerm, BoundingBox) bool {
+       // Keep every row, for the same reason as VisitBBoxIntersects above: 
row-level
+       // geometric evaluation is a query-engine concern, and bounds alone 
cannot
+       // prove a row does not intersect. Must stay true - returning false 
here would
+       // silently drop rows an engine's own predicate would retain.
+       return true
+}
+
+// exprEvaluator handles geospatial predicates, so it implements the optional
+// BoundGeospatialExprVisitor. The assertion keeps that wiring compile-checked 
now
+// that the geo methods are no longer part of BoundBooleanExprVisitor.
+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.
 //
@@ -497,6 +558,20 @@ func (columnNameTranslator) VisitUnbound(pred 
UnboundPredicate) BooleanExpressio
 }
 
 func (c columnNameTranslator) VisitBound(pred BoundPredicate) 
BooleanExpression {
+       // A bbox predicate has no substrait/record-filter form; it is 
evaluated only
+       // during metrics-based file pruning, so the record filter must 
conservatively
+       // keep every row. It must be matched before the column-not-found early 
return,
+       // which would otherwise return AlwaysFalse and silently drop every row 
of a
+       // file that predates the geo column, and before the switch below, whose
+       // default panics on an unhandled predicate. Collapse it to always-true
+       // (mirrors exprEvaluator and sanitizeVisitor); this also keeps it out 
of
+       // substrait, where it would otherwise error. Match the exported
+       // BoundBBoxPredicate interface, not just the concrete type, so a future
+       // implementation is covered too.
+       if _, ok := pred.(BoundBBoxPredicate); ok {
+               return AlwaysTrue{}
+       }
+
        fileColName, found := 
c.fileSchema.FindColumnName(pred.Term().Ref().Field().ID)
        if !found {
                // in the case of schema evolution, the column might not be 
present
@@ -560,6 +635,12 @@ func (sanitizeVisitor) VisitOr(left, right 
BooleanExpression) BooleanExpression
 
 func (sanitizeVisitor) VisitUnbound(pred UnboundPredicate) BooleanExpression {
        switch p := pred.(type) {
+       case *unboundBBoxPredicate:
+               // A bbox predicate carries query-box coordinates, not a 
per-row user
+               // literal, and has no REST expression-JSON form (see 
MarshalJSON).
+               // Collapse it to always-true so the surrounding expression 
still
+               // sanitizes and serializes; nothing user-provided leaks.
+               return AlwaysTrue{}
        case *unboundUnaryPredicate:
                // No literal to mask; the op and term are safe to keep as-is.
                return pred
@@ -573,6 +654,16 @@ func (sanitizeVisitor) VisitUnbound(pred UnboundPredicate) 
BooleanExpression {
 }
 
 func (sanitizeVisitor) VisitBound(pred BoundPredicate) BooleanExpression {
+       // A bbox predicate carries query-box coordinates, not a per-row user 
literal,
+       // and has no REST expression-JSON form (see MarshalJSON), so it cannot 
be
+       // rebuilt as a reference predicate like the cases below. Collapse it to
+       // always-true so the surrounding expression still sanitizes and 
serializes;
+       // nothing user-provided leaks. Matched before ref is taken and on the 
exported
+       // BoundBBoxPredicate interface (mirrors columnNameTranslator).
+       if _, ok := pred.(BoundBBoxPredicate); ok {
+               return AlwaysTrue{}
+       }
+
        // Rebuild over the column name as an unbound reference: the sanitized 
form is
        // only serialized or logged, and an unbound predicate with a masked 
string
        // literal serializes without needing the field's type.
diff --git a/visitors_test.go b/visitors_test.go
index bb8f7160b..638a09b10 100644
--- a/visitors_test.go
+++ b/visitors_test.go
@@ -172,6 +172,18 @@ func (e *FooBoundExprVisitor) 
VisitNotStartsWith(iceberg.BoundTerm, iceberg.Lite
        return e.visitHistory
 }
 
+func (e *FooBoundExprVisitor) VisitBBoxIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) []string {
+       e.visitHistory = append(e.visitHistory, "BBOX_INTERSECTS")
+
+       return e.visitHistory
+}
+
+func (e *FooBoundExprVisitor) VisitBBoxNotIntersects(iceberg.BoundTerm, 
iceberg.BoundingBox) []string {
+       e.visitHistory = append(e.visitHistory, "BBOX_NOT_INTERSECTS")
+
+       return e.visitHistory
+}
+
 func TestBooleanExprVisitor(t *testing.T) {
        expr := iceberg.NewAnd(
                iceberg.NewOr(
@@ -276,6 +288,116 @@ func TestBoundBoolExprVisitor(t *testing.T) {
        }
 }
 
+// noGeoBoundVisitor is a BoundBooleanExprVisitor that deliberately does NOT
+// implement BoundGeospatialExprVisitor. It models an external visitor written
+// before geo support: VisitBoundPredicate must reject a bbox predicate with an
+// ErrNotImplemented error rather than mis-dispatching it. The 
literal/set/unary
+// methods are stubs - a bbox expr panics before any of them is reached.
+type noGeoBoundVisitor struct {
+       ExampleVisitor
+}
+
+func (e *noGeoBoundVisitor) VisitBound(pred iceberg.BoundPredicate) []string {
+       return iceberg.VisitBoundPredicate(pred, e)
+}
+
+func (e *noGeoBoundVisitor) VisitUnbound(iceberg.UnboundPredicate) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitIn(iceberg.BoundTerm, 
iceberg.Set[iceberg.Literal]) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitNotIn(iceberg.BoundTerm, 
iceberg.Set[iceberg.Literal]) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitIsNan(iceberg.BoundTerm) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitNotNan(iceberg.BoundTerm) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitIsNull(iceberg.BoundTerm) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitNotNull(iceberg.BoundTerm) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitEqual(iceberg.BoundTerm, iceberg.Literal) 
[]string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitNotEqual(iceberg.BoundTerm, iceberg.Literal) 
[]string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitGreaterEqual(iceberg.BoundTerm, 
iceberg.Literal) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitGreater(iceberg.BoundTerm, iceberg.Literal) 
[]string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitLessEqual(iceberg.BoundTerm, iceberg.Literal) 
[]string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitLess(iceberg.BoundTerm, iceberg.Literal) 
[]string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitStartsWith(iceberg.BoundTerm, 
iceberg.Literal) []string {
+       return e.visitHistory
+}
+
+func (e *noGeoBoundVisitor) VisitNotStartsWith(iceberg.BoundTerm, 
iceberg.Literal) []string {
+       return e.visitHistory
+}
+
+func geoVisitorSchema() *iceberg.Schema {
+       return iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "geom", Type: 
iceberg.GeometryType{}, Required: false},
+       )
+}
+
+// TestVisitBoundPredicateDispatchesBBox drives a bound bbox predicate through 
the
+// type-assert-and-dispatch path in VisitBoundPredicate: a visitor implementing
+// BoundGeospatialExprVisitor must have VisitBBoxIntersects invoked. This 
guards
+// the extension wiring - a refactor breaking the BoundGeospatialExprVisitor[T]
+// assertion would be caught here.
+func TestVisitBoundPredicateDispatchesBBox(t *testing.T) {
+       bound, err := iceberg.BBoxIntersects(iceberg.Reference("geom"),
+               iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 
10}).Bind(geoVisitorSchema(), true)
+       require.NoError(t, err)
+
+       visitor := FooBoundExprVisitor{ExampleVisitor: 
ExampleVisitor{visitHistory: []string{}}}
+       result, err := iceberg.VisitExpr(bound, &visitor)
+       require.NoError(t, err)
+       assert.Equal(t, []string{"BBOX_INTERSECTS"}, result)
+}
+
+// TestVisitBoundPredicateBBoxWithoutGeoVisitor pins the load-bearing error 
path an
+// external caller hits: a BoundBooleanExprVisitor that does not implement
+// BoundGeospatialExprVisitor, handed a bbox predicate, surfaces an error 
wrapping
+// ErrNotImplemented (the panic recovered by VisitExpr) rather than 
mis-dispatching.
+func TestVisitBoundPredicateBBoxWithoutGeoVisitor(t *testing.T) {
+       bound, err := iceberg.BBoxIntersects(iceberg.Reference("geom"),
+               iceberg.BoundingBox{MinX: 0, MinY: 0, MaxX: 10, MaxY: 
10}).Bind(geoVisitorSchema(), true)
+       require.NoError(t, err)
+
+       visitor := noGeoBoundVisitor{ExampleVisitor: 
ExampleVisitor{visitHistory: []string{}}}
+       _, err = iceberg.VisitExpr(bound, &visitor)
+       require.Error(t, err)
+       assert.ErrorIs(t, err, iceberg.ErrNotImplemented)
+}
+
 type rowTester []any
 
 func (r rowTester) Size() int       { return len(r) }

Reply via email to