diff --git a/core/src/main/java/org/apache/iceberg/FieldLabel.java b/core/src/main/java/org/apache/iceberg/FieldLabel.java new file mode 100644 index 000000000000..b75c9b99453f --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/FieldLabel.java @@ -0,0 +1,30 @@ +/* + * 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.Map; +import org.immutables.value.Value; + +/** Labels attached to a single field, identified by its field id. See {@link Labels}. */ +@Value.Immutable +public interface FieldLabel { + int fieldId(); + + Map labels(); +} diff --git a/core/src/main/java/org/apache/iceberg/FieldLabelParser.java b/core/src/main/java/org/apache/iceberg/FieldLabelParser.java new file mode 100644 index 000000000000..e15c4b00931a --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/FieldLabelParser.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; + +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 FieldLabelParser { + private static final String FIELD_ID = "field-id"; + private static final String LABELS = "labels"; + + private FieldLabelParser() {} + + public static String toJson(FieldLabel fieldLabel) { + return toJson(fieldLabel, false); + } + + public static String toJson(FieldLabel fieldLabel, boolean pretty) { + return JsonUtil.generate(gen -> toJson(fieldLabel, gen), pretty); + } + + public static void toJson(FieldLabel fieldLabel, JsonGenerator gen) throws IOException { + Preconditions.checkArgument(null != fieldLabel, "Invalid field labels: null"); + + gen.writeStartObject(); + + gen.writeNumberField(FIELD_ID, fieldLabel.fieldId()); + JsonUtil.writeStringMap(LABELS, fieldLabel.labels(), gen); + + gen.writeEndObject(); + } + + public static FieldLabel fromJson(String json) { + return JsonUtil.parse(json, FieldLabelParser::fromJson); + } + + public static FieldLabel fromJson(JsonNode json) { + Preconditions.checkArgument(null != json, "Cannot parse field labels from null object"); + + return ImmutableFieldLabel.builder() + .fieldId(JsonUtil.getInt(FIELD_ID, json)) + .labels(JsonUtil.getStringMap(LABELS, json)) + .build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/Labels.java b/core/src/main/java/org/apache/iceberg/Labels.java new file mode 100644 index 000000000000..20f41f4ea069 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/Labels.java @@ -0,0 +1,41 @@ +/* + * 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.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 objectLabels(); + + /** Field-level labels */ + List fields(); + + /** Returns true when there are neither object-level nor field-level labels. */ + @Value.Derived + default boolean isEmpty() { + return objectLabels().isEmpty() && fields().isEmpty(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/LabelsParser.java b/core/src/main/java/org/apache/iceberg/LabelsParser.java new file mode 100644 index 000000000000..32ce968e135a --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/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; + +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_LABELS = "object-labels"; + 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.objectLabels().isEmpty()) { + JsonUtil.writeStringMap(OBJECT_LABELS, labels.objectLabels(), gen); + } + + if (!labels.fields().isEmpty()) { + gen.writeArrayFieldStart(FIELDS); + for (FieldLabel fieldLabel : labels.fields()) { + FieldLabelParser.toJson(fieldLabel, 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_LABELS)) { + builder.objectLabels(JsonUtil.getStringMap(OBJECT_LABELS, json)); + } + + if (json.hasNonNull(FIELDS)) { + builder.fields(JsonUtil.getObjectList(FIELDS, json, FieldLabelParser::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 a60310236dce..24b3ad65fc86 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 @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; +import org.apache.iceberg.Labels; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -47,6 +48,7 @@ public class LoadTableResponse implements RESTResponse { private TableMetadata metadataWithLocation; private List credentials; private RemoteSigningConfig remoteSigningConfig; + private Labels labels; public LoadTableResponse() { // Required for Jackson deserialization @@ -57,12 +59,14 @@ private LoadTableResponse( TableMetadata metadata, Map config, List credentials, - RemoteSigningConfig remoteSigningConfig) { + RemoteSigningConfig remoteSigningConfig, + Labels labels) { this.metadataLocation = metadataLocation; this.metadata = metadata; this.config = config; this.credentials = credentials; this.remoteSigningConfig = remoteSigningConfig; + this.labels = labels; } @Override @@ -95,12 +99,17 @@ public RemoteSigningConfig remoteSigningConfig() { return remoteSigningConfig != null ? remoteSigningConfig : RemoteSigningConfig.EMPTY; } + 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(); } @@ -114,6 +123,7 @@ public static class Builder { private final Map config = Maps.newHashMap(); private final List credentials = Lists.newArrayList(); private RemoteSigningConfig remoteSigningConfig = RemoteSigningConfig.EMPTY; + private Labels labels = Labels.EMPTY; private Builder() {} @@ -148,10 +158,15 @@ public Builder withRemoteSigningConfig(RemoteSigningConfig signingConfig) { 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, remoteSigningConfig); + metadataLocation, metadata, config, credentials, remoteSigningConfig, 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 d53d879beeab..404928bc2a46 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 @@ -21,6 +21,7 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; +import org.apache.iceberg.LabelsParser; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableMetadataParser; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -36,6 +37,7 @@ public class LoadTableResponseParser { private static final String CONFIG = "config"; private static final String STORAGE_CREDENTIALS = "storage-credentials"; private static final String REMOTE_SIGNING_CONFIG = "remote-signing-config"; + private static final String LABELS = "labels"; private LoadTableResponseParser() {} @@ -77,6 +79,11 @@ public static void toJson(LoadTableResponse response, JsonGenerator gen) throws RemoteSigningConfigParser.toJson(response.remoteSigningConfig(), gen); } + if (!response.labels().isEmpty()) { + gen.writeFieldName(LABELS); + LabelsParser.toJson(response.labels(), gen); + } + gen.writeEndObject(); } @@ -113,6 +120,10 @@ public static LoadTableResponse fromJson(JsonNode json) { RemoteSigningConfigParser.fromJson(JsonUtil.get(REMOTE_SIGNING_CONFIG, json))); } + 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..5d47fbbff9ad 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 @@ -19,6 +19,7 @@ package org.apache.iceberg.rest.responses; import java.util.Map; +import org.apache.iceberg.Labels; import org.apache.iceberg.rest.RESTResponse; import org.apache.iceberg.view.ViewMetadata; import org.immutables.value.Value; @@ -31,6 +32,11 @@ public interface LoadViewResponse extends RESTResponse { Map config(); + @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 7964a8455a52..fabcfc34351d 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 @@ -21,6 +21,7 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; +import org.apache.iceberg.LabelsParser; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.JsonUtil; import org.apache.iceberg.view.ViewMetadata; @@ -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/TestFieldLabelParser.java b/core/src/test/java/org/apache/iceberg/TestFieldLabelParser.java new file mode 100644 index 000000000000..72c1a49a0342 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestFieldLabelParser.java @@ -0,0 +1,85 @@ +/* + * 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 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; + +class TestFieldLabelParser { + + @Test + void nullCheck() { + assertThatThrownBy(() -> FieldLabelParser.toJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid field labels: null"); + + assertThatThrownBy(() -> FieldLabelParser.fromJson((JsonNode) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse field labels from null object"); + } + + @Test + void roundTrip() { + FieldLabel fieldLabel = + ImmutableFieldLabel.builder().fieldId(3).labels(ImmutableMap.of("pii", "true")).build(); + + String expectedJson = + """ + { + "field-id" : 3, + "labels" : { + "pii" : "true" + } + }"""; + + assertThat(FieldLabelParser.toJson(fieldLabel, true)).isEqualTo(expectedJson); + assertThat(FieldLabelParser.fromJson(expectedJson)).isEqualTo(fieldLabel); + } + + @Test + void emptyLabels() { + assertThat(ImmutableFieldLabel.builder().fieldId(1).build().labels()).isEmpty(); + } + + @Test + void emptyLabelsFromJson() { + FieldLabel fieldLabel = FieldLabelParser.fromJson("{\"field-id\": 1, \"labels\": {}}"); + + assertThat(fieldLabel.fieldId()).isEqualTo(1); + assertThat(fieldLabel.labels()).isEmpty(); + } + + @Test + void missingLabelsFromJson() { + assertThatThrownBy(() -> FieldLabelParser.fromJson("{\"field-id\": 1}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing map: labels"); + } + + @Test + void emptyJson() { + assertThatThrownBy(() -> FieldLabelParser.fromJson("{}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing int: field-id"); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestLabelsParser.java b/core/src/test/java/org/apache/iceberg/TestLabelsParser.java new file mode 100644 index 000000000000..bb331fcf444b --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/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; + +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; + +class TestLabelsParser { + + @Test + 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 + 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.objectLabels()).isEmpty(); + assertThat(roundTrip.fields()).isEmpty(); + + Labels nonEmpty = ImmutableLabels.builder().objectLabels(ImmutableMap.of("k", "v")).build(); + assertThat(nonEmpty.isEmpty()).isFalse(); + } + + @Test + void objectLabelsOnly() { + Labels labels = + ImmutableLabels.builder() + .objectLabels(ImmutableMap.of("owner", "team-a", "sensitivity", "high")) + .build(); + + String expectedJson = + """ + { + "object-labels" : { + "owner" : "team-a", + "sensitivity" : "high" + } + }"""; + + assertThat(LabelsParser.toJson(labels, true)).isEqualTo(expectedJson); + assertThat(LabelsParser.fromJson(expectedJson)).isEqualTo(labels); + } + + @Test + void fieldLabelsOnly() { + Labels labels = + ImmutableLabels.builder() + .addFields( + ImmutableFieldLabel.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 + void objectAndFieldLabel() { + Labels labels = + ImmutableLabels.builder() + .objectLabels(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabel.builder() + .fieldId(1) + .labels(ImmutableMap.of("classification", "pii")) + .build()) + .addFields( + ImmutableFieldLabel.builder() + .fieldId(2) + .labels(ImmutableMap.of("classification", "public")) + .build()) + .build(); + + String expectedJson = + """ + { + "object-labels" : { + "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..14f740dc305d 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 @@ -26,6 +26,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Map; +import org.apache.iceberg.ImmutableFieldLabel; +import org.apache.iceberg.ImmutableLabels; +import org.apache.iceberg.Labels; import org.apache.iceberg.NullOrder; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; @@ -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() + .objectLabels(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabel.builder() + .fieldId(1) + .labels(ImmutableMap.of("classification", "pii")) + .build()) + .build(); + String json = + String.format( + """ + { + "metadata-location":"%s", + "metadata":%s, + "config":{"foo":"bar"}, + "labels":{ + "object-labels":{"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 3c03f24cf7de..7d6f028b79ea 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 @@ -25,6 +25,8 @@ import com.fasterxml.jackson.databind.JsonNode; import java.util.List; import java.util.Map; +import org.apache.iceberg.ImmutableFieldLabel; +import org.apache.iceberg.ImmutableLabels; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; @@ -557,4 +559,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() + .objectLabels(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabel.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-labels" : { + "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 b4ae60b17424..bd398822900f 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 @@ -22,6 +22,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.JsonNode; +import org.apache.iceberg.ImmutableFieldLabel; +import org.apache.iceberg.ImmutableLabels; import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; @@ -279,4 +281,118 @@ public void nullConfig() { assertThat(parsed.config()).isEmpty(); assertThat(LoadViewResponseParser.toJson(parsed, true)).isEqualTo(jsonWithoutConfig); } + + @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() + .objectLabels(ImmutableMap.of("owner", "team-a")) + .addFields( + ImmutableFieldLabel.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-labels" : { + "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(); + } }