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())
+}