This is an automated email from the ASF dual-hosted git repository.
blue pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git
The following commit(s) were added to refs/heads/main by this push:
new f19643a93f Core: Add View support for REST catalog (#7913)
f19643a93f is described below
commit f19643a93f5dac99bbdbc9881ef19c89d7bcd3eb
Author: Eduard Tudenhoefner <[email protected]>
AuthorDate: Tue Dec 5 17:03:47 2023 +0100
Core: Add View support for REST catalog (#7913)
---
.../org/apache/iceberg/catalog/ViewCatalog.java | 1 +
.../{ViewCatalog.java => ViewSessionCatalog.java} | 23 +-
.../java/org/apache/iceberg/UpdateRequirement.java | 15 +
.../iceberg/catalog/BaseViewSessionCatalog.java | 92 ++++
.../org/apache/iceberg/rest/CatalogHandlers.java | 137 +++++
.../org/apache/iceberg/rest/ErrorHandlers.java | 52 ++
.../java/org/apache/iceberg/rest/RESTCatalog.java | 43 +-
.../org/apache/iceberg/rest/RESTSerializers.java | 51 +-
.../apache/iceberg/rest/RESTSessionCatalog.java | 260 ++++++++-
.../apache/iceberg/rest/RESTViewOperations.java | 83 +++
.../org/apache/iceberg/rest/ResourcePaths.java | 18 +
.../iceberg/rest/requests/CreateViewRequest.java | 45 ++
.../rest/requests/CreateViewRequestParser.java | 99 ++++
.../iceberg/rest/requests/RenameTableRequest.java | 2 +-
.../iceberg/rest/responses/LoadViewResponse.java | 38 ++
.../rest/responses/LoadViewResponseParser.java | 85 +++
.../java/org/apache/iceberg/view/ViewMetadata.java | 38 +-
.../apache/iceberg/view/ViewMetadataParser.java | 2 +-
.../org/apache/iceberg/view/ViewVersionParser.java | 2 +-
.../apache/iceberg/view/ViewVersionReplace.java | 2 +-
.../apache/iceberg/rest/RESTCatalogAdapter.java | 95 +++-
.../apache/iceberg/rest/TestRESTViewCatalog.java | 166 ++++++
.../org/apache/iceberg/rest/TestResourcePaths.java | 47 ++
.../rest/requests/TestCreateViewRequestParser.java | 130 +++++
.../rest/responses/TestLoadViewResponseParser.java | 260 +++++++++
.../org/apache/iceberg/view/TestViewMetadata.java | 23 +-
.../org/apache/iceberg/view/ViewCatalogTests.java | 144 ++++-
open-api/rest-catalog-open-api.py | 111 ++++
open-api/rest-catalog-open-api.yaml | 595 ++++++++++++++++++++-
29 files changed, 2614 insertions(+), 45 deletions(-)
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 df118e2596..ca470eec71 100644
--- a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
+++ b/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
@@ -92,6 +92,7 @@ public interface ViewCatalog {
* @param to new view identifier
* @throws NoSuchViewException if the "from" view does not exist
* @throws AlreadyExistsException if the "to" view already exists
+ * @throws NoSuchNamespaceException if the "to" namespace doesn't exist
*/
void renameView(TableIdentifier from, TableIdentifier to);
diff --git a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java
similarity index 77%
copy from api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
copy to api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java
index df118e2596..106e20d3bc 100644
--- a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java
+++ b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java
@@ -26,8 +26,8 @@ import org.apache.iceberg.exceptions.NoSuchViewException;
import org.apache.iceberg.view.View;
import org.apache.iceberg.view.ViewBuilder;
-/** A Catalog API for view create, drop, and load operations. */
-public interface ViewCatalog {
+/** A session Catalog API for view create, drop, and load operations. */
+public interface ViewSessionCatalog {
/**
* Return the name for this catalog.
@@ -43,7 +43,7 @@ public interface ViewCatalog {
* @return a list of identifiers for views
* @throws NoSuchNamespaceException if the namespace is not found
*/
- List<TableIdentifier> listViews(Namespace namespace);
+ List<TableIdentifier> listViews(SessionCatalog.SessionContext context,
Namespace namespace);
/**
* Load a view.
@@ -52,7 +52,7 @@ public interface ViewCatalog {
* @return instance of {@link View} implementation referred by the identifier
* @throws NoSuchViewException if the view does not exist
*/
- View loadView(TableIdentifier identifier);
+ View loadView(SessionCatalog.SessionContext context, TableIdentifier
identifier);
/**
* Check whether view exists.
@@ -60,9 +60,9 @@ public interface ViewCatalog {
* @param identifier a view identifier
* @return true if the view exists, false otherwise
*/
- default boolean viewExists(TableIdentifier identifier) {
+ default boolean viewExists(SessionCatalog.SessionContext context,
TableIdentifier identifier) {
try {
- loadView(identifier);
+ loadView(context, identifier);
return true;
} catch (NoSuchViewException e) {
return false;
@@ -75,7 +75,7 @@ public interface ViewCatalog {
* @param identifier a view identifier
* @return a view builder
*/
- ViewBuilder buildView(TableIdentifier identifier);
+ ViewBuilder buildView(SessionCatalog.SessionContext context, TableIdentifier
identifier);
/**
* Drop a view.
@@ -83,7 +83,7 @@ public interface ViewCatalog {
* @param identifier a view identifier
* @return true if the view was dropped, false if the view did not exist
*/
- boolean dropView(TableIdentifier identifier);
+ boolean dropView(SessionCatalog.SessionContext context, TableIdentifier
identifier);
/**
* Rename a view.
@@ -92,8 +92,9 @@ public interface ViewCatalog {
* @param to new view identifier
* @throws NoSuchViewException if the "from" view does not exist
* @throws AlreadyExistsException if the "to" view already exists
+ * @throws NoSuchNamespaceException if the "to" namespace doesn't exist
*/
- void renameView(TableIdentifier from, TableIdentifier to);
+ void renameView(SessionCatalog.SessionContext context, TableIdentifier from,
TableIdentifier to);
/**
* Invalidate cached view metadata from current catalog.
@@ -103,7 +104,7 @@ public interface ViewCatalog {
*
* @param identifier a view identifier
*/
- default void invalidateView(TableIdentifier identifier) {}
+ default void invalidateView(SessionCatalog.SessionContext context,
TableIdentifier identifier) {}
/**
* Initialize a view catalog given a custom name and a map of catalog
properties.
@@ -115,5 +116,5 @@ public interface ViewCatalog {
* @param name a custom name for the catalog
* @param properties catalog properties
*/
- default void initialize(String name, Map<String, String> properties) {}
+ void initialize(String name, Map<String, String> properties);
}
diff --git a/core/src/main/java/org/apache/iceberg/UpdateRequirement.java
b/core/src/main/java/org/apache/iceberg/UpdateRequirement.java
index 2645e79fa4..80ecf84efa 100644
--- a/core/src/main/java/org/apache/iceberg/UpdateRequirement.java
+++ b/core/src/main/java/org/apache/iceberg/UpdateRequirement.java
@@ -19,12 +19,19 @@
package org.apache.iceberg;
import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.ValidationException;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.view.ViewMetadata;
/** Represents a requirement for a {@link MetadataUpdate} */
public interface UpdateRequirement {
void validate(TableMetadata base);
+ default void validate(ViewMetadata base) {
+ throw new ValidationException(
+ "Cannot validate %s against a view", this.getClass().getSimpleName());
+ }
+
class AssertTableDoesNotExist implements UpdateRequirement {
public AssertTableDoesNotExist() {}
@@ -55,6 +62,14 @@ public interface UpdateRequirement {
"Requirement failed: UUID does not match: expected %s != %s",
base.uuid(), uuid);
}
}
+
+ @Override
+ public void validate(ViewMetadata base) {
+ if (!uuid.equalsIgnoreCase(base.uuid())) {
+ throw new CommitFailedException(
+ "Requirement failed: UUID does not match: expected %s != %s",
base.uuid(), uuid);
+ }
+ }
}
class AssertRefSnapshotID implements UpdateRequirement {
diff --git
a/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java
b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java
new file mode 100644
index 0000000000..10895e1de9
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java
@@ -0,0 +1,92 @@
+/*
+ * 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.catalog;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
+
+public abstract class BaseViewSessionCatalog extends BaseSessionCatalog
+ implements ViewSessionCatalog {
+
+ private final Cache<String, ViewCatalog> catalogs =
+ Caffeine.newBuilder().expireAfterAccess(10, TimeUnit.MINUTES).build();
+
+ public ViewCatalog asViewCatalog(SessionContext context) {
+ return catalogs.get(context.sessionId(), id -> new AsViewCatalog(context));
+ }
+
+ public class AsViewCatalog implements ViewCatalog {
+ private final SessionContext context;
+
+ private AsViewCatalog(SessionContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public String name() {
+ return BaseViewSessionCatalog.this.name();
+ }
+
+ @Override
+ public List<TableIdentifier> listViews(Namespace namespace) {
+ return BaseViewSessionCatalog.this.listViews(context, namespace);
+ }
+
+ @Override
+ public View loadView(TableIdentifier identifier) {
+ return BaseViewSessionCatalog.this.loadView(context, identifier);
+ }
+
+ @Override
+ public boolean viewExists(TableIdentifier identifier) {
+ return BaseViewSessionCatalog.this.viewExists(context, identifier);
+ }
+
+ @Override
+ public ViewBuilder buildView(TableIdentifier identifier) {
+ return BaseViewSessionCatalog.this.buildView(context, identifier);
+ }
+
+ @Override
+ public boolean dropView(TableIdentifier identifier) {
+ return BaseViewSessionCatalog.this.dropView(context, identifier);
+ }
+
+ @Override
+ public void renameView(TableIdentifier from, TableIdentifier to) {
+ BaseViewSessionCatalog.this.renameView(context, from, to);
+ }
+
+ @Override
+ public void invalidateView(TableIdentifier identifier) {
+ BaseViewSessionCatalog.this.invalidateView(context, identifier);
+ }
+
+ @Override
+ public void initialize(String name, Map<String, String> properties) {
+ throw new UnsupportedOperationException(
+ this.getClass().getSimpleName() + " doesn't support initialization");
+ }
+ }
+}
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 1e0ef66027..e4e3c065fb 100644
--- a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
@@ -45,26 +45,38 @@ import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.SupportsNamespaces;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.apache.iceberg.relocated.com.google.common.collect.Maps;
import org.apache.iceberg.relocated.com.google.common.collect.Sets;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
import org.apache.iceberg.rest.requests.UpdateTableRequest;
import org.apache.iceberg.rest.responses.CreateNamespaceResponse;
import org.apache.iceberg.rest.responses.GetNamespaceResponse;
+import org.apache.iceberg.rest.responses.ImmutableLoadViewResponse;
import org.apache.iceberg.rest.responses.ListNamespacesResponse;
import org.apache.iceberg.rest.responses.ListTablesResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
import org.apache.iceberg.util.Tasks;
+import org.apache.iceberg.view.BaseView;
+import org.apache.iceberg.view.SQLViewRepresentation;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewOperations;
+import org.apache.iceberg.view.ViewRepresentation;
public class CatalogHandlers {
private static final Schema EMPTY_SCHEMA = new Schema();
@@ -374,4 +386,129 @@ public class CatalogHandlers {
return ops.current();
}
+
+ private static BaseView asBaseView(View view) {
+ Preconditions.checkState(
+ view instanceof BaseView, "Cannot wrap catalog that does not produce
BaseView");
+ return (BaseView) view;
+ }
+
+ public static ListTablesResponse listViews(ViewCatalog catalog, Namespace
namespace) {
+ return
ListTablesResponse.builder().addAll(catalog.listViews(namespace)).build();
+ }
+
+ public static LoadViewResponse createView(
+ ViewCatalog catalog, Namespace namespace, CreateViewRequest request) {
+ request.validate();
+
+ ViewBuilder viewBuilder =
+ catalog
+ .buildView(TableIdentifier.of(namespace, request.name()))
+ .withSchema(request.schema())
+ .withProperties(request.properties())
+ .withDefaultNamespace(request.viewVersion().defaultNamespace())
+ .withDefaultCatalog(request.viewVersion().defaultCatalog())
+ .withLocation(request.location());
+
+ Set<String> unsupportedRepresentations =
+ request.viewVersion().representations().stream()
+ .filter(r -> !(r instanceof SQLViewRepresentation))
+ .map(ViewRepresentation::type)
+ .collect(Collectors.toSet());
+
+ if (!unsupportedRepresentations.isEmpty()) {
+ throw new IllegalStateException(
+ String.format("Found unsupported view representations: %s",
unsupportedRepresentations));
+ }
+
+ request.viewVersion().representations().stream()
+ .filter(SQLViewRepresentation.class::isInstance)
+ .map(SQLViewRepresentation.class::cast)
+ .forEach(r -> viewBuilder.withQuery(r.dialect(), r.sql()));
+
+ View view = viewBuilder.create();
+
+ return viewResponse(view);
+ }
+
+ private static LoadViewResponse viewResponse(View view) {
+ ViewMetadata metadata = asBaseView(view).operations().current();
+ return ImmutableLoadViewResponse.builder()
+ .metadata(metadata)
+ .metadataLocation(metadata.metadataFileLocation())
+ .build();
+ }
+
+ public static LoadViewResponse loadView(ViewCatalog catalog, TableIdentifier
viewIdentifier) {
+ View view = catalog.loadView(viewIdentifier);
+ return viewResponse(view);
+ }
+
+ public static LoadViewResponse updateView(
+ ViewCatalog catalog, TableIdentifier ident, UpdateTableRequest request) {
+ View view = catalog.loadView(ident);
+ ViewMetadata metadata = commit(asBaseView(view).operations(), request);
+
+ return ImmutableLoadViewResponse.builder()
+ .metadata(metadata)
+ .metadataLocation(metadata.metadataFileLocation())
+ .build();
+ }
+
+ public static void renameView(ViewCatalog catalog, RenameTableRequest
request) {
+ catalog.renameView(request.source(), request.destination());
+ }
+
+ public static void dropView(ViewCatalog catalog, TableIdentifier
viewIdentifier) {
+ boolean dropped = catalog.dropView(viewIdentifier);
+ if (!dropped) {
+ throw new NoSuchViewException("View does not exist: %s", viewIdentifier);
+ }
+ }
+
+ static ViewMetadata commit(ViewOperations ops, UpdateTableRequest request) {
+ AtomicBoolean isRetry = new AtomicBoolean(false);
+ try {
+ Tasks.foreach(ops)
+ .retry(COMMIT_NUM_RETRIES_DEFAULT)
+ .exponentialBackoff(
+ COMMIT_MIN_RETRY_WAIT_MS_DEFAULT,
+ COMMIT_MAX_RETRY_WAIT_MS_DEFAULT,
+ COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT,
+ 2.0 /* exponential */)
+ .onlyRetryOn(CommitFailedException.class)
+ .run(
+ taskOps -> {
+ ViewMetadata base = isRetry.get() ? taskOps.refresh() :
taskOps.current();
+ isRetry.set(true);
+
+ // validate requirements
+ try {
+ request.requirements().forEach(requirement ->
requirement.validate(base));
+ } catch (CommitFailedException e) {
+ // wrap and rethrow outside of tasks to avoid unnecessary
retry
+ throw new ValidationFailureException(e);
+ }
+
+ // apply changes
+ ViewMetadata.Builder metadataBuilder =
ViewMetadata.buildFrom(base);
+ request.updates().forEach(update ->
update.applyTo(metadataBuilder));
+
+ ViewMetadata updated = metadataBuilder.build();
+
+ if (updated.changes().isEmpty()) {
+ // do not commit if the metadata has not changed
+ return;
+ }
+
+ // commit
+ taskOps.commit(base, updated);
+ });
+
+ } catch (ValidationFailureException e) {
+ throw e.wrapped();
+ }
+
+ return ops.current();
+ }
}
diff --git a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java
b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java
index 5a7f9b59f2..846820a99d 100644
--- a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java
@@ -26,6 +26,7 @@ import
org.apache.iceberg.exceptions.CommitStateUnknownException;
import org.apache.iceberg.exceptions.ForbiddenException;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
import org.apache.iceberg.exceptions.NotAuthorizedException;
import org.apache.iceberg.exceptions.RESTException;
import org.apache.iceberg.exceptions.ServiceFailureException;
@@ -55,6 +56,14 @@ public class ErrorHandlers {
return TableErrorHandler.INSTANCE;
}
+ public static Consumer<ErrorResponse> viewErrorHandler() {
+ return ViewErrorHandler.INSTANCE;
+ }
+
+ public static Consumer<ErrorResponse> viewCommitHandler() {
+ return ViewCommitErrorHandler.INSTANCE;
+ }
+
public static Consumer<ErrorResponse> tableCommitHandler() {
return CommitErrorHandler.INSTANCE;
}
@@ -110,6 +119,49 @@ public class ErrorHandlers {
}
}
+ /** View commit error handler. */
+ private static class ViewCommitErrorHandler extends DefaultErrorHandler {
+ private static final ErrorHandler INSTANCE = new ViewCommitErrorHandler();
+
+ @Override
+ public void accept(ErrorResponse error) {
+ switch (error.code()) {
+ case 404:
+ throw new NoSuchViewException("%s", error.message());
+ case 409:
+ throw new CommitFailedException("Commit failed: %s",
error.message());
+ case 500:
+ case 502:
+ case 504:
+ throw new CommitStateUnknownException(
+ new ServiceFailureException("Service failed: %s: %s",
error.code(), error.message()));
+ }
+
+ super.accept(error);
+ }
+ }
+
+ /** View level error handler. */
+ private static class ViewErrorHandler extends DefaultErrorHandler {
+ private static final ErrorHandler INSTANCE = new ViewErrorHandler();
+
+ @Override
+ public void accept(ErrorResponse error) {
+ switch (error.code()) {
+ case 404:
+ if
(NoSuchNamespaceException.class.getSimpleName().equals(error.type())) {
+ throw new NoSuchNamespaceException("%s", error.message());
+ } else {
+ throw new NoSuchViewException("%s", error.message());
+ }
+ case 409:
+ throw new AlreadyExistsException("%s", error.message());
+ }
+
+ super.accept(error);
+ }
+ }
+
/** Request error handler specifically for CRUD ops on namespaces. */
private static class NamespaceErrorHandler extends DefaultErrorHandler {
private static final ErrorHandler INSTANCE = new NamespaceErrorHandler();
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 63b660c46a..61a7eca272 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
@@ -35,17 +35,22 @@ import org.apache.iceberg.catalog.SessionCatalog;
import org.apache.iceberg.catalog.SupportsNamespaces;
import org.apache.iceberg.catalog.TableCommit;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
import org.apache.iceberg.exceptions.NamespaceNotEmptyException;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.hadoop.Configurable;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
-public class RESTCatalog implements Catalog, SupportsNamespaces,
Configurable<Object>, Closeable {
+public class RESTCatalog
+ implements Catalog, ViewCatalog, SupportsNamespaces, Configurable<Object>,
Closeable {
private final RESTSessionCatalog sessionCatalog;
private final Catalog delegate;
private final SupportsNamespaces nsDelegate;
private final SessionCatalog.SessionContext context;
+ private final ViewCatalog viewSessionCatalog;
public RESTCatalog() {
this(
@@ -64,6 +69,7 @@ public class RESTCatalog implements Catalog,
SupportsNamespaces, Configurable<Ob
this.delegate = sessionCatalog.asCatalog(context);
this.nsDelegate = (SupportsNamespaces) delegate;
this.context = context;
+ this.viewSessionCatalog = sessionCatalog.asViewCatalog(context);
}
@Override
@@ -261,4 +267,39 @@ public class RESTCatalog implements Catalog,
SupportsNamespaces, Configurable<Ob
sessionCatalog.commitTransaction(
context, ImmutableList.<TableCommit>builder().add(commits).build());
}
+
+ @Override
+ public List<TableIdentifier> listViews(Namespace namespace) {
+ return viewSessionCatalog.listViews(namespace);
+ }
+
+ @Override
+ public View loadView(TableIdentifier identifier) {
+ return viewSessionCatalog.loadView(identifier);
+ }
+
+ @Override
+ public ViewBuilder buildView(TableIdentifier identifier) {
+ return viewSessionCatalog.buildView(identifier);
+ }
+
+ @Override
+ public boolean dropView(TableIdentifier identifier) {
+ return viewSessionCatalog.dropView(identifier);
+ }
+
+ @Override
+ public void renameView(TableIdentifier from, TableIdentifier to) {
+ viewSessionCatalog.renameView(from, to);
+ }
+
+ @Override
+ public boolean viewExists(TableIdentifier identifier) {
+ return viewSessionCatalog.viewExists(identifier);
+ }
+
+ @Override
+ public void invalidateView(TableIdentifier identifier) {
+ viewSessionCatalog.invalidateView(identifier);
+ }
}
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 06f2de32df..7d4f327b67 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
@@ -44,6 +44,9 @@ import org.apache.iceberg.catalog.TableIdentifierParser;
import org.apache.iceberg.rest.auth.OAuth2Util;
import org.apache.iceberg.rest.requests.CommitTransactionRequest;
import org.apache.iceberg.rest.requests.CommitTransactionRequestParser;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
+import org.apache.iceberg.rest.requests.CreateViewRequestParser;
+import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest;
import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
import org.apache.iceberg.rest.requests.ImmutableReportMetricsRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
@@ -56,6 +59,9 @@ import
org.apache.iceberg.rest.requests.UpdateTableRequest.UpdateRequirement;
import org.apache.iceberg.rest.requests.UpdateTableRequestParser;
import org.apache.iceberg.rest.responses.ErrorResponse;
import org.apache.iceberg.rest.responses.ErrorResponseParser;
+import org.apache.iceberg.rest.responses.ImmutableLoadViewResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponseParser;
import org.apache.iceberg.rest.responses.OAuthTokenResponse;
import org.apache.iceberg.util.JsonUtil;
@@ -101,7 +107,16 @@ public class RESTSerializers {
.addDeserializer(RegisterTableRequest.class, new
RegisterTableRequestDeserializer<>())
.addSerializer(ImmutableRegisterTableRequest.class, new
RegisterTableRequestSerializer<>())
.addDeserializer(
- ImmutableRegisterTableRequest.class, new
RegisterTableRequestDeserializer<>());
+ ImmutableRegisterTableRequest.class, new
RegisterTableRequestDeserializer<>())
+ .addSerializer(CreateViewRequest.class, new
CreateViewRequestSerializer<>())
+ .addSerializer(ImmutableCreateViewRequest.class, new
CreateViewRequestSerializer<>())
+ .addDeserializer(CreateViewRequest.class, new
CreateViewRequestDeserializer<>())
+ .addDeserializer(ImmutableCreateViewRequest.class, new
CreateViewRequestDeserializer<>())
+ .addSerializer(LoadViewResponse.class, new
LoadViewResponseSerializer<>())
+ .addSerializer(ImmutableLoadViewResponse.class, new
LoadViewResponseSerializer<>())
+ .addDeserializer(LoadViewResponse.class, new
LoadViewResponseDeserializer<>())
+ .addDeserializer(ImmutableLoadViewResponse.class, new
LoadViewResponseDeserializer<>());
+
mapper.registerModule(module);
}
@@ -379,4 +394,38 @@ public class RESTSerializers {
return (T) RegisterTableRequestParser.fromJson(jsonNode);
}
}
+
+ static class CreateViewRequestSerializer<T extends CreateViewRequest>
extends JsonSerializer<T> {
+ @Override
+ public void serialize(T request, JsonGenerator gen, SerializerProvider
serializers)
+ throws IOException {
+ CreateViewRequestParser.toJson(request, gen);
+ }
+ }
+
+ static class CreateViewRequestDeserializer<T extends CreateViewRequest>
+ extends JsonDeserializer<T> {
+ @Override
+ public T deserialize(JsonParser p, DeserializationContext context) throws
IOException {
+ JsonNode jsonNode = p.getCodec().readTree(p);
+ return (T) CreateViewRequestParser.fromJson(jsonNode);
+ }
+ }
+
+ static class LoadViewResponseSerializer<T extends LoadViewResponse> extends
JsonSerializer<T> {
+ @Override
+ public void serialize(T request, JsonGenerator gen, SerializerProvider
serializers)
+ throws IOException {
+ LoadViewResponseParser.toJson(request, gen);
+ }
+ }
+
+ static class LoadViewResponseDeserializer<T extends LoadViewResponse>
+ extends JsonDeserializer<T> {
+ @Override
+ public T deserialize(JsonParser p, DeserializationContext context) throws
IOException {
+ JsonNode jsonNode = p.getCodec().readTree(p);
+ return (T) LoadViewResponseParser.fromJson(jsonNode);
+ }
+ }
}
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 72547eec34..5a55afbfce 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
@@ -38,6 +38,7 @@ import java.util.function.Supplier;
import org.apache.iceberg.BaseTable;
import org.apache.iceberg.CatalogProperties;
import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.EnvironmentContext;
import org.apache.iceberg.MetadataTableType;
import org.apache.iceberg.MetadataTableUtils;
import org.apache.iceberg.MetadataUpdate;
@@ -49,13 +50,14 @@ import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.TableOperations;
import org.apache.iceberg.Transaction;
import org.apache.iceberg.Transactions;
-import org.apache.iceberg.catalog.BaseSessionCatalog;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableCommit;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
import org.apache.iceberg.hadoop.Configurable;
import org.apache.iceberg.io.CloseableGroup;
import org.apache.iceberg.io.FileIO;
@@ -65,12 +67,15 @@ import
org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
import org.apache.iceberg.rest.auth.OAuth2Properties;
import org.apache.iceberg.rest.auth.OAuth2Util;
import org.apache.iceberg.rest.auth.OAuth2Util.AuthSession;
import org.apache.iceberg.rest.requests.CommitTransactionRequest;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+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.RegisterTableRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
@@ -82,15 +87,25 @@ import
org.apache.iceberg.rest.responses.GetNamespaceResponse;
import org.apache.iceberg.rest.responses.ListNamespacesResponse;
import org.apache.iceberg.rest.responses.ListTablesResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
import org.apache.iceberg.rest.responses.OAuthTokenResponse;
import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
import org.apache.iceberg.util.EnvironmentUtil;
import org.apache.iceberg.util.PropertyUtil;
import org.apache.iceberg.util.ThreadPools;
+import org.apache.iceberg.view.BaseView;
+import org.apache.iceberg.view.ImmutableSQLViewRepresentation;
+import org.apache.iceberg.view.ImmutableViewVersion;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewRepresentation;
+import org.apache.iceberg.view.ViewUtil;
+import org.apache.iceberg.view.ViewVersion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class RESTSessionCatalog extends BaseSessionCatalog
+public class RESTSessionCatalog extends BaseViewSessionCatalog
implements Configurable<Object>, Closeable {
private static final Logger LOG =
LoggerFactory.getLogger(RESTSessionCatalog.class);
private static final String DEFAULT_FILE_IO_IMPL =
"org.apache.iceberg.io.ResolvingFileIO";
@@ -919,6 +934,12 @@ public class RESTSessionCatalog extends BaseSessionCatalog
}
}
+ private void checkViewIdentifierIsValid(TableIdentifier identifier) {
+ if (identifier.namespace().isEmpty()) {
+ throw new NoSuchViewException("Invalid view identifier: %s", identifier);
+ }
+ }
+
private void checkNamespaceIsValid(Namespace namespace) {
if (namespace.isEmpty()) {
throw new NoSuchNamespaceException("Invalid namespace: %s", namespace);
@@ -971,4 +992,239 @@ public class RESTSessionCatalog extends BaseSessionCatalog
headers(context),
ErrorHandlers.tableCommitHandler());
}
+
+ @Override
+ public List<TableIdentifier> listViews(SessionContext context, Namespace
namespace) {
+ checkNamespaceIsValid(namespace);
+
+ ListTablesResponse response =
+ client.get(
+ paths.views(namespace),
+ ListTablesResponse.class,
+ headers(context),
+ ErrorHandlers.namespaceErrorHandler());
+ return response.identifiers();
+ }
+
+ @Override
+ public View loadView(SessionContext context, TableIdentifier identifier) {
+ checkViewIdentifierIsValid(identifier);
+
+ LoadViewResponse response =
+ client.get(
+ paths.view(identifier),
+ LoadViewResponse.class,
+ headers(context),
+ ErrorHandlers.viewErrorHandler());
+
+ AuthSession session = tableSession(response.config(), session(context));
+ ViewMetadata metadata = response.metadata();
+
+ RESTViewOperations ops =
+ new RESTViewOperations(client, paths.view(identifier),
session::headers, metadata);
+
+ return new BaseView(ops, ViewUtil.fullViewName(name(), identifier));
+ }
+
+ @Override
+ public RESTViewBuilder buildView(SessionContext context, TableIdentifier
identifier) {
+ return new RESTViewBuilder(context, identifier);
+ }
+
+ @Override
+ public boolean dropView(SessionContext context, TableIdentifier identifier) {
+ checkViewIdentifierIsValid(identifier);
+
+ try {
+ client.delete(
+ paths.view(identifier), null, headers(context),
ErrorHandlers.viewErrorHandler());
+ return true;
+ } catch (NoSuchViewException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void renameView(SessionContext context, TableIdentifier from,
TableIdentifier to) {
+ checkViewIdentifierIsValid(from);
+ checkViewIdentifierIsValid(to);
+
+ RenameTableRequest request =
+
RenameTableRequest.builder().withSource(from).withDestination(to).build();
+
+ client.post(
+ paths.renameView(), request, null, headers(context),
ErrorHandlers.viewErrorHandler());
+ }
+
+ private class RESTViewBuilder implements ViewBuilder {
+ private final SessionContext context;
+ private final TableIdentifier identifier;
+ private final Map<String, String> properties = Maps.newHashMap();
+ private final List<ViewRepresentation> representations =
Lists.newArrayList();
+ private Namespace defaultNamespace = null;
+ private String defaultCatalog = null;
+ private Schema schema = null;
+ private String location = null;
+
+ private RESTViewBuilder(SessionContext context, TableIdentifier
identifier) {
+ checkViewIdentifierIsValid(identifier);
+ this.identifier = identifier;
+ this.context = context;
+ }
+
+ @Override
+ public ViewBuilder withSchema(Schema newSchema) {
+ this.schema = newSchema;
+ return this;
+ }
+
+ @Override
+ public ViewBuilder withQuery(String dialect, String sql) {
+ representations.add(
+
ImmutableSQLViewRepresentation.builder().dialect(dialect).sql(sql).build());
+ return this;
+ }
+
+ @Override
+ public ViewBuilder withDefaultCatalog(String catalog) {
+ this.defaultCatalog = catalog;
+ return this;
+ }
+
+ @Override
+ public ViewBuilder withDefaultNamespace(Namespace namespace) {
+ this.defaultNamespace = namespace;
+ return this;
+ }
+
+ @Override
+ public ViewBuilder withProperties(Map<String, String> newProperties) {
+ this.properties.putAll(newProperties);
+ return this;
+ }
+
+ @Override
+ public ViewBuilder withProperty(String key, String value) {
+ this.properties.put(key, value);
+ return this;
+ }
+
+ @Override
+ public ViewBuilder withLocation(String newLocation) {
+ this.location = newLocation;
+ return this;
+ }
+
+ @Override
+ public View create() {
+ Preconditions.checkState(
+ !representations.isEmpty(), "Cannot create view without specifying a
query");
+ Preconditions.checkState(null != schema, "Cannot create view without
specifying schema");
+ Preconditions.checkState(
+ null != defaultNamespace, "Cannot create view without specifying a
default namespace");
+
+ ViewVersion viewVersion =
+ ImmutableViewVersion.builder()
+ .versionId(1)
+ .schemaId(schema.schemaId())
+ .addAllRepresentations(representations)
+ .defaultNamespace(defaultNamespace)
+ .defaultCatalog(defaultCatalog)
+ .timestampMillis(System.currentTimeMillis())
+ .putAllSummary(EnvironmentContext.get())
+ .build();
+
+ CreateViewRequest request =
+ ImmutableCreateViewRequest.builder()
+ .name(identifier.name())
+ .location(location)
+ .schema(schema)
+ .viewVersion(viewVersion)
+ .properties(properties)
+ .build();
+
+ LoadViewResponse response =
+ client.post(
+ paths.views(identifier.namespace()),
+ request,
+ LoadViewResponse.class,
+ headers(context),
+ ErrorHandlers.viewErrorHandler());
+
+ AuthSession session = tableSession(response.config(), session(context));
+ RESTViewOperations ops =
+ new RESTViewOperations(
+ client, paths.view(identifier), session::headers,
response.metadata());
+
+ return new BaseView(ops, ViewUtil.fullViewName(name(), identifier));
+ }
+
+ @Override
+ public View createOrReplace() {
+ try {
+ return replace(loadView());
+ } catch (NoSuchViewException e) {
+ return create();
+ }
+ }
+
+ @Override
+ public View replace() {
+ return replace(loadView());
+ }
+
+ private LoadViewResponse loadView() {
+ return client.get(
+ paths.view(identifier),
+ LoadViewResponse.class,
+ headers(context),
+ ErrorHandlers.viewErrorHandler());
+ }
+
+ private View replace(LoadViewResponse response) {
+ Preconditions.checkState(
+ !representations.isEmpty(), "Cannot replace view without specifying
a query");
+ Preconditions.checkState(null != schema, "Cannot replace view without
specifying schema");
+ Preconditions.checkState(
+ null != defaultNamespace, "Cannot replace view without specifying a
default namespace");
+
+ ViewMetadata metadata = response.metadata();
+
+ int maxVersionId =
+ metadata.versions().stream()
+ .map(ViewVersion::versionId)
+ .max(Integer::compareTo)
+ .orElseGet(metadata::currentVersionId);
+
+ ViewVersion viewVersion =
+ ImmutableViewVersion.builder()
+ .versionId(maxVersionId + 1)
+ .schemaId(schema.schemaId())
+ .addAllRepresentations(representations)
+ .defaultNamespace(defaultNamespace)
+ .defaultCatalog(defaultCatalog)
+ .timestampMillis(System.currentTimeMillis())
+ .putAllSummary(EnvironmentContext.get())
+ .build();
+
+ ViewMetadata.Builder builder =
+ ViewMetadata.buildFrom(metadata)
+ .setProperties(properties)
+ .setCurrentVersion(viewVersion, schema);
+
+ if (null != location) {
+ builder.setLocation(location);
+ }
+
+ ViewMetadata replacement = builder.build();
+
+ AuthSession session = tableSession(response.config(), session(context));
+ RESTViewOperations ops =
+ new RESTViewOperations(client, paths.view(identifier),
session::headers, metadata);
+
+ ops.commit(metadata, replacement);
+
+ return new BaseView(ops, ViewUtil.fullViewName(name(), identifier));
+ }
+ }
}
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTViewOperations.java
b/core/src/main/java/org/apache/iceberg/rest/RESTViewOperations.java
new file mode 100644
index 0000000000..48dc075b13
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTViewOperations.java
@@ -0,0 +1,83 @@
+/*
+ * 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;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewOperations;
+
+class RESTViewOperations implements ViewOperations {
+ private final RESTClient client;
+ private final String path;
+ private final Supplier<Map<String, String>> headers;
+ private ViewMetadata current;
+
+ RESTViewOperations(
+ RESTClient client, String path, Supplier<Map<String, String>> headers,
ViewMetadata current) {
+ Preconditions.checkArgument(null != current, "Invalid view metadata:
null");
+ this.client = client;
+ this.path = path;
+ this.headers = headers;
+ this.current = current;
+ }
+
+ @Override
+ public ViewMetadata current() {
+ return current;
+ }
+
+ @Override
+ public ViewMetadata refresh() {
+ return updateCurrentMetadata(
+ client.get(path, LoadViewResponse.class, headers,
ErrorHandlers.viewErrorHandler()));
+ }
+
+ @Override
+ public void commit(ViewMetadata base, ViewMetadata metadata) {
+ // this is only used for replacing view metadata
+ Preconditions.checkState(base != null, "Invalid base metadata: null");
+
+ UpdateTableRequest request =
+ UpdateTableRequest.create(
+ null,
+ ImmutableList.of(new
UpdateRequirement.AssertTableUUID(base.uuid())),
+ metadata.changes());
+
+ LoadViewResponse response =
+ client.post(
+ path, request, LoadViewResponse.class, headers,
ErrorHandlers.viewCommitHandler());
+
+ updateCurrentMetadata(response);
+ }
+
+ private ViewMetadata updateCurrentMetadata(LoadViewResponse response) {
+ if (!Objects.equals(current.metadataFileLocation(),
response.metadataLocation())) {
+ this.current = response.metadata();
+ }
+
+ return current;
+ }
+}
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 b5b974f14c..c68a4f4508 100644
--- a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
+++ b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
@@ -93,4 +93,22 @@ public class ResourcePaths {
public String commitTransaction() {
return SLASH.join("v1", prefix, "transactions", "commit");
}
+
+ public String views(Namespace ns) {
+ return SLASH.join("v1", prefix, "namespaces",
RESTUtil.encodeNamespace(ns), "views");
+ }
+
+ public String view(TableIdentifier ident) {
+ return SLASH.join(
+ "v1",
+ prefix,
+ "namespaces",
+ RESTUtil.encodeNamespace(ident.namespace()),
+ "views",
+ RESTUtil.encodeString(ident.name()));
+ }
+
+ public String renameView() {
+ return SLASH.join("v1", prefix, "views", "rename");
+ }
}
diff --git
a/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequest.java
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequest.java
new file mode 100644
index 0000000000..1c355273e0
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequest.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.requests;
+
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.rest.RESTRequest;
+import org.apache.iceberg.view.ViewVersion;
+import org.immutables.value.Value;
+
[email protected]
+public interface CreateViewRequest extends RESTRequest {
+ String name();
+
+ @Nullable
+ String location();
+
+ Schema schema();
+
+ ViewVersion viewVersion();
+
+ Map<String, String> properties();
+
+ @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/CreateViewRequestParser.java
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
new file mode 100644
index 0000000000..7a66bc4a1e
--- /dev/null
+++
b/core/src/main/java/org/apache/iceberg/rest/requests/CreateViewRequestParser.java
@@ -0,0 +1,99 @@
+/*
+ * 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.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.JsonUtil;
+import org.apache.iceberg.view.ViewVersion;
+import org.apache.iceberg.view.ViewVersionParser;
+
+public class CreateViewRequestParser {
+
+ private static final String NAME = "name";
+ private static final String LOCATION = "location";
+ private static final String SCHEMA = "schema";
+ private static final String VIEW_VERSION = "view-version";
+ private static final String PROPERTIES = "properties";
+
+ private CreateViewRequestParser() {}
+
+ public static String toJson(CreateViewRequest request) {
+ return toJson(request, false);
+ }
+
+ public static String toJson(CreateViewRequest request, boolean pretty) {
+ return JsonUtil.generate(gen -> toJson(request, gen), pretty);
+ }
+
+ public static void toJson(CreateViewRequest request, JsonGenerator gen)
throws IOException {
+ Preconditions.checkArgument(null != request, "Invalid create view request:
null");
+
+ gen.writeStartObject();
+
+ gen.writeStringField(NAME, request.name());
+
+ if (null != request.location()) {
+ gen.writeStringField(LOCATION, request.location());
+ }
+
+ gen.writeFieldName(VIEW_VERSION);
+ ViewVersionParser.toJson(request.viewVersion(), gen);
+
+ gen.writeFieldName(SCHEMA);
+ SchemaParser.toJson(request.schema(), gen);
+
+ if (!request.properties().isEmpty()) {
+ JsonUtil.writeStringMap(PROPERTIES, request.properties(), gen);
+ }
+
+ gen.writeEndObject();
+ }
+
+ public static CreateViewRequest fromJson(String json) {
+ return JsonUtil.parse(json, CreateViewRequestParser::fromJson);
+ }
+
+ public static CreateViewRequest fromJson(JsonNode json) {
+ Preconditions.checkArgument(null != json, "Cannot parse create view
request from null object");
+
+ String name = JsonUtil.getString(NAME, json);
+ String location = JsonUtil.getStringOrNull(LOCATION, json);
+
+ ViewVersion viewVersion =
ViewVersionParser.fromJson(JsonUtil.get(VIEW_VERSION, json));
+ Schema schema = SchemaParser.fromJson(JsonUtil.get(SCHEMA, json));
+
+ ImmutableCreateViewRequest.Builder builder =
+ ImmutableCreateViewRequest.builder()
+ .name(name)
+ .location(location)
+ .viewVersion(viewVersion)
+ .schema(schema);
+
+ if (json.has(PROPERTIES)) {
+ builder.properties(JsonUtil.getStringMap(PROPERTIES, json));
+ }
+
+ return builder.build();
+ }
+}
diff --git
a/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java
b/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java
index bb44410f2b..fad0ea3cd2 100644
---
a/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java
+++
b/core/src/main/java/org/apache/iceberg/rest/requests/RenameTableRequest.java
@@ -23,7 +23,7 @@ import
org.apache.iceberg.relocated.com.google.common.base.MoreObjects;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.apache.iceberg.rest.RESTRequest;
-/** A REST request to rename a table. */
+/** A REST request to rename a table or a view. */
public class RenameTableRequest implements RESTRequest {
private TableIdentifier source;
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
new file mode 100644
index 0000000000..d07ba872fd
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponse.java
@@ -0,0 +1,38 @@
+/*
+ * 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.responses;
+
+import java.util.Map;
+import org.apache.iceberg.rest.RESTResponse;
+import org.apache.iceberg.view.ViewMetadata;
+import org.immutables.value.Value;
+
[email protected]
+public interface LoadViewResponse extends RESTResponse {
+ String metadataLocation();
+
+ ViewMetadata metadata();
+
+ Map<String, String> config();
+
+ @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
new file mode 100644
index 0000000000..a8aaf17e5d
--- /dev/null
+++
b/core/src/main/java/org/apache/iceberg/rest/responses/LoadViewResponseParser.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.rest.responses;
+
+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;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewMetadataParser;
+
+public class LoadViewResponseParser {
+
+ private static final String METADATA_LOCATION = "metadata-location";
+ private static final String METADATA = "metadata";
+ private static final String CONFIG = "config";
+
+ private LoadViewResponseParser() {}
+
+ public static String toJson(LoadViewResponse response) {
+ return toJson(response, false);
+ }
+
+ public static String toJson(LoadViewResponse response, boolean pretty) {
+ return JsonUtil.generate(gen -> toJson(response, gen), pretty);
+ }
+
+ public static void toJson(LoadViewResponse response, JsonGenerator gen)
throws IOException {
+ Preconditions.checkArgument(null != response, "Invalid load view response:
null");
+
+ gen.writeStartObject();
+
+ gen.writeStringField(METADATA_LOCATION, response.metadataLocation());
+
+ gen.writeFieldName(METADATA);
+ ViewMetadataParser.toJson(response.metadata(), gen);
+
+ if (!response.config().isEmpty()) {
+ JsonUtil.writeStringMap(CONFIG, response.config(), gen);
+ }
+
+ gen.writeEndObject();
+ }
+
+ public static LoadViewResponse fromJson(String json) {
+ return JsonUtil.parse(json, LoadViewResponseParser::fromJson);
+ }
+
+ public static LoadViewResponse fromJson(JsonNode json) {
+ Preconditions.checkArgument(null != json, "Cannot parse load view response
from null object");
+
+ String metadataLocation = JsonUtil.getString(METADATA_LOCATION, json);
+ ViewMetadata metadata = ViewMetadataParser.fromJson(JsonUtil.get(METADATA,
json));
+
+ if (null == metadata.metadataFileLocation()) {
+ metadata =
ViewMetadata.buildFrom(metadata).setMetadataLocation(metadataLocation).build();
+ }
+
+ ImmutableLoadViewResponse.Builder builder =
+
ImmutableLoadViewResponse.builder().metadataLocation(metadataLocation).metadata(metadata);
+
+ if (json.has(CONFIG)) {
+ builder.config(JsonUtil.getStringMap(CONFIG, json));
+ }
+
+ return builder.build();
+ }
+}
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java
b/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java
index 51921d476c..fa75c352f1 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewMetadata.java
@@ -155,6 +155,7 @@ public interface ViewMetadata extends Serializable {
// internal change tracking
private Integer lastAddedVersionId = null;
+ private Integer lastAddedSchemaId = null;
// indexes
private final Map<Integer, ViewVersion> versionsById;
@@ -257,8 +258,13 @@ public interface ViewMetadata extends Serializable {
return this;
}
- private int addVersionInternal(ViewVersion version) {
- int newVersionId = reuseOrCreateNewViewVersionId(version);
+ private int addVersionInternal(ViewVersion newVersion) {
+ int newVersionId = reuseOrCreateNewViewVersionId(newVersion);
+ ViewVersion version = newVersion;
+ if (newVersionId != version.versionId()) {
+ version =
ImmutableViewVersion.builder().from(version).versionId(newVersionId).build();
+ }
+
if (versionsById.containsKey(newVersionId)) {
boolean addedInBuilder =
changes(MetadataUpdate.AddViewVersion.class)
@@ -267,6 +273,13 @@ public interface ViewMetadata extends Serializable {
return newVersionId;
}
+ if (newVersion.schemaId() == LAST_ADDED) {
+ ValidationException.check(
+ lastAddedSchemaId != null, "Cannot set last added schema: no
schema has been added");
+ version =
+
ImmutableViewVersion.builder().from(newVersion).schemaId(lastAddedSchemaId).build();
+ }
+
Preconditions.checkArgument(
schemasById.containsKey(version.schemaId()),
"Cannot add version with unknown schema: %s",
@@ -283,20 +296,21 @@ public interface ViewMetadata extends Serializable {
}
}
- ViewVersion newVersion;
- if (newVersionId != version.versionId()) {
- newVersion =
ImmutableViewVersion.builder().from(version).versionId(newVersionId).build();
+ versions.add(version);
+ versionsById.put(version.versionId(), version);
+
+ if (null != lastAddedSchemaId && version.schemaId() ==
lastAddedSchemaId) {
+ changes.add(
+ new MetadataUpdate.AddViewVersion(
+
ImmutableViewVersion.builder().from(version).schemaId(LAST_ADDED).build()));
} else {
- newVersion = version;
+ changes.add(new MetadataUpdate.AddViewVersion(version));
}
- versions.add(newVersion);
- versionsById.put(newVersion.versionId(), newVersion);
- changes.add(new MetadataUpdate.AddViewVersion(newVersion));
history.add(
ImmutableViewHistoryEntry.builder()
- .timestampMillis(newVersion.timestampMillis())
- .versionId(newVersion.versionId())
+ .timestampMillis(version.timestampMillis())
+ .versionId(version.versionId())
.build());
this.lastAddedVersionId = newVersionId;
@@ -358,6 +372,8 @@ public interface ViewMetadata extends Serializable {
schemasById.put(newSchema.schemaId(), newSchema);
changes.add(new MetadataUpdate.AddSchema(newSchema, highestFieldId));
+ this.lastAddedSchemaId = newSchemaId;
+
return newSchemaId;
}
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java
b/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java
index f50a6c4a85..5e7f2d9d1d 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java
@@ -62,7 +62,7 @@ public class ViewMetadataParser {
return JsonUtil.generate(gen -> toJson(metadata, gen), pretty);
}
- static void toJson(ViewMetadata metadata, JsonGenerator gen) throws
IOException {
+ public static void toJson(ViewMetadata metadata, JsonGenerator gen) throws
IOException {
Preconditions.checkArgument(null != metadata, "Invalid view metadata:
null");
gen.writeStartObject();
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java
b/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java
index 8bdbdc431c..2645e40d94 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewVersionParser.java
@@ -65,7 +65,7 @@ public class ViewVersionParser {
generator.writeEndObject();
}
- static String toJson(ViewVersion version) {
+ public static String toJson(ViewVersion version) {
return JsonUtil.generate(gen -> toJson(version, gen), false);
}
diff --git a/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java
b/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java
index c1ac43e829..8b3d087940 100644
--- a/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java
+++ b/core/src/main/java/org/apache/iceberg/view/ViewVersionReplace.java
@@ -55,7 +55,7 @@ class ViewVersionReplace implements ReplaceViewVersion {
return internalApply().currentVersion();
}
- private ViewMetadata internalApply() {
+ ViewMetadata internalApply() {
Preconditions.checkState(
!representations.isEmpty(), "Cannot replace view without specifying a
query");
Preconditions.checkState(null != schema, "Cannot replace view without
specifying schema");
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 0772601b77..1974838ede 100644
--- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
+++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
@@ -31,6 +31,7 @@ import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.SupportsNamespaces;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.exceptions.CommitStateUnknownException;
@@ -39,6 +40,7 @@ import
org.apache.iceberg.exceptions.NamespaceNotEmptyException;
import org.apache.iceberg.exceptions.NoSuchIcebergTableException;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.exceptions.NoSuchViewException;
import org.apache.iceberg.exceptions.NotAuthorizedException;
import org.apache.iceberg.exceptions.RESTException;
import org.apache.iceberg.exceptions.UnprocessableEntityException;
@@ -49,6 +51,7 @@ import
org.apache.iceberg.relocated.com.google.common.collect.Lists;
import org.apache.iceberg.rest.requests.CommitTransactionRequest;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.CreateViewRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.apache.iceberg.rest.requests.ReportMetricsRequest;
@@ -61,6 +64,7 @@ import org.apache.iceberg.rest.responses.GetNamespaceResponse;
import org.apache.iceberg.rest.responses.ListNamespacesResponse;
import org.apache.iceberg.rest.responses.ListTablesResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
import org.apache.iceberg.rest.responses.OAuthTokenResponse;
import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
import org.apache.iceberg.util.Pair;
@@ -79,6 +83,7 @@ public class RESTCatalogAdapter implements RESTClient {
.put(ForbiddenException.class, 403)
.put(NoSuchNamespaceException.class, 404)
.put(NoSuchTableException.class, 404)
+ .put(NoSuchViewException.class, 404)
.put(NoSuchIcebergTableException.class, 404)
.put(UnsupportedOperationException.class, 406)
.put(AlreadyExistsException.class, 409)
@@ -89,11 +94,13 @@ public class RESTCatalogAdapter implements RESTClient {
private final Catalog catalog;
private final SupportsNamespaces asNamespaceCatalog;
+ private final ViewCatalog asViewCatalog;
public RESTCatalogAdapter(Catalog catalog) {
this.catalog = catalog;
this.asNamespaceCatalog =
catalog instanceof SupportsNamespaces ? (SupportsNamespaces) catalog :
null;
+ this.asViewCatalog = catalog instanceof ViewCatalog ? (ViewCatalog)
catalog : null;
}
enum HTTPMethod {
@@ -126,7 +133,7 @@ public class RESTCatalogAdapter implements RESTClient {
CreateTableRequest.class,
LoadTableResponse.class),
LOAD_TABLE(
- HTTPMethod.GET, "v1/namespaces/{namespace}/tables/{table}", null,
LoadTableResponse.class),
+ HTTPMethod.GET, "v1/namespaces/{namespace}/tables/{name}", null,
LoadTableResponse.class),
REGISTER_TABLE(
HTTPMethod.POST,
"v1/namespaces/{namespace}/register",
@@ -134,18 +141,33 @@ public class RESTCatalogAdapter implements RESTClient {
LoadTableResponse.class),
UPDATE_TABLE(
HTTPMethod.POST,
- "v1/namespaces/{namespace}/tables/{table}",
+ "v1/namespaces/{namespace}/tables/{name}",
UpdateTableRequest.class,
LoadTableResponse.class),
- DROP_TABLE(HTTPMethod.DELETE, "v1/namespaces/{namespace}/tables/{table}"),
+ DROP_TABLE(HTTPMethod.DELETE, "v1/namespaces/{namespace}/tables/{name}"),
RENAME_TABLE(HTTPMethod.POST, "v1/tables/rename",
RenameTableRequest.class, null),
REPORT_METRICS(
HTTPMethod.POST,
- "v1/namespaces/{namespace}/tables/{table}/metrics",
+ "v1/namespaces/{namespace}/tables/{name}/metrics",
ReportMetricsRequest.class,
null),
COMMIT_TRANSACTION(
- HTTPMethod.POST, "v1/transactions/commit",
CommitTransactionRequest.class, null);
+ HTTPMethod.POST, "v1/transactions/commit",
CommitTransactionRequest.class, null),
+ LIST_VIEWS(HTTPMethod.GET, "v1/namespaces/{namespace}/views", null,
ListTablesResponse.class),
+ LOAD_VIEW(
+ HTTPMethod.GET, "v1/namespaces/{namespace}/views/{name}", null,
LoadViewResponse.class),
+ CREATE_VIEW(
+ HTTPMethod.POST,
+ "v1/namespaces/{namespace}/views",
+ CreateViewRequest.class,
+ LoadViewResponse.class),
+ UPDATE_VIEW(
+ HTTPMethod.POST,
+ "v1/namespaces/{namespace}/views/{name}",
+ UpdateTableRequest.class,
+ LoadViewResponse.class),
+ RENAME_VIEW(HTTPMethod.POST, "v1/views/rename", RenameTableRequest.class,
null),
+ DROP_VIEW(HTTPMethod.DELETE, "v1/namespaces/{namespace}/views/{name}");
private final HTTPMethod method;
private final int requiredLength;
@@ -223,7 +245,7 @@ public class RESTCatalogAdapter implements RESTClient {
}
}
- @SuppressWarnings("MethodLength")
+ @SuppressWarnings({"MethodLength", "checkstyle:CyclomaticComplexity"})
public <T extends RESTResponse> T handleRequest(
Route route, Map<String, String> vars, Object body, Class<T>
responseType) {
switch (route) {
@@ -387,6 +409,65 @@ public class RESTCatalogAdapter implements RESTClient {
return null;
}
+ case LIST_VIEWS:
+ {
+ if (null != asViewCatalog) {
+ Namespace namespace = namespaceFromPathVars(vars);
+ return castResponse(responseType,
CatalogHandlers.listViews(asViewCatalog, namespace));
+ }
+ break;
+ }
+
+ case CREATE_VIEW:
+ {
+ if (null != asViewCatalog) {
+ Namespace namespace = namespaceFromPathVars(vars);
+ CreateViewRequest request = castRequest(CreateViewRequest.class,
body);
+ return castResponse(
+ responseType, CatalogHandlers.createView(asViewCatalog,
namespace, request));
+ }
+ break;
+ }
+
+ case LOAD_VIEW:
+ {
+ if (null != asViewCatalog) {
+ TableIdentifier ident = identFromPathVars(vars);
+ return castResponse(responseType,
CatalogHandlers.loadView(asViewCatalog, ident));
+ }
+ break;
+ }
+
+ case UPDATE_VIEW:
+ {
+ if (null != asViewCatalog) {
+ TableIdentifier ident = identFromPathVars(vars);
+ UpdateTableRequest request = castRequest(UpdateTableRequest.class,
body);
+ return castResponse(
+ responseType, CatalogHandlers.updateView(asViewCatalog, ident,
request));
+ }
+ break;
+ }
+
+ case RENAME_VIEW:
+ {
+ if (null != asViewCatalog) {
+ RenameTableRequest request = castRequest(RenameTableRequest.class,
body);
+ CatalogHandlers.renameView(asViewCatalog, request);
+ return null;
+ }
+ break;
+ }
+
+ case DROP_VIEW:
+ {
+ if (null != asViewCatalog) {
+ CatalogHandlers.dropView(asViewCatalog, identFromPathVars(vars));
+ return null;
+ }
+ break;
+ }
+
default:
}
@@ -566,6 +647,6 @@ public class RESTCatalogAdapter implements RESTClient {
private static TableIdentifier identFromPathVars(Map<String, String>
pathVars) {
return TableIdentifier.of(
- namespaceFromPathVars(pathVars),
RESTUtil.decodeString(pathVars.get("table")));
+ namespaceFromPathVars(pathVars),
RESTUtil.decodeString(pathVars.get("name")));
}
}
diff --git
a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java
b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java
new file mode 100644
index 0000000000..0b29da7042
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java
@@ -0,0 +1,166 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Consumer;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.SessionCatalog;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.rest.RESTCatalogAdapter.HTTPMethod;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.apache.iceberg.view.ViewCatalogTests;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.handler.gzip.GzipHandler;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.io.TempDir;
+
+public class TestRESTViewCatalog extends ViewCatalogTests<RESTCatalog> {
+ private static final ObjectMapper MAPPER = RESTObjectMapper.mapper();
+
+ @TempDir private Path temp;
+
+ private RESTCatalog restCatalog;
+ private InMemoryCatalog backendCatalog;
+ private Server httpServer;
+
+ @BeforeEach
+ public void createCatalog() throws Exception {
+ File warehouse = temp.toFile();
+
+ this.backendCatalog = new InMemoryCatalog();
+ this.backendCatalog.initialize(
+ "in-memory",
+ ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION,
warehouse.getAbsolutePath()));
+
+ RESTCatalogAdapter adaptor =
+ new RESTCatalogAdapter(backendCatalog) {
+ @Override
+ public <T extends RESTResponse> T execute(
+ HTTPMethod method,
+ String path,
+ Map<String, String> queryParams,
+ Object body,
+ Class<T> responseType,
+ Map<String, String> headers,
+ Consumer<ErrorResponse> errorHandler) {
+ Object request = roundTripSerialize(body, "request");
+ T response =
+ super.execute(
+ method, path, queryParams, request, responseType, headers,
errorHandler);
+ T responseAfterSerialization = roundTripSerialize(response,
"response");
+ return responseAfterSerialization;
+ }
+ };
+
+ RESTCatalogServlet servlet = new RESTCatalogServlet(adaptor);
+ ServletContextHandler servletContext =
+ new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
+ servletContext.setContextPath("/");
+ ServletHolder servletHolder = new ServletHolder(servlet);
+ servletHolder.setInitParameter("javax.ws.rs.Application",
"ServiceListPublic");
+ servletContext.addServlet(servletHolder, "/*");
+ servletContext.setVirtualHosts(null);
+ servletContext.setGzipHandler(new GzipHandler());
+
+ this.httpServer = new Server(0);
+ httpServer.setHandler(servletContext);
+ httpServer.start();
+
+ SessionCatalog.SessionContext context =
+ new SessionCatalog.SessionContext(
+ UUID.randomUUID().toString(),
+ "user",
+ ImmutableMap.of("credential", "user:12345"),
+ ImmutableMap.of());
+
+ this.restCatalog =
+ new RESTCatalog(
+ context,
+ (config) ->
HTTPClient.builder(config).uri(config.get(CatalogProperties.URI)).build());
+ restCatalog.initialize(
+ "prod",
+ ImmutableMap.of(
+ CatalogProperties.URI, httpServer.getURI().toString(),
"credential", "catalog:12345"));
+ }
+
+ @SuppressWarnings("unchecked")
+ public static <T> T roundTripSerialize(T payload, String description) {
+ if (payload != null) {
+ try {
+ if (payload instanceof RESTMessage) {
+ return (T) MAPPER.readValue(MAPPER.writeValueAsString(payload),
payload.getClass());
+ } else {
+ // use Map so that Jackson doesn't try to instantiate ImmutableMap
from payload.getClass()
+ return (T) MAPPER.readValue(MAPPER.writeValueAsString(payload),
Map.class);
+ }
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(
+ String.format("Failed to serialize and deserialize %s: %s",
description, payload), e);
+ }
+ }
+ return null;
+ }
+
+ @AfterEach
+ public void closeCatalog() throws Exception {
+ if (restCatalog != null) {
+ restCatalog.close();
+ }
+
+ if (backendCatalog != null) {
+ backendCatalog.close();
+ }
+
+ if (httpServer != null) {
+ httpServer.stop();
+ httpServer.join();
+ }
+ }
+
+ @Override
+ protected RESTCatalog catalog() {
+ return restCatalog;
+ }
+
+ @Override
+ protected Catalog tableCatalog() {
+ return restCatalog;
+ }
+
+ @Override
+ protected boolean requiresNamespaceCreate() {
+ return true;
+ }
+
+ @Override
+ protected boolean supportsServerSideRetry() {
+ return true;
+ }
+}
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 e0e61a594e..4b91fbbad3 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
@@ -143,4 +143,51 @@ public class TestResourcePaths {
.isEqualTo("v1/ws/catalog/namespaces/ns/register");
Assertions.assertThat(withoutPrefix.register(ns)).isEqualTo("v1/namespaces/ns/register");
}
+
+ @Test
+ public void views() {
+ Namespace ns = Namespace.of("ns");
+
Assertions.assertThat(withPrefix.views(ns)).isEqualTo("v1/ws/catalog/namespaces/ns/views");
+
Assertions.assertThat(withoutPrefix.views(ns)).isEqualTo("v1/namespaces/ns/views");
+ }
+
+ @Test
+ public void viewsWithSlash() {
+ Namespace ns = Namespace.of("n/s");
+
Assertions.assertThat(withPrefix.views(ns)).isEqualTo("v1/ws/catalog/namespaces/n%2Fs/views");
+
Assertions.assertThat(withoutPrefix.views(ns)).isEqualTo("v1/namespaces/n%2Fs/views");
+ }
+
+ @Test
+ public void viewsWithMultipartNamespace() {
+ Namespace ns = Namespace.of("n", "s");
+
Assertions.assertThat(withPrefix.views(ns)).isEqualTo("v1/ws/catalog/namespaces/n%1Fs/views");
+
Assertions.assertThat(withoutPrefix.views(ns)).isEqualTo("v1/namespaces/n%1Fs/views");
+ }
+
+ @Test
+ public void view() {
+ TableIdentifier ident = TableIdentifier.of("ns", "view-name");
+ Assertions.assertThat(withPrefix.view(ident))
+ .isEqualTo("v1/ws/catalog/namespaces/ns/views/view-name");
+
Assertions.assertThat(withoutPrefix.view(ident)).isEqualTo("v1/namespaces/ns/views/view-name");
+ }
+
+ @Test
+ public void viewWithSlash() {
+ TableIdentifier ident = TableIdentifier.of("n/s", "vi/ew-name");
+ Assertions.assertThat(withPrefix.view(ident))
+ .isEqualTo("v1/ws/catalog/namespaces/n%2Fs/views/vi%2Few-name");
+ Assertions.assertThat(withoutPrefix.view(ident))
+ .isEqualTo("v1/namespaces/n%2Fs/views/vi%2Few-name");
+ }
+
+ @Test
+ public void viewWithMultipartNamespace() {
+ TableIdentifier ident = TableIdentifier.of("n", "s", "view-name");
+ Assertions.assertThat(withPrefix.view(ident))
+ .isEqualTo("v1/ws/catalog/namespaces/n%1Fs/views/view-name");
+ Assertions.assertThat(withoutPrefix.view(ident))
+ .isEqualTo("v1/namespaces/n%1Fs/views/view-name");
+ }
}
diff --git
a/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
b/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
new file mode 100644
index 0000000000..a228c94a08
--- /dev/null
+++
b/core/src/test/java/org/apache/iceberg/rest/requests/TestCreateViewRequestParser.java
@@ -0,0 +1,130 @@
+/*
+ * 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.apache.iceberg.Schema;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.view.ImmutableViewVersion;
+import org.apache.iceberg.view.ViewVersionParser;
+import org.junit.jupiter.api.Test;
+
+public class TestCreateViewRequestParser {
+
+ @Test
+ public void nullAndEmptyCheck() {
+ assertThatThrownBy(() -> CreateViewRequestParser.toJson(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Invalid create view request: null");
+
+ assertThatThrownBy(() -> CreateViewRequestParser.fromJson((JsonNode) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse create view request from null object");
+
+ assertThatThrownBy(() -> CreateViewRequestParser.fromJson("{}"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse missing string: name");
+ }
+
+ @Test
+ public void missingFields() {
+ assertThatThrownBy(() -> CreateViewRequestParser.fromJson("{\"x\":
\"val\"}"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse missing string: name");
+
+ assertThatThrownBy(() -> CreateViewRequestParser.fromJson("{\"name\":
\"view-name\"}"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse missing field: view-version");
+
+ String viewVersion =
+ ViewVersionParser.toJson(
+ ImmutableViewVersion.builder()
+ .schemaId(0)
+ .versionId(1)
+ .timestampMillis(23L)
+ .defaultNamespace(Namespace.of("ns1"))
+ .build());
+
+ assertThatThrownBy(
+ () ->
+ CreateViewRequestParser.fromJson(
+ String.format(
+ "{\"name\": \"view-name\", \"location\": \"loc\",
\"view-version\": %s}",
+ viewVersion)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse missing field: schema");
+ }
+
+ @Test
+ public void roundTripSerde() {
+ CreateViewRequest request =
+ ImmutableCreateViewRequest.builder()
+ .name("view-name")
+ .viewVersion(
+ ImmutableViewVersion.builder()
+ .schemaId(0)
+ .versionId(1)
+ .timestampMillis(23L)
+ .defaultNamespace(Namespace.of("ns1"))
+ .build())
+ .location("location")
+ .schema(new Schema(Types.NestedField.required(1, "x",
Types.LongType.get())))
+ .properties(ImmutableMap.of("key1", "val1"))
+ .build();
+
+ String expectedJson =
+ "{\n"
+ + " \"name\" : \"view-name\",\n"
+ + " \"location\" : \"location\",\n"
+ + " \"view-version\" : {\n"
+ + " \"version-id\" : 1,\n"
+ + " \"timestamp-ms\" : 23,\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"summary\" : { },\n"
+ + " \"default-namespace\" : [ \"ns1\" ],\n"
+ + " \"representations\" : [ ]\n"
+ + " },\n"
+ + " \"schema\" : {\n"
+ + " \"type\" : \"struct\",\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"fields\" : [ {\n"
+ + " \"id\" : 1,\n"
+ + " \"name\" : \"x\",\n"
+ + " \"required\" : true,\n"
+ + " \"type\" : \"long\"\n"
+ + " } ]\n"
+ + " },\n"
+ + " \"properties\" : {\n"
+ + " \"key1\" : \"val1\"\n"
+ + " }\n"
+ + "}";
+
+ String json = CreateViewRequestParser.toJson(request, true);
+ assertThat(json).isEqualTo(expectedJson);
+
+ // can't do an equality comparison because Schema doesn't implement
equals/hashCode
+
assertThat(CreateViewRequestParser.toJson(CreateViewRequestParser.fromJson(json),
true))
+ .isEqualTo(expectedJson);
+ }
+}
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
new file mode 100644
index 0000000000..d94d035596
--- /dev/null
+++
b/core/src/test/java/org/apache/iceberg/rest/responses/TestLoadViewResponseParser.java
@@ -0,0 +1,260 @@
+/*
+ * 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.responses;
+
+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.Schema;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.view.ImmutableViewVersion;
+import org.apache.iceberg.view.ViewMetadata;
+import org.junit.jupiter.api.Test;
+
+public class TestLoadViewResponseParser {
+
+ @Test
+ public void nullAndEmptyCheck() {
+ assertThatThrownBy(() -> LoadViewResponseParser.toJson(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Invalid load view response: null");
+
+ assertThatThrownBy(() -> LoadViewResponseParser.fromJson((JsonNode) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse load view response from null object");
+
+ assertThatThrownBy(() -> LoadViewResponseParser.fromJson("{}"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse missing string: metadata-location");
+ }
+
+ @Test
+ public void missingFields() {
+ assertThatThrownBy(() -> LoadViewResponseParser.fromJson("{\"x\":
\"val\"}"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse missing string: metadata-location");
+
+ assertThatThrownBy(
+ () -> LoadViewResponseParser.fromJson("{\"metadata-location\":
\"custom-location\"}"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Cannot parse missing field: metadata");
+ }
+
+ @Test
+ public void roundTripSerde() {
+ 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())
+ .addVersion(
+ ImmutableViewVersion.builder()
+ .schemaId(0)
+ .versionId(2)
+ .timestampMillis(24L)
+ .defaultNamespace(Namespace.of("ns2"))
+ .build())
+ .addVersion(
+ ImmutableViewVersion.builder()
+ .schemaId(0)
+ .versionId(3)
+ .timestampMillis(25L)
+ .defaultNamespace(Namespace.of("ns3"))
+ .build())
+ .setCurrentVersionId(3)
+ .build();
+
+ LoadViewResponse response =
+ ImmutableLoadViewResponse.builder()
+ .metadata(viewMetadata)
+ .metadataLocation("custom-location")
+ .build();
+ String expectedJson =
+ "{\n"
+ + " \"metadata-location\" : \"custom-location\",\n"
+ + " \"metadata\" : {\n"
+ + " \"view-uuid\" : \"386b9f01-002b-4d8c-b77f-42c3fd3b7c9b\",\n"
+ + " \"format-version\" : 1,\n"
+ + " \"location\" : \"location\",\n"
+ + " \"schemas\" : [ {\n"
+ + " \"type\" : \"struct\",\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"fields\" : [ {\n"
+ + " \"id\" : 1,\n"
+ + " \"name\" : \"x\",\n"
+ + " \"required\" : true,\n"
+ + " \"type\" : \"long\"\n"
+ + " } ]\n"
+ + " } ],\n"
+ + " \"current-version-id\" : 3,\n"
+ + " \"versions\" : [ {\n"
+ + " \"version-id\" : 1,\n"
+ + " \"timestamp-ms\" : 23,\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"summary\" : { },\n"
+ + " \"default-namespace\" : [ \"ns1\" ],\n"
+ + " \"representations\" : [ ]\n"
+ + " }, {\n"
+ + " \"version-id\" : 2,\n"
+ + " \"timestamp-ms\" : 24,\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"summary\" : { },\n"
+ + " \"default-namespace\" : [ \"ns2\" ],\n"
+ + " \"representations\" : [ ]\n"
+ + " }, {\n"
+ + " \"version-id\" : 3,\n"
+ + " \"timestamp-ms\" : 25,\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"summary\" : { },\n"
+ + " \"default-namespace\" : [ \"ns3\" ],\n"
+ + " \"representations\" : [ ]\n"
+ + " } ],\n"
+ + " \"version-log\" : [ {\n"
+ + " \"timestamp-ms\" : 23,\n"
+ + " \"version-id\" : 1\n"
+ + " }, {\n"
+ + " \"timestamp-ms\" : 24,\n"
+ + " \"version-id\" : 2\n"
+ + " }, {\n"
+ + " \"timestamp-ms\" : 25,\n"
+ + " \"version-id\" : 3\n"
+ + " } ]\n"
+ + " }\n"
+ + "}";
+
+ 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 roundTripSerdeWithConfig() {
+ 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())
+ .addVersion(
+ ImmutableViewVersion.builder()
+ .schemaId(0)
+ .versionId(2)
+ .timestampMillis(24L)
+ .defaultNamespace(Namespace.of("ns2"))
+ .build())
+ .addVersion(
+ ImmutableViewVersion.builder()
+ .schemaId(0)
+ .versionId(3)
+ .timestampMillis(25L)
+ .defaultNamespace(Namespace.of("ns3"))
+ .build())
+ .setCurrentVersionId(3)
+ .build();
+
+ LoadViewResponse response =
+ ImmutableLoadViewResponse.builder()
+ .metadata(viewMetadata)
+ .metadataLocation("custom-location")
+ .config(ImmutableMap.of("key1", "val1", "key2", "val2"))
+ .build();
+ String expectedJson =
+ "{\n"
+ + " \"metadata-location\" : \"custom-location\",\n"
+ + " \"metadata\" : {\n"
+ + " \"view-uuid\" : \"386b9f01-002b-4d8c-b77f-42c3fd3b7c9b\",\n"
+ + " \"format-version\" : 1,\n"
+ + " \"location\" : \"location\",\n"
+ + " \"schemas\" : [ {\n"
+ + " \"type\" : \"struct\",\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"fields\" : [ {\n"
+ + " \"id\" : 1,\n"
+ + " \"name\" : \"x\",\n"
+ + " \"required\" : true,\n"
+ + " \"type\" : \"long\"\n"
+ + " } ]\n"
+ + " } ],\n"
+ + " \"current-version-id\" : 3,\n"
+ + " \"versions\" : [ {\n"
+ + " \"version-id\" : 1,\n"
+ + " \"timestamp-ms\" : 23,\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"summary\" : { },\n"
+ + " \"default-namespace\" : [ \"ns1\" ],\n"
+ + " \"representations\" : [ ]\n"
+ + " }, {\n"
+ + " \"version-id\" : 2,\n"
+ + " \"timestamp-ms\" : 24,\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"summary\" : { },\n"
+ + " \"default-namespace\" : [ \"ns2\" ],\n"
+ + " \"representations\" : [ ]\n"
+ + " }, {\n"
+ + " \"version-id\" : 3,\n"
+ + " \"timestamp-ms\" : 25,\n"
+ + " \"schema-id\" : 0,\n"
+ + " \"summary\" : { },\n"
+ + " \"default-namespace\" : [ \"ns3\" ],\n"
+ + " \"representations\" : [ ]\n"
+ + " } ],\n"
+ + " \"version-log\" : [ {\n"
+ + " \"timestamp-ms\" : 23,\n"
+ + " \"version-id\" : 1\n"
+ + " }, {\n"
+ + " \"timestamp-ms\" : 24,\n"
+ + " \"version-id\" : 2\n"
+ + " }, {\n"
+ + " \"timestamp-ms\" : 25,\n"
+ + " \"version-id\" : 3\n"
+ + " } ]\n"
+ + " },\n"
+ + " \"config\" : {\n"
+ + " \"key1\" : \"val1\",\n"
+ + " \"key2\" : \"val2\"\n"
+ + " }\n"
+ + "}";
+
+ 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);
+ }
+}
diff --git a/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java
b/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java
index 0f1758f85e..e60fe3b285 100644
--- a/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java
+++ b/core/src/test/java/org/apache/iceberg/view/TestViewMetadata.java
@@ -28,6 +28,7 @@ import java.util.UUID;
import org.apache.iceberg.MetadataUpdate;
import org.apache.iceberg.Schema;
import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.ValidationException;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet;
@@ -289,21 +290,21 @@ public class TestViewMetadata {
.isInstanceOf(MetadataUpdate.AddViewVersion.class)
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
.extracting(MetadataUpdate.AddViewVersion::viewVersion)
- .isEqualTo(viewVersionOne);
+
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionOne).schemaId(-1).build());
assertThat(changes)
.element(4)
.isInstanceOf(MetadataUpdate.AddViewVersion.class)
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
.extracting(MetadataUpdate.AddViewVersion::viewVersion)
- .isEqualTo(viewVersionTwo);
+
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionTwo).schemaId(-1).build());
assertThat(changes)
.element(5)
.isInstanceOf(MetadataUpdate.AddViewVersion.class)
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
.extracting(MetadataUpdate.AddViewVersion::viewVersion)
- .isEqualTo(viewVersionThree);
+
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionThree).schemaId(-1).build());
assertThat(changes)
.element(6)
@@ -409,7 +410,7 @@ public class TestViewMetadata {
.isInstanceOf(MetadataUpdate.AddViewVersion.class)
.asInstanceOf(InstanceOfAssertFactories.type(MetadataUpdate.AddViewVersion.class))
.extracting(MetadataUpdate.AddViewVersion::viewVersion)
- .isEqualTo(viewVersionThree);
+
.isEqualTo(ImmutableViewVersion.builder().from(viewVersionThree).schemaId(-1).build());
assertThat(changes)
.element(8)
@@ -784,4 +785,18 @@ public class TestViewMetadata {
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Invalid view version: Cannot add multiple queries for
dialect spark");
}
+
+ @Test
+ public void lastAddedSchemaFailure() {
+ ViewVersion viewVersion = newViewVersion(1, -1, "select * from ns.tbl");
+ assertThatThrownBy(
+ () ->
+ ViewMetadata.builder()
+ .setLocation("custom-location")
+ .addVersion(viewVersion)
+ .setCurrentVersionId(1)
+ .build())
+ .isInstanceOf(ValidationException.class)
+ .hasMessage("Cannot set last added schema: no schema has been added");
+ }
}
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 682d7ade67..8cb77a7762 100644
--- a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java
+++ b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java
@@ -34,6 +34,7 @@ import org.apache.iceberg.catalog.SupportsNamespaces;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.catalog.ViewCatalog;
import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.exceptions.NoSuchViewException;
@@ -69,6 +70,10 @@ public abstract class ViewCatalogTests<C extends ViewCatalog
& SupportsNamespace
return false;
}
+ protected boolean supportsServerSideRetry() {
+ return false;
+ }
+
@Test
public void basicCreateView() {
TableIdentifier identifier = TableIdentifier.of("ns", "view");
@@ -247,8 +252,9 @@ public abstract class ViewCatalogTests<C extends
ViewCatalog & SupportsNamespace
.withQuery(trino.dialect(), trino.sql())
.withQuery(trino.dialect(), trino.sql())
.create())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Invalid view version: Cannot add multiple queries for
dialect trino");
+ .isInstanceOf(Exception.class)
+ .hasMessageContaining(
+ "Invalid view version: Cannot add multiple queries for dialect
trino");
}
@Test
@@ -1534,4 +1540,138 @@ public abstract class ViewCatalogTests<C extends
ViewCatalog & SupportsNamespace
.isInstanceOf(NoSuchViewException.class)
.hasMessageContaining("View does not exist: ns.view");
}
+
+ @Test
+ public void concurrentReplaceViewVersion() {
+ TableIdentifier identifier = TableIdentifier.of("ns", "view");
+
+ if (requiresNamespaceCreate()) {
+ catalog().createNamespace(identifier.namespace());
+ }
+
+ assertThat(catalog().viewExists(identifier)).as("View should not
exist").isFalse();
+
+ View view =
+ catalog()
+ .buildView(identifier)
+ .withSchema(SCHEMA)
+ .withDefaultNamespace(identifier.namespace())
+ .withQuery("trino", "select * from ns.tbl")
+ .create();
+
+ assertThat(catalog().viewExists(identifier)).as("View should
exist").isTrue();
+
+ ReplaceViewVersion replaceViewVersionOne =
+ view.replaceVersion()
+ .withQuery("trino", "select count(id) from ns.tbl")
+ .withSchema(SCHEMA)
+ .withDefaultNamespace(identifier.namespace());
+
+ ReplaceViewVersion replaceViewVersionTwo =
+ view.replaceVersion()
+ .withQuery("spark", "select count(some_id) from ns.tbl")
+ .withSchema(OTHER_SCHEMA)
+ .withDefaultNamespace(identifier.namespace());
+
+ // simulate a concurrent replace of the view version
+ ViewOperations viewOps = ((BaseView) view).operations();
+ ViewMetadata current = viewOps.current();
+
+ ViewMetadata trinoUpdate = ((ViewVersionReplace)
replaceViewVersionTwo).internalApply();
+ ViewMetadata sparkUpdate = ((ViewVersionReplace)
replaceViewVersionOne).internalApply();
+
+ viewOps.commit(current, trinoUpdate);
+
+ if (supportsServerSideRetry()) {
+ // retry should succeed and the changes should be applied
+ viewOps.commit(current, sparkUpdate);
+
+ View updatedView = catalog().loadView(identifier);
+ ViewVersion viewVersion = updatedView.currentVersion();
+ assertThat(viewVersion.versionId()).isEqualTo(3);
+ assertThat(updatedView.versions()).hasSize(3);
+ assertThat(updatedView.version(1))
+ .isEqualTo(
+ ImmutableViewVersion.builder()
+ .timestampMillis(updatedView.version(1).timestampMillis())
+ .versionId(1)
+ .schemaId(0)
+ .summary(updatedView.version(1).summary())
+ .defaultNamespace(identifier.namespace())
+ .addRepresentations(
+ ImmutableSQLViewRepresentation.builder()
+ .sql("select * from ns.tbl")
+ .dialect("trino")
+ .build())
+ .build());
+
+ assertThat(updatedView.version(2))
+ .isEqualTo(
+ ImmutableViewVersion.builder()
+ .timestampMillis(updatedView.version(2).timestampMillis())
+ .versionId(2)
+ .schemaId(1)
+ .summary(updatedView.version(2).summary())
+ .defaultNamespace(identifier.namespace())
+ .addRepresentations(
+ ImmutableSQLViewRepresentation.builder()
+ .sql("select count(some_id) from ns.tbl")
+ .dialect("spark")
+ .build())
+ .build());
+
+ assertThat(updatedView.version(3))
+ .isEqualTo(
+ ImmutableViewVersion.builder()
+ .timestampMillis(updatedView.version(3).timestampMillis())
+ .versionId(3)
+ .schemaId(0)
+ .summary(updatedView.version(3).summary())
+ .defaultNamespace(identifier.namespace())
+ .addRepresentations(
+ ImmutableSQLViewRepresentation.builder()
+ .sql("select count(id) from ns.tbl")
+ .dialect("trino")
+ .build())
+ .build());
+ } else {
+ assertThatThrownBy(() -> viewOps.commit(current, sparkUpdate))
+ .isInstanceOf(CommitFailedException.class)
+ .hasMessageContaining("Cannot commit");
+
+ View updatedView = catalog().loadView(identifier);
+ ViewVersion viewVersion = updatedView.currentVersion();
+ assertThat(viewVersion.versionId()).isEqualTo(2);
+ assertThat(updatedView.versions()).hasSize(2);
+ assertThat(updatedView.version(1))
+ .isEqualTo(
+ ImmutableViewVersion.builder()
+ .timestampMillis(updatedView.version(1).timestampMillis())
+ .versionId(1)
+ .schemaId(0)
+ .summary(updatedView.version(1).summary())
+ .defaultNamespace(identifier.namespace())
+ .addRepresentations(
+ ImmutableSQLViewRepresentation.builder()
+ .sql("select * from ns.tbl")
+ .dialect("trino")
+ .build())
+ .build());
+
+ assertThat(updatedView.version(2))
+ .isEqualTo(
+ ImmutableViewVersion.builder()
+ .timestampMillis(updatedView.version(2).timestampMillis())
+ .versionId(2)
+ .schemaId(1)
+ .summary(updatedView.version(2).summary())
+ .defaultNamespace(identifier.namespace())
+ .addRepresentations(
+ ImmutableSQLViewRepresentation.builder()
+ .sql("select count(some_id) from ns.tbl")
+ .dialect("spark")
+ .build())
+ .build());
+ }
+ }
}
diff --git a/open-api/rest-catalog-open-api.py
b/open-api/rest-catalog-open-api.py
index 4db1fccdc2..5da91a16f9 100644
--- a/open-api/rest-catalog-open-api.py
+++ b/open-api/rest-catalog-open-api.py
@@ -209,6 +209,35 @@ class MetadataLog(BaseModel):
__root__: List[MetadataLogItem]
+class SQLViewRepresentation(BaseModel):
+ type: str
+ sql: str
+ dialect: str
+
+
+class ViewRepresentation(BaseModel):
+ __root__: SQLViewRepresentation
+
+
+class ViewHistoryEntry(BaseModel):
+ version_id: int = Field(..., alias='version-id')
+ timestamp_ms: int = Field(..., alias='timestamp-ms')
+
+
+class ViewVersion(BaseModel):
+ version_id: int = Field(..., alias='version-id')
+ timestamp_ms: int = Field(..., alias='timestamp-ms')
+ schema_id: int = Field(
+ ...,
+ alias='schema-id',
+ description='Schema ID to set as current, or -1 to set last added
schema',
+ )
+ summary: Dict[str, str]
+ representations: List[ViewRepresentation]
+ default_catalog: Optional[str] = Field(None, alias='default-catalog')
+ default_namespace: Namespace = Field(..., alias='default-namespace')
+
+
class BaseUpdate(BaseModel):
action: Literal[
'assign-uuid',
@@ -226,6 +255,8 @@ class BaseUpdate(BaseModel):
'set-location',
'set-properties',
'remove-properties',
+ 'add-view-version',
+ 'set-current-view-version',
]
@@ -301,6 +332,18 @@ class RemovePropertiesUpdate(BaseUpdate):
removals: List[str]
+class AddViewVersionUpdate(BaseUpdate):
+ view_version: ViewVersion = Field(..., alias='view-version')
+
+
+class SetCurrentViewVersionUpdate(BaseUpdate):
+ view_version_id: int = Field(
+ ...,
+ alias='view-version-id',
+ description='The view version id to set as current, or -1 to set last
added view version id',
+ )
+
+
class TableRequirement(BaseModel):
type: str
@@ -675,6 +718,17 @@ class TableMetadata(BaseModel):
metadata_log: Optional[MetadataLog] = Field(None, alias='metadata-log')
+class ViewMetadata(BaseModel):
+ view_uuid: str = Field(..., alias='view-uuid')
+ format_version: int = Field(..., alias='format-version', ge=1, le=1)
+ location: str
+ current_version_id: int = Field(..., alias='current-version-id')
+ versions: List[ViewVersion]
+ version_log: List[ViewHistoryEntry] = Field(..., alias='version-log')
+ schemas: List[Schema]
+ properties: Optional[Dict[str, str]] = None
+
+
class AddSchemaUpdate(BaseUpdate):
schema_: Schema = Field(..., alias='schema')
last_column_id: Optional[int] = Field(
@@ -704,6 +758,19 @@ class TableUpdate(BaseModel):
]
+class ViewUpdate(BaseModel):
+ __root__: Union[
+ AssignUUIDUpdate,
+ UpgradeFormatVersionUpdate,
+ AddSchemaUpdate,
+ SetLocationUpdate,
+ SetPropertiesUpdate,
+ RemovePropertiesUpdate,
+ AddViewVersionUpdate,
+ SetCurrentViewVersionUpdate,
+ ]
+
+
class LoadTableResult(BaseModel):
"""
Result used when a table is successfully loaded.
@@ -751,6 +818,13 @@ class CommitTableRequest(BaseModel):
updates: List[TableUpdate]
+class CommitViewRequest(BaseModel):
+ identifier: Optional[TableIdentifier] = Field(
+ None, description='View identifier to update'
+ )
+ updates: List[ViewUpdate]
+
+
class CommitTransactionRequest(BaseModel):
table_changes: List[CommitTableRequest] = Field(..., alias='table-changes')
@@ -765,6 +839,41 @@ class CreateTableRequest(BaseModel):
properties: Optional[Dict[str, str]] = None
+class CreateViewRequest(BaseModel):
+ name: str
+ location: Optional[str] = None
+ schema_: Schema = Field(..., alias='schema')
+ view_version: ViewVersion = Field(
+ ...,
+ alias='view-version',
+ description='The view version to create, will replace the schema-id
sent within the view-version with the id assigned to the provided schema',
+ )
+ properties: Dict[str, str]
+
+
+class LoadViewResult(BaseModel):
+ """
+ Result used when a view is successfully loaded.
+
+
+ The view metadata JSON is returned in the `metadata` field. The
corresponding file location of view metadata is returned in the
`metadata-location` field.
+ Clients can check whether metadata has changed by comparing metadata
locations after the view has been created.
+
+ The `config` map returns view-specific configuration for the view's
resources.
+
+ The following configurations should be respected by clients:
+
+ ## General Configurations
+
+ - `token`: Authorization bearer token to use for view requests if OAuth2
security is enabled
+
+ """
+
+ metadata_location: str = Field(..., alias='metadata-location')
+ metadata: ViewMetadata
+ config: Optional[Dict[str, str]] = None
+
+
class ReportMetricsRequest2(BaseModel):
__root__: Union[ReportMetricsRequest, ReportMetricsRequest1]
@@ -801,6 +910,8 @@ ListType.update_forward_refs()
MapType.update_forward_refs()
Expression.update_forward_refs()
TableMetadata.update_forward_refs()
+ViewMetadata.update_forward_refs()
AddSchemaUpdate.update_forward_refs()
CreateTableRequest.update_forward_refs()
+CreateViewRequest.update_forward_refs()
ReportMetricsRequest2.update_forward_refs()
diff --git a/open-api/rest-catalog-open-api.yaml
b/open-api/rest-catalog-open-api.yaml
index 9370ef8f2d..c965793cb2 100644
--- a/open-api/rest-catalog-open-api.yaml
+++ b/open-api/rest-catalog-open-api.yaml
@@ -884,7 +884,7 @@ paths:
406:
$ref: '#/components/responses/UnsupportedOperationResponse'
409:
- description: Conflict - The target table identifier to rename to
already exists
+ description: Conflict - The target identifier to rename to already
exists as a table or view
content:
application/json:
schema:
@@ -1056,6 +1056,356 @@ paths:
}
}
+ /v1/{prefix}/namespaces/{namespace}/views:
+ parameters:
+ - $ref: '#/components/parameters/prefix'
+ - $ref: '#/components/parameters/namespace'
+
+ get:
+ tags:
+ - Catalog API
+ summary: List all view identifiers underneath a given namespace
+ description: Return all view identifiers under this namespace
+ operationId: listViews
+ responses:
+ 200:
+ $ref: '#/components/responses/ListTablesResponse'
+ 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/ErrorModel'
+ examples:
+ NamespaceNotFound:
+ $ref: '#/components/examples/NoSuchNamespaceError'
+ 419:
+ $ref: '#/components/responses/AuthenticationTimeoutResponse'
+ 503:
+ $ref: '#/components/responses/ServiceUnavailableResponse'
+ 5XX:
+ $ref: '#/components/responses/ServerErrorResponse'
+
+ post:
+ tags:
+ - Catalog API
+ summary: Create a view in the given namespace
+ description:
+ Create a view in the given namespace.
+ operationId: createView
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateViewRequest'
+ 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/ErrorModel'
+ examples:
+ NamespaceNotFound:
+ $ref: '#/components/examples/NoSuchNamespaceError'
+ 409:
+ description: Conflict - The view already exists
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ examples:
+ NamespaceAlreadyExists:
+ $ref: '#/components/examples/ViewAlreadyExistsError'
+ 419:
+ $ref: '#/components/responses/AuthenticationTimeoutResponse'
+ 503:
+ $ref: '#/components/responses/ServiceUnavailableResponse'
+ 5XX:
+ $ref: '#/components/responses/ServerErrorResponse'
+
+ /v1/{prefix}/namespaces/{namespace}/views/{view}:
+ parameters:
+ - $ref: '#/components/parameters/prefix'
+ - $ref: '#/components/parameters/namespace'
+ - $ref: '#/components/parameters/view'
+
+ get:
+ tags:
+ - Catalog API
+ summary: Load a view from the catalog
+ operationId: loadView
+ description:
+ Load a view from the catalog.
+
+
+ The response contains both configuration and view metadata. The
configuration, if non-empty is used
+ as additional configuration for the view that overrides catalog
configuration.
+
+
+ The response also contains the view's full metadata, matching the view
metadata JSON file.
+
+
+ The catalog configuration may contain credentials that should be used
for subsequent requests for the
+ view. The configuration key "token" is used to pass an access token to
be used as a bearer token
+ for view requests. Otherwise, a token may be passed using a RFC 8693
token type as a configuration
+ key. For example, "urn:ietf:params:oauth:token-type:jwt=<JWT-token>".
+ 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 - NoSuchViewException, view to load does not exist
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ examples:
+ ViewToLoadDoesNotExist:
+ $ref: '#/components/examples/NoSuchViewError'
+ 419:
+ $ref: '#/components/responses/AuthenticationTimeoutResponse'
+ 503:
+ $ref: '#/components/responses/ServiceUnavailableResponse'
+ 5XX:
+ $ref: '#/components/responses/ServerErrorResponse'
+
+ post:
+ tags:
+ - Catalog API
+ summary: Replace a view
+ operationId: replaceView
+ description:
+ Commit updates to a view.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CommitViewRequest'
+ 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 - NoSuchViewException, view to load does not exist
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ examples:
+ ViewToUpdateDoesNotExist:
+ $ref: '#/components/examples/NoSuchViewError'
+ 409:
+ description:
+ Conflict - CommitFailedException. The client may retry.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ 419:
+ $ref: '#/components/responses/AuthenticationTimeoutResponse'
+ 500:
+ description:
+ An unknown server-side problem occurred; the commit state is
unknown.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ example: {
+ "error": {
+ "message": "Internal Server Error",
+ "type": "CommitStateUnknownException",
+ "code": 500
+ }
+ }
+ 503:
+ $ref: '#/components/responses/ServiceUnavailableResponse'
+ 502:
+ description:
+ A gateway or proxy received an invalid response from the upstream
server; the commit state is unknown.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ example: {
+ "error": {
+ "message": "Invalid response from the upstream server",
+ "type": "CommitStateUnknownException",
+ "code": 502
+ }
+ }
+ 504:
+ description:
+ A server-side gateway timeout occurred; the commit state is
unknown.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ example: {
+ "error": {
+ "message": "Gateway timed out during commit",
+ "type": "CommitStateUnknownException",
+ "code": 504
+ }
+ }
+ 5XX:
+ description:
+ A server-side problem that might not be addressable on the client.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ example: {
+ "error": {
+ "message": "Bad Gateway",
+ "type": "InternalServerError",
+ "code": 502
+ }
+ }
+
+ delete:
+ tags:
+ - Catalog API
+ summary: Drop a view from the catalog
+ operationId: dropView
+ description: Remove a view from the catalog
+ responses:
+ 204:
+ description: Success, no content
+ 400:
+ $ref: '#/components/responses/BadRequestErrorResponse'
+ 401:
+ $ref: '#/components/responses/UnauthorizedResponse'
+ 403:
+ $ref: '#/components/responses/ForbiddenResponse'
+ 404:
+ description:
+ Not Found - NoSuchViewException, view to drop does not exist
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ examples:
+ ViewToDeleteDoesNotExist:
+ $ref: '#/components/examples/NoSuchViewError'
+ 419:
+ $ref: '#/components/responses/AuthenticationTimeoutResponse'
+ 503:
+ $ref: '#/components/responses/ServiceUnavailableResponse'
+ 5XX:
+ $ref: '#/components/responses/ServerErrorResponse'
+
+ head:
+ tags:
+ - Catalog API
+ summary: Check if a view exists
+ operationId: viewExists
+ description:
+ Check if a view exists within a given namespace. This request does not
return a response body.
+ responses:
+ 204:
+ description: Success, no content
+ 400:
+ description: Bad Request
+ 401:
+ description: Unauthorized
+ 404:
+ description: Not Found
+ 419:
+ $ref: '#/components/responses/AuthenticationTimeoutResponse'
+ 503:
+ $ref: '#/components/responses/ServiceUnavailableResponse'
+ 5XX:
+ $ref: '#/components/responses/ServerErrorResponse'
+
+ /v1/{prefix}/views/rename:
+ parameters:
+ - $ref: '#/components/parameters/prefix'
+
+ post:
+ tags:
+ - Catalog API
+ summary: Rename a view from its current name to a new name
+ description:
+ Rename a view from one identifier to another. It's valid to move a view
+ across namespaces, but the server implementation is not required to
support it.
+ operationId: renameView
+ requestBody:
+ description: Current view identifier to rename and new view identifier
to rename to
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RenameTableRequest'
+ examples:
+ RenameViewSameNamespace:
+ $ref: '#/components/examples/RenameViewSameNamespace'
+ required: true
+ responses:
+ 200:
+ description: OK
+ 400:
+ $ref: '#/components/responses/BadRequestErrorResponse'
+ 401:
+ $ref: '#/components/responses/UnauthorizedResponse'
+ 403:
+ $ref: '#/components/responses/ForbiddenResponse'
+ 404:
+ description:
+ Not Found
+ - NoSuchViewException, view to rename does not exist
+ - NoSuchNamespaceException, The target namespace of the new
identifier does not exist
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ examples:
+ ViewToRenameDoesNotExist:
+ $ref: '#/components/examples/NoSuchViewError'
+ NamespaceToRenameToDoesNotExist:
+ $ref: '#/components/examples/NoSuchNamespaceError'
+ 406:
+ $ref: '#/components/responses/UnsupportedOperationResponse'
+ 409:
+ description: Conflict - The target identifier to rename to already
exists as a table or view
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorModel'
+ example:
+ $ref: '#/components/examples/ViewAlreadyExistsError'
+ 419:
+ $ref: '#/components/responses/AuthenticationTimeoutResponse'
+ 503:
+ $ref: '#/components/responses/ServiceUnavailableResponse'
+ 5XX:
+ $ref: '#/components/responses/ServerErrorResponse'
+
components:
#######################################################
@@ -1094,6 +1444,15 @@ components:
type: string
example: "sales"
+ view:
+ name: view
+ in: path
+ description: A view name
+ required: true
+ schema:
+ type: string
+ example: "sales"
+
##############################
# Application Schema Objects #
##############################
@@ -1671,6 +2030,105 @@ components:
metadata-log:
$ref: '#/components/schemas/MetadataLog'
+ SQLViewRepresentation:
+ type: object
+ required:
+ - type
+ - sql
+ - dialect
+ properties:
+ type:
+ type: string
+ sql:
+ type: string
+ dialect:
+ type: string
+
+ ViewRepresentation:
+ oneOf:
+ - $ref: '#/components/schemas/SQLViewRepresentation'
+
+ ViewHistoryEntry:
+ type: object
+ required:
+ - version-id
+ - timestamp-ms
+ properties:
+ version-id:
+ type: integer
+ timestamp-ms:
+ type: integer
+ format: int64
+
+ ViewVersion:
+ type: object
+ required:
+ - version-id
+ - timestamp-ms
+ - schema-id
+ - summary
+ - representations
+ - default-namespace
+ properties:
+ version-id:
+ type: integer
+ timestamp-ms:
+ type: integer
+ format: int64
+ schema-id:
+ type: integer
+ description: Schema ID to set as current, or -1 to set last added
schema
+ summary:
+ type: object
+ additionalProperties:
+ type: string
+ representations:
+ type: array
+ items:
+ $ref: '#/components/schemas/ViewRepresentation'
+ default-catalog:
+ type: string
+ default-namespace:
+ $ref: '#/components/schemas/Namespace'
+
+ ViewMetadata:
+ type: object
+ required:
+ - view-uuid
+ - format-version
+ - location
+ - current-version-id
+ - versions
+ - version-log
+ - schemas
+ properties:
+ view-uuid:
+ type: string
+ format-version:
+ type: integer
+ minimum: 1
+ maximum: 1
+ location:
+ type: string
+ current-version-id:
+ type: integer
+ versions:
+ type: array
+ items:
+ $ref: '#/components/schemas/ViewVersion'
+ version-log:
+ type: array
+ items:
+ $ref: '#/components/schemas/ViewHistoryEntry'
+ schemas:
+ type: array
+ items:
+ $ref: '#/components/schemas/Schema'
+ properties:
+ type: object
+ additionalProperties:
+ type: string
+
BaseUpdate:
type: object
required:
@@ -1694,6 +2152,8 @@ components:
- set-location
- set-properties
- remove-properties
+ - add-view-version
+ - set-current-view-version
AssignUUIDUpdate:
description: Assigning a UUID to a table/view should only be done when
creating the table/view. It is not safe to re-assign the UUID if a table/view
already has a UUID assigned
@@ -1860,6 +2320,27 @@ components:
items:
type: string
+ AddViewVersionUpdate:
+ allOf:
+ - $ref: '#/components/schemas/BaseUpdate'
+ - type: object
+ required:
+ - view-version
+ properties:
+ view-version:
+ $ref: '#/components/schemas/ViewVersion'
+
+ SetCurrentViewVersionUpdate:
+ allOf:
+ - $ref: '#/components/schemas/BaseUpdate'
+ - type: object
+ required:
+ - view-version-id
+ properties:
+ view-version-id:
+ type: integer
+ description: The view version id to set as current, or -1 to set
last added view version id
+
TableUpdate:
anyOf:
- $ref: '#/components/schemas/AssignUUIDUpdate'
@@ -1878,6 +2359,17 @@ components:
- $ref: '#/components/schemas/SetPropertiesUpdate'
- $ref: '#/components/schemas/RemovePropertiesUpdate'
+ ViewUpdate:
+ anyOf:
+ - $ref: '#/components/schemas/AssignUUIDUpdate'
+ - $ref: '#/components/schemas/UpgradeFormatVersionUpdate'
+ - $ref: '#/components/schemas/AddSchemaUpdate'
+ - $ref: '#/components/schemas/SetLocationUpdate'
+ - $ref: '#/components/schemas/SetPropertiesUpdate'
+ - $ref: '#/components/schemas/RemovePropertiesUpdate'
+ - $ref: '#/components/schemas/AddViewVersionUpdate'
+ - $ref: '#/components/schemas/SetCurrentViewVersionUpdate'
+
TableRequirement:
discriminator:
propertyName: type
@@ -2076,6 +2568,19 @@ components:
items:
$ref: '#/components/schemas/TableUpdate'
+ CommitViewRequest:
+ type: object
+ required:
+ - updates
+ properties:
+ identifier:
+ description: View identifier to update
+ $ref: '#/components/schemas/TableIdentifier'
+ updates:
+ type: array
+ items:
+ $ref: '#/components/schemas/ViewUpdate'
+
CommitTransactionRequest:
type: object
required:
@@ -2121,6 +2626,58 @@ components:
metadata-location:
type: string
+ CreateViewRequest:
+ type: object
+ required:
+ - name
+ - schema
+ - view-version
+ - properties
+ properties:
+ name:
+ type: string
+ location:
+ type: string
+ schema:
+ $ref: '#/components/schemas/Schema'
+ view-version:
+ $ref: '#/components/schemas/ViewVersion'
+ description: The view version to create, will replace the schema-id
sent within the view-version with the id assigned to the provided schema
+ properties:
+ type: object
+ additionalProperties:
+ type: string
+
+ LoadViewResult:
+ description: |
+ Result used when a view is successfully loaded.
+
+
+ The view metadata JSON is returned in the `metadata` field. The
corresponding file location of view metadata is returned in the
`metadata-location` field.
+ Clients can check whether metadata has changed by comparing metadata
locations after the view has been created.
+
+ The `config` map returns view-specific configuration for the view's
resources.
+
+ The following configurations should be respected by clients:
+
+ ## General Configurations
+
+ - `token`: Authorization bearer token to use for view requests if
OAuth2 security is enabled
+
+ type: object
+ required:
+ - metadata-location
+ - metadata
+ properties:
+ metadata-location:
+ type: string
+ metadata:
+ $ref: '#/components/schemas/ViewMetadata'
+ config:
+ type: object
+ additionalProperties:
+ type: string
+
TokenType:
type: string
enum:
@@ -2742,6 +3299,13 @@ components:
schema:
$ref: '#/components/schemas/LoadTableResult'
+ LoadViewResponse:
+ description: View metadata result when loading a view
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoadViewResult'
+
CommitTableResponse:
description:
Response used when a table is successfully updated.
@@ -2806,7 +3370,7 @@ components:
}
NoSuchTableError:
- summary: The requested table does not
+ summary: The requested table does not exist
value: {
"error": {
"message": "The given table does not exist",
@@ -2815,6 +3379,16 @@ components:
}
}
+ NoSuchViewError:
+ summary: The requested view does not exist
+ value: {
+ "error": {
+ "message": "The given view does not exist",
+ "type": "NoSuchViewException",
+ "code": 404
+ }
+ }
+
NoSuchNamespaceError:
summary: The requested namespace does not exist
value: {
@@ -2832,6 +3406,13 @@ components:
"destination": { "namespace": ["accounting", "tax"], "name": "owed" }
}
+ RenameViewSameNamespace:
+ summary: Rename a view in the same namespace
+ value: {
+ "source": { "namespace": [ "accounting", "tax" ], "name": "paid-view"
},
+ "destination": { "namespace": [ "accounting", "tax" ], "name":
"owed-view" }
+ }
+
TableAlreadyExistsError:
summary: The requested table identifier already exists
value: {
@@ -2842,6 +3423,16 @@ components:
}
}
+ ViewAlreadyExistsError:
+ summary: The requested view identifier already exists
+ value: {
+ "error": {
+ "message": "The given view already exists",
+ "type": "AlreadyExistsException",
+ "code": 409
+ }
+ }
+
# This is an example response and is not meant to be prescriptive
regarding the message or type.
UnprocessableEntityDuplicateKey:
summary: