From 0f538f6a14144d8ef589f5a438ae54e6e8c2db15 Mon Sep 17 00:00:00 2001 From: laskoviymishka Date: Wed, 29 Jul 2026 00:30:01 +0200 Subject: [PATCH 1/3] Core: add labels to the load table/view read path Reference client implementation for the IRC labels read-path spec change (apache/iceberg#15750). Without it, RESTObjectMapper (which sets FAIL_ON_UNKNOWN_PROPERTIES = false) silently drops the labels field on deserialization, so labels returned by a catalog are invisible to the Java client. New rest.labels package (mirroring rest.credentials): Labels (object + fields sub-scopes, with a shared empty instance) and FieldLabels (per-field, keyed by field-id) value types as immutables interfaces, each with a JSON parser. FieldLabels validates field-id >= 1 and a non-empty labels map, matching Credential. Wire an optional labels field into LoadTableResponse / LoadViewResponse and their parsers. labels() never returns null (empty instance when absent), on LoadViewResponse via a @Value.Default default method so no interface API break is introduced. Labels are omitted from the wire when absent (or empty), so the change is additive and backward compatible. --- .../iceberg/rest/labels/FieldLabels.java | 37 +++++ .../rest/labels/FieldLabelsParser.java | 64 ++++++++ .../apache/iceberg/rest/labels/Labels.java | 45 ++++++ .../iceberg/rest/labels/LabelsParser.java | 81 ++++++++++ .../rest/responses/LoadTableResponse.java | 20 ++- .../responses/LoadTableResponseParser.java | 11 ++ .../rest/responses/LoadViewResponse.java | 7 + .../responses/LoadViewResponseParser.java | 11 ++ .../rest/labels/TestFieldLabelsParser.java | 96 ++++++++++++ .../iceberg/rest/labels/TestLabelsParser.java | 142 ++++++++++++++++++ .../rest/responses/TestLoadTableResponse.java | 46 ++++++ .../TestLoadTableResponseParser.java | 117 +++++++++++++++ .../responses/TestLoadViewResponseParser.java | 116 ++++++++++++++ 13 files changed, 791 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/org/apache/iceberg/rest/labels/FieldLabels.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/labels/FieldLabelsParser.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/labels/Labels.java create mode 100644 core/src/main/java/org/apache/iceberg/rest/labels/LabelsParser.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/labels/TestFieldLabelsParser.java create mode 100644 core/src/test/java/org/apache/iceberg/rest/labels/TestLabelsParser.java diff --git a/core/src/main/java/org/apache/iceberg/rest/labels/FieldLabels.java b/core/src/main/java/org/apache/iceberg/rest/labels/FieldLabels.java new file mode 100644 index 000000000000..b6636a8cab90 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/labels/FieldLabels.java @@ -0,0 +1,37 @@ +/* + * 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 org.apache.iceberg.rest.labels; + +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.immutables.value.Value; + +/** Labels attached to a single field, identified by its field id. See {@link Labels}. */ +@Value.Immutable +public interface FieldLabels { + int fieldId(); + + Map labels(); + + @Value.Check + default void validate() { + Preconditions.checkArgument(fieldId() >= 1, "Invalid field id: must be >= 1"); + Preconditions.checkArgument(!labels().isEmpty(), "Invalid labels: must be non-empty"); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/labels/FieldLabelsParser.java b/core/src/main/java/org/apache/iceberg/rest/labels/FieldLabelsParser.java new file mode 100644 index 000000000000..6bf7b847818a --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/labels/FieldLabelsParser.java @@ -0,0 +1,64 @@ +/* + * 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 org.apache.iceberg.rest.labels; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.JsonUtil; + +public class FieldLabelsParser { + private static final String FIELD_ID = "field-id"; + private static final String LABELS = "labels"; + + private FieldLabelsParser() {} + + public static String toJson(FieldLabels fieldLabels) { + return toJson(fieldLabels, false); + } + + public static String toJson(FieldLabels fieldLabels, boolean pretty) { + return JsonUtil.generate(gen -> toJson(fieldLabels, gen), pretty); + } + + public static void toJson(FieldLabels fieldLabels, JsonGenerator gen) throws IOException { + Preconditions.checkArgument(null != fieldLabels, "Invalid field labels: null"); + + gen.writeStartObject(); + + gen.writeNumberField(FIELD_ID, fieldLabels.fieldId()); + JsonUtil.writeStringMap(LABELS, fieldLabels.labels(), gen); + + gen.writeEndObject(); + } + + public static FieldLabels fromJson(String json) { + return JsonUtil.parse(json, FieldLabelsParser::fromJson); + } + + public static FieldLabels fromJson(JsonNode json) { + Preconditions.checkArgument(null != json, "Cannot parse field labels from null object"); + + return ImmutableFieldLabels.builder() + .fieldId(JsonUtil.getInt(FIELD_ID, json)) + .labels(JsonUtil.getStringMap(LABELS, json)) + .build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/labels/Labels.java b/core/src/main/java/org/apache/iceberg/rest/labels/Labels.java new file mode 100644 index 000000000000..40cf73da47b4 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/labels/Labels.java @@ -0,0 +1,45 @@ +/* + * 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 org.apache.iceberg.rest.labels; + +import java.util.List; +import java.util.Map; +import org.immutables.value.Value; + +/** Optional catalog-provided labels returned on a load response. */ +@Value.Immutable +public interface Labels { + Labels EMPTY = ImmutableLabels.builder().build(); + + /** Object-level labels. */ + Map object(); + + /** Field-level labels, keyed by field id. */ + List fields(); + + /** Returns true when there are neither object-level nor field-level labels. */ + default boolean isEmpty() { + return object().isEmpty() && fields().isEmpty(); + } + + /** Returns a shared empty instance. */ + static Labels empty() { + return EMPTY; + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/labels/LabelsParser.java b/core/src/main/java/org/apache/iceberg/rest/labels/LabelsParser.java new file mode 100644 index 000000000000..3bc18a9d1bf2 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/labels/LabelsParser.java @@ -0,0 +1,81 @@ +/* + * 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 org.apache.iceberg.rest.labels; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.JsonUtil; + +public class LabelsParser { + private static final String OBJECT = "object"; + private static final String FIELDS = "fields"; + + private LabelsParser() {} + + public static String toJson(Labels labels) { + return toJson(labels, false); + } + + public static String toJson(Labels labels, boolean pretty) { + return JsonUtil.generate(gen -> toJson(labels, gen), pretty); + } + + public static void toJson(Labels labels, JsonGenerator gen) throws IOException { + Preconditions.checkArgument(null != labels, "Invalid labels: null"); + + gen.writeStartObject(); + + if (!labels.object().isEmpty()) { + JsonUtil.writeStringMap(OBJECT, labels.object(), gen); + } + + if (!labels.fields().isEmpty()) { + gen.writeArrayFieldStart(FIELDS); + for (FieldLabels fieldLabels : labels.fields()) { + FieldLabelsParser.toJson(fieldLabels, gen); + } + + gen.writeEndArray(); + } + + gen.writeEndObject(); + } + + public static Labels fromJson(String json) { + return JsonUtil.parse(json, LabelsParser::fromJson); + } + + public static Labels fromJson(JsonNode json) { + Preconditions.checkArgument(null != json, "Cannot parse labels from null object"); + + ImmutableLabels.Builder builder = ImmutableLabels.builder(); + + if (json.hasNonNull(OBJECT)) { + builder.object(JsonUtil.getStringMap(OBJECT, json)); + } + + if (json.hasNonNull(FIELDS)) { + builder.fields(JsonUtil.getObjectList(FIELDS, json, FieldLabelsParser::fromJson)); + } + + return builder.build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java index 977220e7d782..fc754698109c 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponse.java @@ -29,6 +29,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.rest.RESTResponse; import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.labels.Labels; /** * A REST response that is used when a table is successfully loaded. @@ -45,6 +46,7 @@ public class LoadTableResponse implements RESTResponse { private Map config; private TableMetadata metadataWithLocation; private List credentials; + private Labels labels; public LoadTableResponse() { // Required for Jackson deserialization @@ -54,11 +56,13 @@ private LoadTableResponse( String metadataLocation, TableMetadata metadata, Map config, - List credentials) { + List credentials, + Labels labels) { this.metadataLocation = metadataLocation; this.metadata = metadata; this.config = config; this.credentials = credentials; + this.labels = labels; } @Override @@ -87,12 +91,18 @@ public List credentials() { return credentials != null ? credentials : ImmutableList.of(); } + /** Catalog-provided labels, or an empty instance when the response carries no labels. */ + public Labels labels() { + return labels != null ? labels : Labels.empty(); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) .add("metadataLocation", metadataLocation) .add("metadata", metadata) .add("config", config) + .add("labels", labels) .toString(); } @@ -105,6 +115,7 @@ public static class Builder { private TableMetadata metadata; private final Map config = Maps.newHashMap(); private final List credentials = Lists.newArrayList(); + private Labels labels; private Builder() {} @@ -134,9 +145,14 @@ public Builder addAllCredentials(List credentialsToAdd) { return this; } + public Builder withLabels(Labels tableLabels) { + this.labels = tableLabels; + return this; + } + public LoadTableResponse build() { Preconditions.checkNotNull(metadata, "Invalid metadata: null"); - return new LoadTableResponse(metadataLocation, metadata, config, credentials); + return new LoadTableResponse(metadataLocation, metadata, config, credentials, labels); } } } diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java index 8d34b1498369..858df5fb0c2e 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadTableResponseParser.java @@ -26,6 +26,7 @@ import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.rest.credentials.Credential; import org.apache.iceberg.rest.credentials.CredentialParser; +import org.apache.iceberg.rest.labels.LabelsParser; import org.apache.iceberg.util.JsonUtil; public class LoadTableResponseParser { @@ -34,6 +35,7 @@ public class LoadTableResponseParser { private static final String METADATA = "metadata"; private static final String CONFIG = "config"; private static final String STORAGE_CREDENTIALS = "storage-credentials"; + private static final String LABELS = "labels"; private LoadTableResponseParser() {} @@ -70,6 +72,11 @@ public static void toJson(LoadTableResponse response, JsonGenerator gen) throws gen.writeEndArray(); } + if (!response.labels().isEmpty()) { + gen.writeFieldName(LABELS); + LabelsParser.toJson(response.labels(), gen); + } + gen.writeEndObject(); } @@ -101,6 +108,10 @@ public static LoadTableResponse fromJson(JsonNode json) { builder.addAllCredentials(LoadCredentialsResponseParser.fromJson(json).credentials()); } + if (json.hasNonNull(LABELS)) { + builder.withLabels(LabelsParser.fromJson(JsonUtil.get(LABELS, json))); + } + return builder.build(); } } diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java index d07ba872fdaa..389ce5b19cc3 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java @@ -20,6 +20,7 @@ import java.util.Map; import org.apache.iceberg.rest.RESTResponse; +import org.apache.iceberg.rest.labels.Labels; import org.apache.iceberg.view.ViewMetadata; import org.immutables.value.Value; @@ -31,6 +32,12 @@ public interface LoadViewResponse extends RESTResponse { Map config(); + /** Catalog-provided labels, or an empty instance when the response carries no labels. */ + @Value.Default + default Labels labels() { + return Labels.empty(); + } + @Override default void validate() { // nothing to validate as it's not possible to create an invalid instance diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java index a8aaf17e5d76..a8dcc9d1b737 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.rest.labels.LabelsParser; import org.apache.iceberg.util.JsonUtil; import org.apache.iceberg.view.ViewMetadata; import org.apache.iceberg.view.ViewMetadataParser; @@ -31,6 +32,7 @@ public class LoadViewResponseParser { private static final String METADATA_LOCATION = "metadata-location"; private static final String METADATA = "metadata"; private static final String CONFIG = "config"; + private static final String LABELS = "labels"; private LoadViewResponseParser() {} @@ -56,6 +58,11 @@ public static void toJson(LoadViewResponse response, JsonGenerator gen) throws I JsonUtil.writeStringMap(CONFIG, response.config(), gen); } + if (!response.labels().isEmpty()) { + gen.writeFieldName(LABELS); + LabelsParser.toJson(response.labels(), gen); + } + gen.writeEndObject(); } @@ -80,6 +87,10 @@ public static LoadViewResponse fromJson(JsonNode json) { builder.config(JsonUtil.getStringMap(CONFIG, json)); } + if (json.hasNonNull(LABELS)) { + builder.labels(LabelsParser.fromJson(JsonUtil.get(LABELS, json))); + } + return builder.build(); } } diff --git a/core/src/test/java/org/apache/iceberg/rest/labels/TestFieldLabelsParser.java b/core/src/test/java/org/apache/iceberg/rest/labels/TestFieldLabelsParser.java new file mode 100644 index 000000000000..1394c95f1dce --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/labels/TestFieldLabelsParser.java @@ -0,0 +1,96 @@ +/* + * 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 org.apache.iceberg.rest.labels; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Test; + +public class TestFieldLabelsParser { + + @Test + public void nullCheck() { + assertThatThrownBy(() -> FieldLabelsParser.toJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid field labels: null"); + + assertThatThrownBy(() -> FieldLabelsParser.fromJson((JsonNode) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse field labels from null object"); + } + + @Test + public void roundTrip() { + FieldLabels fieldLabels = + ImmutableFieldLabels.builder().fieldId(3).labels(ImmutableMap.of("pii", "true")).build(); + + String expectedJson = + """ + { + "field-id" : 3, + "labels" : { + "pii" : "true" + } + }"""; + + assertThat(FieldLabelsParser.toJson(fieldLabels, true)).isEqualTo(expectedJson); + assertThat(FieldLabelsParser.fromJson(expectedJson)).isEqualTo(fieldLabels); + } + + @Test + public void invalidFieldId() { + assertThatThrownBy( + () -> + ImmutableFieldLabels.builder().fieldId(0).labels(ImmutableMap.of("k", "v")).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid field id: must be >= 1"); + } + + @Test + public void invalidFieldIdFromJson() { + assertThatThrownBy( + () -> FieldLabelsParser.fromJson("{\"field-id\": 0, \"labels\": {\"k\": \"v\"}}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid field id: must be >= 1"); + } + + @Test + public void emptyLabels() { + assertThatThrownBy(() -> ImmutableFieldLabels.builder().fieldId(1).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid labels: must be non-empty"); + } + + @Test + public void emptyLabelsFromJson() { + assertThatThrownBy(() -> FieldLabelsParser.fromJson("{\"field-id\": 1, \"labels\": {}}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid labels: must be non-empty"); + } + + @Test + public void missingLabelsFromJson() { + assertThatThrownBy(() -> FieldLabelsParser.fromJson("{\"field-id\": 1}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing map: labels"); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/labels/TestLabelsParser.java b/core/src/test/java/org/apache/iceberg/rest/labels/TestLabelsParser.java new file mode 100644 index 000000000000..c6f1e128aa6f --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/labels/TestLabelsParser.java @@ -0,0 +1,142 @@ +/* + * 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 org.apache.iceberg.rest.labels; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Test; + +public class TestLabelsParser { + + @Test + public void nullCheck() { + assertThatThrownBy(() -> LabelsParser.toJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid labels: null"); + + assertThatThrownBy(() -> LabelsParser.fromJson((JsonNode) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse labels from null object"); + } + + @Test + public void emptyLabels() { + Labels labels = ImmutableLabels.builder().build(); + assertThat(labels.isEmpty()).isTrue(); + + String expectedJson = "{ }"; + assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson); + + Labels roundTrip = LabelsParser.fromJson(expectedJson); + assertThat(roundTrip.object()).isEmpty(); + assertThat(roundTrip.fields()).isEmpty(); + + Labels nonEmpty = ImmutableLabels.builder().object(ImmutableMap.of("k", "v")).build(); + assertThat(nonEmpty.isEmpty()).isFalse(); + } + + @Test + public void objectLabelsOnly() { + Labels labels = + ImmutableLabels.builder() + .object(ImmutableMap.of("owner", "team-a", "sensitivity", "high")) + .build(); + + String expectedJson = + """ + { + "object" : { + "owner" : "team-a", + "sensitivity" : "high" + } + }"""; + + assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson); + assertThat(LabelsParser.fromJson(expectedJson)).isEqualTo(labels); + } + + @Test + public void fieldLabelsOnly() { + Labels labels = + ImmutableLabels.builder() + .addFields( + ImmutableFieldLabels.builder() + .fieldId(3) + .labels(ImmutableMap.of("pii", "true")) + .build()) + .build(); + + String expectedJson = + """ + { + "fields" : [ { + "field-id" : 3, + "labels" : { + "pii" : "true" + } + } ] + }"""; + + assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson); + assertThat(LabelsParser.fromJson(expectedJson)).isEqualTo(labels); + } + + @Test + public void objectAndFieldLabels() { + Labels labels = + ImmutableLabels.builder() + .object(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabels.builder() + .fieldId(1) + .labels(ImmutableMap.of("classification", "pii")) + .build()) + .addFields( + ImmutableFieldLabels.builder() + .fieldId(2) + .labels(ImmutableMap.of("classification", "public")) + .build()) + .build(); + + String expectedJson = + """ + { + "object" : { + "owner" : "team-a" + }, + "fields" : [ { + "field-id" : 1, + "labels" : { + "classification" : "pii" + } + }, { + "field-id" : 2, + "labels" : { + "classification" : "public" + } + } ] + }"""; + + assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson); + assertThat(LabelsParser.fromJson(expectedJson)).isEqualTo(labels); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java index 96854fe64c08..a52fa75b37a7 100644 --- a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java +++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponse.java @@ -37,6 +37,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.rest.RequestResponseTestBase; +import org.apache.iceberg.rest.labels.ImmutableFieldLabels; +import org.apache.iceberg.rest.labels.ImmutableLabels; +import org.apache.iceberg.rest.labels.Labels; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; @@ -162,6 +165,48 @@ public void testRoundTripSerdeWithV3TableMetadata() throws Exception { assertRoundTripSerializesEquallyFrom(json, resp); } + @Test + public void testRoundTripSerdeWithLabels() throws Exception { + String tableMetadataJson = readTableMetadataInputFile("TableMetadataV2Valid.json"); + TableMetadata metadata = + TableMetadataParser.fromJson(TEST_METADATA_LOCATION, tableMetadataJson); + Labels labels = + ImmutableLabels.builder() + .object(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabels.builder() + .fieldId(1) + .labels(ImmutableMap.of("classification", "pii")) + .build()) + .build(); + String json = + String.format( + """ + { + "metadata-location":"%s", + "metadata":%s, + "config":{"foo":"bar"}, + "labels":{ + "object":{"owner":"team-a"}, + "fields":[{"field-id":1,"labels":{"classification":"pii"}}] + } + }""", + TEST_METADATA_LOCATION, TableMetadataParser.toJson(metadata)); + + // exercise the full RESTObjectMapper (Jackson) path, not just the parser + LoadTableResponse actual = deserialize(json); + assertThat(actual.labels()).isEqualTo(labels); + + LoadTableResponse resp = + LoadTableResponse.builder() + .withTableMetadata(metadata) + .addAllConfig(CONFIG) + .withLabels(labels) + .build(); + // compare as JSON trees so the readable (pretty) input above is whitespace-insensitive + assertThat(mapper().readTree(serialize(resp))).isEqualTo(mapper().readTree(json)); + } + @Test public void testCanDeserializeWithoutDefaultValues() throws Exception { String metadataJson = readTableMetadataInputFile("TableMetadataV1Valid.json"); @@ -187,6 +232,7 @@ public void assertEquals(LoadTableResponse actual, LoadTableResponse expected) { assertThat(actual.metadataLocation()) .as("Should have the same metadata location") .isEqualTo(expected.metadataLocation()); + assertThat(actual.labels()).as("Should have the same labels").isEqualTo(expected.labels()); } private void assertEqualTableMetadata(TableMetadata actual, TableMetadata expected) { diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java index d0a2be263e23..8bc4d82cc2d7 100644 --- a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadTableResponseParser.java @@ -29,6 +29,8 @@ import org.apache.iceberg.TableMetadata; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.credentials.ImmutableCredential; +import org.apache.iceberg.rest.labels.ImmutableFieldLabels; +import org.apache.iceberg.rest.labels.ImmutableLabels; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -478,4 +480,119 @@ public void roundTripSerdeWithCredentials() { assertThat(LoadTableResponseParser.toJson(LoadTableResponseParser.fromJson(json), true)) .isEqualTo(expectedJson); } + + @Test + public void roundTripSerdeWithLabels() { + String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b"; + TableMetadata metadata = + TableMetadata.buildFromEmpty() + .assignUUID(uuid) + .setLocation("location") + .setCurrentSchema( + new Schema(Types.NestedField.required(1, "x", Types.LongType.get())), 1) + .addPartitionSpec(PartitionSpec.unpartitioned()) + .addSortOrder(SortOrder.unsorted()) + .discardChanges() + .withMetadataLocation("metadata-location") + .build(); + + LoadTableResponse response = + LoadTableResponse.builder() + .withTableMetadata(metadata) + .withLabels( + ImmutableLabels.builder() + .object(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabels.builder() + .fieldId(1) + .labels(ImmutableMap.of("classification", "pii")) + .build()) + .build()) + .build(); + + String expectedJson = + String.format( + """ + { + "metadata-location" : "metadata-location", + "metadata" : { + "format-version" : 2, + "table-uuid" : "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b", + "location" : "location", + "last-sequence-number" : 0, + "last-updated-ms" : %d, + "last-column-id" : 1, + "current-schema-id" : 0, + "schemas" : [ { + "type" : "struct", + "schema-id" : 0, + "fields" : [ { + "id" : 1, + "name" : "x", + "required" : true, + "type" : "long" + } ] + } ], + "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" : { }, + "current-snapshot-id" : -1, + "refs" : { }, + "snapshots" : [ ], + "statistics" : [ ], + "partition-statistics" : [ ], + "snapshot-log" : [ ], + "metadata-log" : [ ] + }, + "labels" : { + "object" : { + "owner" : "team-a" + }, + "fields" : [ { + "field-id" : 1, + "labels" : { + "classification" : "pii" + } + } ] + } + }""", + metadata.lastUpdatedMillis()); + + String json = LoadTableResponseParser.toJson(response, true); + assertThat(json).isEqualTo(expectedJson); + // can't do an equality comparison because Schema doesn't implement equals/hashCode + assertThat(LoadTableResponseParser.toJson(LoadTableResponseParser.fromJson(json), true)) + .isEqualTo(expectedJson); + } + + @Test + public void labelsAreOptional() { + String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b"; + TableMetadata metadata = + TableMetadata.buildFromEmpty() + .assignUUID(uuid) + .setLocation("location") + .setCurrentSchema( + new Schema(Types.NestedField.required(1, "x", Types.LongType.get())), 1) + .addPartitionSpec(PartitionSpec.unpartitioned()) + .addSortOrder(SortOrder.unsorted()) + .discardChanges() + .withMetadataLocation("metadata-location") + .build(); + + LoadTableResponse response = LoadTableResponse.builder().withTableMetadata(metadata).build(); + + String json = LoadTableResponseParser.toJson(response, true); + assertThat(json).doesNotContain("labels"); + assertThat(LoadTableResponseParser.fromJson(json).labels().isEmpty()).isTrue(); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java index f3de08cd2912..63eb9632e2a3 100644 --- a/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java @@ -25,6 +25,8 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.labels.ImmutableFieldLabels; +import org.apache.iceberg.rest.labels.ImmutableLabels; import org.apache.iceberg.types.Types; import org.apache.iceberg.view.ImmutableViewVersion; import org.apache.iceberg.view.ViewMetadata; @@ -245,4 +247,118 @@ public void roundTripSerdeWithConfig() { assertThat(LoadViewResponseParser.toJson(LoadViewResponseParser.fromJson(json), true)) .isEqualTo(expectedJson); } + + @Test + public void roundTripSerdeWithLabels() { + String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b"; + ViewMetadata viewMetadata = + ViewMetadata.builder() + .assignUUID(uuid) + .setLocation("location") + .addSchema(new Schema(Types.NestedField.required(1, "x", Types.LongType.get()))) + .addVersion( + ImmutableViewVersion.builder() + .schemaId(0) + .versionId(1) + .timestampMillis(23L) + .defaultNamespace(Namespace.of("ns1")) + .build()) + .setCurrentVersionId(1) + .build(); + + LoadViewResponse response = + ImmutableLoadViewResponse.builder() + .metadata(viewMetadata) + .metadataLocation("custom-location") + .labels( + ImmutableLabels.builder() + .object(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabels.builder() + .fieldId(1) + .labels(ImmutableMap.of("classification", "pii")) + .build()) + .build()) + .build(); + + String expectedJson = + """ + { + "metadata-location" : "custom-location", + "metadata" : { + "view-uuid" : "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b", + "format-version" : 1, + "location" : "location", + "schemas" : [ { + "type" : "struct", + "schema-id" : 0, + "fields" : [ { + "id" : 1, + "name" : "x", + "required" : true, + "type" : "long" + } ] + } ], + "current-version-id" : 1, + "versions" : [ { + "version-id" : 1, + "timestamp-ms" : 23, + "schema-id" : 0, + "summary" : { }, + "default-namespace" : [ "ns1" ], + "representations" : [ ] + } ], + "version-log" : [ { + "timestamp-ms" : 23, + "version-id" : 1 + } ] + }, + "labels" : { + "object" : { + "owner" : "team-a" + }, + "fields" : [ { + "field-id" : 1, + "labels" : { + "classification" : "pii" + } + } ] + } + }"""; + + String json = LoadViewResponseParser.toJson(response, true); + assertThat(json).isEqualTo(expectedJson); + // can't do an equality comparison because Schema doesn't implement equals/hashCode + assertThat(LoadViewResponseParser.toJson(LoadViewResponseParser.fromJson(json), true)) + .isEqualTo(expectedJson); + } + + @Test + public void labelsAreOptional() { + String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b"; + ViewMetadata viewMetadata = + ViewMetadata.builder() + .assignUUID(uuid) + .setLocation("location") + .addSchema(new Schema(Types.NestedField.required(1, "x", Types.LongType.get()))) + .addVersion( + ImmutableViewVersion.builder() + .schemaId(0) + .versionId(1) + .timestampMillis(23L) + .defaultNamespace(Namespace.of("ns1")) + .build()) + .setCurrentVersionId(1) + .build(); + + LoadViewResponse response = + ImmutableLoadViewResponse.builder() + .metadata(viewMetadata) + .metadataLocation("custom-location") + .build(); + + String json = LoadViewResponseParser.toJson(response, true); + assertThat(json).doesNotContain("labels"); + assertThat(LoadViewResponseParser.fromJson(json).labels().isEmpty()).isTrue(); + } } From 77ddd7c5f6bf9d6551bd24b4d33972c4d1faec1d Mon Sep 17 00:00:00 2001 From: laskoviymishka Date: Wed, 29 Jul 2026 00:30:30 +0200 Subject: [PATCH 2/3] Core: expose labels on the loaded table via SupportsLabels Builds on the labels read-path serde: make the catalog-provided labels from the load response reachable by consumers. - New SupportsLabels mixin interface (mirroring SupportsDistributedScanPlanning) with a single labels() accessor. - BaseTable implements SupportsLabels via a new constructor that carries an optional Labels; existing constructors default to an empty instance. The field is transient and not copied into SerializableTable, so labels are ephemeral catalog enrichment and are not preserved across table serialization. - RESTSessionCatalog populates labels from the load response for both the plain BaseTable and the server-side scan-planning RESTTable paths, across loadTable, registerTable, and createTable. --- .../java/org/apache/iceberg/BaseTable.java | 23 +++++- .../org/apache/iceberg/SupportsLabels.java | 34 +++++++++ .../iceberg/rest/RESTSessionCatalog.java | 36 +++++++--- .../org/apache/iceberg/rest/RESTTable.java | 6 +- .../apache/iceberg/TestBaseTableLabels.java | 71 +++++++++++++++++++ 5 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 core/src/main/java/org/apache/iceberg/SupportsLabels.java create mode 100644 core/src/test/java/org/apache/iceberg/TestBaseTableLabels.java diff --git a/core/src/main/java/org/apache/iceberg/BaseTable.java b/core/src/main/java/org/apache/iceberg/BaseTable.java index 4d73c96fec28..858d0bf8b7ca 100644 --- a/core/src/main/java/org/apache/iceberg/BaseTable.java +++ b/core/src/main/java/org/apache/iceberg/BaseTable.java @@ -29,6 +29,7 @@ import org.apache.iceberg.metrics.MetricsReporter; import org.apache.iceberg.metrics.MetricsReporters; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.rest.labels.Labels; /** * Base {@link Table} implementation. @@ -40,20 +41,34 @@ * when reading the table data after deserialization. */ public class BaseTable - implements Table, HasTableOperations, Serializable, SupportsDistributedScanPlanning { + implements Table, + HasTableOperations, + Serializable, + SupportsDistributedScanPlanning, + SupportsLabels { private final TableOperations ops; private final String name; private MetricsReporter reporter; + // Ephemeral catalog enrichment, not table state. Transient because it is not preserved across + // serialization: writeReplace() swaps in a SerializableTable for Java serialization, and any + // path that bypasses writeReplace (e.g. Kryo) leaves this null, handled by labels(). + private final transient Labels labels; public BaseTable(TableOperations ops, String name) { this(ops, name, LoggingMetricsReporter.instance()); } public BaseTable(TableOperations ops, String name, MetricsReporter reporter) { + this(ops, name, reporter, Labels.empty()); + } + + public BaseTable(TableOperations ops, String name, MetricsReporter reporter, Labels labels) { Preconditions.checkNotNull(reporter, "reporter cannot be null"); + Preconditions.checkNotNull(labels, "labels cannot be null"); this.ops = ops; this.name = name; this.reporter = reporter; + this.labels = labels; } public MetricsReporter reporter() { @@ -74,6 +89,12 @@ public String name() { return name; } + @Override + public Labels labels() { + // labels is null only when this instance was deserialized bypassing writeReplace (e.g. Kryo). + return labels != null ? labels : Labels.empty(); + } + @Override public void refresh() { ops.refresh(); diff --git a/core/src/main/java/org/apache/iceberg/SupportsLabels.java b/core/src/main/java/org/apache/iceberg/SupportsLabels.java new file mode 100644 index 000000000000..b9c534480155 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/SupportsLabels.java @@ -0,0 +1,34 @@ +/* + * 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 org.apache.iceberg; + +import org.apache.iceberg.rest.labels.Labels; + +/** + * Implemented by tables that can expose catalog-provided labels obtained from the load response. + * + *

Labels are optional, catalog-provided metadata enrichment. They are not part of table state + * and are not preserved when the table is serialized. + */ +public interface SupportsLabels { + /** + * Returns the catalog-provided labels for this table, or an empty instance when there are none. + */ + Labels labels(); +} diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java index 1d8e9da6d43c..5fbb1da40baa 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java @@ -79,6 +79,7 @@ import org.apache.iceberg.rest.auth.AuthManagers; import org.apache.iceberg.rest.auth.AuthSession; import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.labels.Labels; import org.apache.iceberg.rest.requests.CommitTransactionRequest; import org.apache.iceberg.rest.requests.CreateNamespaceRequest; import org.apache.iceberg.rest.requests.CreateTableRequest; @@ -557,10 +558,11 @@ public Table loadTable(SessionContext context, TableIdentifier identifier) { } List credentials = response.credentials(); + Labels labels = response.labels(); RESTClient tableClient = client.withAuthSession(tableSession); Supplier tableSupplier = createTableSupplier( - finalIdentifier, tableMetadata, context, tableClient, tableConf, credentials); + finalIdentifier, tableMetadata, context, tableClient, tableConf, credentials, labels); String eTag = responseHeaders.getOrDefault(HttpHeaders.ETAG, null); if (eTag != null) { @@ -580,7 +582,8 @@ private Supplier createTableSupplier( SessionContext context, RESTClient tableClient, Map tableConf, - List credentials) { + List credentials, + Labels labels) { return () -> { RESTTableOperations ops = newTableOps( @@ -594,13 +597,16 @@ private Supplier createTableSupplier( trackFileIO(ops); - RESTTable table = restTableForScanPlanning(ops, identifier, tableClient, tableConf); + RESTTable table = restTableForScanPlanning(ops, identifier, tableClient, tableConf, labels); if (table != null) { return table; } return new BaseTable( - ops, fullTableName(identifier), metricsReporter(paths.metrics(identifier), tableClient)); + ops, + fullTableName(identifier), + metricsReporter(paths.metrics(identifier), tableClient), + labels); }; } @@ -608,7 +614,8 @@ private RESTTable restTableForScanPlanning( TableOperations ops, TableIdentifier finalIdentifier, RESTClient restClient, - Map tableConf) { + Map tableConf, + Labels labels) { String planningModeServerConfig = tableConf.get(RESTCatalogProperties.SCAN_PLANNING_MODE); ScanPlanningMode serverScanPlanningMode = planningModeServerConfig == null @@ -653,7 +660,8 @@ private RESTTable restTableForScanPlanning( paths, endpoints, properties(), - conf); + conf, + labels); } // Default to client-side planning @@ -740,13 +748,17 @@ public Table registerTable( trackFileIO(ops); - RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient, tableConf); + RESTTable restTable = + restTableForScanPlanning(ops, ident, tableClient, tableConf, response.labels()); if (restTable != null) { return restTable; } return new BaseTable( - ops, fullTableName(ident), metricsReporter(paths.metrics(ident), tableClient)); + ops, + fullTableName(ident), + metricsReporter(paths.metrics(ident), tableClient), + response.labels()); } @Override @@ -1009,13 +1021,17 @@ public Table create() { trackFileIO(ops); - RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient, tableConf); + RESTTable restTable = + restTableForScanPlanning(ops, ident, tableClient, tableConf, response.labels()); if (restTable != null) { return restTable; } return new BaseTable( - ops, fullTableName(ident), metricsReporter(paths.metrics(ident), tableClient)); + ops, + fullTableName(ident), + metricsReporter(paths.metrics(ident), tableClient), + response.labels()); } @Override diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTTable.java b/core/src/main/java/org/apache/iceberg/rest/RESTTable.java index 21d84d847781..e8501aff0b0a 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTTable.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTTable.java @@ -30,6 +30,7 @@ import org.apache.iceberg.TableScan; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.metrics.MetricsReporter; +import org.apache.iceberg.rest.labels.Labels; class RESTTable extends BaseTable implements SupportsDistributedScanPlanning { private final RESTClient client; @@ -51,8 +52,9 @@ class RESTTable extends BaseTable implements SupportsDistributedScanPlanning { ResourcePaths resourcePaths, Set supportedEndpoints, Map catalogProperties, - Object hadoopConf) { - super(ops, name, reporter); + Object hadoopConf, + Labels labels) { + super(ops, name, reporter, labels); this.reporter = reporter; this.client = client; this.headers = headers; diff --git a/core/src/test/java/org/apache/iceberg/TestBaseTableLabels.java b/core/src/test/java/org/apache/iceberg/TestBaseTableLabels.java new file mode 100644 index 000000000000..83e32d2fdbeb --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestBaseTableLabels.java @@ -0,0 +1,71 @@ +/* + * 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 org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import org.apache.iceberg.metrics.LoggingMetricsReporter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.labels.ImmutableFieldLabels; +import org.apache.iceberg.rest.labels.ImmutableLabels; +import org.apache.iceberg.rest.labels.Labels; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TestBaseTableLabels { + + private static final String TABLE_NAME = "tbl"; + + @TempDir private File temp; + + // Only the labels accessor is exercised here, not table operations, so no metadata is set up. + @Test + public void labelsDefaultToEmptyWhenNotProvided() { + BaseTable table = + new BaseTable(new TestTables.TestTableOperations(TABLE_NAME, temp), TABLE_NAME); + + assertThat(table).isInstanceOf(SupportsLabels.class); + assertThat(table.labels().isEmpty()).isTrue(); + } + + @Test + public void labelsAreExposedWhenProvided() { + Labels labels = + ImmutableLabels.builder() + .object(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabels.builder() + .fieldId(1) + .labels(ImmutableMap.of("classification", "pii")) + .build()) + .build(); + + BaseTable table = + new BaseTable( + new TestTables.TestTableOperations(TABLE_NAME, temp), + TABLE_NAME, + LoggingMetricsReporter.instance(), + labels); + + assertThat(table.labels()).isEqualTo(labels); + assertThat(table.labels().object()).containsEntry("owner", "team-a"); + assertThat(table.labels().fields()).hasSize(1); + } +} From 9255aa3264a79bcf88d16c8797fc5cc560600995 Mon Sep 17 00:00:00 2001 From: laskoviymishka Date: Wed, 29 Jul 2026 09:39:17 +0200 Subject: [PATCH 3/3] Spark 4.1: expose catalog labels via metadata table and DESCRIBE Builds on the SupportsLabels table exposure to make catalog-provided labels reachable from Spark. - New LabelsTable metadata table (core): MetadataTableType.LABELS plus a BaseMetadataTable that flattens a table's labels into rows {scope, field_id, key, value}. object-level labels use scope "object" with a null field_id; field-level labels use scope "field". Registered in MetadataTableUtils, so it is queryable as `SELECT * FROM tbl.labels` (Spark routes metadata tables generically, no Spark-side change needed). - Spark BaseSparkTable.properties() surfaces labels under labels.object.* and labels.field..* so they appear in DESCRIBE EXTENDED. Driver-side only; labels are ephemeral catalog enrichment and are not distributed to executors. --- .../java/org/apache/iceberg/LabelsTable.java | 120 ++++++++++++++++++ .../org/apache/iceberg/MetadataTableType.java | 3 +- .../apache/iceberg/MetadataTableUtils.java | 2 + .../org/apache/iceberg/TestLabelsTable.java | 81 ++++++++++++ .../iceberg/spark/source/BaseSparkTable.java | 20 +++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/apache/iceberg/LabelsTable.java create mode 100644 core/src/test/java/org/apache/iceberg/TestLabelsTable.java diff --git a/core/src/main/java/org/apache/iceberg/LabelsTable.java b/core/src/main/java/org/apache/iceberg/LabelsTable.java new file mode 100644 index 000000000000..dbf78a92af69 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/LabelsTable.java @@ -0,0 +1,120 @@ +/* + * 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 org.apache.iceberg; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.rest.labels.FieldLabels; +import org.apache.iceberg.rest.labels.Labels; +import org.apache.iceberg.types.Types; + +/** + * A {@link Table} implementation that exposes the catalog-provided labels of its base table as + * rows. + * + *

Each label key-value pair is a row. Object-level labels have {@code scope = "object"} and a + * null {@code field_id}; field-level labels have {@code scope = "field"} and the field id they are + * attached to. Labels are catalog-provided enrichment obtained at load time; a re-scan re-reads the + * base table's labels and may observe different values. + */ +public class LabelsTable extends BaseMetadataTable { + private static final String OBJECT_SCOPE = "object"; + private static final String FIELD_SCOPE = "field"; + + private static final Schema LABELS_SCHEMA = + new Schema( + Types.NestedField.required(1, "scope", Types.StringType.get()), + Types.NestedField.optional(2, "field_id", Types.IntegerType.get()), + Types.NestedField.required(3, "key", Types.StringType.get()), + Types.NestedField.required(4, "value", Types.StringType.get())); + + LabelsTable(Table table) { + this(table, table.name() + ".labels"); + } + + LabelsTable(Table table, String name) { + super(table, name); + } + + @Override + MetadataTableType metadataTableType() { + return MetadataTableType.LABELS; + } + + @Override + public TableScan newScan() { + return new LabelsScan(table()); + } + + @Override + public Schema schema() { + return LABELS_SCHEMA; + } + + private Labels labels() { + return table() instanceof SupportsLabels + ? ((SupportsLabels) table()).labels() + : Labels.empty(); + } + + private DataTask task(TableScan scan) { + Labels labels = labels(); + List rows = Lists.newArrayList(); + for (Map.Entry entry : labels.object().entrySet()) { + rows.add(StaticDataTask.Row.of(OBJECT_SCOPE, null, entry.getKey(), entry.getValue())); + } + + for (FieldLabels fieldLabels : labels.fields()) { + for (Map.Entry entry : fieldLabels.labels().entrySet()) { + rows.add( + StaticDataTask.Row.of( + FIELD_SCOPE, fieldLabels.fieldId(), entry.getKey(), entry.getValue())); + } + } + + return StaticDataTask.of( + table().io().newInputFile(table().operations().current().metadataFileLocation()), + schema(), + scan.schema(), + rows, + row -> row); + } + + private class LabelsScan extends StaticTableScan { + LabelsScan(Table table) { + super(table, LABELS_SCHEMA, MetadataTableType.LABELS, LabelsTable.this::task); + } + + LabelsScan(Table table, TableScanContext context) { + super(table, LABELS_SCHEMA, MetadataTableType.LABELS, LabelsTable.this::task, context); + } + + @Override + protected TableScan newRefinedScan(Table table, Schema schema, TableScanContext context) { + return new LabelsScan(table, context); + } + + @Override + public CloseableIterable planFiles() { + return CloseableIterable.withNoopClose(LabelsTable.this.task(this)); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/MetadataTableType.java b/core/src/main/java/org/apache/iceberg/MetadataTableType.java index 733a11d900f1..c5355df9adc4 100644 --- a/core/src/main/java/org/apache/iceberg/MetadataTableType.java +++ b/core/src/main/java/org/apache/iceberg/MetadataTableType.java @@ -36,7 +36,8 @@ public enum MetadataTableType { ALL_FILES, ALL_MANIFESTS, ALL_ENTRIES, - POSITION_DELETES; + POSITION_DELETES, + LABELS; public static MetadataTableType from(String name) { try { diff --git a/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java b/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java index adb0f18ba1ad..aa63a5f9794a 100644 --- a/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java +++ b/core/src/main/java/org/apache/iceberg/MetadataTableUtils.java @@ -88,6 +88,8 @@ private static Table createMetadataTableInstance( return new AllEntriesTable(baseTable, metadataTableName); case POSITION_DELETES: return new PositionDeletesTable(baseTable, metadataTableName); + case LABELS: + return new LabelsTable(baseTable, metadataTableName); default: throw new NoSuchTableException( "Unknown metadata table type: %s for %s", type, metadataTableName); diff --git a/core/src/test/java/org/apache/iceberg/TestLabelsTable.java b/core/src/test/java/org/apache/iceberg/TestLabelsTable.java new file mode 100644 index 000000000000..f7ba21820fc4 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestLabelsTable.java @@ -0,0 +1,81 @@ +/* + * 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 org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.util.List; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestLabelsTable { + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + @TempDir private File tableDir; + private Table table; + + @BeforeEach + public void createTable() { + this.table = + TABLES.create( + SCHEMA, PartitionSpec.unpartitioned(), Maps.newHashMap(), tableDir.toURI().toString()); + } + + @Test + public void labelsTableResolvesThroughMetadataTableUtils() { + Table labelsTable = + MetadataTableUtils.createMetadataTableInstance(table, MetadataTableType.LABELS); + + assertThat(labelsTable).isInstanceOf(LabelsTable.class); + assertThat(labelsTable.schema().findField("scope")).isNotNull(); + assertThat(labelsTable.schema().findField("field_id")).isNotNull(); + assertThat(labelsTable.schema().findField("key")).isNotNull(); + assertThat(labelsTable.schema().findField("value")).isNotNull(); + } + + @Test + public void scanYieldsNoRowsWhenNoLabels() throws Exception { + Table labelsTable = new LabelsTable(table); + + List rows = ImmutableList.of(); + try (CloseableIterable tasks = labelsTable.newScan().planFiles()) { + for (FileScanTask task : tasks) { + try (CloseableIterable taskRows = task.asDataTask().rows()) { + rows = ImmutableList.copyOf(taskRows); + } + } + } + + // a HadoopTables table carries no catalog-provided labels + assertThat(rows).isEmpty(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkTable.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkTable.java index 30afa9b7b172..2648f8a759c0 100644 --- a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkTable.java +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkTable.java @@ -29,12 +29,15 @@ import org.apache.iceberg.BaseTable; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SupportsLabels; import org.apache.iceberg.Table; import org.apache.iceberg.TableOperations; import org.apache.iceberg.TableUtil; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.rest.labels.FieldLabels; +import org.apache.iceberg.rest.labels.Labels; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.spark.sql.SparkSession; @@ -53,6 +56,8 @@ abstract class BaseSparkTable private static final String LOCATION = "location"; private static final String SORT_ORDER = "sort-order"; private static final String IDENTIFIER_FIELDS = "identifier-fields"; + private static final String LABELS_OBJECT_PREFIX = "labels.object."; + private static final String LABELS_FIELD_PREFIX = "labels.field."; private static final Set RESERVED_PROPERTIES = ImmutableSet.of( PROVIDER, @@ -136,6 +141,21 @@ public Map properties() { .filter(entry -> !RESERVED_PROPERTIES.contains(entry.getKey())) .forEach(propsBuilder::put); + // Surface catalog-provided labels (driver-side only; not part of table state) so they are + // visible in DESCRIBE EXTENDED. The tbl.labels metadata table is the queryable counterpart. + if (table instanceof SupportsLabels) { + Labels labels = ((SupportsLabels) table).labels(); + labels.object().forEach((key, value) -> propsBuilder.put(LABELS_OBJECT_PREFIX + key, value)); + for (FieldLabels fieldLabels : labels.fields()) { + fieldLabels + .labels() + .forEach( + (key, value) -> + propsBuilder.put( + LABELS_FIELD_PREFIX + fieldLabels.fieldId() + "." + key, value)); + } + } + return propsBuilder.build(); }