diff --git a/api/src/main/java/org/apache/iceberg/UpdateLocation.java b/api/src/main/java/org/apache/iceberg/UpdateLocation.java index 646fbb1229ad..b069d32f1086 100644 --- a/api/src/main/java/org/apache/iceberg/UpdateLocation.java +++ b/api/src/main/java/org/apache/iceberg/UpdateLocation.java @@ -18,10 +18,10 @@ */ package org.apache.iceberg; -/** API for setting a table's base location. */ +/** API for setting a table's or view's base location. */ public interface UpdateLocation extends PendingUpdate { /** - * Set the table's location. + * Set the table's or view's location. * * @param location a String location * @return this for method chaining diff --git a/api/src/main/java/org/apache/iceberg/view/View.java b/api/src/main/java/org/apache/iceberg/view/View.java index 284c561b7848..9c33c545ec90 100644 --- a/api/src/main/java/org/apache/iceberg/view/View.java +++ b/api/src/main/java/org/apache/iceberg/view/View.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import org.apache.iceberg.Schema; +import org.apache.iceberg.UpdateLocation; /** Interface for view definition. */ public interface View { @@ -77,6 +78,15 @@ public interface View { */ Map properties(); + /** + * Return the view's base location. + * + * @return this view's location + */ + default String location() { + throw new UnsupportedOperationException("Retrieving a view's location is not supported"); + } + /** * Create a new {@link UpdateViewProperties} to update view properties. * @@ -92,4 +102,13 @@ public interface View { default ReplaceViewVersion replaceVersion() { throw new UnsupportedOperationException("Replacing a view's version is not supported"); } + + /** + * Create a new {@link UpdateLocation} to set the view's location. + * + * @return a new {@link UpdateLocation} + */ + default UpdateLocation updateLocation() { + throw new UnsupportedOperationException("Updating a view's location is not supported"); + } } diff --git a/api/src/main/java/org/apache/iceberg/view/ViewBuilder.java b/api/src/main/java/org/apache/iceberg/view/ViewBuilder.java index 02620de72289..0717e492fc58 100644 --- a/api/src/main/java/org/apache/iceberg/view/ViewBuilder.java +++ b/api/src/main/java/org/apache/iceberg/view/ViewBuilder.java @@ -45,6 +45,16 @@ public interface ViewBuilder extends VersionBuilder { */ ViewBuilder withProperty(String key, String value); + /** + * Sets a location for the view + * + * @param location the location to set for the view + * @return this for method chaining + */ + default ViewBuilder withLocation(String location) { + throw new UnsupportedOperationException("Setting a view's location is not supported"); + } + /** * Create the view. * diff --git a/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java b/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java index 42eb80a0472a..1cede9b2e7d3 100644 --- a/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java +++ b/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java @@ -71,6 +71,7 @@ protected class BaseViewBuilder implements ViewBuilder { private Namespace defaultNamespace = null; private String defaultCatalog = null; private Schema schema = null; + private String location = null; protected BaseViewBuilder(TableIdentifier identifier) { Preconditions.checkArgument( @@ -115,6 +116,12 @@ public ViewBuilder withProperty(String key, String value) { return this; } + @Override + public ViewBuilder withLocation(String newLocation) { + this.location = newLocation; + return this; + } + @Override public View create() { return create(newViewOps(identifier)); @@ -160,7 +167,7 @@ private View create(ViewOperations ops) { ViewMetadata viewMetadata = ViewMetadata.builder() .setProperties(properties) - .setLocation(defaultWarehouseLocation(identifier)) + .setLocation(null != location ? location : defaultWarehouseLocation(identifier)) .setCurrentVersion(viewVersion, schema) .build(); @@ -202,11 +209,16 @@ private View replace(ViewOperations ops) { .putSummary("operation", "replace") .build(); - ViewMetadata replacement = + ViewMetadata.Builder builder = ViewMetadata.buildFrom(metadata) .setProperties(properties) - .setCurrentVersion(viewVersion, schema) - .build(); + .setCurrentVersion(viewVersion, schema); + + if (null != location) { + builder.setLocation(location); + } + + ViewMetadata replacement = builder.build(); try { ops.commit(metadata, replacement); diff --git a/core/src/main/java/org/apache/iceberg/view/BaseView.java b/core/src/main/java/org/apache/iceberg/view/BaseView.java index a21bc2381f90..a1b2863eef6a 100644 --- a/core/src/main/java/org/apache/iceberg/view/BaseView.java +++ b/core/src/main/java/org/apache/iceberg/view/BaseView.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import org.apache.iceberg.Schema; +import org.apache.iceberg.UpdateLocation; public class BaseView implements View, Serializable { @@ -77,6 +78,11 @@ public Map properties() { return operations().current().properties(); } + @Override + public String location() { + return operations().current().location(); + } + @Override public UpdateViewProperties updateProperties() { return new PropertiesUpdate(ops); @@ -86,4 +92,9 @@ public UpdateViewProperties updateProperties() { public ReplaceViewVersion replaceVersion() { return new ViewVersionReplace(ops); } + + @Override + public UpdateLocation updateLocation() { + return new SetViewLocation(ops); + } } diff --git a/core/src/main/java/org/apache/iceberg/view/SetViewLocation.java b/core/src/main/java/org/apache/iceberg/view/SetViewLocation.java new file mode 100644 index 000000000000..481118c85991 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/view/SetViewLocation.java @@ -0,0 +1,76 @@ +/* + * 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.view; + +import static org.apache.iceberg.TableProperties.COMMIT_MAX_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_MAX_RETRY_WAIT_MS_DEFAULT; +import static org.apache.iceberg.TableProperties.COMMIT_MIN_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_MIN_RETRY_WAIT_MS_DEFAULT; +import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES; +import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES_DEFAULT; +import static org.apache.iceberg.TableProperties.COMMIT_TOTAL_RETRY_TIME_MS; +import static org.apache.iceberg.TableProperties.COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT; + +import org.apache.iceberg.UpdateLocation; +import org.apache.iceberg.exceptions.CommitFailedException; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.Tasks; + +class SetViewLocation implements UpdateLocation { + private final ViewOperations ops; + private String newLocation = null; + + SetViewLocation(ViewOperations ops) { + this.ops = ops; + } + + @Override + public String apply() { + Preconditions.checkState(null != newLocation, "Invalid view location: null"); + return newLocation; + } + + @Override + public void commit() { + ViewMetadata base = ops.refresh(); + Tasks.foreach(ops) + .retry( + PropertyUtil.propertyAsInt( + base.properties(), COMMIT_NUM_RETRIES, COMMIT_NUM_RETRIES_DEFAULT)) + .exponentialBackoff( + PropertyUtil.propertyAsInt( + base.properties(), COMMIT_MIN_RETRY_WAIT_MS, COMMIT_MIN_RETRY_WAIT_MS_DEFAULT), + PropertyUtil.propertyAsInt( + base.properties(), COMMIT_MAX_RETRY_WAIT_MS, COMMIT_MAX_RETRY_WAIT_MS_DEFAULT), + PropertyUtil.propertyAsInt( + base.properties(), COMMIT_TOTAL_RETRY_TIME_MS, COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT), + 2.0 /* exponential */) + .onlyRetryOn(CommitFailedException.class) + .run( + taskOps -> + taskOps.commit(base, ViewMetadata.buildFrom(base).setLocation(apply()).build())); + } + + @Override + public UpdateLocation setLocation(String location) { + this.newLocation = location; + return this; + } +} 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 1c95955383d6..3e19aaddee3d 100644 --- a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java +++ b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java @@ -24,6 +24,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Transaction; +import org.apache.iceberg.UpdateLocation; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.SupportsNamespaces; @@ -131,6 +132,7 @@ public void completeCreateView() { .withQuery("trino", "select * from ns.tbl using X") .withProperty("prop1", "val1") .withProperty("prop2", "val2") + .withLocation("file://tmp/ns/view") .create(); assertThat(view).isNotNull(); @@ -138,6 +140,7 @@ public void completeCreateView() { // validate view settings assertThat(view.name()).isEqualTo(ViewUtil.fullViewName(catalog().name(), identifier)); + assertThat(view.location()).isEqualTo("file://tmp/ns/view"); assertThat(view.properties()).containsEntry("prop1", "val1").containsEntry("prop2", "val2"); assertThat(view.history()) .hasSize(1) @@ -1337,4 +1340,113 @@ public void replaceViewConflict() { .isInstanceOf(NoSuchViewException.class) .hasMessageStartingWith("View does not exist: ns.view"); } + + @Test + public void createAndReplaceViewWithLocation() { + 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") + .withLocation("file://tmp/ns/view") + .create(); + + assertThat(catalog().viewExists(identifier)).as("View should exist").isTrue(); + assertThat(view.location()).isEqualTo("file://tmp/ns/view"); + + view = + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withQuery("trino", "select * from ns.tbl") + .withLocation("file://updated_tmp/ns/view") + .replace(); + + assertThat(view.location()).isEqualTo("file://updated_tmp/ns/view"); + + assertThat(catalog().dropView(identifier)).isTrue(); + assertThat(catalog().viewExists(identifier)).as("View should not exist").isFalse(); + } + + @Test + public void updateViewLocation() { + 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") + .withLocation("file://tmp/ns/view") + .create(); + + assertThat(catalog().viewExists(identifier)).as("View should exist").isTrue(); + assertThat(view.location()).isEqualTo("file://tmp/ns/view"); + + view.updateLocation().setLocation("file://updated_tmp/ns/view").commit(); + + View updatedView = catalog().loadView(identifier); + + assertThat(updatedView.location()).isEqualTo("file://updated_tmp/ns/view"); + + // history and view versions should stay the same after updating view properties + assertThat(updatedView.history()).hasSize(1).isEqualTo(view.history()); + assertThat(updatedView.versions()).hasSize(1).containsExactly(view.currentVersion()); + + assertThat(catalog().dropView(identifier)).isTrue(); + assertThat(catalog().viewExists(identifier)).as("View should not exist").isFalse(); + } + + @Test + public void updateViewLocationConflict() { + 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(); + + // new location must be non-null + assertThatThrownBy(() -> view.updateLocation().setLocation(null).commit()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Invalid view location: null"); + + UpdateLocation updateViewLocation = view.updateLocation(); + + catalog().dropView(identifier); + assertThat(catalog().viewExists(identifier)).as("View should not exist").isFalse(); + + // the view was already dropped concurrently + assertThatThrownBy(() -> updateViewLocation.setLocation("new-location").commit()) + .isInstanceOf(CommitFailedException.class) + .hasMessageContaining("Cannot commit"); + } }