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 b11534070 fix(table): accept EWKB-encoded geometry values when 
computing geo bounds (#1598)
b11534070 is described below

commit b115340709db5493d430ae556ada79548628a639
Author: Tobias Pütz <[email protected]>
AuthorDate: Mon Aug 3 14:47:51 2026 +0200

    fix(table): accept EWKB-encoded geometry values when computing geo bounds 
(#1598)
    
    Iceberg prescribes ISO WKB, but geometry values with EWKB type codes
    (dimension flags in the high bits, an optional embedded SRID) exist in
    the wild (Trino 483), and the ISO-only decoder used for bounds failed
    with "wkb: unknown type: 2147483649", aborting the entire file rewrite.
    
    Dispatch on the flag bits so both encodings decode. Coordinates are
    identical either way, so bounds are encoding-independent, and the stored
    bytes pass through unmodified.
    
    
    This fixes the read/stats path, but it doesn't normalize the stored
    bytes, so a table compacted by iceberg-go still carries EWKB in its data
    files. Fully closing the gap is a write-path normalization follow-up
---
 table/internal/geo_codec.go               |  61 +++++-
 table/internal/geo_codec_internal_test.go | 335 ++++++++++++++++++++++++++++++
 2 files changed, 394 insertions(+), 2 deletions(-)

diff --git a/table/internal/geo_codec.go b/table/internal/geo_codec.go
index fe905f502..dd400f5a9 100644
--- a/table/internal/geo_codec.go
+++ b/table/internal/geo_codec.go
@@ -24,6 +24,7 @@ import (
 
        "github.com/apache/iceberg-go"
        "github.com/twpayne/go-geom"
+       "github.com/twpayne/go-geom/encoding/ewkb"
        "github.com/twpayne/go-geom/encoding/wkb"
 )
 
@@ -75,9 +76,9 @@ func newGeoBoundsAccumulator(isGeography bool) 
*geoBoundsAccumulator {
 }
 
 // AddWKB unmarshals a single WKB value and extends the bounding box with its
-// coordinates.
+// coordinates. Both ISO WKB and EWKB values are accepted (see decodeWKB).
 func (a *geoBoundsAccumulator) AddWKB(data []byte) error {
-       g, err := wkb.Unmarshal(data)
+       g, err := decodeWKB(data)
        if err != nil {
                return err
        }
@@ -87,6 +88,62 @@ func (a *geoBoundsAccumulator) AddWKB(data []byte) error {
        return nil
 }
 
+// EWKB dimension and SRID flags, carried in the high bits of the WKB type 
word.
+const (
+       ewkbFlagZ    uint32 = 0x80000000
+       ewkbFlagM    uint32 = 0x40000000
+       ewkbFlagSRID uint32 = 0x20000000
+       ewkbFlags           = ewkbFlagZ | ewkbFlagM | ewkbFlagSRID
+)
+
+// WKB byte-order markers, the first byte of every WKB value.
+const (
+       wkbBigEndian    = 0
+       wkbLittleEndian = 1
+)
+
+// decodeWKB decodes a geospatial value for statistics purposes, accepting both
+// encodings found in the wild. Iceberg prescribes ISO WKB, which encodes the
+// dimension in the type value itself (PointZ = 1001); some writers instead 
emit
+// EWKB, which flags Z/M in the high bits of the type word and may embed an 
SRID
+// after it. Both yield the same coordinates, so bounds do not depend on the
+// encoding. Stored values are never rewritten - this decode is read-only.
+func decodeWKB(data []byte) (geom.T, error) {
+       if isEWKB(data) {
+               return ewkb.Unmarshal(data)
+       }
+
+       return wkb.Unmarshal(data)
+}
+
+// isEWKB reports whether data's type word carries EWKB flags. The two decoders
+// are not interchangeable: the ISO decoder rejects flagged type words, and the
+// EWKB decoder rejects the ISO dimension offsets (it reads 1001 as an unknown
+// type rather than PointZ), so the flags select which one applies. Values too
+// short to hold a type word are left to the ISO decoder to reject.
+//
+// A 2D EWKB value without an SRID carries no flags, so it is reported as ISO 
and
+// decoded by the ISO decoder. That is correct because the two encodings are
+// byte-identical for plain 2D geometries; do not tighten the heuristic to 
claim
+// such values as EWKB.
+func isEWKB(data []byte) bool {
+       if len(data) < 5 {
+               return false
+       }
+
+       var typeWord uint32
+       switch data[0] {
+       case wkbBigEndian:
+               typeWord = binary.BigEndian.Uint32(data[1:5])
+       case wkbLittleEndian:
+               typeWord = binary.LittleEndian.Uint32(data[1:5])
+       default:
+               return false
+       }
+
+       return typeWord&ewkbFlags != 0
+}
+
 // extend walks every coordinate of g, recursing into geometry collections,
 // which cannot expose flat coordinates directly.
 func (a *geoBoundsAccumulator) extend(g geom.T) {
diff --git a/table/internal/geo_codec_internal_test.go 
b/table/internal/geo_codec_internal_test.go
index 601f0ac25..8845b569f 100644
--- a/table/internal/geo_codec_internal_test.go
+++ b/table/internal/geo_codec_internal_test.go
@@ -237,6 +237,341 @@ func TestGeoBoundsAccumulatorInvalidWKB(t *testing.T) {
        assert.Error(t, acc.AddWKB([]byte{0x01, 0x02, 0x03}))
 }
 
+// WKB type words used by the EWKB tests below. ISO WKB encodes the dimension 
in
+// the type value itself (PointZ = 1001), while EWKB sets flags in the high 
bits
+// of the type word (ewkbFlagZ etc., shared with geo_codec.go) and optionally
+// embeds an SRID after it.
+const (
+       wkbPoint               = 1
+       wkbLineString          = 2
+       wkbGeometryCollection  = 7
+       wkbPointZ              = 1001
+       wkbPointM              = 2001
+       wkbPointZM             = 3001
+       wkbLineStringZ         = 1002
+       wkbGeometryCollectionZ = 1007
+)
+
+// wkbBuilder assembles a WKB value byte by byte: the byte-order marker, then
+// uint32 headers and float64 coordinates in that byte order.
+type wkbBuilder struct {
+       buf   []byte
+       order binary.AppendByteOrder
+}
+
+// newWKBBuilder builds a little-endian (NDR) value.
+func newWKBBuilder(typeWord uint32) *wkbBuilder {
+       return (&wkbBuilder{buf: []byte{wkbLittleEndian}, order: 
binary.LittleEndian}).u32(typeWord)
+}
+
+// newXDRWKBBuilder builds a big-endian (XDR) value.
+func newXDRWKBBuilder(typeWord uint32) *wkbBuilder {
+       return (&wkbBuilder{buf: []byte{wkbBigEndian}, order: 
binary.BigEndian}).u32(typeWord)
+}
+
+func (b *wkbBuilder) u32(v uint32) *wkbBuilder {
+       b.buf = b.order.AppendUint32(b.buf, v)
+
+       return b
+}
+
+func (b *wkbBuilder) f64(vals ...float64) *wkbBuilder {
+       for _, v := range vals {
+               b.buf = b.order.AppendUint64(b.buf, math.Float64bits(v))
+       }
+
+       return b
+}
+
+// nested appends complete WKB values, each carrying its own byte-order marker,
+// as the sub-geometries of a collection.
+func (b *wkbBuilder) nested(vals ...[]byte) *wkbBuilder {
+       for _, v := range vals {
+               b.buf = append(b.buf, v...)
+       }
+
+       return b
+}
+
+func (b *wkbBuilder) bytes() []byte { return b.buf }
+
+// assertCoords compares bound coordinates treating NaN as equal to NaN, which
+// assert.Equal does not (the XYM bound carries NaN in its Z slot).
+func assertCoords(t *testing.T, want, got []float64) {
+       t.Helper()
+       require.Len(t, got, len(want))
+       for i := range want {
+               if math.IsNaN(want[i]) {
+                       assert.True(t, math.IsNaN(got[i]), "coord %d: want NaN, 
got %v", i, got[i])
+
+                       continue
+               }
+               assert.Equal(t, want[i], got[i], "coord %d", i)
+       }
+}
+
+// TestGeoBoundsAccumulatorEWKB verifies that bounds are computed from both ISO
+// WKB (as Iceberg prescribes) and EWKB-flagged values, which some writers 
emit:
+// dimension flags in the high bits of the type word, with an optional embedded
+// SRID that is irrelevant to the bounding box.
+func TestGeoBoundsAccumulatorEWKB(t *testing.T) {
+       tests := []struct {
+               name       string
+               wkb        []byte
+               wantLower  []float64
+               wantUpper  []float64
+               wantLength int
+       }{
+               {
+                       name:       "iso point xy",
+                       wkb:        newWKBBuilder(wkbPoint).f64(1, 2).bytes(),
+                       wantLower:  []float64{1, 2},
+                       wantUpper:  []float64{1, 2},
+                       wantLength: 16,
+               },
+               {
+                       name:       "iso point z",
+                       wkb:        newWKBBuilder(wkbPointZ).f64(1, 2, 
3).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{1, 2, 3},
+                       wantLength: 24,
+               },
+               {
+                       name:       "ewkb point z",
+                       wkb:        newWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 2, 
3).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{1, 2, 3},
+                       wantLength: 24,
+               },
+               {
+                       name:       "ewkb point z with srid",
+                       wkb:        
newWKBBuilder(wkbPoint|ewkbFlagZ|ewkbFlagSRID).u32(4326).f64(1, 2, 3).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{1, 2, 3},
+                       wantLength: 24,
+               },
+               {
+                       name:       "ewkb point xy with srid",
+                       wkb:        
newWKBBuilder(wkbPoint|ewkbFlagSRID).u32(4326).f64(1, 2).bytes(),
+                       wantLower:  []float64{1, 2},
+                       wantUpper:  []float64{1, 2},
+                       wantLength: 16,
+               },
+               {
+                       name:       "iso point m",
+                       wkb:        newWKBBuilder(wkbPointM).f64(1, 2, 
100).bytes(),
+                       wantLower:  []float64{1, 2, math.NaN(), 100},
+                       wantUpper:  []float64{1, 2, math.NaN(), 100},
+                       wantLength: 32,
+               },
+               {
+                       name:       "ewkb point m",
+                       wkb:        newWKBBuilder(wkbPoint|ewkbFlagM).f64(1, 2, 
100).bytes(),
+                       wantLower:  []float64{1, 2, math.NaN(), 100},
+                       wantUpper:  []float64{1, 2, math.NaN(), 100},
+                       wantLength: 32,
+               },
+               {
+                       name:       "iso point zm",
+                       wkb:        newWKBBuilder(wkbPointZM).f64(1, 2, 3, 
100).bytes(),
+                       wantLower:  []float64{1, 2, 3, 100},
+                       wantUpper:  []float64{1, 2, 3, 100},
+                       wantLength: 32,
+               },
+               {
+                       name:       "ewkb point zm",
+                       wkb:        
newWKBBuilder(wkbPoint|ewkbFlagZ|ewkbFlagM).f64(1, 2, 3, 100).bytes(),
+                       wantLower:  []float64{1, 2, 3, 100},
+                       wantUpper:  []float64{1, 2, 3, 100},
+                       wantLength: 32,
+               },
+               {
+                       name:       "ewkb linestring z",
+                       wkb:        
newWKBBuilder(wkbLineString|ewkbFlagZ).u32(2).f64(1, 2, 3, 4, 5, 6).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{4, 5, 6},
+                       wantLength: 24,
+               },
+               {
+                       name:       "ewkb point z big endian",
+                       wkb:        newXDRWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 
2, 3).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{1, 2, 3},
+                       wantLength: 24,
+               },
+               {
+                       name:       "ewkb linestring z with srid",
+                       wkb:        
newWKBBuilder(wkbLineString|ewkbFlagZ|ewkbFlagSRID).u32(4326).u32(2).f64(1, 2, 
3, 4, 5, 6).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{4, 5, 6},
+                       wantLength: 24,
+               },
+               {
+                       // A collection is the only value whose sub-geometries 
are decoded
+                       // recursively, and Trino and PostGIS both emit these; 
each sub-geometry
+                       // repeats the byte-order marker and the flagged type 
word.
+                       name: "ewkb geometry collection z",
+                       wkb: 
newWKBBuilder(wkbGeometryCollection|ewkbFlagZ).u32(2).nested(
+                               newWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 2, 
3).bytes(),
+                               
newWKBBuilder(wkbLineString|ewkbFlagZ).u32(2).f64(4, 5, 6, 7, 8, 9).bytes(),
+                       ).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{7, 8, 9},
+                       wantLength: 24,
+               },
+               {
+                       name: "ewkb geometry collection z with srid",
+                       wkb: 
newWKBBuilder(wkbGeometryCollection|ewkbFlagZ|ewkbFlagSRID).u32(4326).u32(2).nested(
+                               
newWKBBuilder(wkbPoint|ewkbFlagZ|ewkbFlagSRID).u32(4326).f64(1, 2, 3).bytes(),
+                               
newWKBBuilder(wkbPoint|ewkbFlagZ|ewkbFlagSRID).u32(4326).f64(7, 8, 9).bytes(),
+                       ).bytes(),
+                       wantLower:  []float64{1, 2, 3},
+                       wantUpper:  []float64{7, 8, 9},
+                       wantLength: 24,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       acc := newGeoBoundsAccumulator(false)
+                       require.NoError(t, acc.AddWKB(tt.wkb))
+
+                       lower, upper := acc.Bounds()
+                       require.Len(t, lower, tt.wantLength)
+                       require.Len(t, upper, tt.wantLength)
+
+                       assertCoords(t, tt.wantLower, decodeBound(t, lower))
+                       assertCoords(t, tt.wantUpper, decodeBound(t, upper))
+               })
+       }
+}
+
+// TestGeoBoundsAccumulatorEWKBMatchesISO verifies that the two encodings of 
the
+// same coordinates produce byte-identical bounds, so a file's statistics do 
not
+// depend on which encoding its writer used.
+func TestGeoBoundsAccumulatorEWKBMatchesISO(t *testing.T) {
+       tests := []struct {
+               name     string
+               iso      []byte
+               ewkb     []byte
+               geograph bool
+       }{
+               {
+                       name: "point xy",
+                       iso:  newWKBBuilder(wkbPoint).f64(1, 2).bytes(),
+                       ewkb: 
newWKBBuilder(wkbPoint|ewkbFlagSRID).u32(4326).f64(1, 2).bytes(),
+               },
+               {
+                       name: "point z",
+                       iso:  newWKBBuilder(wkbPointZ).f64(1, 2, 3).bytes(),
+                       ewkb: newWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 2, 
3).bytes(),
+               },
+               {
+                       name: "point m",
+                       iso:  newWKBBuilder(wkbPointM).f64(1, 2, 100).bytes(),
+                       ewkb: newWKBBuilder(wkbPoint|ewkbFlagM).f64(1, 2, 
100).bytes(),
+               },
+               {
+                       name: "point zm",
+                       iso:  newWKBBuilder(wkbPointZM).f64(1, 2, 3, 
100).bytes(),
+                       ewkb: 
newWKBBuilder(wkbPoint|ewkbFlagZ|ewkbFlagM).f64(1, 2, 3, 100).bytes(),
+               },
+               {
+                       name: "point z big endian",
+                       iso:  newWKBBuilder(wkbPointZ).f64(1, 2, 3).bytes(),
+                       ewkb: newXDRWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 2, 
3).bytes(),
+               },
+               {
+                       name: "linestring z",
+                       iso:  newWKBBuilder(wkbLineStringZ).u32(2).f64(1, 2, 3, 
4, 5, 6).bytes(),
+                       ewkb: 
newWKBBuilder(wkbLineString|ewkbFlagZ).u32(2).f64(1, 2, 3, 4, 5, 6).bytes(),
+               },
+               {
+                       // Geography emits no bounds for either encoding, but 
the value must
+                       // still decode: a decode error aborts the whole file 
rewrite.
+                       name:     "geography point z",
+                       iso:      newWKBBuilder(wkbPointZ).f64(1, 2, 3).bytes(),
+                       ewkb:     newWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 2, 
3).bytes(),
+                       geograph: true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       isoAcc := newGeoBoundsAccumulator(tt.geograph)
+                       require.NoError(t, isoAcc.AddWKB(tt.iso))
+                       isoLower, isoUpper := isoAcc.Bounds()
+
+                       ewkbAcc := newGeoBoundsAccumulator(tt.geograph)
+                       require.NoError(t, ewkbAcc.AddWKB(tt.ewkb))
+                       ewkbLower, ewkbUpper := ewkbAcc.Bounds()
+
+                       // Both accumulators must have consumed coordinates. 
Bounds alone cannot
+                       // show this for geography, where the comparison is nil 
against nil and
+                       // would stay green if the decode returned an empty 
geometry.
+                       assert.Positive(t, isoAcc.geoms, "ISO value contributed 
no geometry")
+                       assert.Positive(t, ewkbAcc.geoms, "EWKB value 
contributed no geometry")
+                       assert.Equal(t, isoAcc.min, ewkbAcc.min, "accumulated 
minimums must match")
+                       assert.Equal(t, isoAcc.max, ewkbAcc.max, "accumulated 
maximums must match")
+
+                       assert.Equal(t, isoLower, ewkbLower)
+                       assert.Equal(t, isoUpper, ewkbUpper)
+                       if tt.geograph {
+                               assert.Nil(t, ewkbLower, "geography bounds must 
be omitted")
+                       }
+               })
+       }
+}
+
+// TestGeoBoundsAccumulatorRejectsInvalidWKB verifies that malformed values 
still
+// error rather than panicking or silently contributing no coordinates.
+//
+// The two collection cases pin the boundary of the encoding heuristic: isEWKB
+// sniffs only the outer type word, so a collection whose sub-geometries use 
the
+// other encoding reaches the wrong decoder. Mixing encodings within one value 
is
+// unsupported, and the failure mode is an error that aborts the file rather 
than
+// bounds computed from a partial decode.
+func TestGeoBoundsAccumulatorRejectsInvalidWKB(t *testing.T) {
+       tests := []struct {
+               name string
+               wkb  []byte
+       }{
+               {name: "empty", wkb: nil},
+               {name: "byte order only", wkb: []byte{wkbLittleEndian}},
+               {name: "unknown byte order", wkb: []byte{0x07, 0x01, 0x00, 
0x00, 0x00}},
+               {name: "truncated type word", wkb: []byte{wkbLittleEndian, 
0x01, 0x00}},
+               {name: "unknown iso type", wkb: newWKBBuilder(42).f64(1, 
2).bytes()},
+               {name: "unknown iso dimension", wkb: newWKBBuilder(9001).f64(1, 
2).bytes()},
+               {name: "unknown ewkb type", wkb: 
newWKBBuilder(42|ewkbFlagZ).f64(1, 2, 3).bytes()},
+               {name: "truncated coords", wkb: 
newWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 2).bytes()},
+               {name: "missing srid", wkb: newWKBBuilder(wkbPoint | 
ewkbFlagSRID).bytes()},
+               {
+                       name: "ewkb collection with iso sub-geometry",
+                       wkb: newWKBBuilder(wkbGeometryCollection | 
ewkbFlagZ).u32(1).nested(
+                               newWKBBuilder(wkbPointZ).f64(1, 2, 3).bytes(),
+                       ).bytes(),
+               },
+               {
+                       name: "iso collection with ewkb sub-geometry",
+                       wkb: 
newWKBBuilder(wkbGeometryCollectionZ).u32(1).nested(
+                               newWKBBuilder(wkbPoint|ewkbFlagZ).f64(1, 2, 
3).bytes(),
+                       ).bytes(),
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       acc := newGeoBoundsAccumulator(false)
+                       require.Error(t, acc.AddWKB(tt.wkb))
+
+                       lower, upper := acc.Bounds()
+                       assert.Zero(t, acc.geoms, "a rejected value must 
contribute no geometry")
+                       assert.Nil(t, lower)
+                       assert.Nil(t, upper)
+               })
+       }
+}
+
 // TestEncodeGeoBoundRoundTrip pins the exact byte layout of the single-value
 // serialization for each dimensionality.
 func TestEncodeGeoBoundRoundTrip(t *testing.T) {

Reply via email to