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