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 858379a65 refactor(puffin)!: unify statistics blob types (#2295)
858379a65 is described below
commit 858379a657387a2a327b8a025398779a741ae10a
Author: Minh Vu <[email protected]>
AuthorDate: Sun Oct 25 12:53:09 2026 +0100
refactor(puffin)!: unify statistics blob types (#2295)
---
puffin/blob_type_test.go | 77 +++++++++++++++++++++++++++++++++++++++++
puffin/puffin.go | 34 ++++++++++++++++++
puffin/puffin_test.go | 29 ++++++++++++++++
table/metadata_getters_test.go | 3 +-
table/metadata_internal_test.go | 5 +--
table/statistics.go | 49 ++------------------------
table/statistics_test.go | 8 ++---
website/src/releases.md | 1 +
8 files changed, 153 insertions(+), 53 deletions(-)
diff --git a/puffin/blob_type_test.go b/puffin/blob_type_test.go
new file mode 100644
index 000000000..21295b2e8
--- /dev/null
+++ b/puffin/blob_type_test.go
@@ -0,0 +1,77 @@
+// 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 puffin_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/apache/iceberg-go/puffin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBlobTypeIsKnown(t *testing.T) {
+ assert.True(t, puffin.BlobTypeDataSketchesTheta.IsKnown())
+ assert.True(t, puffin.BlobTypeDeletionVector.IsKnown())
+ assert.False(t, puffin.BlobType("vendor-sketch-v1").IsKnown())
+ assert.False(t, puffin.BlobType("").IsKnown())
+}
+
+func TestBlobTypeUnmarshalErrorPreservesPreviousValue(t *testing.T) {
+ for _, input := range []string{`""`, "null", "123", `{}`} {
+ t.Run(input, func(t *testing.T) {
+ got := puffin.BlobTypeDeletionVector
+ err := json.Unmarshal([]byte(input), &got)
+ require.Error(t, err)
+ assert.Equal(t, puffin.BlobTypeDeletionVector, got)
+ })
+ }
+}
+
+func TestBlobTypeJSON(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ input string
+ want puffin.BlobType
+ wantErr bool
+ }{
+ {name: "theta", input: `"apache-datasketches-theta-v1"`, want:
puffin.BlobTypeDataSketchesTheta},
+ {name: "deletion vector", input: `"deletion-vector-v1"`, want:
puffin.BlobTypeDeletionVector},
+ {name: "vendor type", input: `"vendor-sketch-v1"`, want:
puffin.BlobType("vendor-sketch-v1")},
+ {name: "empty", input: `""`, wantErr: true},
+ {name: "null", input: "null", wantErr: true},
+ {name: "non-string", input: "123", wantErr: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var got puffin.BlobType
+ err := json.Unmarshal([]byte(tc.input), &got)
+ if tc.wantErr {
+ require.Error(t, err)
+
+ return
+ }
+
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, got)
+ encoded, err := json.Marshal(got)
+ require.NoError(t, err)
+ assert.JSONEq(t, tc.input, string(encoded))
+ })
+ }
+}
diff --git a/puffin/puffin.go b/puffin/puffin.go
index c4b88303e..1a8466139 100644
--- a/puffin/puffin.go
+++ b/puffin/puffin.go
@@ -28,6 +28,11 @@
// See the specification at https://iceberg.apache.org/puffin-spec/
package puffin
+import (
+ "encoding/json"
+ "fmt"
+)
+
var magic = [4]byte{'P', 'F', 'A', '1'}
const (
@@ -74,6 +79,8 @@ type Footer struct {
Properties map[string]string `json:"properties,omitempty"`
}
+// BlobType identifies the format of a Puffin blob. Unknown non-empty names
+// are preserved so that callers can round-trip vendor-defined blob types.
type BlobType string
const (
@@ -85,3 +92,30 @@ const (
// Iceberg spec. Requires snapshot-id and sequence-number to be -1.
BlobTypeDeletionVector BlobType = "deletion-vector-v1"
)
+
+// IsKnown reports whether the type is one of the standard blob types
+// defined by the Puffin spec. Unknown non-empty types are still valid
metadata.
+func (bt BlobType) IsKnown() bool {
+ switch bt {
+ case BlobTypeDataSketchesTheta, BlobTypeDeletionVector:
+ return true
+ default:
+ return false
+ }
+}
+
+// UnmarshalJSON rejects empty and null blob types while preserving unknown
+// non-empty type names for forward compatibility.
+func (bt *BlobType) UnmarshalJSON(data []byte) error {
+ var name string
+ if err := json.Unmarshal(data, &name); err != nil {
+ return err
+ }
+ if name == "" {
+ return fmt.Errorf("invalid blob type: %s", name)
+ }
+
+ *bt = BlobType(name)
+
+ return nil
+}
diff --git a/puffin/puffin_test.go b/puffin/puffin_test.go
index d06041e90..aad597296 100644
--- a/puffin/puffin_test.go
+++ b/puffin/puffin_test.go
@@ -830,6 +830,35 @@ func TestReaderRejectsTrailingFooterData(t *testing.T) {
}
}
+// TestReaderRejectsMissingBlobTypes checks footer decoding and validation,
+// including when the malformed blob follows an otherwise valid one.
+func TestReaderRejectsMissingBlobTypes(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ typeJSON string
+ wantErr string
+ }{
+ {name: "empty", typeJSON: `"type":""`, wantErr: "invalid blob
type"},
+ {name: "null", typeJSON: `"type":null`, wantErr: "invalid blob
type"},
+ {name: "absent", wantErr: "puffin: blob 1: type is required"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ second := `{"fields":[],"offset":4,"length":0}`
+ if tt.typeJSON != "" {
+ second = `{` + tt.typeJSON +
`,"fields":[],"offset":4,"length":0}`
+ }
+ footer :=
`{"blobs":[{"type":"vendor-sketch-v1","fields":[],"offset":4,"length":0},` +
second + `]}`
+ _, err :=
puffin.NewReader(bytes.NewReader(fileWithFooterPayload([]byte(footer))))
+ require.ErrorContains(t, err, tt.wantErr)
+ })
+ }
+}
+
func TestReaderReadsLZ4CompressedFooter(t *testing.T) {
data := fileWithCompressedFooterPayload(t,
[]byte(`{"blobs":[],"properties":{"source":"lz4"}}`))
diff --git a/table/metadata_getters_test.go b/table/metadata_getters_test.go
index 99d007aa0..93a7be6cc 100644
--- a/table/metadata_getters_test.go
+++ b/table/metadata_getters_test.go
@@ -24,6 +24,7 @@ import (
"github.com/apache/iceberg-go"
iceinternal "github.com/apache/iceberg-go/internal"
+ "github.com/apache/iceberg-go/puffin"
"github.com/stretchr/testify/require"
)
@@ -69,7 +70,7 @@ func TestMetadataGettersReturnDefensiveCopies(t *testing.T) {
StatisticsPath: "stats.puffin",
KeyMetadata: &keyMetadata,
BlobMetadata: []BlobMetadata{{
- Type: BlobTypeApacheDatasketchesThetaV1,
+ Type: puffin.BlobTypeDataSketchesTheta,
Fields: []int32{1},
Properties: map[string]string{"source": "test"},
}},
diff --git a/table/metadata_internal_test.go b/table/metadata_internal_test.go
index 9c9e3d3c2..d8db65ec9 100644
--- a/table/metadata_internal_test.go
+++ b/table/metadata_internal_test.go
@@ -30,6 +30,7 @@ import (
"testing"
"github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/puffin"
"github.com/davecgh/go-spew/spew"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
@@ -1725,7 +1726,7 @@ func TestMetadataV1Serialize(t *testing.T) {
KeyMetadata: nil,
BlobMetadata: []BlobMetadata{
{
- Type:
BlobTypeApacheDatasketchesThetaV1,
+ Type:
puffin.BlobTypeDataSketchesTheta,
SnapshotID:
1234567890,
SequenceNumber: 1,
Fields:
[]int32{1, 2},
@@ -1817,7 +1818,7 @@ func TestMetadataV2Serialize(t *testing.T) {
FileFooterSizeInBytes: 1024,
BlobMetadata: []BlobMetadata{
{
- Type:
BlobTypeDeletionVectorV1,
+ Type:
puffin.BlobTypeDeletionVector,
SnapshotID:
9876543210,
SequenceNumber: 5,
Fields:
[]int32{1},
diff --git a/table/statistics.go b/table/statistics.go
index 645e119d4..46a429c4d 100644
--- a/table/statistics.go
+++ b/table/statistics.go
@@ -17,51 +17,7 @@
package table
-import (
- "encoding/json"
- "fmt"
-)
-
-// BlobType is the type of blob in a Puffin file.
-type BlobType string
-
-const (
- BlobTypeApacheDatasketchesThetaV1 BlobType =
"apache-datasketches-theta-v1"
- BlobTypeDeletionVectorV1 BlobType = "deletion-vector-v1"
-)
-
-// IsKnown reports whether bt is one of the standard blob types understood by
-// this package. Unknown non-empty wire values may still be preserved for
-// forward compatibility.
-func (bt BlobType) IsKnown() bool {
- switch bt {
- case BlobTypeApacheDatasketchesThetaV1, BlobTypeDeletionVectorV1:
- return true
- default:
- return false
- }
-}
-
-// IsValid reports whether bt is non-nil and names a standard blob type.
-// Unknown non-empty values can be decoded and preserved, but are not valid for
-// operations that require a known blob type.
-func (bt *BlobType) IsValid() bool {
- return bt != nil && bt.IsKnown()
-}
-
-func (bt *BlobType) UnmarshalJSON(data []byte) error {
- var s string
- if err := json.Unmarshal(data, &s); err != nil {
- return err
- }
-
- *bt = BlobType(s)
- if s == "" {
- return fmt.Errorf("invalid blob type: %s", s)
- }
-
- return nil
-}
+import "github.com/apache/iceberg-go/puffin"
// StatisticsFile represents a statistics file in the Puffin format, that can
be used to read table data more
// efficiently.
@@ -78,8 +34,9 @@ type StatisticsFile struct {
}
// BlobMetadata is the metadata of a statistics or indices blob.
+// Its type uses the same Puffin blob identifiers as the file reader and
writer.
type BlobMetadata struct {
- Type BlobType `json:"type"`
+ Type puffin.BlobType `json:"type"`
SnapshotID int64 `json:"snapshot-id"`
SequenceNumber int64 `json:"sequence-number"`
Fields []int32 `json:"fields"`
diff --git a/table/statistics_test.go b/table/statistics_test.go
index d5ae594bd..d9796bde6 100644
--- a/table/statistics_test.go
+++ b/table/statistics_test.go
@@ -20,6 +20,7 @@ import (
"encoding/json"
"testing"
+ "github.com/apache/iceberg-go/puffin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -32,7 +33,7 @@ func TestMetadataPreservesUnknownStatisticsBlobType(t
*testing.T) {
SnapshotID: 3055729675574597004,
StatisticsPath: "s3://bucket/stats/vendor.puffin",
BlobMetadata: []BlobMetadata{{
- Type: BlobType("vendor-sketch-v1"),
+ Type: puffin.BlobType("vendor-sketch-v1"),
SnapshotID: 3055729675574597004,
SequenceNumber: 2,
Fields: []int32{1},
@@ -51,12 +52,11 @@ func TestMetadataPreservesUnknownStatisticsBlobType(t
*testing.T) {
}
require.Len(t, stats, 1)
require.Len(t, stats[0].BlobMetadata, 1)
- assert.Equal(t, BlobType("vendor-sketch-v1"),
stats[0].BlobMetadata[0].Type)
+ assert.Equal(t, puffin.BlobType("vendor-sketch-v1"),
stats[0].BlobMetadata[0].Type)
assert.False(t, stats[0].BlobMetadata[0].Type.IsKnown())
- assert.False(t, stats[0].BlobMetadata[0].Type.IsValid())
}
-func TestBlobTypeRejectsNullAndEmptyValues(t *testing.T) {
+func TestBlobMetadataRejectsNullAndEmptyType(t *testing.T) {
for _, value := range []string{"null", `""`} {
t.Run(value, func(t *testing.T) {
var blob BlobMetadata
diff --git a/website/src/releases.md b/website/src/releases.md
index 074e1453f..e0d398b74 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -42,6 +42,7 @@ Per-release notes (highlights, breaking changes,
contributors) are published on
### Unreleased compatibility notes
+- **Unify statistics blob types with Puffin.** Replace `table.BlobType` with
`puffin.BlobType` when constructing `table.BlobMetadata`. Replace
`table.BlobTypeApacheDatasketchesThetaV1` with
`puffin.BlobTypeDataSketchesTheta` and `table.BlobTypeDeletionVectorV1` with
`puffin.BlobTypeDeletionVector`. Use `puffin.BlobType.IsKnown()` to identify
the types defined by the Puffin specification; the redundant
`table.BlobType.IsValid()` method is removed. Unknown non-empty blob type names
still r [...]
- **View representation field uses the Go SQL initialism.** Replace
`view.Representation.Sql` with `view.Representation.SQL`. Serialized view
metadata still uses the `sql` JSON field.
- **Orphan cleanup results now expose only `OrphanFiles`.** Replace
`result.OrphanFileLocations` with `result.OrphanFiles` (each entry has `Path`
and `SizeBytes`). `OrphanCleanupPlan.Files()` still provides path-only lists
during planning.
- **Catalog identifier helper renamed.** `catalog.TableNameFromIdent` is
removed. Use `catalog.ObjectNameFromIdent` for tables, views, and functions.