This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new d28a6b9c2 feat(table): Add Parquet logical type Info for
`GEOMETRY`/`GEOGRAPHY` type and bump geoarrow-go version (#2125)
d28a6b9c2 is described below
commit d28a6b9c2b199a39984b02a24ef097e2917026de
Author: Colton Loftus <[email protected]>
AuthorDate: Mon Oct 19 11:17:04 2026 -0400
feat(table): Add Parquet logical type Info for `GEOMETRY`/`GEOGRAPHY` type
and bump geoarrow-go version (#2125)
* Bump Geoarrow Version
* Respond to feedback
* fix todo issue number
* add test without arrow schema info
* Respond to feedback
* Respond to last feedback / nits
* add issue into comment
---
go.mod | 2 +-
go.sum | 4 +-
table/arrow_utils_test.go | 198 ++++++++++++++++++++++++--
table/geo_write_test.go | 115 ++++++++++++++-
table/internal/geo_codec.go | 5 +
table/internal/parquet_files.go | 56 ++++++--
table/internal/parquet_files_internal_test.go | 129 +++++++++++++++++
table/internal/parquet_files_test.go | 72 +++++++++-
8 files changed, 552 insertions(+), 29 deletions(-)
diff --git a/go.mod b/go.mod
index 07ffe504f..04605d447 100644
--- a/go.mod
+++ b/go.mod
@@ -38,7 +38,7 @@ require (
github.com/beltran/gohive v1.8.1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/docker/docker v28.5.2+incompatible
- github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1
+ github.com/geoarrow/geoarrow-go v0.0.0-20261005150217-fc2b33c3141d
github.com/go-sql-driver/mysql v1.10.1
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
diff --git a/go.sum b/go.sum
index 8125a833a..02a51af57 100644
--- a/go.sum
+++ b/go.sum
@@ -277,8 +277,8 @@ github.com/fsnotify/fsevents v0.2.0
h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o
github.com/fsnotify/fsevents v0.2.0/go.mod
h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w=
github.com/fvbommel/sortorder v1.1.0
h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
github.com/fvbommel/sortorder v1.1.0/go.mod
h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
-github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1
h1:8VcjuP5pKZ717K4zOLS9Lm4Zsn8JwD9X+CQGFCiRT2E=
-github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1/go.mod
h1:XMOIOA5J96jEzAOpBROPyz0n14sHsooeF+y2FtGdrCY=
+github.com/geoarrow/geoarrow-go v0.0.0-20261005150217-fc2b33c3141d
h1:QQOSvqmfmuD5LddsaabCTswIGGaRfACq8TafAf3IVJ4=
+github.com/geoarrow/geoarrow-go v0.0.0-20261005150217-fc2b33c3141d/go.mod
h1:axOiUIw8QTsT/Y72+ITKRIXh2UYKjfIQKncOcKsAh54=
github.com/go-jose/go-jose/v4 v4.1.4
h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod
h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod
h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
diff --git a/table/arrow_utils_test.go b/table/arrow_utils_test.go
index 8eabb9e08..7b34a366b 100644
--- a/table/arrow_utils_test.go
+++ b/table/arrow_utils_test.go
@@ -22,6 +22,8 @@ import (
"bytes"
"context"
"encoding/json"
+ "os"
+ "path/filepath"
"strconv"
"strings"
"testing"
@@ -38,6 +40,7 @@ import (
"github.com/apache/arrow-go/v18/parquet/schema"
"github.com/apache/arrow-go/v18/parquet/variant"
"github.com/apache/iceberg-go"
+ iceio "github.com/apache/iceberg-go/io"
"github.com/apache/iceberg-go/table"
"github.com/geoarrow/geoarrow-go"
"github.com/google/uuid"
@@ -3339,8 +3342,17 @@ func
TestToRequestedSchemaGeoAbsentCRSAgainstSRID0SchemaFails(t *testing.T) {
}
// TestGeoTypeParquetRoundTrip pins the CRS a geo column carries on the Parquet
-// wire. arrow-go writes no GEOMETRY or GEOGRAPHY logical type, so the CRS
-// travels in the GeoArrow metadata of the stored Arrow schema.
+// wire. geoarrow-go's WKB type maps to the Parquet GEOMETRY or GEOGRAPHY
logical
+// type, and the CRS also travels in the GeoArrow metadata of the stored Arrow
+// schema.
+// Per the Parquet spec, SRIDs are written as "srid:<id>" and the default
+// OGC:CRS84 is omitted.
+//
+// Two cases differ from what Java writes and are pinned so a geoarrow-go bump
+// can't change them silently: EPSG:4326 collapses to OGC:CRS84 (so no Parquet
+// crs, and it reads back as the default CRS) where Java writes "EPSG:4326"
+// verbatim, and a projjson:<property> reference is written as the inline
+// PROJJSON document where Java writes the reference itself.
func TestGeoTypeParquetRoundTrip(t *testing.T) {
geomSRID0, err := iceberg.GeometryTypeOf("srid:0")
require.NoError(t, err)
@@ -3350,36 +3362,71 @@ func TestGeoTypeParquetRoundTrip(t *testing.T) {
require.NoError(t, err)
geomEPSG3857, err := iceberg.GeometryTypeOf("EPSG:3857")
require.NoError(t, err)
+ geomEPSG4326, err := iceberg.GeometryTypeOf("EPSG:4326")
+ require.NoError(t, err)
+ geomPROJJSON, err := iceberg.GeometryTypeOf("projjson:geo.crs")
+ require.NoError(t, err)
+
+ const projjson = `{"type":"GeographicCRS","name":"Custom WGS 84"}`
tests := []struct {
name string
icebergType iceberg.Type
+ tableProps iceberg.Properties
geoarrowMetaJSON string
+ parquetLogical schema.LogicalType
+ // readType is the type read back from the file; nil means
icebergType.
+ readType iceberg.Type
}{
{
name: "geometry_default_crs",
icebergType: iceberg.GeometryType{},
geoarrowMetaJSON:
`{"crs":"OGC:CRS84","crs_type":"authority_code"}`,
+ parquetLogical: schema.GeometryLogicalType{},
},
{
name: "geometry_srid_0",
icebergType: geomSRID0,
geoarrowMetaJSON: `{"crs":"0","crs_type":"srid"}`,
+ parquetLogical: schema.GeometryLogicalType{Crs:
"srid:0"},
+ },
+ {
+ name: "geography_default",
+ icebergType: iceberg.GeographyType{},
+ geoarrowMetaJSON:
`{"crs":"OGC:CRS84","crs_type":"authority_code","edges":"spherical"}`,
+ parquetLogical:
schema.GeographyLogicalType{Algorithm: schema.GeographyEdgeSpherical},
},
{
name: "geography_srid_0",
icebergType: geogSRID0,
geoarrowMetaJSON:
`{"crs":"0","crs_type":"srid","edges":"spherical"}`,
+ parquetLogical: schema.GeographyLogicalType{Crs:
"srid:0", Algorithm: schema.GeographyEdgeSpherical},
},
{
name: "geometry_srid_4326",
icebergType: geomSRID,
geoarrowMetaJSON: `{"crs":"4326","crs_type":"srid"}`,
+ parquetLogical: schema.GeometryLogicalType{Crs:
"srid:4326"},
},
{
name: "geometry_authority_code",
icebergType: geomEPSG3857,
geoarrowMetaJSON:
`{"crs":"EPSG:3857","crs_type":"authority_code"}`,
+ parquetLogical: schema.GeometryLogicalType{Crs:
"EPSG:3857"},
+ },
+ {
+ name: "geometry_epsg_4326",
+ icebergType: geomEPSG4326,
+ geoarrowMetaJSON:
`{"crs":"OGC:CRS84","crs_type":"authority_code"}`,
+ parquetLogical: schema.GeometryLogicalType{},
+ readType: iceberg.GeometryType{},
+ },
+ {
+ name: "geometry_projjson",
+ icebergType: geomPROJJSON,
+ tableProps: iceberg.Properties{"geo.crs":
projjson},
+ geoarrowMetaJSON: `{"crs":` + projjson +
`,"crs_type":"projjson"}`,
+ parquetLogical: schema.GeometryLogicalType{Crs:
projjson},
},
}
@@ -3387,7 +3434,10 @@ func TestGeoTypeParquetRoundTrip(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
mem := memory.NewGoAllocator()
- dt, err := table.TypeToArrowType(tt.icebergType, true,
false)
+ dt, err :=
table.TypeToArrowTypeWithOptions(tt.icebergType, table.ArrowSchemaOptions{
+ IncludeFieldIDs: true,
+ TableProperties: tt.tableProps,
+ })
require.NoError(t, err)
wkb, ok := dt.(*geoarrow.WKBType)
@@ -3414,10 +3464,8 @@ func TestGeoTypeParquetRoundTrip(t *testing.T) {
require.NoError(t, err)
defer rdr.Close()
- // Revisit the CRS spelling once arrow-go emits the geo
logical types:
- // the Parquet CRS field takes the prefixed srid:<id>
form.
logical := rdr.MetaData().Schema.Column(0).LogicalType()
- assert.True(t, logical.Equals(schema.NoLogicalType{}),
"unexpected logical type %s", logical)
+ assert.True(t, logical.Equals(tt.parquetLogical),
"expected logical type %s, got %s", tt.parquetLogical, logical)
arrRdr, err := pqarrow.NewFileReader(rdr,
pqarrow.ArrowReadProperties{}, mem)
require.NoError(t, err)
@@ -3425,13 +3473,145 @@ func TestGeoTypeParquetRoundTrip(t *testing.T) {
readSchema, err := arrRdr.Schema()
require.NoError(t, err)
- readIceberg, err :=
table.ArrowSchemaToIceberg(readSchema, false, nil)
+ readIceberg, err :=
table.ArrowSchemaToIcebergWithOptions(readSchema, table.ArrowToIcebergOptions{
+ TableSchema: iceberg.NewSchema(0,
iceberg.NestedField{
+ ID: 1, Name: "geom", Type:
tt.icebergType, Required: false,
+ }),
+ TableProperties: tt.tableProps,
+ })
require.NoError(t, err)
+ wantType := tt.readType
+ if wantType == nil {
+ wantType = tt.icebergType
+ }
field, ok := readIceberg.FindFieldByID(1)
require.True(t, ok)
- assert.Equal(t, tt.icebergType.String(),
field.Type.String())
- assert.True(t, tt.icebergType.Equals(field.Type),
"expected %s, got %s", tt.icebergType, field.Type)
+ assert.Equal(t, wantType.String(), field.Type.String())
+ assert.True(t, wantType.Equals(field.Type), "expected
%s, got %s", wantType, field.Type)
})
}
}
+
+// TestGeoParquetReadWithoutStoredSchema reads a GEOMETRY file that has no
+// stored ARROW:schema, as Java/Spark writers produce. geoarrow-go maps the
+// Parquet GEOMETRY logical type to geoarrow.wkb on read, so the file projects
+// onto a matching geometry column. A binary table column currently rejects it,
+// both on read and in the FileToDataFile (AddFiles) schema check. Whether
+// geometry should project onto a binary column is still an open decision
+// (https://github.com/apache/iceberg-go/issues/2193); flip the binary cases if
+// that promotion is allowed.
+func TestGeoParquetReadWithoutStoredSchema(t *testing.T) {
+ mem := memory.NewCheckedAllocator(memory.NewGoAllocator())
+ defer mem.AssertSize(t, 0)
+
+ geomType, err := iceberg.GeometryTypeOf("srid:4326")
+ require.NoError(t, err)
+
+ dt, err := table.TypeToArrowType(geomType, true, false)
+ require.NoError(t, err)
+ wkb, ok := dt.(*geoarrow.WKBType)
+ require.True(t, ok, "expected geoarrow.wkb, got %T", dt)
+
+ source := newExtensionArrayOverBinary(t, mem, wkb, wkbPointRows())
+ defer source.Release()
+ rec := singleColumnRecord(arrow.Field{
+ Name: "geom", Type: wkb, Nullable: true, Metadata:
fieldIDMeta("1"),
+ }, source)
+ defer rec.Release()
+
+ // No pqarrow.WithStoreSchema: the reader must derive types from the
+ // Parquet schema alone.
+ path := filepath.Join(t.TempDir(), "geo-no-arrow-schema.parquet")
+ f, err := os.Create(path)
+ require.NoError(t, err)
+ writer, err := pqarrow.NewFileWriter(rec.Schema(), f,
+ parquet.NewWriterProperties(parquet.WithAllocator(mem)),
+ pqarrow.NewArrowWriterProperties(pqarrow.WithAllocator(mem)))
+ require.NoError(t, err)
+ require.NoError(t, writer.Write(rec))
+ require.NoError(t, writer.Close())
+
+ readFile := func(t *testing.T) arrow.RecordBatch {
+ t.Helper()
+
+ rdr, err := file.OpenParquetFile(path, false,
file.WithReadProps(parquet.NewReaderProperties(mem)))
+ require.NoError(t, err)
+ defer rdr.Close()
+ require.Nil(t,
rdr.MetaData().KeyValueMetadata().FindValue("ARROW:schema"), "file must not
carry a stored Arrow schema")
+
+ arrRdr, err := pqarrow.NewFileReader(rdr,
pqarrow.ArrowReadProperties{}, mem)
+ require.NoError(t, err)
+ tbl, err := arrRdr.ReadTable(context.Background())
+ require.NoError(t, err)
+ defer tbl.Release()
+
+ trdr := array.NewTableReader(tbl, -1)
+ defer trdr.Release()
+ require.True(t, trdr.Next())
+ out := trdr.RecordBatch()
+ out.Retain()
+
+ return out
+ }
+
+ t.Run("reads as geoarrow.wkb", func(t *testing.T) {
+ got := readFile(t)
+ defer got.Release()
+
+ readType, ok := got.Schema().Field(0).Type.(*geoarrow.WKBType)
+ require.True(t, ok, "expected geoarrow.wkb, got %s",
got.Schema().Field(0).Type)
+
+ fileSchema, err := table.ArrowSchemaToIceberg(got.Schema(),
false, nil)
+ require.NoError(t, err)
+ field, ok := fileSchema.FindFieldByID(1)
+ require.True(t, ok)
+ assert.True(t, geomType.Equals(field.Type), "expected %s, got
%s (%s)", geomType, field.Type, readType.Serialize())
+ })
+
+ t.Run("projects onto geometry column", func(t *testing.T) {
+ got := readFile(t)
+ defer got.Release()
+
+ fileSchema, err := table.ArrowSchemaToIceberg(got.Schema(),
false, nil)
+ require.NoError(t, err)
+ requested := iceberg.NewSchema(0, iceberg.NestedField{ID: 1,
Name: "geom", Type: geomType, Required: false})
+
+ ctx := compute.WithAllocator(context.Background(), mem)
+ projected, err := table.ToRequestedSchema(ctx, requested,
fileSchema, got, table.SchemaOptions{IncludeFieldIDs: true})
+ require.NoError(t, err)
+ defer projected.Release()
+
+ require.EqualValues(t, 3, projected.NumRows())
+ storage := projected.Column(0).(array.ExtensionArray).Storage()
+ assert.Equal(t, wkbPointRows()[0], binaryValueAt(t, storage, 0))
+ assert.True(t, storage.IsNull(1))
+ assert.Equal(t, wkbPointRows()[2], binaryValueAt(t, storage, 2))
+ })
+
+ t.Run("binary column rejects it", func(t *testing.T) {
+ got := readFile(t)
+ defer got.Release()
+
+ fileSchema, err := table.ArrowSchemaToIceberg(got.Schema(),
false, nil)
+ require.NoError(t, err)
+ requested := iceberg.NewSchema(0, iceberg.NestedField{ID: 1,
Name: "geom", Type: iceberg.PrimitiveTypes.Binary, Required: false})
+
+ ctx := compute.WithAllocator(context.Background(), mem)
+ _, err = table.ToRequestedSchema(ctx, requested, fileSchema,
got, table.SchemaOptions{IncludeFieldIDs: true})
+ require.Error(t, err)
+ assert.ErrorContains(t, err, "cannot promote
geometry(srid:4326) to binary")
+ })
+
+ t.Run("AddFiles schema check rejects binary column", func(t *testing.T)
{
+ binarySchema := iceberg.NewSchema(0, iceberg.NestedField{ID: 1,
Name: "geom", Type: iceberg.PrimitiveTypes.Binary, Required: false})
+ _, err := table.FileToDataFile(context.Background(),
iceio.LocalFS{}, path, binarySchema, *iceberg.UnpartitionedSpec, 0, nil)
+ require.Error(t, err)
+ assert.ErrorContains(t, err, "mismatch in fields")
+ assert.ErrorContains(t, err, "geometry(srid:4326)")
+
+ geomSchema := iceberg.NewSchema(0, iceberg.NestedField{ID: 1,
Name: "geom", Type: geomType, Required: false})
+ _, err = table.FileToDataFile(context.Background(),
iceio.LocalFS{}, path, geomSchema, *iceberg.UnpartitionedSpec, 0, nil)
+ require.NoError(t, err)
+ })
+}
diff --git a/table/geo_write_test.go b/table/geo_write_test.go
index 409d0bf41..804ff6738 100644
--- a/table/geo_write_test.go
+++ b/table/geo_write_test.go
@@ -25,6 +25,7 @@ import (
"github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/compute"
"github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/arrow-go/v18/parquet/file"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/io"
tblutils "github.com/apache/iceberg-go/table/internal"
@@ -61,7 +62,7 @@ func wktToWKB(t *testing.T, s string) geoarrow.WKBBytes {
// exercise the stats-plan dispatch that decides whether geo bounds are
recorded.
//
// The geo columns are all top-level; nested geo columns are still unhandled in
-// the writer (see the TODO(#992) in parquet_files.go), so nothing here covers
+// the writer (see the TODO(#2158) in parquet_files.go), so nothing here covers
// them.
func newGeoTestWriter(t *testing.T, dir string, props iceberg.Properties)
(*defaultDataFileWriter, *iceberg.Schema, *arrow.Schema) {
t.Helper()
@@ -296,3 +297,115 @@ func TestWriteGeometryColumnCheckedAllocator(t
*testing.T) {
require.Contains(t, df.LowerBoundValues(), geoTestGeomFieldID,
"geometry column must record a lower bound")
require.Contains(t, df.UpperBoundValues(), geoTestGeomFieldID,
"geometry column must record an upper bound")
}
+
+// TestWriteGeoColumnMultiRowGroupStats writes geo columns across several row
+// groups and batches. Parquet GEOMETRY/GEOGRAPHY column chunks omit the
+// standard Statistics block (min, max, null count), so DataFileStatsFromMeta
+// invalidates the column in the first row group; value counts and column sizes
+// (which live outside that block) must still sum over every row group, and the
+// null count (tallied from the Arrow data) must sum over every batch. A value
+// count from row group 0 only, paired with the whole-file null count, would
make
+// the file look all-null and let NotNull/IsNull evaluators drop live rows.
+func TestWriteGeoColumnMultiRowGroupStats(t *testing.T) {
+ t.Parallel()
+
+ writer, schema, arrowSchema := newGeoTestWriter(t, t.TempDir(),
iceberg.Properties{
+ tblutils.ParquetRowGroupLimitKey: "2",
+ })
+
+ pt := wktToWKB(t, "POINT (1 2)").String()
+ // Nulls fill row group 0 only. The second batch has none, so a null
tally
+ // that overwrote rather than summed across batches would record 0.
+ first, _, err := array.RecordFromJSON(memory.DefaultAllocator,
arrowSchema, strings.NewReader(`[
+ {"id": 1, "geom": null, "geog": null},
+ {"id": 2, "geom": null, "geog": null},
+ {"id": 3, "geom": "`+pt+`", "geog": "`+pt+`"}
+ ]`))
+ require.NoError(t, err)
+ defer first.Release()
+ second, _, err := array.RecordFromJSON(memory.DefaultAllocator,
arrowSchema, strings.NewReader(`[
+ {"id": 4, "geom": "`+pt+`", "geog": "`+pt+`"},
+ {"id": 5, "geom": "`+pt+`", "geog": "`+pt+`"},
+ {"id": 6, "geom": "`+pt+`", "geog": "`+pt+`"}
+ ]`))
+ require.NoError(t, err)
+ defer second.Release()
+
+ df, err := writer.writeFile(t.Context(), nil, WriteTask{
+ Uuid: uuid.New(),
+ ID: 0,
+ FileCount: 1,
+ Schema: schema,
+ Batches: []arrow.RecordBatch{first, second},
+ })
+ require.NoError(t, err)
+ require.EqualValues(t, 6, df.Count())
+ require.Len(t, df.SplitOffsets(), 3, "row group limit of 2 must produce
3 row groups")
+
+ for _, tt := range []struct {
+ name string
+ fieldID int
+ }{
+ {"geom", geoTestGeomFieldID},
+ {"geog", geoTestGeogFieldID},
+ } {
+ require.Contains(t, df.ValueCounts(), tt.fieldID)
+ assert.EqualValues(t, 6, df.ValueCounts()[tt.fieldID], "value
count must cover every row group")
+ require.Contains(t, df.NullValueCounts(), tt.fieldID)
+ assert.EqualValues(t, 2, df.NullValueCounts()[tt.fieldID],
"null count must cover every batch")
+
+ // Recompute the column's compressed size directly from the row
groups
+ // on disk, independently of DataFileStatsFromMeta, and require
an exact
+ // match. A regression that only summed row group 0 would still
produce
+ // a non-zero, Contains-passing size, so the independent total
is the
+ // only thing that actually pins the multi-row-group sum.
+ require.Contains(t, df.ColumnSizes(), tt.fieldID)
+ wantSize := sumColumnChunkCompressedSize(t, df.FilePath(),
tt.name)
+ assert.Greater(t, wantSize, int64(0), "sanity: the column must
occupy some space across 3 row groups")
+ assert.EqualValues(t, wantSize, df.ColumnSizes()[tt.fieldID],
"column size must sum TotalCompressedSize over every row group")
+ }
+
+ for _, name := range []string{"geom", "geog"} {
+ notNull, err := newInclusiveMetricsEvaluator(schema,
iceberg.NotNull(iceberg.Reference(name)), true, true)
+ require.NoError(t, err)
+ mightMatch, err := notNull(df)
+ require.NoError(t, err)
+ assert.True(t, mightMatch, "NotNull(%s) must not prune a file
with non-null rows", name)
+
+ isNull, err := newStrictMetricsEvaluator(schema,
iceberg.IsNull(iceberg.Reference(name)), true, true)
+ require.NoError(t, err)
+ mustMatch, err := isNull(df)
+ require.NoError(t, err)
+ assert.False(t, mustMatch, "IsNull(%s) must not match every row
of a file with non-null rows", name)
+ }
+}
+
+// sumColumnChunkCompressedSize opens the Parquet file at path and sums
+// TotalCompressedSize for colName's column chunk across every row group, the
+// same quantity DataFileStatsFromMeta accumulates into DataFile.ColumnSizes.
+// Computing it independently here, straight from the file's row groups,
+// catches a regression that only tallies row group 0.
+func sumColumnChunkCompressedSize(t *testing.T, path, colName string) int64 {
+ t.Helper()
+
+ f, err := (io.LocalFS{}).Open(path)
+ require.NoError(t, err)
+ defer f.Close()
+
+ rdr, err := file.NewParquetReader(f)
+ require.NoError(t, err)
+ defer rdr.Close()
+
+ meta := rdr.MetaData()
+ pos := meta.Schema.ColumnIndexByName(colName)
+ require.GreaterOrEqual(t, pos, 0, "column %q not found in Parquet
schema", colName)
+
+ var total int64
+ for rg := range meta.NumRowGroups() {
+ colChunk, err := meta.RowGroup(rg).ColumnChunk(pos)
+ require.NoError(t, err)
+ total += colChunk.TotalCompressedSize()
+ }
+
+ return total
+}
diff --git a/table/internal/geo_codec.go b/table/internal/geo_codec.go
index 48de8c119..6484690c1 100644
--- a/table/internal/geo_codec.go
+++ b/table/internal/geo_codec.go
@@ -72,6 +72,11 @@ type geoBoundsAccumulator struct {
// isGeography marks a column whose edges are geodesics on a sphere, for
// which raw vertex min/max is not a safe bounding box (see Bounds).
isGeography bool
+
+ // nulls counts the null rows seen across every batch written to the
file.
+ // Parquet omits the Statistics block for GEOMETRY/GEOGRAPHY column
chunks,
+ // so the footer cannot supply this count.
+ nulls int64
}
func newGeoBoundsAccumulator(isGeography bool) *geoBoundsAccumulator {
diff --git a/table/internal/parquet_files.go b/table/internal/parquet_files.go
index 7a8b36f37..f8ee6b4f7 100644
--- a/table/internal/parquet_files.go
+++ b/table/internal/parquet_files.go
@@ -531,7 +531,15 @@ type geoColumn struct {
// collectGeoColumns finds top-level WKB-encoded geo columns in the Arrow
schema
// and pairs each with its Iceberg field ID. Geo bounds for columns nested
inside
// structs/lists/maps are not yet computed, which diverges from Java/PyIceberg.
-// TODO(#992): compute geo bounds for geo columns nested in structs/lists/maps.
+//
+// Nested geo leaves also lose their Parquet Statistics block (min, max, null
+// count), because the GEOMETRY/GEOGRAPHY logical types have an undefined sort
+// order. They are not collected here, so their null counts are not recovered
+// either: the manifest omits them, which costs pruning but never gives a wrong
+// answer. The same applies to
+// geo files imported via DataFileStatsFromMeta alone (e.g. AddFiles).
+// TODO(#2158): compute geo bounds and null counts for geo columns nested in
+// structs/lists/maps.
func collectGeoColumns(sc *arrow.Schema, colMapping map[string]int)
[]geoColumn {
var result []geoColumn
for i, f := range sc.Fields() {
@@ -1188,25 +1196,34 @@ func normalizeWKBArrayReachable(ext
array.ExtensionArray, active []bool, mem mem
}
// accumulateGeoBounds extends the per-field bounding boxes with the WKB values
-// in this batch. Null rows are skipped; a malformed WKB value fails the write.
+// in this batch and tallies their null counts. Null rows are skipped; a
+// malformed WKB value fails the write.
+//
+// Every geoCols entry comes from a *geoarrow.WKBType field of the writer's
+// schema, so a batch whose column does not match is an error rather than a
+// skip: skipping would leave a partial null count that under-reports nulls.
func (w *ParquetFileWriter) accumulateGeoBounds(batch arrow.RecordBatch) error
{
for _, gc := range w.geoCols {
if gc.colIdx >= int(batch.NumCols()) {
- continue
+ return fmt.Errorf("geo field %d: column %d missing from
batch with %d columns",
+ gc.fieldID, gc.colIdx, batch.NumCols())
}
ext, ok := batch.Column(gc.colIdx).(array.ExtensionArray)
if !ok {
- continue
+ return fmt.Errorf("geo field %d: expected a WKB
extension array, got %s",
+ gc.fieldID, batch.Column(gc.colIdx).DataType())
}
storage, ok := ext.Storage().(wkbStorage)
if !ok {
- continue
+ return fmt.Errorf("geo field %d: unsupported WKB
storage type %s",
+ gc.fieldID, ext.Storage().DataType())
}
acc, ok := w.geoAccs[gc.fieldID]
if !ok {
- continue
+ return fmt.Errorf("geo field %d: no bounds
accumulator", gc.fieldID)
}
+ acc.nulls += int64(storage.NullN())
for i := range storage.Len() {
if storage.IsNull(i) {
continue
@@ -1268,17 +1285,30 @@ func (w *ParquetFileWriter) Abort() error {
return errors.Join(closeErr, removeErr)
}
-// applyGeoBounds injects the WKB single-point bounds accumulated during the
-// write into the file statistics, so they flow through ToDataFile into the
-// manifest entry like any other typed bound.
+// applyGeoBounds injects the WKB single-point bounds and null counts
+// accumulated during the write into the file statistics, so they flow through
+// ToDataFile into the manifest entry like any other typed bound.
+//
+// Parquet GEOMETRY/GEOGRAPHY columns have an undefined sort order, so the
+// Parquet writer omits the standard Statistics block (min, max, null count)
+// from their column chunks and DataFileStatsFromMeta cannot recover null
+// counts for them.
func (w *ParquetFileWriter) applyGeoBounds(stats *DataFileStatistics) error {
for fieldID, acc := range w.geoAccs {
// Honor the column's metrics mode: a column the caller never
registered
- // (missing key) or one set to counts/none does not record
bounds. Check
- // presence first — a missing key yields a zero-value mode of
"" that would
- // otherwise fall through and write bounds the caller asked to
skip.
+ // (missing key) or one set to none records nothing, and counts
records
+ // no bounds. Check presence first — a missing key yields a
zero-value
+ // mode of "" that would otherwise fall through and write
bounds the
+ // caller asked to skip.
sc, ok := w.info.StatsCols[fieldID]
- if !ok || sc.Mode.Typ == MetricModeNone || sc.Mode.Typ ==
MetricModeCounts {
+ if !ok || sc.Mode.Typ == MetricModeNone {
+ continue
+ }
+ if stats.NullValueCounts == nil {
+ stats.NullValueCounts = make(map[int]int64)
+ }
+ stats.NullValueCounts[fieldID] = acc.nulls
+ if sc.Mode.Typ == MetricModeCounts {
continue
}
diff --git a/table/internal/parquet_files_internal_test.go
b/table/internal/parquet_files_internal_test.go
index a665f8c50..b7f4d6f6f 100644
--- a/table/internal/parquet_files_internal_test.go
+++ b/table/internal/parquet_files_internal_test.go
@@ -23,6 +23,7 @@ import (
"testing"
"github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/extensions"
"github.com/apache/arrow-go/v18/arrow/memory"
"github.com/apache/arrow-go/v18/parquet"
@@ -96,6 +97,134 @@ func TestWriteDataFileBatchesAbortsOnWriteError(t
*testing.T) {
}
}
+// TestWriteRejectsMismatchedGeoBatch drives the public Write method with a
+// batch that doesn't match the geo columns collected from the writer's Arrow
+// schema, pinning accumulateGeoBounds's error returns against a regression
+// back to silently skipping
+func TestWriteRejectsMismatchedGeoBatch(t *testing.T) {
+ mem := memory.DefaultAllocator
+
+ typeDef := geoarrow.NewWKBType(geoarrow.WKBWithBinaryStorage())
+ arrowSchema := arrow.NewSchema([]arrow.Field{
+ {Name: "id", Type: arrow.PrimitiveTypes.Int32, Nullable: false},
+ {Name: "geom", Type: typeDef, Nullable: true},
+ }, nil)
+ fileSchema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int32, Required: true},
+ iceberg.NestedField{ID: 2, Name: "geom", Type:
iceberg.GeometryType{}, Required: false},
+ )
+ format := parquetFormat{}
+
+ newWriter := func(t *testing.T) FileWriter {
+ t.Helper()
+
+ writer, err := format.NewFileWriter(context.Background(),
iceio.NewMemFS(), nil, WriteFileInfo{
+ FileSchema: fileSchema,
+ Spec: *iceberg.UnpartitionedSpec,
+ FileName: "mismatched-geo.parquet",
+ WriteProps:
format.GetWriteProperties(iceberg.Properties{}),
+ }, arrowSchema)
+ require.NoError(t, err)
+
+ return writer
+ }
+
+ idField := arrow.Field{Name: "id", Type: arrow.PrimitiveTypes.Int32}
+ idBuilder := array.NewInt32Builder(mem)
+ idBuilder.AppendValues([]int32{1, 2}, nil)
+ idArr := idBuilder.NewArray()
+ idBuilder.Release()
+ defer idArr.Release()
+
+ t.Run("column missing from batch", func(t *testing.T) {
+ writer := newWriter(t)
+ defer func() { require.NoError(t, writer.Abort()) }()
+
+ sc := arrow.NewSchema([]arrow.Field{idField}, nil)
+ batch := array.NewRecordBatch(sc, []arrow.Array{idArr},
int64(idArr.Len()))
+ defer batch.Release()
+
+ err := writer.Write(batch)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "missing from batch")
+ })
+
+ t.Run("column is not a WKB extension array", func(t *testing.T) {
+ writer := newWriter(t)
+ defer func() { require.NoError(t, writer.Abort()) }()
+
+ geomBuilder := array.NewInt32Builder(mem)
+ geomBuilder.AppendValues([]int32{10, 20}, nil)
+ geomArr := geomBuilder.NewArray()
+ geomBuilder.Release()
+ defer geomArr.Release()
+
+ sc := arrow.NewSchema([]arrow.Field{idField, {Name: "geom",
Type: arrow.PrimitiveTypes.Int32}}, nil)
+ batch := array.NewRecordBatch(sc, []arrow.Array{idArr,
geomArr}, int64(idArr.Len()))
+ defer batch.Release()
+
+ err := writer.Write(batch)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "expected a WKB extension
array")
+ })
+
+ t.Run("extension storage is not a WKB-compatible array", func(t
*testing.T) {
+ writer := newWriter(t)
+ defer func() { require.NoError(t, writer.Abort()) }()
+
+ // OpaqueType lets the test pick an arbitrary storage type
(here Int32,
+ // which has no Value(int) []byte method) without implementing
a whole
+ // fake extension type by hand. normalizeGeoBatch leaves it
untouched
+ // (it only rewrites *geoarrow.WKBType extensions), so it
reaches
+ // accumulateGeoBounds exactly as built here.
+ opaqueType :=
extensions.NewOpaqueType(arrow.PrimitiveTypes.Int32, "fake-wkb", "test")
+ storageBuilder := array.NewInt32Builder(mem)
+ storageBuilder.AppendValues([]int32{1, 2}, nil)
+ storage := storageBuilder.NewArray()
+ storageBuilder.Release()
+ defer storage.Release()
+
+ geomArr := array.NewExtensionArrayWithStorage(opaqueType,
storage)
+ defer geomArr.Release()
+
+ sc := arrow.NewSchema([]arrow.Field{idField, {Name: "geom",
Type: opaqueType}}, nil)
+ batch := array.NewRecordBatch(sc, []arrow.Array{idArr,
geomArr}, int64(idArr.Len()))
+ defer batch.Release()
+
+ err := writer.Write(batch)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported WKB storage type")
+ })
+
+ t.Run("geo column has no bounds accumulator", func(t *testing.T) {
+ writer := newWriter(t)
+ defer func() { require.NoError(t, writer.Abort()) }()
+
+ // geoAccs is built from the same geoCols today; drop the entry
to
+ // simulate the two drifting apart.
+ pw, ok := writer.(*ParquetFileWriter)
+ require.True(t, ok, "expected *ParquetFileWriter, got %T",
writer)
+ delete(pw.geoAccs, 2)
+
+ storageBuilder := array.NewBinaryBuilder(mem,
arrow.BinaryTypes.Binary)
+ storageBuilder.AppendNull()
+ storageBuilder.AppendNull()
+ storage := storageBuilder.NewArray()
+ storageBuilder.Release()
+ defer storage.Release()
+
+ geomArr := array.NewExtensionArrayWithStorage(typeDef, storage)
+ defer geomArr.Release()
+
+ batch := array.NewRecordBatch(arrowSchema, []arrow.Array{idArr,
geomArr}, int64(idArr.Len()))
+ defer batch.Release()
+
+ err := writer.Write(batch)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no bounds accumulator")
+ })
+}
+
func TestNewFileWriterCachesGeoNormalizationColumns(t *testing.T) {
typeDef := geoarrow.NewWKBType(geoarrow.WKBWithBinaryStorage())
arrowSchema := arrow.NewSchema([]arrow.Field{
diff --git a/table/internal/parquet_files_test.go
b/table/internal/parquet_files_test.go
index 7eefaed81..2b477cdaa 100644
--- a/table/internal/parquet_files_test.go
+++ b/table/internal/parquet_files_test.go
@@ -3210,7 +3210,8 @@ func TestWriteDataFileGeoBounds(t *testing.T) {
rec, _, err := array.RecordFromJSON(memory.DefaultAllocator,
arrowSchema, strings.NewReader(`[
{"id": 1, "geom": "`+wkbStr("POINT (30 10)")+`", "geog":
"`+wkbStr("POINT (20 5)")+`"},
- {"id": 2, "geom": "`+wkbStr("POINT (5 40)")+`", "geog": null}
+ {"id": 2, "geom": "`+wkbStr("POINT (5 40)")+`", "geog": null},
+ {"id": 3, "geom": null, "geog": "`+wkbStr("POINT (20 5)")+`"}
]`))
require.NoError(t, err)
defer rec.Release()
@@ -3245,7 +3246,7 @@ func TestWriteDataFileGeoBounds(t *testing.T) {
df := writeWithGeomMode(t, internal.MetricsMode{Typ:
internal.MetricModeFull})
lower, upper := df.LowerBoundValues(), df.UpperBoundValues()
- // Geometry bounds span both rows: lower (5, 10), upper (30,
40), encoded as
+ // Geometry bounds span both non-null rows: lower (5, 10),
upper (30, 40), encoded as
// two little-endian float64 coordinates (16 bytes), not WKB.
require.Len(t, lower[2], 16)
require.Len(t, upper[2], 16)
@@ -3263,7 +3264,13 @@ func TestWriteDataFileGeoBounds(t *testing.T) {
assert.True(t, geomType.Equals(lit.Type()), "want geo type, got
%s", lit.Type())
assert.Equal(t, geoBoundBytes(5, 10),
lit.(iceberg.GeoLiteral).Value())
- // Null counts are still recorded for geo columns.
+ // Null counts are still recorded for geo columns. The Parquet
writer
+ // omits the Statistics block (min, max, null count) for
GEOMETRY/
+ // GEOGRAPHY column chunks, so these come from the Arrow data
rather
+ // than the footer.
+ require.Contains(t, df.NullValueCounts(), 2)
+ assert.Equal(t, int64(1), df.NullValueCounts()[2])
+ require.Contains(t, df.NullValueCounts(), 3)
assert.Equal(t, int64(1), df.NullValueCounts()[3])
})
@@ -3282,10 +3289,69 @@ func TestWriteDataFileGeoBounds(t *testing.T) {
df := writeWithGeomMode(t, tt.mode)
assert.NotContains(t, df.LowerBoundValues(), 2,
"geometry lower bound must be omitted for %s", tt.mode.Typ)
assert.NotContains(t, df.UpperBoundValues(), 2,
"geometry upper bound must be omitted for %s", tt.mode.Typ)
+
+ // Counts mode still records the geometry null count;
none records nothing.
+ if tt.mode.Typ == internal.MetricModeCounts {
+ require.Contains(t, df.NullValueCounts(), 2,
"counts mode must record the geometry null count")
+ assert.Equal(t, int64(1),
df.NullValueCounts()[2])
+ } else {
+ assert.NotContains(t, df.NullValueCounts(), 2)
+ }
})
}
}
+// TestWriteDataFileNestedGeoNullCount pins the current metrics for a geo
column
+// nested in a struct. The Parquet GEOMETRY logical type drops the leaf's
footer
+// statistics, and only top-level geo columns have their null counts tallied
+// from the Arrow data, so the nested leaf records a value count but no null
+// count. A missing null count only costs pruning. Flip this when nested geo
+// metrics land (TODO(#2158)).
+func TestWriteDataFileNestedGeoNullCount(t *testing.T) {
+ iceSchema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int32, Required: false},
+ iceberg.NestedField{ID: 2, Name: "s", Required: false, Type:
&iceberg.StructType{
+ FieldList: []iceberg.NestedField{
+ {ID: 3, Name: "g", Type:
iceberg.GeometryType{}, Required: false},
+ },
+ }},
+ )
+
+ arrowSchema, err := table.SchemaToArrowSchema(iceSchema, nil, true,
false)
+ require.NoError(t, err)
+
+ pt, err := wktToWKB("POINT (1 2)")
+ require.NoError(t, err)
+
+ rec, _, err := array.RecordFromJSON(memory.DefaultAllocator,
arrowSchema, strings.NewReader(`[
+ {"id": 1, "s": {"g": "`+pt.String()+`"}},
+ {"id": 2, "s": {"g": null}},
+ {"id": 3, "s": {"g": "`+pt.String()+`"}}
+ ]`))
+ require.NoError(t, err)
+ defer rec.Release()
+
+ fm := internal.GetFileFormat(iceberg.ParquetFile)
+ df, err := fm.WriteDataFile(context.Background(), iceio.NewMemFS(),
nil, internal.WriteFileInfo{
+ FileSchema: iceSchema,
+ Spec: *iceberg.UnpartitionedSpec,
+ FileName: "nested-geo.parquet",
+ StatsCols: map[int]internal.StatisticsCollector{
+ 1: {FieldID: 1, IcebergTyp:
iceberg.PrimitiveTypes.Int32, ColName: "id", Mode: internal.MetricsMode{Typ:
internal.MetricModeFull}},
+ 3: {FieldID: 3, IcebergTyp: iceberg.GeometryType{},
ColName: "s.g", Mode: internal.MetricsMode{Typ: internal.MetricModeFull}},
+ },
+ WriteProps: fm.GetWriteProperties(iceberg.Properties{}),
+ Content: iceberg.EntryContentData,
+ }, []arrow.RecordBatch{rec})
+ require.NoError(t, err)
+
+ require.Contains(t, df.ValueCounts(), 3)
+ assert.EqualValues(t, 3, df.ValueCounts()[3])
+ assert.NotContains(t, df.NullValueCounts(), 3, "nested geo null counts
are not recovered yet")
+ assert.NotContains(t, df.LowerBoundValues(), 3)
+ assert.NotContains(t, df.UpperBoundValues(), 3)
+}
+
// geoBoundBytes builds the Iceberg geospatial single-value serialization of a
// bound point: little-endian float64 coordinates in X, Y[, Z][, M] order.
func geoBoundBytes(coords ...float64) []byte {