laskoviymishka commented on code in PR #1597:
URL: https://github.com/apache/iceberg-go/pull/1597#discussion_r3673919799
##########
table/substrait/substrait.go:
##########
@@ -443,3 +443,11 @@ func (t *toSubstraitExpr) VisitNotStartsWith(term
iceberg.BoundTerm, lit iceberg
t.makeLitFunc(startsWithID, term, lit).(expr.FuncArgBuilder),
)
}
+
+func (t *toSubstraitExpr) VisitBBoxIntersects(iceberg.BoundTerm,
iceberg.BoundingBox) expr.Builder {
+ panic("geospatial bbox predicates cannot be converted to substrait")
Review Comment:
This panic isn't just defensive — it's reachable on what looks like the
primary use of the new API. Run `table.Scan(WithFilter(BBoxIntersects(...)))`
and the predicate survives column-name translation (because
`boundBBoxPredicate` satisfies `BoundUnaryPredicate`), survives `Bind`, and
lands here via `getRecordFilter` → `ConvertExpr`. `VisitExpr`'s `recover()`
turns it into an error, so the whole scan fails — which contradicts the
"conservatively keeps every row" contract the `exprEvaluator` advertises (that
path only runs for partition eval, never the record filter).
The real fix is upstream: drop the bbox predicate to `AlwaysTrue` before it
reaches substrait, either in `getRecordFilter` or by special-casing
`*boundBBoxPredicate` in `columnNameTranslator.VisitBound` (see my note there —
same fix closes the all-rows-dropped case too). Even as a backstop here, I'd
return `fmt.Errorf("%w: ...", ErrNotImplemented)` rather than a bare panic
string so callers get something typed and wrappable. wdyt?
##########
visitors.go:
##########
@@ -578,6 +604,13 @@ func (sanitizeVisitor) VisitBound(pred BoundPredicate)
BooleanExpression {
// literal serializes without needing the field's type.
ref := Reference(pred.Term().Ref().Field().Name)
switch p := pred.(type) {
+ case *boundBBoxPredicate:
Review Comment:
You guard this correctly here in `sanitizeVisitor`, but
`columnNameTranslator.VisitBound` has the same shape of switch and doesn't
special-case `*boundBBoxPredicate` — and its column-not-found early return
fires before the `BoundUnaryPredicate` arm is ever reached.
So a geo-filtered scan against a data file that predates the geometry column
hits the not-found path, returns `AlwaysFalse`, and silently drops every row
instead of keeping them. That's the one direction pruning must never be wrong.
The same guard you wrote here (match `*boundBBoxPredicate` → `AlwaysTrue`)
in `columnNameTranslator.VisitBound` fixes it, and because it also drops the
predicate before substrait, it resolves the panic path from my other comment in
one shot. More fundamentally, this is the cost of `boundBBoxPredicate`
deliberately satisfying `BoundUnaryPredicate` — every type-switch on that
interface now has to remember to match the concrete type first. Worth a hard
look at whether that trick is paying for itself. wdyt?
##########
table/internal/geo_codec.go:
##########
@@ -315,6 +315,34 @@ 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
+ }
+
+ return lo[geoDimX], lo[geoDimY], hi[geoDimX], hi[geoDimY], true
Review Comment:
We reject NaN here but not inverted bounds. If a third-party writer emits
`lo[X] > hi[X]`, `BBoxIntersectsXY` gets `aMaxX < aMinX`, the intersection test
is always false, and we prune a file we should have kept — a dropped-rows false
negative, same wrong direction as the schema-evolution case.
iceberg-go's own accumulator never produces inverted bounds, but this is a
trust-on-read decode path, so the writer isn't ours to trust. I'd add `if
lo[geoDimX] > hi[geoDimX] || lo[geoDimY] > hi[geoDimY] { return 0, 0, 0, 0,
false }` right after the NaN check so a malformed bound just makes the file
unprunable. wdyt?
##########
visitors.go:
##########
@@ -60,6 +60,8 @@ type BoundBooleanExprVisitor[T any] interface {
VisitLess(BoundTerm, Literal) T
VisitStartsWith(BoundTerm, Literal) T
VisitNotStartsWith(BoundTerm, Literal) T
+ VisitBBoxIntersects(BoundTerm, BoundingBox) T
Review Comment:
Adding two methods to this exported interface breaks every external
implementation — Trino/Dremio adapters and downstream visitors won't compile
until they add the geo methods. `iceberg` is v0 so semver doesn't strictly
bind, but it's churn we're choosing.
Worth noting Java deliberately keeps geo out of `BoundExpressionVisitor` and
dispatches it through standalone `GeospatialPredicateEvaluators` instead.
Either we follow that and keep bbox eval outside the visitor via a
type-assertion path, or we accept the break here — but I'd make it a deliberate
call, and if we keep it in the visitor, either note it in the changelog or ship
a `DefaultBoundBooleanExprVisitor` with no-op geo methods embedders can mix in.
wdyt?
##########
expr_bbox_test.go:
##########
@@ -0,0 +1,179 @@
+// 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"} {
Review Comment:
This loop doesn't wrap the body in `t.Run`, so a failure on `"geom"` aborts
before `"geog"` runs — and the rest of the PR uses subtests. I'd wrap it in
`t.Run(name, func(t *testing.T){...})` so both geo types are exercised
independently.
##########
table/evaluators_geo_test.go:
##########
@@ -0,0 +1,168 @@
+// 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 (
+ "encoding/binary"
+ "math"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ "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) {
Review Comment:
These are solid unit tests for the metrics evaluator, but everything here
calls `newInclusiveMetricsEvaluator` directly on a hand-built `mockDataFile`.
Nothing drives the predicate through a real
`table.Scan(WithFilter(BBoxIntersects(...)))` — binding, column-name
translation, projection, rewrite — into the manifest→metrics path.
That gap is exactly where the two blockers I flagged live (the substrait
panic and the `AlwaysFalse` drop both happen in translation, upstream of this
evaluator). A scan-level test with two data files, one overlapping and one
disjoint, asserting only the overlapping file lands in the scan tasks, would
have caught both and would keep them caught. Could we add one?
##########
exprs.go:
##########
@@ -1132,3 +1143,159 @@ 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.
+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.
+func BBoxIntersects(t UnboundTerm, bbox BoundingBox) UnboundPredicate {
Review Comment:
This is the outermost user-facing entry point, and a caller deriving a box
from user input (a map viewport, a request param) can't guard construction
without wrapping in `recover()`. I'd lean toward `BBoxIntersects(t, bbox)
(UnboundPredicate, error)`.
`NewNot`/`NewAnd` panic too, so there's precedent for the style — if we keep
the panic, I'd at least document the panic contract in the godoc so it isn't a
surprise. wdyt?
##########
expr_json.go:
##########
@@ -195,6 +195,14 @@ 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.
+func (u *unboundBBoxPredicate) MarshalJSON() ([]byte, error) { return nil,
errBBoxNotSerializable }
+func (b *boundBBoxPredicate) MarshalJSON() ([]byte, error) { return nil,
errBBoxNotSerializable }
+
+var errBBoxNotSerializable = fmt.Errorf("%w: geospatial bbox predicates cannot
be serialized to REST expression JSON", ErrInvalidArgument)
Review Comment:
Small thing — the sentinel is declared after the methods that use it, which
reads backward, and the repo keeps wrapped sentinel errors in `errors.go`. I'd
move it there (or at least above these methods) to match convention.
##########
visitors.go:
##########
@@ -385,6 +391,20 @@ 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 {
Review Comment:
`VisitBBoxIntersects` just above has a solid comment on why it keeps every
row; `VisitBBoxNotIntersects` returns `true` with none. The `true` is correct
(bounds can't prove a not-intersects), but it's exactly the kind of bare
`return true` a future maintainer might "tidy" to `false` and start dropping
rows. A one-line comment mirroring the one above would protect it.
##########
exprs.go:
##########
@@ -1132,3 +1143,159 @@ 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.
+type BoundingBox struct {
Review Comment:
This box is XY-only, which is fine for the pruning the spec requires — no
correctness issue, inclusive eval just keeps more files than Java would for
Z/M-bounded data. My concern is forward-compat: Java's `BoundingBox` carries
optional Z and M, and once this struct ships in the public `iceberg` package,
its `Valid()`/`Equals()` semantics are effectively frozen.
Since callers build these as literals, adding optional `*float64`
`MinZ`/`MaxZ`/`MinM`/`MaxM` later is mostly additive, but the validity/equality
contract would shift under existing callers. I'd at least decide now whether to
reserve those fields so the door stays open. Not blocking — just cheaper to
settle before it's public. wdyt?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]