laskoviymishka commented on code in PR #1599:
URL: https://github.com/apache/iceberg-go/pull/1599#discussion_r3678176193
##########
table/arrow_utils.go:
##########
@@ -1131,6 +1135,35 @@ func (a *arrowProjectionVisitor) castIfNeeded(field
iceberg.NestedField, vals ar
return vals
}
+// rewrapExtension re-wraps vals in targetType when both are extension types
+// with the same extension name but differing extension parameters, returning
+// false when the types are not such a pair. Arrow's compute layer has no
+// extension-to-extension cast kernel, and the values are already in the
+// extension's storage encoding, so only the storage array is cast and that
only
+// when the storage types differ.
+func (a *arrowProjectionVisitor) rewrapExtension(targetType arrow.DataType,
vals arrow.Array) (arrow.Array, bool) {
+ tgtExt, ok := targetType.(arrow.ExtensionType)
+ if !ok {
+ return nil, false
+ }
+
+ srcExt, ok := vals.DataType().(arrow.ExtensionType)
+ if !ok || srcExt.ExtensionName() != tgtExt.ExtensionName() {
Review Comment:
The rewrap fires for any two extension arrays that share a name, which is
exactly right for `geoarrow.wkb` — CRS is metadata, the WKB bytes don't change.
But the helper reads as general-purpose, and a future extension whose
parameters actually affect the storage encoding would get silently rewrapped
here instead of erroring.
I'd add a line to the doc comment naming the invariant we're leaning on —
something like "safe only because a shared extension name implies the storage
payload is interpretation-compatible." That way the next person extending this
knows the precondition before they add a type where it doesn't hold.
##########
table/arrow_utils_test.go:
##########
@@ -2555,3 +2555,200 @@ func TestToRequestedSchemaMissingNestedFieldID(t
*testing.T) {
}, nil)
require.True(t, targetSchema.Equal(rec2.Schema()), "Schema is not
perfectly equal")
}
+
+// Some writers tag a non-PROJJSON CRS as an SRID, while the Iceberg schema
+// resolves the same CRS as an authority code. Both describe the same WKB
+// payload, so projection must not attempt a value cast.
+func sridWKBType(t *testing.T, storage arrow.DataType, crs string, edges
geoarrow.EdgeInterpolation) arrow.ExtensionType {
+ t.Helper()
+
+ meta := geoarrow.Metadata{
+ CRS: jsonCRS(crs),
+ CRSType: geoarrow.CRSTypeSRID,
+ Edges: edges,
+ }
+
+ switch storage.ID() {
+ case arrow.BINARY:
+ return geoarrow.NewWKBType(geoarrow.WKBWithBinaryStorage(),
geoarrow.WKBWithMetadata(meta))
+ case arrow.LARGE_BINARY:
+ return
geoarrow.NewWKBType(geoarrow.WKBWithLargeBinaryStorage(),
geoarrow.WKBWithMetadata(meta))
+ default:
+ t.Fatalf("unsupported WKB storage type %s", storage)
+
+ return nil
+ }
+}
+
+func newExtensionArrayOverBinary(t *testing.T, mem memory.Allocator, dt
arrow.ExtensionType, values [][]byte) arrow.Array {
+ t.Helper()
+
+ bldr := array.NewBinaryBuilder(mem,
dt.StorageType().(arrow.BinaryDataType))
+ defer bldr.Release()
+
+ for _, v := range values {
+ if v == nil {
+ bldr.AppendNull()
+
+ continue
+ }
+ bldr.Append(v)
+ }
+
+ storage := bldr.NewArray()
+ defer storage.Release()
+
+ return array.NewExtensionArrayWithStorage(dt, storage)
+}
+
+func binaryValueAt(t *testing.T, arr arrow.Array, i int) []byte {
+ t.Helper()
+
+ switch a := arr.(type) {
+ case *array.Binary:
+ return a.Value(i)
+ case *array.LargeBinary:
+ return a.Value(i)
+ default:
+ t.Fatalf("expected binary-like array, got %T", arr)
+
+ return nil
+ }
+}
+
+func singleColumnRecord(field arrow.Field, arr arrow.Array) arrow.RecordBatch {
+ sc := arrow.NewSchema([]arrow.Field{field}, nil)
+
+ return array.NewRecordBatch(sc, []arrow.Array{arr}, int64(arr.Len()))
+}
+
+func TestToRequestedSchemaGeoExtensionRewrap(t *testing.T) {
+ // POINT(1 2) and POINT(3 4) little-endian WKB.
+ point12 := []byte{
+ 0x01, 0x01, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40,
+ }
+ point34 := []byte{
+ 0x01, 0x01, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x40,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x40,
+ }
+ values := [][]byte{point12, nil, point34}
+
+ geography, err := iceberg.GeographyTypeOf("OGC:CRS84", "spherical")
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ icebergType iceberg.Type
+ sourceStorage arrow.DataType
+ sourceEdges geoarrow.EdgeInterpolation
+ useLargeTypes bool
+ }{
+ {
+ name: "geometry_metadata_only",
+ icebergType: iceberg.GeometryType{},
+ sourceStorage: arrow.BinaryTypes.Binary,
Review Comment:
These geometry cases leave `sourceEdges` at its zero value, which
`geoArrowMetadataToIcebergType` happens to treat as planar. It works, but an
explicit `sourceEdges: geoarrow.EdgePlanar` would make the test say what it
means rather than lean on the default. Minor — take it or leave it.
##########
table/arrow_utils.go:
##########
@@ -1131,6 +1135,35 @@ func (a *arrowProjectionVisitor) castIfNeeded(field
iceberg.NestedField, vals ar
return vals
}
+// rewrapExtension re-wraps vals in targetType when both are extension types
+// with the same extension name but differing extension parameters, returning
+// false when the types are not such a pair. Arrow's compute layer has no
+// extension-to-extension cast kernel, and the values are already in the
+// extension's storage encoding, so only the storage array is cast and that
only
+// when the storage types differ.
+func (a *arrowProjectionVisitor) rewrapExtension(targetType arrow.DataType,
vals arrow.Array) (arrow.Array, bool) {
+ tgtExt, ok := targetType.(arrow.ExtensionType)
+ if !ok {
+ return nil, false
+ }
+
+ srcExt, ok := vals.DataType().(arrow.ExtensionType)
+ if !ok || srcExt.ExtensionName() != tgtExt.ExtensionName() {
+ return nil, false
+ }
+
+ if arrow.TypeEqual(srcExt.StorageType(), tgtExt.StorageType()) {
+ return array.NewExtensionArrayWithStorage(tgtExt,
+ vals.(array.ExtensionArray).Storage()), true
+ }
+
+ storage := retOrPanic(compute.CastArray(a.ctx, vals,
Review Comment:
This casts the whole extension array to the target storage type, and it does
work — Arrow's `addCastFuncs` registers `CastFromExtension` for every
EXTENSION→target cast, so the kernel unwraps the extension and casts the
storage for us. But it only works because of that registration, and it reads as
"cast a WKB array to large_binary" when what we actually want is to widen the
storage.
Since we've already confirmed `vals` is an extension array, I'd cast the
storage directly:
```go
storage := retOrPanic(compute.CastArray(a.ctx,
vals.(array.ExtensionArray).Storage(),
compute.SafeCastOptions(tgtExt.StorageType())))
```
Same result, but the intent is explicit and we're not relying on the
extension-cast path (it also lines up with the `.Storage()` call the zero-copy
branch already makes). wdyt?
##########
table/arrow_utils_test.go:
##########
@@ -2555,3 +2555,200 @@ func TestToRequestedSchemaMissingNestedFieldID(t
*testing.T) {
}, nil)
require.True(t, targetSchema.Equal(rec2.Schema()), "Schema is not
perfectly equal")
}
+
+// Some writers tag a non-PROJJSON CRS as an SRID, while the Iceberg schema
+// resolves the same CRS as an authority code. Both describe the same WKB
+// payload, so projection must not attempt a value cast.
+func sridWKBType(t *testing.T, storage arrow.DataType, crs string, edges
geoarrow.EdgeInterpolation) arrow.ExtensionType {
+ t.Helper()
+
+ meta := geoarrow.Metadata{
+ CRS: jsonCRS(crs),
+ CRSType: geoarrow.CRSTypeSRID,
+ Edges: edges,
+ }
+
+ switch storage.ID() {
+ case arrow.BINARY:
+ return geoarrow.NewWKBType(geoarrow.WKBWithBinaryStorage(),
geoarrow.WKBWithMetadata(meta))
+ case arrow.LARGE_BINARY:
+ return
geoarrow.NewWKBType(geoarrow.WKBWithLargeBinaryStorage(),
geoarrow.WKBWithMetadata(meta))
+ default:
+ t.Fatalf("unsupported WKB storage type %s", storage)
+
+ return nil
+ }
+}
+
+func newExtensionArrayOverBinary(t *testing.T, mem memory.Allocator, dt
arrow.ExtensionType, values [][]byte) arrow.Array {
+ t.Helper()
+
+ bldr := array.NewBinaryBuilder(mem,
dt.StorageType().(arrow.BinaryDataType))
+ defer bldr.Release()
+
+ for _, v := range values {
+ if v == nil {
+ bldr.AppendNull()
+
+ continue
+ }
+ bldr.Append(v)
+ }
+
+ storage := bldr.NewArray()
+ defer storage.Release()
+
+ return array.NewExtensionArrayWithStorage(dt, storage)
+}
+
+func binaryValueAt(t *testing.T, arr arrow.Array, i int) []byte {
+ t.Helper()
+
+ switch a := arr.(type) {
+ case *array.Binary:
+ return a.Value(i)
+ case *array.LargeBinary:
+ return a.Value(i)
+ default:
+ t.Fatalf("expected binary-like array, got %T", arr)
+
+ return nil
+ }
+}
+
+func singleColumnRecord(field arrow.Field, arr arrow.Array) arrow.RecordBatch {
+ sc := arrow.NewSchema([]arrow.Field{field}, nil)
+
+ return array.NewRecordBatch(sc, []arrow.Array{arr}, int64(arr.Len()))
+}
+
+func TestToRequestedSchemaGeoExtensionRewrap(t *testing.T) {
+ // POINT(1 2) and POINT(3 4) little-endian WKB.
+ point12 := []byte{
+ 0x01, 0x01, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x3f,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40,
+ }
+ point34 := []byte{
+ 0x01, 0x01, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x40,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x40,
+ }
+ values := [][]byte{point12, nil, point34}
+
+ geography, err := iceberg.GeographyTypeOf("OGC:CRS84", "spherical")
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ icebergType iceberg.Type
+ sourceStorage arrow.DataType
+ sourceEdges geoarrow.EdgeInterpolation
+ useLargeTypes bool
+ }{
+ {
+ name: "geometry_metadata_only",
+ icebergType: iceberg.GeometryType{},
+ sourceStorage: arrow.BinaryTypes.Binary,
+ },
+ {
+ name: "geometry_storage_width_mismatch",
+ icebergType: iceberg.GeometryType{},
+ sourceStorage: arrow.BinaryTypes.Binary,
+ useLargeTypes: true,
+ },
+ {
+ name: "geography_spherical_metadata_only",
+ icebergType: geography,
+ sourceStorage: arrow.BinaryTypes.Binary,
+ sourceEdges: geoarrow.EdgeSpherical,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mem :=
memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+
+ sourceType := sridWKBType(t, tt.sourceStorage,
"OGC:CRS84", tt.sourceEdges)
+ source := newExtensionArrayOverBinary(t, mem,
sourceType, values)
+ defer source.Release()
+
+ rec := singleColumnRecord(arrow.Field{
+ Name: "geom", Type: sourceType, Nullable: true,
Metadata: fieldIDMeta("1"),
+ }, source)
+ defer rec.Release()
+
+ sc := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: "geom", Type: tt.icebergType,
Required: false,
+ })
+
+ opts := table.SchemaOptions{IncludeFieldIDs: true,
UseLargeTypes: tt.useLargeTypes}
+
+ ctx := compute.WithAllocator(context.Background(), mem)
+ out, err := table.ToRequestedSchema(ctx, sc, sc, rec,
opts)
+ require.NoError(t, err)
+ defer out.Release()
+
+ want, err :=
table.TypeToArrowTypeWithOptions(tt.icebergType, table.ArrowSchemaOptions{
+ IncludeFieldIDs: opts.IncludeFieldIDs,
+ UseLargeTypes: opts.UseLargeTypes,
+ })
+ require.NoError(t, err)
+
+ got, ok := out.Column(0).(array.ExtensionArray)
+ require.True(t, ok, "expected extension array, got %T",
out.Column(0))
+ assert.True(t, arrow.TypeEqual(want, got.DataType()),
"expected: %s\ngot: %s", want, got.DataType())
+
+ gotStorage := got.Storage()
+ require.Equal(t, len(values), gotStorage.Len())
+
+ for i, v := range values {
+ if v == nil {
+ assert.True(t, gotStorage.IsNull(i),
"row %d should be null", i)
+
+ continue
+ }
+ require.False(t, gotStorage.IsNull(i), "row %d
should not be null", i)
+ assert.Equal(t, v, binaryValueAt(t, gotStorage,
i))
+ }
+
+ if arrow.TypeEqual(tt.sourceStorage,
got.DataType().(arrow.ExtensionType).StorageType()) {
+ srcBufs :=
source.(array.ExtensionArray).Storage().Data().Buffers()
+ gotBufs := gotStorage.Data().Buffers()
+ require.Equal(t, len(srcBufs), len(gotBufs))
+
+ for i := range srcBufs {
+ assert.Same(t, srcBufs[i], gotBufs[i],
"buffer %d should be shared", i)
+ }
+ }
+ })
+ }
+}
+
+func TestToRequestedSchemaMismatchedExtensionNameNotRewrapped(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+
+ sourceType := extensions.NewUUIDType()
+ bldr := array.NewExtensionBuilder(mem, sourceType)
+ defer bldr.Release()
+ bldr.AppendNull()
+
+ source := bldr.NewArray()
+ defer source.Release()
+
+ rec := singleColumnRecord(arrow.Field{
+ Name: "geom", Type: sourceType, Nullable: true, Metadata:
fieldIDMeta("1"),
+ }, source)
+ defer rec.Release()
+
+ sc := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: "geom", Type: iceberg.GeometryType{}, Required:
false,
+ })
+
+ ctx := compute.WithAllocator(context.Background(), mem)
+ _, err := table.ToRequestedSchema(ctx, sc, sc, rec,
table.SchemaOptions{IncludeFieldIDs: true})
+ require.Error(t, err)
+ assert.ErrorContains(t, err, "cast_extension")
Review Comment:
`cast_extension` is Arrow's internal kernel name — it shows up in the error
text but it isn't a stable API surface, so an Arrow error-message refactor
would break this assertion for reasons unrelated to what we're testing. Since
the thing we actually care about is "mismatched extension names don't get
rewrapped and the cast fails", I'd drop to `require.Error(t, err)` with a
one-line comment saying why. If we want something more specific, better to wrap
the failure in an iceberg-go error and match on that. 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]