diff --git a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java b/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java index ca470eec7171..ce8f122832da 100644 --- a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java +++ b/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java @@ -106,6 +106,18 @@ default boolean viewExists(TableIdentifier identifier) { */ default void invalidateView(TableIdentifier identifier) {} + /** + * Register a view with the catalog if it does not exist. + * + * @param identifier a view identifier + * @param metadataFileLocation the location of a metadata file + * @return a View instance + * @throws AlreadyExistsException if the view already exists in the catalog. + */ + default View registerView(TableIdentifier identifier, String metadataFileLocation) { + throw new UnsupportedOperationException("Registering views is not supported"); + } + /** * Initialize a view catalog given a custom name and a map of catalog properties. * diff --git a/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java index 106e20d3bce1..6c2c83b2f1f6 100644 --- a/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java +++ b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java @@ -106,6 +106,20 @@ default boolean viewExists(SessionCatalog.SessionContext context, TableIdentifie */ default void invalidateView(SessionCatalog.SessionContext context, TableIdentifier identifier) {} + /** + * Register a view if it does not exist. + * + * @param context session context + * @param ident a view identifier + * @param metadataFileLocation the location of a metadata file + * @return a View instance + * @throws AlreadyExistsException if the view already exists in the catalog. + */ + default View registerView( + SessionCatalog.SessionContext context, TableIdentifier ident, String metadataFileLocation) { + throw new UnsupportedOperationException("Registering views is not supported"); + } + /** * Initialize a view catalog given a custom name and a map of catalog properties. * diff --git a/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java index 10895e1de9e6..ce76481d159e 100644 --- a/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java +++ b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java @@ -83,6 +83,11 @@ public void invalidateView(TableIdentifier identifier) { BaseViewSessionCatalog.this.invalidateView(context, identifier); } + @Override + public View registerView(TableIdentifier identifier, String metadataFileLocation) { + return BaseViewSessionCatalog.this.registerView(context, identifier, metadataFileLocation); + } + @Override public void initialize(String name, Map properties) { throw new UnsupportedOperationException( diff --git a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java index 229497576af9..828fa9248998 100644 --- a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java +++ b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java @@ -77,6 +77,7 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; import org.apache.iceberg.rest.requests.UpdateTableRequest; @@ -595,6 +596,18 @@ public static void dropView(ViewCatalog catalog, TableIdentifier viewIdentifier) } } + public static LoadViewResponse registerView( + ViewCatalog catalog, Namespace namespace, RegisterViewRequest request) { + request.validate(); + + TableIdentifier identifier = TableIdentifier.of(namespace, request.name()); + View view = catalog.registerView(identifier, request.metadataLocation()); + return ImmutableLoadViewResponse.builder() + .metadata(asBaseView(view).operations().current()) + .metadataLocation(request.metadataLocation()) + .build(); + } + static ViewMetadata commit(ViewOperations ops, UpdateTableRequest request) { AtomicBoolean isRetry = new AtomicBoolean(false); try { diff --git a/core/src/main/java/org/apache/iceberg/rest/Endpoint.java b/core/src/main/java/org/apache/iceberg/rest/Endpoint.java index b4b617b8ec5e..c2369a0fa57d 100644 --- a/core/src/main/java/org/apache/iceberg/rest/Endpoint.java +++ b/core/src/main/java/org/apache/iceberg/rest/Endpoint.java @@ -86,6 +86,8 @@ public class Endpoint { public static final Endpoint V1_DELETE_VIEW = Endpoint.create("DELETE", ResourcePaths.V1_VIEW); public static final Endpoint V1_RENAME_VIEW = Endpoint.create("POST", ResourcePaths.V1_VIEW_RENAME); + public static final Endpoint V1_REGISTER_VIEW = + Endpoint.create("POST", ResourcePaths.V1_VIEW_REGISTER); private static final Splitter ENDPOINT_SPLITTER = Splitter.on(" "); private static final Joiner ENDPOINT_JOINER = Joiner.on(" "); diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java b/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java index aff8832c6bf4..029d3a9890b0 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java @@ -325,4 +325,9 @@ public boolean viewExists(TableIdentifier identifier) { public void invalidateView(TableIdentifier identifier) { viewSessionCatalog.invalidateView(identifier); } + + @Override + public View registerView(TableIdentifier identifier, String metadataFileLocation) { + return viewSessionCatalog.registerView(identifier, metadataFileLocation); + } } diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java index 3e0e1750115f..8bec9fa8836c 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java @@ -52,11 +52,14 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequestParser; import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest; import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest; +import org.apache.iceberg.rest.requests.ImmutableRegisterViewRequest; import org.apache.iceberg.rest.requests.ImmutableReportMetricsRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequestParser; import org.apache.iceberg.rest.requests.RegisterTableRequest; import org.apache.iceberg.rest.requests.RegisterTableRequestParser; +import org.apache.iceberg.rest.requests.RegisterViewRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequestParser; import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.ReportMetricsRequestParser; import org.apache.iceberg.rest.requests.UpdateTableRequest; @@ -131,6 +134,11 @@ public static void registerAll(ObjectMapper mapper) { .addSerializer(ImmutableLoadViewResponse.class, new LoadViewResponseSerializer<>()) .addDeserializer(LoadViewResponse.class, new LoadViewResponseDeserializer<>()) .addDeserializer(ImmutableLoadViewResponse.class, new LoadViewResponseDeserializer<>()) + .addSerializer(RegisterViewRequest.class, new RegisterViewRequestSerializer<>()) + .addDeserializer(RegisterViewRequest.class, new RegisterViewRequestDeserializer<>()) + .addSerializer(ImmutableRegisterViewRequest.class, new RegisterViewRequestSerializer<>()) + .addDeserializer( + ImmutableRegisterViewRequest.class, new RegisterViewRequestDeserializer<>()) .addSerializer(ConfigResponse.class, new ConfigResponseSerializer<>()) .addDeserializer(ConfigResponse.class, new ConfigResponseDeserializer<>()) .addSerializer(LoadTableResponse.class, new LoadTableResponseSerializer<>()) @@ -444,6 +452,24 @@ public T deserialize(JsonParser p, DeserializationContext context) throws IOExce } } + public static class RegisterViewRequestSerializer + extends JsonSerializer { + @Override + public void serialize(T request, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + RegisterViewRequestParser.toJson(request, gen); + } + } + + public static class RegisterViewRequestDeserializer + extends JsonDeserializer { + @Override + public T deserialize(JsonParser p, DeserializationContext context) throws IOException { + JsonNode jsonNode = p.getCodec().readTree(p); + return (T) RegisterViewRequestParser.fromJson(jsonNode); + } + } + static class ConfigResponseSerializer extends JsonSerializer { @Override public void serialize(T request, JsonGenerator gen, SerializerProvider serializers) 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 61e25d3d4fc6..c0cd87d2c282 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java @@ -77,7 +77,9 @@ import org.apache.iceberg.rest.requests.CreateViewRequest; import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest; import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest; +import org.apache.iceberg.rest.requests.ImmutableRegisterViewRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; import org.apache.iceberg.rest.requests.UpdateTableRequest; @@ -1366,6 +1368,48 @@ public void renameView(SessionContext context, TableIdentifier from, TableIdenti .post(paths.renameView(), request, null, mutationHeaders, ErrorHandlers.viewErrorHandler()); } + @Override + public View registerView( + SessionContext context, TableIdentifier ident, String metadataFileLocation) { + Endpoint.check(endpoints, Endpoint.V1_REGISTER_VIEW); + checkViewIdentifierIsValid(ident); + + Preconditions.checkArgument( + metadataFileLocation != null && !metadataFileLocation.isEmpty(), + "Invalid metadata file location: %s", + metadataFileLocation); + + RegisterViewRequest request = + ImmutableRegisterViewRequest.builder() + .name(ident.name()) + .metadataLocation(metadataFileLocation) + .build(); + + AuthSession contextualSession = authManager.contextualSession(context, catalogAuth); + LoadViewResponse response = + client + .withAuthSession(contextualSession) + .post( + paths.registerView(ident.namespace()), + request, + LoadViewResponse.class, + mutationHeaders, + ErrorHandlers.viewErrorHandler()); + + AuthSession tableSession = + authManager.tableSession(ident, response.config(), contextualSession); + RESTViewOperations ops = + newViewOps( + client.withAuthSession(tableSession), + paths.view(ident), + Map::of, + mutationHeaders, + response.metadata(), + endpoints); + + return new BaseView(ops, ViewUtil.fullViewName(name(), ident)); + } + private class RESTViewBuilder implements ViewBuilder { private final SessionContext context; private final TableIdentifier identifier; diff --git a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java index 231a966f8062..0fc55c1a44d8 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java +++ b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java @@ -49,6 +49,7 @@ public class ResourcePaths { public static final String V1_VIEWS = "/v1/{prefix}/namespaces/{namespace}/views"; public static final String V1_VIEW = "/v1/{prefix}/namespaces/{namespace}/views/{view}"; public static final String V1_VIEW_RENAME = "/v1/{prefix}/views/rename"; + public static final String V1_VIEW_REGISTER = "/v1/{prefix}/namespaces/{namespace}/register-view"; public static ResourcePaths forCatalogProperties(Map properties) { return new ResourcePaths( @@ -151,6 +152,10 @@ public String renameView() { return SLASH.join("v1", prefix, "views", "rename"); } + public String registerView(Namespace ns) { + return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "register-view"); + } + public String planTableScan(TableIdentifier ident) { return SLASH.join( "v1", diff --git a/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequest.java b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequest.java new file mode 100644 index 000000000000..4b382003d7d6 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequest.java @@ -0,0 +1,35 @@ +/* + * 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.requests; + +import org.apache.iceberg.rest.RESTRequest; +import org.immutables.value.Value; + +@Value.Immutable +public interface RegisterViewRequest extends RESTRequest { + + String name(); + + String metadataLocation(); + + @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/requests/RegisterViewRequestParser.java b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequestParser.java new file mode 100644 index 000000000000..7061245bf949 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequestParser.java @@ -0,0 +1,69 @@ +/* + * 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.requests; + +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 RegisterViewRequestParser { + + private static final String NAME = "name"; + private static final String METADATA_LOCATION = "metadata-location"; + + private RegisterViewRequestParser() {} + + public static String toJson(RegisterViewRequest request) { + return toJson(request, false); + } + + public static String toJson(RegisterViewRequest request, boolean pretty) { + return JsonUtil.generate(gen -> toJson(request, gen), pretty); + } + + public static void toJson(RegisterViewRequest request, JsonGenerator gen) throws IOException { + Preconditions.checkArgument(null != request, "Invalid register view request: null"); + + gen.writeStartObject(); + + gen.writeStringField(NAME, request.name()); + gen.writeStringField(METADATA_LOCATION, request.metadataLocation()); + + gen.writeEndObject(); + } + + public static RegisterViewRequest fromJson(String json) { + return JsonUtil.parse(json, RegisterViewRequestParser::fromJson); + } + + public static RegisterViewRequest fromJson(JsonNode json) { + Preconditions.checkArgument( + null != json, "Cannot parse register view request from null object"); + + String name = JsonUtil.getString(NAME, json); + String metadataLocation = JsonUtil.getString(METADATA_LOCATION, json); + + return ImmutableRegisterViewRequest.builder() + .name(name) + .metadataLocation(metadataLocation) + .build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java b/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java index 76e87711a9e9..411b1748782a 100644 --- a/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java +++ b/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java @@ -31,6 +31,7 @@ import org.apache.iceberg.exceptions.AlreadyExistsException; import org.apache.iceberg.exceptions.CommitFailedException; import org.apache.iceberg.exceptions.NoSuchViewException; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -298,4 +299,28 @@ public Transaction replaceTransaction() { return super.replaceTransaction(); } } + + @Override + public View registerView(TableIdentifier identifier, String metadataFileLocation) { + Preconditions.checkArgument( + identifier != null && isValidIdentifier(identifier), "Invalid identifier: %s", identifier); + Preconditions.checkArgument( + metadataFileLocation != null && !metadataFileLocation.isEmpty(), + "Cannot register an empty metadata file location as a view"); + + if (viewExists(identifier)) { + throw new AlreadyExistsException("View already exists: %s", identifier); + } + + if (tableExists(identifier)) { + throw new AlreadyExistsException("Table with same name already exists: %s", identifier); + } + + ViewOperations ops = newViewOps(identifier); + InputFile metadataFile = ((BaseViewOperations) ops).io().newInputFile(metadataFileLocation); + ViewMetadata metadata = ViewMetadataParser.read(metadataFile); + ops.commit(null, metadata); + + return new BaseView(ops, ViewUtil.fullViewName(name(), identifier)); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java index 524b3e760ca6..fd58acdf417b 100644 --- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java +++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java @@ -69,6 +69,7 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; @@ -474,6 +475,17 @@ public T handleRequest( break; } + case REGISTER_VIEW: + { + if (null != asViewCatalog) { + Namespace namespace = namespaceFromPathVars(vars); + RegisterViewRequest request = castRequest(RegisterViewRequest.class, body); + return castResponse( + responseType, CatalogHandlers.registerView(asViewCatalog, namespace, request)); + } + break; + } + default: if (responseType == OAuthTokenResponse.class) { return castResponse(responseType, handleOAuthRequest(body)); diff --git a/core/src/test/java/org/apache/iceberg/rest/Route.java b/core/src/test/java/org/apache/iceberg/rest/Route.java index eedb2615ad64..8680915bff64 100644 --- a/core/src/test/java/org/apache/iceberg/rest/Route.java +++ b/core/src/test/java/org/apache/iceberg/rest/Route.java @@ -29,6 +29,7 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; @@ -115,6 +116,11 @@ enum Route { RENAME_VIEW( HTTPRequest.HTTPMethod.POST, ResourcePaths.V1_VIEW_RENAME, RenameTableRequest.class, null), DROP_VIEW(HTTPRequest.HTTPMethod.DELETE, ResourcePaths.V1_VIEW), + REGISTER_VIEW( + HTTPRequest.HTTPMethod.POST, + ResourcePaths.V1_VIEW_REGISTER, + RegisterViewRequest.class, + LoadViewResponse.class), PLAN_TABLE_SCAN( HTTPRequest.HTTPMethod.POST, ResourcePaths.V1_TABLE_SCAN_PLAN_SUBMIT, diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java index 2ac08284433d..2add38b4a20a 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java @@ -18,6 +18,8 @@ */ package org.apache.iceberg.rest; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import java.io.File; import java.net.InetAddress; import java.net.InetSocketAddress; @@ -107,4 +109,31 @@ public T handleRequest( CatalogProperties.VIEW_OVERRIDE_PREFIX + "key4", "catalog-override-key4")); } + + @Override + public void registerView() { + // Older client doesn't support the newer endpoint. + assertThatThrownBy(super::registerView) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Server does not support endpoint: POST /v1/{prefix}/namespaces/{namespace}/register-view"); + } + + @Override + public void registerExistingView() { + // Older client doesn't support the newer endpoint. + assertThatThrownBy(super::registerExistingView) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Server does not support endpoint: POST /v1/{prefix}/namespaces/{namespace}/register-view"); + } + + @Override + public void registerViewWithExistingTable() { + // Older client doesn't support the newer endpoint. + assertThatThrownBy(super::registerViewWithExistingTable) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Server does not support endpoint: POST /v1/{prefix}/namespaces/{namespace}/register-view"); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java index 1f6306eab0a2..1a1018be95ea 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java @@ -267,6 +267,13 @@ public void viewWithMultipartNamespace() { assertThat(withoutPrefix.view(ident)).isEqualTo("v1/namespaces/n%1Fs/views/view-name"); } + @Test + public void testRegisterView() { + Namespace ns = Namespace.of("ns"); + assertThat(withPrefix.registerView(ns)).isEqualTo("v1/ws/catalog/namespaces/ns/register-view"); + assertThat(withoutPrefix.registerView(ns)).isEqualTo("v1/namespaces/ns/register-view"); + } + @Test public void planEndpointPath() { TableIdentifier tableId = TableIdentifier.of("test_namespace", "test_table"); diff --git a/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterViewRequestParser.java b/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterViewRequestParser.java new file mode 100644 index 000000000000..5f55dff1120e --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterViewRequestParser.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.requests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +public class TestRegisterViewRequestParser { + + @Test + public void nullCheck() { + assertThatThrownBy(() -> RegisterViewRequestParser.toJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid register view request: null"); + + assertThatThrownBy(() -> RegisterViewRequestParser.fromJson((JsonNode) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse register view request from null object"); + } + + @Test + public void missingFields() { + assertThatThrownBy(() -> RegisterViewRequestParser.fromJson("{}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: name"); + + assertThatThrownBy(() -> RegisterViewRequestParser.fromJson("{\"name\" : \"test_vw\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: metadata-location"); + + assertThatThrownBy( + () -> + RegisterViewRequestParser.fromJson( + "{\"metadata-location\" : \"file://tmp/NS/test_vw/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: name"); + } + + @Test + public void roundTripSerde() { + RegisterViewRequest request = + ImmutableRegisterViewRequest.builder() + .name("view_1") + .metadataLocation( + "file://tmp/NS/view_1/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json") + .build(); + + String expectedJson = + "{\n" + + " \"name\" : \"view_1\",\n" + + " \"metadata-location\" : \"file://tmp/NS/view_1/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json\"\n" + + "}"; + + String json = RegisterViewRequestParser.toJson(request, true); + assertThat(json).isEqualTo(expectedJson); + + assertThat(RegisterViewRequestParser.toJson(RegisterViewRequestParser.fromJson(json), true)) + .isEqualTo(expectedJson); + } +} diff --git a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java index fdf4138f3ce5..d76330e5a734 100644 --- a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java +++ b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java @@ -18,6 +18,7 @@ */ package org.apache.iceberg.view; +import static org.apache.iceberg.TableProperties.GC_ENABLED; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -25,7 +26,10 @@ import java.nio.file.Path; import java.util.UUID; +import org.apache.iceberg.BaseTable; import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; import org.apache.iceberg.Transaction; import org.apache.iceberg.UpdateLocation; import org.apache.iceberg.catalog.Catalog; @@ -1968,4 +1972,133 @@ public void dropNonEmptyNamespace() { .as("Namespace should not exist") .isFalse(); } + + @Test + public void registerView() { + C catalog = catalog(); + + TableIdentifier identifier = TableIdentifier.of("ns", "view"); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(identifier.namespace()); + } + + View originalView = + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withDefaultCatalog(catalog().name()) + .withQuery("spark", "select * from ns.tbl") + .withProperty(GC_ENABLED, "false") + .create(); + + ViewOperations ops = ((BaseView) originalView).operations(); + String metadataLocation = ops.current().metadataFileLocation(); + + assertThat(catalog.dropView(identifier)).isTrue(); + assertThat(catalog.viewExists(identifier)).as("View must not exist").isFalse(); + + View registeredView = catalog.registerView(identifier, metadataLocation); + + assertThat(registeredView).isNotNull(); + assertThat(catalog.viewExists(identifier)).as("View must exist").isTrue(); + assertThat(registeredView.name()) + .isEqualTo(ViewUtil.fullViewName(catalog().name(), identifier)); + assertThat(registeredView.history()) + .hasSize(1) + .first() + .extracting(ViewHistoryEntry::versionId) + .isEqualTo(1); + assertThat(registeredView.schemas()).hasSize(1).containsKey(0); + assertThat(registeredView.schema().asStruct()).isEqualTo(SCHEMA.asStruct()); + assertThat(registeredView.currentVersion().operation()).isEqualTo("create"); + assertThat(registeredView.versions()) + .hasSize(1) + .containsExactly(registeredView.currentVersion()); + assertThat(registeredView.versions()).isEqualTo(originalView.versions()); + assertThat(registeredView.currentVersion()) + .isEqualTo( + ImmutableViewVersion.builder() + .timestampMillis(registeredView.currentVersion().timestampMillis()) + .versionId(1) + .schemaId(0) + .summary(registeredView.currentVersion().summary()) + .defaultNamespace(identifier.namespace()) + .defaultCatalog(catalog().name()) + .addRepresentations( + ImmutableSQLViewRepresentation.builder() + .sql("select * from ns.tbl") + .dialect("spark") + .build()) + .build()); + + assertThat(catalog.loadView(identifier)).isNotNull(); + assertThat(catalog.dropView(identifier)).isTrue(); + assertThat(catalog.viewExists(identifier)).isFalse(); + } + + @Test + public void registerExistingView() { + C catalog = catalog(); + + TableIdentifier identifier = TableIdentifier.of("ns", "view"); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(identifier.namespace()); + } + + View view = + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withDefaultCatalog(catalog().name()) + .withQuery("spark", "select * from ns.tbl") + .create(); + + ViewOperations ops = ((BaseView) view).operations(); + String metadataLocation = ops.current().metadataFileLocation(); + + assertThatThrownBy(() -> catalog.registerView(identifier, metadataLocation)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("View already exists: ns.view"); + assertThat(catalog.dropView(identifier)).isTrue(); + } + + @Test + public void registerViewWithExistingTable() { + C catalog = catalog(); + + TableIdentifier identifier = TableIdentifier.of("ns", "view"); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(identifier.namespace()); + } + + // create a table with the same name as the view + tableCatalog().createTable(identifier, SCHEMA); + + ViewBuilder viewBuilder = + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withDefaultCatalog(catalog().name()) + .withQuery("spark", "select * from ns.tbl"); + + assertThatThrownBy(viewBuilder::create) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("Table with same name already exists: ns.view"); + + Table table = tableCatalog().loadTable(identifier); + TableOperations ops = ((BaseTable) table).operations(); + String metadataLocation = ops.current().metadataFileLocation(); + + assertThatThrownBy(() -> catalog.registerView(identifier, metadataLocation)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("Table with same name already exists: ns.view"); + + assertThat(tableCatalog().dropTable(identifier)).isTrue(); + } } diff --git a/open-api/rest-catalog-open-api.py b/open-api/rest-catalog-open-api.py index c39416388e7e..3aec7b77d7c0 100644 --- a/open-api/rest-catalog-open-api.py +++ b/open-api/rest-catalog-open-api.py @@ -559,6 +559,11 @@ class RegisterTableRequest(BaseModel): ) +class RegisterViewRequest(BaseModel): + name: str + metadata_location: str = Field(..., alias='metadata-location') + + class TokenType(BaseModel): __root__: Literal[ 'urn:ietf:params:oauth:token-type:access_token', diff --git a/open-api/rest-catalog-open-api.yaml b/open-api/rest-catalog-open-api.yaml index d322b0c7c7c0..615d64797d05 100644 --- a/open-api/rest-catalog-open-api.yaml +++ b/open-api/rest-catalog-open-api.yaml @@ -1835,6 +1835,60 @@ paths: 5XX: $ref: '#/components/responses/ServerErrorResponse' + /v1/{prefix}/namespaces/{namespace}/register-view: + parameters: + - $ref: '#/components/parameters/prefix' + - $ref: '#/components/parameters/namespace' + + post: + tags: + - Catalog API + summary: Register a view in the catalog + parameters: + - $ref: '#/components/parameters/idempotency-key' + description: + Register a view in the given namespace using given metadata file location. + + operationId: registerView + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterViewRequest' + responses: + 200: + $ref: '#/components/responses/LoadViewResponse' + 400: + $ref: '#/components/responses/BadRequestErrorResponse' + 401: + $ref: '#/components/responses/UnauthorizedResponse' + 403: + $ref: '#/components/responses/ForbiddenResponse' + 404: + description: Not Found - The namespace specified does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/IcebergErrorResponse' + examples: + NamespaceNotFound: + $ref: '#/components/examples/NoSuchNamespaceError' + 409: + description: Conflict - The view already exists + content: + application/json: + schema: + $ref: '#/components/schemas/IcebergErrorResponse' + examples: + ViewAlreadyExists: + $ref: '#/components/examples/ViewAlreadyExistsError' + 419: + $ref: '#/components/responses/AuthenticationTimeoutResponse' + 503: + $ref: '#/components/responses/ServiceUnavailableResponse' + 5XX: + $ref: '#/components/responses/ServerErrorResponse' components: ####################################################### @@ -3690,6 +3744,17 @@ components: additionalProperties: type: string + RegisterViewRequest: + type: object + required: + - name + - metadata-location + properties: + name: + type: string + metadata-location: + type: string + TokenType: type: string enum: