You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/09/21 19:06:54 UTC
(iceberg-go) branch main updated: feat(rest): add catalog labels to the load table/view read path (#2013)
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 f3dc938de feat(rest): add catalog labels to the load table/view read path (#2013)
f3dc938de is described below
commit f3dc938de4efe69f46a22355f08750fb77a0a5a3
Author: Andrei Tserakhau <la...@gmail.com>
AuthorDate: Mon Sep 21 21:06:46 2026 +0200
feat(rest): add catalog labels to the load table/view read path (#2013)
Add the iceberg.Labels and iceberg.FieldLabel value types and decode the
optional "labels" object from LoadTableResult and LoadViewResult responses.
Labels are catalog-provided, transient enrichment and are never persisted to
metadata; field labels are keyed by field ID and tolerate dropped columns.
This decodes labels off the wire only; exposing them on the loaded table and
view follows in a later change.
Co-authored-by: Isaac <no...@databricks.com>
---
catalog/rest/labels_internal_test.go | 80 ++++++++++++++++++++++++++++++++++++
catalog/rest/rest.go | 2 +
labels.go | 66 +++++++++++++++++++++++++++++
labels_test.go | 79 +++++++++++++++++++++++++++++++++++
4 files changed, 227 insertions(+)
diff --git a/catalog/rest/labels_internal_test.go b/catalog/rest/labels_internal_test.go
new file mode 100644
index 000000000..00479ff7b
--- /dev/null
+++ b/catalog/rest/labels_internal_test.go
@@ -0,0 +1,80 @@
+// 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 rest
+
+import (
+ "encoding/json"
+ "testing"
+
+ iceberg "github.com/apache/iceberg-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const labelsTestTableMetadata = `{
+ "format-version": 1,
+ "table-uuid": "b55d9dda-6561-423a-8bfc-787980ce421f",
+ "location": "s3://warehouse/database/table",
+ "last-updated-ms": 1646787054459,
+ "last-column-id": 2,
+ "schema": {"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]},
+ "current-schema-id": 0,
+ "schemas": [{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]}],
+ "partition-spec": [],
+ "default-spec-id": 0,
+ "partition-specs": [{"spec-id":0,"fields":[]}],
+ "last-partition-id": 999,
+ "default-sort-order-id": 0,
+ "sort-orders": [{"order-id":0,"fields":[]}],
+ "properties": {}
+}`
+
+const labelsTestBlock = `{"object-labels":{"owner":"data-eng"},"fields":[{"field-id":2,"labels":{"classification":"pii"}}]}`
+
+func TestLoadTableResponseLabels(t *testing.T) {
+ body := `{"metadata-location":"s3://x","metadata":` + labelsTestTableMetadata + `,"config":{},"labels":` + labelsTestBlock + `}`
+
+ var resp loadTableResponse
+ require.NoError(t, json.Unmarshal([]byte(body), &resp))
+ require.NotNil(t, resp.Labels)
+ assert.Equal(t, "data-eng", resp.Labels.Object()["owner"])
+ assert.Equal(t, iceberg.Properties{"classification": "pii"}, resp.Labels.Field(2))
+}
+
+func TestLoadViewResponseLabels(t *testing.T) {
+ body := `{"metadata-location":"s3://x","metadata":{},"config":{},"labels":` + labelsTestBlock + `}`
+
+ var resp loadViewResponse
+ require.NoError(t, json.Unmarshal([]byte(body), &resp))
+ require.NotNil(t, resp.Labels)
+ assert.Equal(t, "data-eng", resp.Labels.Object()["owner"])
+ assert.Equal(t, iceberg.Properties{"classification": "pii"}, resp.Labels.Field(2))
+}
+
+func TestLoadResponseLabelsAbsent(t *testing.T) {
+ var viewResp loadViewResponse
+ require.NoError(t, json.Unmarshal([]byte(`{"metadata-location":"s3://x","metadata":{},"config":{}}`), &viewResp))
+ assert.Nil(t, viewResp.Labels)
+
+ // loadTableResponse decodes through a custom UnmarshalJSON (alias pattern),
+ // a mechanically different path, so cover its absent case too.
+ var tableResp loadTableResponse
+ require.NoError(t, json.Unmarshal([]byte(
+ `{"metadata-location":"s3://x","metadata":`+labelsTestTableMetadata+`,"config":{}}`), &tableResp))
+ assert.Nil(t, tableResp.Labels)
+}
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 3ab7906a4..2f5af16df 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -193,6 +193,7 @@ type loadTableResponse struct {
RawMetadata json.RawMessage `json:"metadata"`
Config iceberg.Properties `json:"config"`
StorageCredentials []StorageCredential `json:"storage-credentials"`
+ Labels *iceberg.Labels `json:"labels,omitempty"`
Metadata table.Metadata `json:"-"`
}
@@ -2456,6 +2457,7 @@ type loadViewResponse struct {
MetadataLoc string `json:"metadata-location"`
RawMetadata json.RawMessage `json:"metadata"`
Config iceberg.Properties `json:"config"`
+ Labels *iceberg.Labels `json:"labels,omitempty"`
}
// RegisterView registers an existing view in the catalog using its metadata file location.
diff --git a/labels.go b/labels.go
new file mode 100644
index 000000000..00a39c351
--- /dev/null
+++ b/labels.go
@@ -0,0 +1,66 @@
+// 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
+
+// Labels is catalog-provided enrichment returned with a table or view in a REST
+// load response. Labels are transient: generated per request and never persisted
+// to metadata, so a client may ignore them and two catalogs may return different
+// labels for the same object. It maps to the "labels" property of
+// LoadTableResult and LoadViewResult in the REST catalog spec.
+type Labels struct {
+ ObjectLabels Properties `json:"object-labels,omitempty"`
+ Fields []FieldLabel `json:"fields,omitempty"`
+}
+
+// FieldLabel holds the labels for a single schema field. FieldID may reference a
+// column that has since been dropped, so callers must tolerate unresolved IDs.
+type FieldLabel struct {
+ FieldID int `json:"field-id"`
+ Labels Properties `json:"labels,omitempty"`
+}
+
+// IsEmpty reports whether there are neither object-level nor field-level labels.
+// It is nil-safe.
+func (l *Labels) IsEmpty() bool {
+ return l == nil || (len(l.ObjectLabels) == 0 && len(l.Fields) == 0)
+}
+
+// Object returns the object-level labels, or nil if there are none.
+func (l *Labels) Object() Properties {
+ if l == nil {
+ return nil
+ }
+
+ return l.ObjectLabels
+}
+
+// Field returns the labels for the given field ID, or nil if it carries none
+// (including a dropped field). The first match wins if IDs repeat.
+func (l *Labels) Field(fieldID int) Properties {
+ if l == nil {
+ return nil
+ }
+
+ for i := range l.Fields {
+ if l.Fields[i].FieldID == fieldID {
+ return l.Fields[i].Labels
+ }
+ }
+
+ return nil
+}
diff --git a/labels_test.go b/labels_test.go
new file mode 100644
index 000000000..10f9865e5
--- /dev/null
+++ b/labels_test.go
@@ -0,0 +1,79 @@
+// 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"
+ "testing"
+
+ iceberg "github.com/apache/iceberg-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLabelsUnmarshal(t *testing.T) {
+ const body = `{
+ "object-labels": {"owner": "data-eng", "cost-center": "42"},
+ "fields": [
+ {"field-id": 2, "labels": {"classification": "pii"}},
+ {"field-id": 5, "labels": {"classification": "internal"}}
+ ]
+ }`
+
+ var l iceberg.Labels
+ require.NoError(t, json.Unmarshal([]byte(body), &l))
+
+ assert.Equal(t, iceberg.Properties{"owner": "data-eng", "cost-center": "42"}, l.Object())
+ assert.Equal(t, iceberg.Properties{"classification": "pii"}, l.Field(2))
+ assert.Equal(t, iceberg.Properties{"classification": "internal"}, l.Field(5))
+ assert.Nil(t, l.Field(999)) // unknown or dropped field id
+}
+
+func TestLabelsRoundTrip(t *testing.T) {
+ l := iceberg.Labels{
+ ObjectLabels: iceberg.Properties{"owner": "data-eng"},
+ Fields: []iceberg.FieldLabel{{FieldID: 3, Labels: iceberg.Properties{"classification": "pii"}}},
+ }
+
+ b, err := json.Marshal(l)
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"object-labels":{"owner":"data-eng"},"fields":[{"field-id":3,"labels":{"classification":"pii"}}]}`, string(b))
+
+ var got iceberg.Labels
+ require.NoError(t, json.Unmarshal(b, &got))
+ assert.Equal(t, l, got)
+}
+
+func TestLabelsEmptyOmitted(t *testing.T) {
+ b, err := json.Marshal(iceberg.Labels{})
+ require.NoError(t, err)
+ assert.JSONEq(t, `{}`, string(b))
+}
+
+func TestLabelsNilReceiver(t *testing.T) {
+ var l *iceberg.Labels
+ assert.Nil(t, l.Object())
+ assert.Nil(t, l.Field(1))
+ assert.True(t, l.IsEmpty())
+}
+
+func TestLabelsIsEmpty(t *testing.T) {
+ assert.True(t, (&iceberg.Labels{}).IsEmpty())
+ assert.False(t, (&iceberg.Labels{ObjectLabels: iceberg.Properties{"owner": "x"}}).IsEmpty())
+ assert.False(t, (&iceberg.Labels{Fields: []iceberg.FieldLabel{{FieldID: 1, Labels: iceberg.Properties{"a": "b"}}}}).IsEmpty())
+}