This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 9046cbe8e1 [#10923] feat(core): implement view CRUD dispatchers with
entity-store coordination (#10924)
9046cbe8e1 is described below
commit 9046cbe8e147869a375415b231c3002e19cc93fe
Author: mchades <[email protected]>
AuthorDate: Thu May 7 20:46:08 2026 +0800
[#10923] feat(core): implement view CRUD dispatchers with entity-store
coordination (#10924)
### What changes were proposed in this pull request?
This PR implements the full view CRUD dispatcher layer:
- Add `Capability.Scope.VIEW` and fix `CapabilityHelpers` to include
VIEW scope in namespace normalization
- Complete `internalLoadView` and `importView` with managed entity
check, string identifier path, and `withHiddenProperties`
- Add `validatePropertyForCreate` and `validateAlterProperties` to view
create/alter operations
- Add `ViewChange` handling to `OperationDispatcher` for property
set/remove
- Add `ViewNormalizeDispatcher` with full implementation and tests
- Add JavaDoc to all public methods in `ViewOperationDispatcher`
### Why are the changes needed?
The view CRUD dispatcher layer was incomplete and missing key behaviors
compared to the expected implementation pattern (entity-store
coordination, property validation, capability-based normalization).
Fix: #10923
### Does this PR introduce _any_ user-facing change?
`Capability.Scope` (an `@Evolving` SPI enum) gains a new `VIEW` value.
Downstream implementors with exhaustive `switch` statements over
`Capability.Scope` that lack a `default` case will see compilation
errors and must add handling for the new value.
### How was this patch tested?
- Added `TestViewNormalizeDispatcher` covering all normalization
scenarios
- Extended `TestViewOperationDispatcher` to cover property validation,
hidden properties, and all CRUD scenarios
---------
Co-authored-by: Copilot <[email protected]>
---
.../java/org/apache/gravitino/GravitinoEnv.java | 5 +-
.../gravitino/catalog/CapabilityHelpers.java | 23 +
.../gravitino/catalog/EntityCombinedView.java | 47 +-
.../gravitino/catalog/OperationDispatcher.java | 7 +
.../gravitino/catalog/ViewNormalizeDispatcher.java | 123 +++++
.../gravitino/catalog/ViewOperationDispatcher.java | 542 +++++++++++++++++----
.../gravitino/connector/capability/Capability.java | 1 +
.../catalog/TestViewNormalizeDispatcher.java | 106 ++++
.../catalog/TestViewOperationDispatcher.java | 201 +++++++-
.../gravitino/connector/TestCatalogOperations.java | 140 +++++-
10 files changed, 1097 insertions(+), 98 deletions(-)
diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index f26b630765..fd00c2a0e0 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -53,6 +53,7 @@ import org.apache.gravitino.catalog.TopicDispatcher;
import org.apache.gravitino.catalog.TopicNormalizeDispatcher;
import org.apache.gravitino.catalog.TopicOperationDispatcher;
import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.catalog.ViewNormalizeDispatcher;
import org.apache.gravitino.catalog.ViewOperationDispatcher;
import org.apache.gravitino.credential.CredentialOperationDispatcher;
import org.apache.gravitino.hook.AccessControlHookDispatcher;
@@ -636,7 +637,9 @@ public class GravitinoEnv {
// and event handling.
ViewOperationDispatcher viewOperationDispatcher =
new ViewOperationDispatcher(catalogManager, entityStore, idGenerator);
- this.viewDispatcher = viewOperationDispatcher;
+ ViewNormalizeDispatcher viewNormalizeDispatcher =
+ new ViewNormalizeDispatcher(viewOperationDispatcher, catalogManager);
+ this.viewDispatcher = viewNormalizeDispatcher;
this.statisticDispatcher =
new StatisticEventDispatcher(
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
b/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
index d619eff2ed..5825a97142 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
@@ -30,6 +30,7 @@ import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.file.FilesetChange;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.ViewChange;
import org.apache.gravitino.rel.expressions.Expression;
import org.apache.gravitino.rel.expressions.FunctionExpression;
import org.apache.gravitino.rel.expressions.NamedReference;
@@ -93,6 +94,18 @@ public class CapabilityHelpers {
.toArray(FilesetChange[]::new);
}
+ public static ViewChange[] applyCapabilities(Capability capabilities,
ViewChange... changes) {
+ return Arrays.stream(changes)
+ .map(
+ change -> {
+ if (change instanceof ViewChange.RenameView) {
+ return applyCapabilities((ViewChange.RenameView) change,
capabilities);
+ }
+ return change;
+ })
+ .toArray(ViewChange[]::new);
+ }
+
public static NameIdentifier[] applyCapabilities(
NameIdentifier[] idents, Capability.Scope scope, Capability
capabilities) {
return Arrays.stream(idents)
@@ -140,6 +153,7 @@ public class CapabilityHelpers {
String metalake = namespace.level(0);
String catalog = namespace.level(1);
if (identScope == Capability.Scope.TABLE
+ || identScope == Capability.Scope.VIEW
|| identScope == Capability.Scope.FILESET
|| identScope == Capability.Scope.TOPIC
|| identScope == Capability.Scope.MODEL
@@ -212,6 +226,7 @@ public class CapabilityHelpers {
String metalake = namespace.level(0);
String catalog = namespace.level(1);
if (identScope == Capability.Scope.TABLE
+ || identScope == Capability.Scope.VIEW
|| identScope == Capability.Scope.FILESET
|| identScope == Capability.Scope.TOPIC
|| identScope == Capability.Scope.FUNCTION) {
@@ -337,6 +352,14 @@ public class CapabilityHelpers {
return FilesetChange.rename(newName);
}
+ private static ViewChange applyCapabilities(
+ ViewChange.RenameView renameView, Capability capabilities) {
+ String newName =
+ applyCaseSensitiveOnName(Capability.Scope.VIEW,
renameView.getNewName(), capabilities);
+ applyNameSpecification(Capability.Scope.VIEW, newName, capabilities);
+ return ViewChange.rename(newName);
+ }
+
private static TableChange applyCapabilities(
TableChange.RenameTable renameTable, Capability capabilities) {
String newName =
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedView.java
b/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedView.java
index efecf015de..2a270d5e2a 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedView.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/EntityCombinedView.java
@@ -18,8 +18,13 @@
*/
package org.apache.gravitino.catalog;
+import java.util.Collections;
import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
import org.apache.gravitino.Audit;
+import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.ViewEntity;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Representation;
@@ -33,14 +38,17 @@ public final class EntityCombinedView implements View {
private final View view;
- private final ViewEntity viewEntity;
+ @Nullable private final ViewEntity viewEntity;
+
+ // Sets of properties that should be hidden from the user.
+ private Set<String> hiddenProperties = Collections.emptySet();
// Field "imported" is used to indicate whether the entity has been imported
to Gravitino
// managed storage backend. If "imported" is true, it means that storage
backend have stored
// the correct entity. Otherwise, we should import the external entity to
the storage backend.
private boolean imported;
- private EntityCombinedView(View view, ViewEntity viewEntity) {
+ private EntityCombinedView(View view, @Nullable ViewEntity viewEntity) {
this.view = view;
this.viewEntity = viewEntity;
this.imported = false;
@@ -59,6 +67,11 @@ public final class EntityCombinedView implements View {
return this;
}
+ public EntityCombinedView withHiddenProperties(Set<String> hiddenProperties)
{
+ this.hiddenProperties = hiddenProperties == null ? Collections.emptySet()
: hiddenProperties;
+ return this;
+ }
+
@Override
public Column[] columns() {
return view.columns();
@@ -91,12 +104,29 @@ public final class EntityCombinedView implements View {
@Override
public Map<String, String> properties() {
- return view.properties();
+ Map<String, String> props = view.properties();
+ if (props == null) {
+ return Collections.emptyMap();
+ }
+ return props.entrySet().stream()
+ .filter(p -> !hiddenProperties.contains(p.getKey()))
+ .filter(entry -> entry.getKey() != null && entry.getValue() != null)
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@Override
public Audit auditInfo() {
- return view.auditInfo();
+ if (viewEntity == null) {
+ return view.auditInfo();
+ }
+ AuditInfo mergedAudit =
+ AuditInfo.builder()
+ .withCreator(view.auditInfo().creator())
+ .withCreateTime(view.auditInfo().createTime())
+ .withLastModifier(view.auditInfo().lastModifier())
+ .withLastModifiedTime(view.auditInfo().lastModifiedTime())
+ .build();
+ return mergedAudit.merge(viewEntity.auditInfo(), true /* overwrite */);
}
public boolean imported() {
@@ -107,7 +137,14 @@ public final class EntityCombinedView implements View {
return view;
}
- public ViewEntity viewFromGravitino() {
+ /**
+ * Returns the Gravitino-side view entity when this combined view was built
from a full {@link
+ * ViewEntity}, otherwise {@code null}.
+ *
+ * @return The view entity, or {@code null}.
+ */
+ @Nullable
+ public ViewEntity viewEntity() {
return viewEntity;
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java
index 006e05c64b..733d411cfd 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java
@@ -39,6 +39,7 @@ import org.apache.gravitino.file.FilesetChange;
import org.apache.gravitino.messaging.TopicChange;
import org.apache.gravitino.rel.SupportsPartitions;
import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.ViewChange;
import org.apache.gravitino.storage.IdGenerator;
import org.apache.gravitino.utils.ThrowableFunction;
import org.slf4j.Logger;
@@ -178,6 +179,9 @@ public abstract class OperationDispatcher {
} else if (item instanceof TopicChange.RemoveProperty) {
TopicChange.RemoveProperty removeProperty =
(TopicChange.RemoveProperty) item;
properties.put(removeProperty.getProperty(),
removeProperty.getProperty());
+ } else if (item instanceof ViewChange.RemoveProperty) {
+ ViewChange.RemoveProperty removeProperty = (ViewChange.RemoveProperty)
item;
+ properties.put(removeProperty.getProperty(),
removeProperty.getProperty());
}
}
@@ -257,6 +261,9 @@ public abstract class OperationDispatcher {
} else if (item instanceof TopicChange.SetProperty) {
TopicChange.SetProperty setProperty = (TopicChange.SetProperty) item;
properties.put(setProperty.getProperty(), setProperty.getValue());
+ } else if (item instanceof ViewChange.SetProperty) {
+ ViewChange.SetProperty setProperty = (ViewChange.SetProperty) item;
+ properties.put(setProperty.getProperty(), setProperty.getValue());
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/ViewNormalizeDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/ViewNormalizeDispatcher.java
new file mode 100644
index 0000000000..a73f2cf6a3
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/catalog/ViewNormalizeDispatcher.java
@@ -0,0 +1,123 @@
+/*
+ * 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.gravitino.catalog;
+
+import static org.apache.gravitino.catalog.CapabilityHelpers.applyCapabilities;
+import static
org.apache.gravitino.catalog.CapabilityHelpers.applyCaseSensitive;
+import static org.apache.gravitino.catalog.CapabilityHelpers.getCapability;
+
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.connector.capability.Capability;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.apache.gravitino.exceptions.NoSuchViewException;
+import org.apache.gravitino.exceptions.ViewAlreadyExistsException;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Representation;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.ViewChange;
+
+public class ViewNormalizeDispatcher implements ViewDispatcher {
+
+ private final CatalogManager catalogManager;
+ private final ViewDispatcher dispatcher;
+
+ public ViewNormalizeDispatcher(ViewDispatcher dispatcher, CatalogManager
catalogManager) {
+ this.dispatcher = dispatcher;
+ this.catalogManager = catalogManager;
+ }
+
+ @Override
+ public NameIdentifier[] listViews(Namespace namespace) throws
NoSuchSchemaException {
+ // The constraints of the name spec may be more strict than underlying
catalog,
+ // and for compatibility reasons, we only apply case-sensitive
capabilities here.
+ Namespace caseSensitiveNs = normalizeCaseSensitive(namespace);
+ NameIdentifier[] identifiers = dispatcher.listViews(caseSensitiveNs);
+ return normalizeCaseSensitive(identifiers);
+ }
+
+ @Override
+ public View loadView(NameIdentifier ident) throws NoSuchViewException {
+ return dispatcher.loadView(normalizeCaseSensitive(ident));
+ }
+
+ @Override
+ public boolean viewExists(NameIdentifier ident) {
+ return dispatcher.viewExists(normalizeCaseSensitive(ident));
+ }
+
+ @Override
+ public View createView(
+ NameIdentifier ident,
+ String comment,
+ Column[] columns,
+ Representation[] representations,
+ @Nullable String defaultCatalog,
+ @Nullable String defaultSchema,
+ Map<String, String> properties)
+ throws NoSuchSchemaException, ViewAlreadyExistsException {
+ return dispatcher.createView(
+ normalizeNameIdentifier(ident),
+ comment,
+ columns,
+ representations,
+ defaultCatalog,
+ defaultSchema,
+ properties);
+ }
+
+ @Override
+ public View alterView(NameIdentifier ident, ViewChange... changes)
+ throws NoSuchViewException, IllegalArgumentException {
+ Capability capability = getCapability(ident, catalogManager);
+ return dispatcher.alterView(
+ normalizeCaseSensitive(ident), applyCapabilities(capability, changes));
+ }
+
+ @Override
+ public boolean dropView(NameIdentifier ident) {
+ return dispatcher.dropView(normalizeNameIdentifier(ident));
+ }
+
+ private Namespace normalizeCaseSensitive(Namespace namespace) {
+ Capability capabilities =
getCapability(NameIdentifier.of(namespace.levels()), catalogManager);
+ return applyCaseSensitive(namespace, Capability.Scope.VIEW, capabilities);
+ }
+
+ private NameIdentifier normalizeCaseSensitive(NameIdentifier ident) {
+ Capability capabilities = getCapability(ident, catalogManager);
+ return applyCaseSensitive(ident, Capability.Scope.VIEW, capabilities);
+ }
+
+ private NameIdentifier[] normalizeCaseSensitive(NameIdentifier[] idents) {
+ if (ArrayUtils.isEmpty(idents)) {
+ return idents;
+ }
+ Capability capabilities = getCapability(idents[0], catalogManager);
+ return applyCaseSensitive(idents, Capability.Scope.VIEW, capabilities);
+ }
+
+ private NameIdentifier normalizeNameIdentifier(NameIdentifier ident) {
+ Capability capability = getCapability(ident, catalogManager);
+ return applyCapabilities(ident, Capability.Scope.VIEW, capability);
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
index 1c1ad964a0..a7b9ab1416 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
@@ -18,19 +18,30 @@
*/
package org.apache.gravitino.catalog;
+import static org.apache.gravitino.Entity.EntityType.VIEW;
+import static org.apache.gravitino.catalog.CapabilityHelpers.applyCapabilities;
+import static
org.apache.gravitino.catalog.PropertiesMetadataHelpers.validatePropertyForCreate;
import static
org.apache.gravitino.utils.NameIdentifierUtil.getCatalogIdentifier;
+import static
org.apache.gravitino.utils.NameIdentifierUtil.getSchemaIdentifier;
-import java.io.IOException;
+import com.google.common.base.Preconditions;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nullable;
-import org.apache.gravitino.Audit;
-import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.EntityStore;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
+import org.apache.gravitino.StringIdentifier;
+import org.apache.gravitino.connector.HasPropertyMetadata;
+import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
import org.apache.gravitino.exceptions.NoSuchViewException;
+import org.apache.gravitino.exceptions.ViewAlreadyExistsException;
import org.apache.gravitino.lock.LockType;
import org.apache.gravitino.lock.TreeLockUtils;
import org.apache.gravitino.meta.AuditInfo;
@@ -40,15 +51,11 @@ import org.apache.gravitino.rel.Representation;
import org.apache.gravitino.rel.View;
import org.apache.gravitino.rel.ViewChange;
import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.PrincipalUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-/**
- * {@code ViewOperationDispatcher} is the operation dispatcher for view
operations.
- *
- * <p>Currently only supports loadView() with EntityStore auto-import. Full
CRUD operations
- * (listViews, createView, alterView, dropView) will be implemented in a
follow-up PR.
- */
+/** {@code ViewOperationDispatcher} is the operation dispatcher for view
operations. */
public class ViewOperationDispatcher extends OperationDispatcher implements
ViewDispatcher {
private static final Logger LOG =
LoggerFactory.getLogger(ViewOperationDispatcher.class);
@@ -66,30 +73,44 @@ public class ViewOperationDispatcher extends
OperationDispatcher implements View
}
/**
- * Load view metadata by identifier from the catalog.
+ * Lists the views within a schema.
*
- * <p>This method first checks if the view exists in Gravitino's
EntityStore. If not found, it
- * loads from the catalog and auto-imports into EntityStore.
+ * @param namespace The namespace of the schema containing the views.
+ * @return An array of {@link NameIdentifier} objects representing the
identifiers of the views in
+ * the schema.
+ * @throws NoSuchSchemaException If the specified schema does not exist.
+ */
+ @Override
+ public NameIdentifier[] listViews(Namespace namespace) throws
NoSuchSchemaException {
+ return TreeLockUtils.doWithTreeLock(
+ NameIdentifier.of(namespace.levels()),
+ LockType.READ,
+ () ->
+ doWithCatalog(
+ getCatalogIdentifier(NameIdentifier.of(namespace.levels())),
+ c -> c.doWithViewOps(v -> v.listViews(namespace)),
+ NoSuchSchemaException.class));
+ }
+
+ /**
+ * Loads a view.
*
- * @param ident The view identifier.
- * @return The loaded view metadata.
- * @throws NoSuchViewException If the view does not exist.
+ * @param ident The identifier of the view to load.
+ * @return The loaded {@link View} object representing the requested view.
+ * @throws NoSuchViewException If the specified view does not exist.
*/
@Override
public View loadView(NameIdentifier ident) throws NoSuchViewException {
LOG.info("Loading view: {}", ident);
- // First load with READ lock to check if view is already imported
EntityCombinedView entityCombinedView =
TreeLockUtils.doWithTreeLock(ident, LockType.READ, () ->
internalLoadView(ident));
if (!entityCombinedView.imported()) {
- // Load the schema to make sure the schema is imported.
SchemaDispatcher schemaDispatcher =
GravitinoEnv.getInstance().schemaDispatcher();
NameIdentifier schemaIdent =
NameIdentifier.of(ident.namespace().levels());
schemaDispatcher.loadSchema(schemaIdent);
- // Import the view.
entityCombinedView =
TreeLockUtils.doWithTreeLock(schemaIdent, LockType.WRITE, () ->
importView(ident));
}
@@ -97,18 +118,185 @@ public class ViewOperationDispatcher extends
OperationDispatcher implements View
return entityCombinedView;
}
+ /**
+ * Creates a new view in a schema.
+ *
+ * @param ident The identifier of the view to create.
+ * @param comment A description or comment associated with the view.
+ * @param columns An array of {@link Column} objects representing the output
columns of the view.
+ * @param representations An array of {@link Representation} objects
representing the SQL
+ * definitions of the view across different dialects.
+ * @param defaultCatalog The default catalog to use for unqualified
references.
+ * @param defaultSchema The default schema to use for unqualified references.
+ * @param properties Additional properties to set for the view.
+ * @return The newly created {@link View} object.
+ * @throws NoSuchSchemaException If the schema in which to create the view
does not exist.
+ * @throws ViewAlreadyExistsException If a view with the same name already
exists in the schema.
+ */
+ @Override
+ public View createView(
+ NameIdentifier ident,
+ String comment,
+ Column[] columns,
+ Representation[] representations,
+ @Nullable String defaultCatalog,
+ @Nullable String defaultSchema,
+ Map<String, String> properties)
+ throws NoSuchSchemaException, ViewAlreadyExistsException {
+ Preconditions.checkArgument(
+ representations != null && representations.length >= 1,
+ "representations must not be null or empty");
+
+ // Load the schema to make sure the schema exists.
+ SchemaDispatcher schemaDispatcher =
GravitinoEnv.getInstance().schemaDispatcher();
+ NameIdentifier schemaIdent = NameIdentifier.of(ident.namespace().levels());
+ schemaDispatcher.loadSchema(schemaIdent);
+
+ return TreeLockUtils.doWithTreeLock(
+ schemaIdent,
+ LockType.WRITE,
+ () ->
+ internalCreateView(
+ ident,
+ comment,
+ columns,
+ representations,
+ defaultCatalog,
+ defaultSchema,
+ properties));
+ }
+
+ /**
+ * Alters an existing view.
+ *
+ * @param ident The identifier of the view to alter.
+ * @param changes An array of {@link ViewChange} objects representing the
changes to apply to the
+ * view.
+ * @return The altered {@link View} object after applying the changes.
+ * @throws NoSuchViewException If the view to alter does not exist.
+ * @throws IllegalArgumentException If an unsupported or invalid change is
specified.
+ */
@Override
- public NameIdentifier[] listViews(Namespace namespace) {
- throw new UnsupportedOperationException("Listing views is not supported
yet");
+ public View alterView(NameIdentifier ident, ViewChange... changes)
+ throws NoSuchViewException, IllegalArgumentException {
+ validateAlterProperties(ident,
HasPropertyMetadata::tablePropertiesMetadata, changes);
+ NameIdentifier lockIdent = ident;
+ for (ViewChange change : changes) {
+ if (change instanceof ViewChange.RenameView) {
+ lockIdent = getSchemaIdentifier(ident);
+ break;
+ }
+ }
+
+ NameIdentifier nameIdentifierForLock = lockIdent;
+ return TreeLockUtils.doWithTreeLock(
+ nameIdentifierForLock,
+ nameIdentifierForLock.equals(ident) ? LockType.READ : LockType.WRITE,
+ () -> {
+ NameIdentifier catalogIdent = getCatalogIdentifier(ident);
+ View alteredView =
+ doWithCatalog(
+ catalogIdent,
+ c ->
+ c.doWithViewOps(
+ v -> v.alterView(ident,
applyCapabilities(c.capabilities(), changes))),
+ NoSuchViewException.class,
+ IllegalArgumentException.class);
+
+ boolean isManagedView = isManagedEntity(catalogIdent,
Capability.Scope.VIEW);
+ if (isManagedView) {
+ return EntityCombinedView.of(alteredView)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdent,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ alteredView.properties()));
+ }
+
+ StringIdentifier stringId =
getStringIdFromProperties(alteredView.properties());
+ // Case 1: The view is not created by Gravitino and this view is
never imported.
+ ViewEntity existing = null;
+ if (stringId == null) {
+ existing = getEntity(ident, VIEW, ViewEntity.class);
+ if (existing == null) {
+ return EntityCombinedView.of(alteredView)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdent,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ alteredView.properties()));
+ }
+ }
+
+ long viewId = stringId != null ? stringId.id() : existing.id();
+ ViewEntity updatedViewEntity =
+ operateOnEntity(
+ ident,
+ id ->
+ store.update(
+ id,
+ ViewEntity.class,
+ VIEW,
+ viewEntity -> applyChangesToEntity(viewEntity,
alteredView, changes)),
+ "UPDATE",
+ viewId);
+
+ return EntityCombinedView.of(alteredView, updatedViewEntity)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdent,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ alteredView.properties()));
+ });
}
+ /**
+ * Drops a view from the catalog.
+ *
+ * @param ident The identifier of the view to drop.
+ * @return {@code true} if the view was successfully dropped, {@code false}
if the view does not
+ * exist.
+ * @throws RuntimeException If an error occurs while dropping the view.
+ */
@Override
public boolean dropView(NameIdentifier ident) {
- throw new UnsupportedOperationException("Dropping a view is not supported
yet");
+ NameIdentifier schemaIdentifier = getSchemaIdentifier(ident);
+ return TreeLockUtils.doWithTreeLock(
+ schemaIdentifier,
+ LockType.WRITE,
+ () -> {
+ NameIdentifier catalogIdent = getCatalogIdentifier(ident);
+ boolean droppedFromCatalog =
+ doWithCatalog(
+ catalogIdent,
+ c -> c.doWithViewOps(v -> v.dropView(ident)),
+ RuntimeException.class);
+
+ boolean isManagedView = isManagedEntity(catalogIdent,
Capability.Scope.VIEW);
+ if (isManagedView) {
+ return droppedFromCatalog;
+ }
+
+ // For unmanaged view, it could happen that the view:
+ // 1. Is not found in the catalog (dropped directly from underlying
sources)
+ // 2. Is found in the catalog but not in the store (not managed by
Gravitino)
+ // 3. Is found in the catalog and the store (managed by Gravitino)
+ // 4. Neither found in the catalog nor in the store.
+ // In all situations, we try to delete the view from the store, but
we don't take the
+ // return value of the store operation into account. We only take
the return value of the
+ // catalog into account.
+ try {
+ store.delete(ident, VIEW);
+ } catch (NoSuchEntityException e) {
+ LOG.warn("The view to be dropped does not exist in the store: {}",
ident, e);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return droppedFromCatalog;
+ });
}
- @Override
- public View createView(
+ private View internalCreateView(
NameIdentifier ident,
String comment,
Column[] columns,
@@ -116,99 +304,275 @@ public class ViewOperationDispatcher extends
OperationDispatcher implements View
@Nullable String defaultCatalog,
@Nullable String defaultSchema,
Map<String, String> properties) {
- throw new UnsupportedOperationException("Creating a view is not supported
yet");
- }
+ NameIdentifier catalogIdent = getCatalogIdentifier(ident);
- @Override
- public View alterView(NameIdentifier ident, ViewChange... changes) {
- throw new UnsupportedOperationException("Altering a view is not supported
yet");
+ doWithCatalog(
+ catalogIdent,
+ c ->
+ c.doWithPropertiesMeta(
+ p -> {
+ validatePropertyForCreate(p.tablePropertiesMetadata(),
properties);
+ return null;
+ }),
+ IllegalArgumentException.class);
+
+ long uid = idGenerator.nextId();
+ // Add StringIdentifier to the properties, the specific catalog will
handle this
+ // StringIdentifier to make sure only when the operation is successful,
the related
+ // ViewEntity will be visible.
+ StringIdentifier stringId = StringIdentifier.fromId(uid);
+ Map<String, String> updatedProperties =
+ StringIdentifier.newPropertiesWithId(stringId, properties);
+
+ View catalogView =
+ doWithCatalog(
+ catalogIdent,
+ c ->
+ c.doWithViewOps(
+ v ->
+ v.createView(
+ ident,
+ comment,
+ columns == null ? new Column[0] : columns,
+ representations,
+ defaultCatalog,
+ defaultSchema,
+ updatedProperties)),
+ NoSuchSchemaException.class,
+ ViewAlreadyExistsException.class);
+
+ // If the view is managed by Gravitino, we don't need to create ViewEntity
and store it again.
+ boolean isManagedView = isManagedEntity(catalogIdent,
Capability.Scope.VIEW);
+ if (isManagedView) {
+ return EntityCombinedView.of(catalogView)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdent,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ catalogView.properties()));
+ }
+
+ AuditInfo audit =
+ AuditInfo.builder()
+ .withCreator(PrincipalUtils.getCurrentPrincipal().getName())
+ .withCreateTime(Instant.now())
+ .build();
+
+ ViewEntity viewEntity =
+ ViewEntity.builder()
+ .withId(uid)
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withComment(comment)
+ .withColumns(columns == null ? new Column[0] : columns)
+ .withRepresentations(representations)
+ .withDefaultCatalog(defaultCatalog)
+ .withDefaultSchema(defaultSchema)
+ .withProperties(properties)
+ .withAuditInfo(audit)
+ .build();
+
+ try {
+ store.put(viewEntity, true /* overwrite */);
+ } catch (Exception e) {
+ LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "put", ident, e);
+ return EntityCombinedView.of(catalogView)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdent,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ catalogView.properties()));
+ }
+
+ // Merge both the metadata from catalog operation and the metadata from
entity store.
+ return EntityCombinedView.of(catalogView, viewEntity)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdent,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ catalogView.properties()));
}
- /**
- * Internal method to load view and check if it exists in entity store.
- *
- * @param ident The view identifier.
- * @return EntityCombinedView containing the view and import status.
- * @throws NoSuchViewException If the view does not exist.
- */
private EntityCombinedView internalLoadView(NameIdentifier ident) throws
NoSuchViewException {
- // Load view from the underlying catalog
- View catalogView =
+ NameIdentifier catalogIdentifier = getCatalogIdentifier(ident);
+ View view =
doWithCatalog(
- getCatalogIdentifier(ident),
+ catalogIdentifier,
c -> c.doWithViewOps(v -> v.loadView(ident)),
NoSuchViewException.class);
- // Check if view exists in entity store
- try {
- ViewEntity viewEntity = store.get(ident, Entity.EntityType.VIEW,
ViewEntity.class);
- return EntityCombinedView.of(catalogView, viewEntity).withImported(true);
- } catch (NoSuchEntityException e) {
- // View not in store yet
- LOG.debug("View {} not found in entity store", ident);
- return EntityCombinedView.of(catalogView).withImported(false);
- } catch (IOException ioe) {
- LOG.warn("Failed to check if view {} exists in entity store", ident,
ioe);
- return EntityCombinedView.of(catalogView).withImported(false);
+ boolean isManagedView = isManagedEntity(catalogIdentifier,
Capability.Scope.VIEW);
+ if (isManagedView) {
+ return EntityCombinedView.of(view)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdentifier,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ view.properties()))
+ // The metadata of managed view is stored by Gravitino, so it is
always imported.
+ .withImported(true /* imported */);
+ }
+
+ StringIdentifier stringId = getStringIdFromProperties(view.properties());
+ // Case 1: The view is not created by Gravitino or the external system
does not support storing
+ // string identifier.
+ if (stringId == null) {
+ ViewEntity viewEntity = getEntity(ident, VIEW, ViewEntity.class);
+ if (viewEntity == null) {
+ return EntityCombinedView.of(view)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdentifier,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ view.properties()))
+ // Some views don't have properties or are not created by
Gravitino,
+ // we can't use stringIdentifier to judge whether view is ever
imported or not.
+ // We need to check whether the entity exists.
+ .withImported(false);
+ }
+
+ return EntityCombinedView.of(view, viewEntity)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdentifier,
+ HasPropertyMetadata::tablePropertiesMetadata,
+ view.properties()))
+ // For some catalogs, the identifier information is not stored in
the view's
+ // metadata, we need to check if this view exists in the store, if
so we don't
+ // need to import.
+ .withImported(true);
}
+
+ ViewEntity viewEntity =
+ operateOnEntity(
+ ident,
+ identifier -> store.get(identifier, VIEW, ViewEntity.class),
+ "GET",
+ stringId.id());
+
+ return EntityCombinedView.of(view, viewEntity)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ catalogIdentifier,
HasPropertyMetadata::tablePropertiesMetadata, view.properties()))
+ .withImported(viewEntity != null);
}
- /**
- * Import view into Gravitino entity store.
- *
- * @param ident The view identifier.
- * @return EntityCombinedView containing the view and import status.
- * @throws NoSuchViewException If the view does not exist.
- */
private EntityCombinedView importView(NameIdentifier ident) throws
NoSuchViewException {
- // Double-check if already imported (another thread might have imported
between locks)
EntityCombinedView entityCombinedView = internalLoadView(ident);
if (entityCombinedView.imported()) {
return entityCombinedView;
}
- LOG.info("Auto-importing view {} into Gravitino entity store", ident);
- long uid = idGenerator.nextId();
+ StringIdentifier stringId =
+
getStringIdFromProperties(entityCombinedView.viewFromCatalog().properties());
+
+ long uid;
+ if (stringId != null) {
+ // If the entity in the store doesn't match the external system, we use
the data
+ // of external system to correct it.
+ LOG.warn(
+ "The View uid {} existed but still need to be imported, this could
happen "
+ + "when View is renamed by external systems not controlled by
Gravitino. In this "
+ + "case, we need to overwrite the stored entity to keep the
consistency.",
+ stringId);
+ uid = stringId.id();
+ } else {
+ // If entity doesn't exist, we import the entity from the external
system.
+ uid = idGenerator.nextId();
+ }
+
View catalogView = entityCombinedView.viewFromCatalog();
- ViewEntity newViewEntity = buildViewEntityForImport(uid, ident,
catalogView);
+ AuditInfo audit =
+ AuditInfo.builder()
+ .withCreator(catalogView.auditInfo().creator())
+ .withCreateTime(catalogView.auditInfo().createTime())
+ .withLastModifier(catalogView.auditInfo().lastModifier())
+ .withLastModifiedTime(catalogView.auditInfo().lastModifiedTime())
+ .build();
+ ViewEntity viewEntity =
+ ViewEntity.builder()
+ .withId(uid)
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withComment(catalogView.comment())
+ .withColumns(catalogView.columns() == null ? new Column[0] :
catalogView.columns())
+ .withRepresentations(
+ catalogView.representations() == null
+ ? new Representation[0]
+ : catalogView.representations())
+ .withDefaultCatalog(catalogView.defaultCatalog())
+ .withDefaultSchema(catalogView.defaultSchema())
+ .withProperties(catalogView.properties())
+ .withAuditInfo(audit)
+ .build();
try {
- store.put(newViewEntity, false /* overwrite */);
- LOG.info("Successfully imported view {} into entity store with id {}",
ident, uid);
- return EntityCombinedView.of(catalogView,
newViewEntity).withImported(true);
+ store.put(viewEntity, true /* overwrite */);
+ } catch (EntityAlreadyExistsException e) {
+ LOG.error("Failed to import view {} with id {} to the store.", ident,
uid, e);
+ throw new UnsupportedOperationException(
+ "View managed by multiple catalogs. This may cause unexpected issues
such as privilege conflicts. "
+ + "To resolve: Remove all catalogs managing this view, then
recreate one catalog to ensure single-catalog management.");
} catch (Exception e) {
- // Log but don't fail - view import is best-effort
- LOG.warn("Failed to import view {} into entity store: {}", ident,
e.getMessage());
- return EntityCombinedView.of(catalogView).withImported(false);
+ LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "put", ident, e);
+ throw new RuntimeException("Fail to import the view entity to the
store.", e);
}
- }
- private ViewEntity buildViewEntityForImport(Long uid, NameIdentifier ident,
View view) {
- return ViewEntity.builder()
- .withId(uid)
- .withName(ident.name())
- .withNamespace(ident.namespace())
- .withComment(view.comment())
- .withColumns(view.columns() == null ? new Column[0] : view.columns())
- .withRepresentations(
- view.representations() == null ? new Representation[0] :
view.representations())
- .withDefaultCatalog(view.defaultCatalog())
- .withDefaultSchema(view.defaultSchema())
- .withProperties(view.properties())
- .withAuditInfo(toAuditInfo(view.auditInfo()))
- .build();
+ return EntityCombinedView.of(catalogView, viewEntity)
+ .withHiddenProperties(
+ getHiddenPropertyNames(
+ getCatalogIdentifier(ident),
+ HasPropertyMetadata::tablePropertiesMetadata,
+ catalogView.properties()))
+ .withImported(true);
}
- private AuditInfo toAuditInfo(Audit audit) {
- if (audit == null) {
- return AuditInfo.EMPTY;
+ private ViewEntity applyChangesToEntity(
+ ViewEntity current, View alteredView, ViewChange[] changes) {
+ String name = alteredView.name();
+ String comment = alteredView.comment();
+ String defaultCatalog = alteredView.defaultCatalog();
+ String defaultSchema = alteredView.defaultSchema();
+ Map<String, String> properties =
+ current.properties() == null ? new HashMap<>() : new
HashMap<>(current.properties());
+
+ for (ViewChange change : changes) {
+ if (change instanceof ViewChange.SetProperty) {
+ ViewChange.SetProperty sp = (ViewChange.SetProperty) change;
+ properties.put(sp.getProperty(), sp.getValue());
+ } else if (change instanceof ViewChange.RemoveProperty) {
+ properties.remove(((ViewChange.RemoveProperty) change).getProperty());
+ } else if (!(change instanceof ViewChange.RenameView)
+ && !(change instanceof ViewChange.ReplaceView)) {
+ throw new IllegalArgumentException("Unsupported view change: " +
change);
+ }
}
- return AuditInfo.builder()
- .withCreator(audit.creator())
- .withCreateTime(audit.createTime())
- .withLastModifier(audit.lastModifier())
- .withLastModifiedTime(audit.lastModifiedTime())
+ Namespace namespace = current.namespace();
+
+ AuditInfo newAudit =
+ AuditInfo.builder()
+ .withCreator(current.auditInfo().creator())
+ .withCreateTime(current.auditInfo().createTime())
+ .withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
+ .withLastModifiedTime(Instant.now())
+ .build();
+
+ return ViewEntity.builder()
+ .withId(current.id())
+ .withName(name)
+ .withNamespace(namespace)
+ .withComment(comment)
+ .withColumns(
+ alteredView.columns() == null
+ ? new Column[0]
+ : Arrays.copyOf(alteredView.columns(),
alteredView.columns().length))
+ .withRepresentations(
+ Arrays.copyOf(alteredView.representations(),
alteredView.representations().length))
+ .withDefaultCatalog(defaultCatalog)
+ .withDefaultSchema(defaultSchema)
+ .withProperties(properties)
+ .withAuditInfo(newAudit)
.build();
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/connector/capability/Capability.java
b/core/src/main/java/org/apache/gravitino/connector/capability/Capability.java
index 784946c59e..ceaecce2cf 100644
---
a/core/src/main/java/org/apache/gravitino/connector/capability/Capability.java
+++
b/core/src/main/java/org/apache/gravitino/connector/capability/Capability.java
@@ -36,6 +36,7 @@ public interface Capability {
enum Scope {
SCHEMA,
TABLE,
+ VIEW,
COLUMN,
FILESET,
TOPIC,
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestViewNormalizeDispatcher.java
b/core/src/test/java/org/apache/gravitino/catalog/TestViewNormalizeDispatcher.java
new file mode 100644
index 0000000000..b04015959d
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestViewNormalizeDispatcher.java
@@ -0,0 +1,106 @@
+/*
+ * 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.gravitino.catalog;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Representation;
+import org.apache.gravitino.rel.SQLRepresentation;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.ViewChange;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+public class TestViewNormalizeDispatcher extends TestOperationDispatcher {
+ private static ViewNormalizeDispatcher viewNormalizeDispatcher;
+ private static SchemaNormalizeDispatcher schemaNormalizeDispatcher;
+
+ @BeforeAll
+ public static void initialize() throws IOException, IllegalAccessException {
+ TestViewOperationDispatcher.initialize();
+ viewNormalizeDispatcher =
+ new ViewNormalizeDispatcher(
+ TestViewOperationDispatcher.viewOperationDispatcher,
catalogManager);
+ schemaNormalizeDispatcher =
+ new SchemaNormalizeDispatcher(
+ TestViewOperationDispatcher.schemaOperationDispatcher,
catalogManager);
+ }
+
+ @Test
+ public void testNameCaseInsensitive() {
+ Namespace viewNs = Namespace.of(metalake, catalog,
"schemaVIEW_NORMALIZE_1");
+ Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+ schemaNormalizeDispatcher.createSchema(NameIdentifier.of(viewNs.levels()),
"comment", props);
+
+ NameIdentifier viewIdent = NameIdentifier.of(viewNs, "viewNAME");
+ Representation[] representations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ };
+
+ View created =
+ viewNormalizeDispatcher.createView(
+ viewIdent, "comment", new Column[0], representations, null, null,
props);
+ Assertions.assertEquals(viewIdent.name().toLowerCase(), created.name());
+
+ // Loading with any case should work and return lower-case.
+ View loaded =
+ viewNormalizeDispatcher.loadView(NameIdentifier.of(viewNs,
viewIdent.name().toUpperCase()));
+ Assertions.assertEquals(viewIdent.name().toLowerCase(), loaded.name());
+
+ // Listing returns lowercase names.
+ NameIdentifier[] idents = viewNormalizeDispatcher.listViews(viewNs);
+ Arrays.stream(idents).forEach(i ->
Assertions.assertEquals(i.name().toLowerCase(), i.name()));
+
+ // Altering with mixed case should work.
+ View altered =
+ viewNormalizeDispatcher.alterView(
+ NameIdentifier.of(viewNs, viewIdent.name().toUpperCase()),
+ ViewChange.setProperty("k2", "v2"));
+ Assertions.assertEquals(viewIdent.name().toLowerCase(), altered.name());
+
+ Assertions.assertTrue(
+ viewNormalizeDispatcher.viewExists(
+ NameIdentifier.of(viewNs, viewIdent.name().toUpperCase())));
+
+ // Dropping with mixed case should work.
+ Assertions.assertTrue(
+ viewNormalizeDispatcher.dropView(
+ NameIdentifier.of(viewNs, viewIdent.name().toUpperCase())));
+ }
+
+ @Test
+ public void testDropViewNameSpec() {
+ Namespace viewNs = Namespace.of(metalake, catalog,
"schema_drop_view_name_spec");
+ Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+ schemaNormalizeDispatcher.createSchema(NameIdentifier.of(viewNs.levels()),
"comment", props);
+
+ Exception exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> viewNormalizeDispatcher.dropView(NameIdentifier.of(viewNs,
"a?")));
+ Assertions.assertEquals(
+ "The VIEW name 'a?' is illegal. Illegal name: a?",
exception.getMessage());
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
b/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
index 3d43cb95d8..a3c4146e8a 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
@@ -22,6 +22,8 @@ import static
org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
import static org.apache.gravitino.Entity.EntityType.VIEW;
+import static org.apache.gravitino.StringIdentifier.ID_KEY;
+import static org.apache.gravitino.TestBasePropertiesMetadata.COMMENT_KEY;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
@@ -29,6 +31,8 @@ import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
@@ -53,6 +57,7 @@ import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Representation;
import org.apache.gravitino.rel.SQLRepresentation;
import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.ViewChange;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -321,7 +326,7 @@ public class TestViewOperationDispatcher extends
TestOperationDispatcher {
Assertions.assertEquals("concurrent_view", loadedView.name());
EntityCombinedView combinedView = (EntityCombinedView) loadedView;
- long currentEntityId = combinedView.viewFromGravitino().id();
+ long currentEntityId = combinedView.viewEntity().id();
if (entityId == null) {
entityId = currentEntityId;
@@ -372,4 +377,198 @@ public class TestViewOperationDispatcher extends
TestOperationDispatcher {
ViewEntity viewEntity = entityStore.get(viewIdent, VIEW, ViewEntity.class);
Assertions.assertNotNull(viewEntity);
}
+
+ @Test
+ public void testCreateView() throws IOException {
+ Namespace viewNs = Namespace.of(metalake, catalog, "schema_create_view");
+ Map<String, String> schemaProps = ImmutableMap.of("k1", "v1", "k2", "v2");
+ schemaOperationDispatcher.createSchema(
+ NameIdentifier.of(viewNs.levels()), "comment", schemaProps);
+
+ NameIdentifier viewIdent = NameIdentifier.of(viewNs, "created_view");
+ Representation[] representations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ };
+ Map<String, String> viewProps = ImmutableMap.of("k1", "v1", "p1", "pv1");
+
+ View created =
+ viewOperationDispatcher.createView(
+ viewIdent, "view comment", new Column[0], representations, null,
null, viewProps);
+
+ Assertions.assertEquals("created_view", created.name());
+ Assertions.assertEquals("view comment", created.comment());
+ Assertions.assertTrue(created instanceof EntityCombinedView);
+
+ // Entity stored as ViewEntity in entity store.
+ ViewEntity stored = entityStore.get(viewIdent, VIEW, ViewEntity.class);
+ Assertions.assertNotNull(stored);
+ Assertions.assertEquals("view comment", stored.comment());
+ Assertions.assertEquals("pv1", stored.properties().get("p1"));
+
+ // Test required view properties exception
+ Map<String, String> missingRequired = new HashMap<>();
+ missingRequired.put("p1", "pv1");
+ testPropertyException(
+ () ->
+ viewOperationDispatcher.createView(
+ NameIdentifier.of(viewNs, "missing_required"),
+ "c",
+ new Column[0],
+ representations,
+ null,
+ null,
+ missingRequired),
+ "Properties or property prefixes are required and must be set");
+
+ // Test reserved view properties exception
+ Map<String, String> reservedProps = new HashMap<>();
+ reservedProps.put("k1", "v1");
+ reservedProps.put(COMMENT_KEY, "view comment");
+ reservedProps.put(ID_KEY, "gravitino.v1.uidfdsafdsa");
+ testPropertyException(
+ () ->
+ viewOperationDispatcher.createView(
+ NameIdentifier.of(viewNs, "reserved_props"),
+ "c",
+ new Column[0],
+ representations,
+ null,
+ null,
+ reservedProps),
+ "Properties or property prefixes are reserved and cannot be set",
+ "comment",
+ "gravitino.identifier");
+
+ Assertions.assertFalse(created.properties().containsKey(ID_KEY));
+ }
+
+ @Test
+ public void testDropView() throws IOException {
+ Namespace viewNs = Namespace.of(metalake, catalog, "schema_drop_view");
+ schemaOperationDispatcher.createSchema(
+ NameIdentifier.of(viewNs.levels()), "comment", ImmutableMap.of("k1",
"v1", "k2", "v2"));
+
+ NameIdentifier viewIdent = NameIdentifier.of(viewNs, "to_drop");
+ Representation[] representations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ };
+ viewOperationDispatcher.createView(
+ viewIdent, null, new Column[0], representations, null, null,
ImmutableMap.of("k1", "v1"));
+
+ Assertions.assertTrue(viewOperationDispatcher.dropView(viewIdent));
+ // Entity removed.
+ Assertions.assertThrows(
+ NoSuchEntityException.class, () -> entityStore.get(viewIdent, VIEW,
ViewEntity.class));
+ // Dropping again returns false (underlying catalog reports missing).
+ Assertions.assertFalse(viewOperationDispatcher.dropView(viewIdent));
+ }
+
+ @Test
+ public void testListViews() throws IOException {
+ Namespace viewNs = Namespace.of(metalake, catalog, "schema_list_view");
+ schemaOperationDispatcher.createSchema(
+ NameIdentifier.of(viewNs.levels()), "comment", ImmutableMap.of("k1",
"v1", "k2", "v2"));
+
+ Representation[] representations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ };
+ for (int i = 0; i < 3; i++) {
+ viewOperationDispatcher.createView(
+ NameIdentifier.of(viewNs, "lv" + i),
+ null,
+ new Column[0],
+ representations,
+ null,
+ null,
+ ImmutableMap.of("k1", "v1"));
+ }
+
+ NameIdentifier[] listed = viewOperationDispatcher.listViews(viewNs);
+ Assertions.assertEquals(3, listed.length);
+ }
+
+ @Test
+ public void testAlterViewProperties() throws IOException {
+ Namespace viewNs = Namespace.of(metalake, catalog, "schema_alter_props");
+ schemaOperationDispatcher.createSchema(
+ NameIdentifier.of(viewNs.levels()), "c", ImmutableMap.of("k1", "v1",
"k2", "v2"));
+
+ NameIdentifier viewIdent = NameIdentifier.of(viewNs, "alter_props");
+ Representation[] representations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ };
+ viewOperationDispatcher.createView(
+ viewIdent, "c", new Column[0], representations, null, null,
ImmutableMap.of("k1", "v1"));
+
+ View altered =
+ viewOperationDispatcher.alterView(
+ viewIdent, ViewChange.setProperty("k2", "v2"),
ViewChange.removeProperty("k1"));
+ Assertions.assertEquals("v2", altered.properties().get("k2"));
+ Assertions.assertFalse(altered.properties().containsKey("k1"));
+
+ ViewEntity stored = entityStore.get(viewIdent, VIEW, ViewEntity.class);
+ Assertions.assertEquals("v2", stored.properties().get("k2"));
+ Assertions.assertFalse(stored.properties().containsKey("k1"));
+
+ // Test immutable view properties
+ ViewChange[] illegalChange =
+ new ViewChange[] {ViewChange.setProperty(COMMENT_KEY, "new comment")};
+ testPropertyException(
+ () -> viewOperationDispatcher.alterView(viewIdent, illegalChange),
+ "Property comment is immutable or reserved, cannot be set");
+ }
+
+ @Test
+ public void testAlterViewRename() throws IOException {
+ Namespace viewNs = Namespace.of(metalake, catalog, "schema_alter_rename");
+ schemaOperationDispatcher.createSchema(
+ NameIdentifier.of(viewNs.levels()), "c", ImmutableMap.of("k1", "v1",
"k2", "v2"));
+
+ NameIdentifier oldIdent = NameIdentifier.of(viewNs, "old_view");
+ Representation[] representations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ };
+ viewOperationDispatcher.createView(
+ oldIdent, "c", new Column[0], representations, null, null,
ImmutableMap.of("k1", "v1"));
+
+ View altered = viewOperationDispatcher.alterView(oldIdent,
ViewChange.rename("new_view"));
+ Assertions.assertEquals("new_view", altered.name());
+ }
+
+ @Test
+ public void testAlterViewReplace() throws IOException {
+ Namespace viewNs = Namespace.of(metalake, catalog, "schema_alter_replace");
+ schemaOperationDispatcher.createSchema(
+ NameIdentifier.of(viewNs.levels()), "c", ImmutableMap.of("k1", "v1",
"k2", "v2"));
+
+ NameIdentifier viewIdent = NameIdentifier.of(viewNs, "replace");
+ Representation[] representations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ };
+ viewOperationDispatcher.createView(
+ viewIdent, "c", new Column[0], representations, null, null,
ImmutableMap.of("k1", "v1"));
+
+ Representation[] newRepresentations = {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build(),
+ SQLRepresentation.builder().withDialect("trino").withSql("SELECT
2").build()
+ };
+ View altered =
+ viewOperationDispatcher.alterView(
+ viewIdent,
+ ViewChange.replaceView(
+ new Column[0], newRepresentations, "cat1", "sch1", "new
comment"));
+ Assertions.assertEquals(2, altered.representations().length);
+ Assertions.assertEquals("new comment", altered.comment());
+ Assertions.assertEquals("cat1", altered.defaultCatalog());
+ Assertions.assertEquals("sch1", altered.defaultSchema());
+
+ SQLRepresentation trino =
+ (SQLRepresentation)
+ Arrays.stream(altered.representations())
+ .filter(r -> r instanceof SQLRepresentation)
+ .filter(r -> "trino".equals(((SQLRepresentation) r).dialect()))
+ .findFirst()
+ .orElseThrow(AssertionError::new);
+ Assertions.assertEquals("SELECT 2", trino.sql());
+ }
}
diff --git
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
index 2ec9ec0239..27e6780a21 100644
---
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
+++
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
@@ -75,6 +75,7 @@ import
org.apache.gravitino.exceptions.NonEmptySchemaException;
import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
import org.apache.gravitino.exceptions.TableAlreadyExistsException;
import org.apache.gravitino.exceptions.TopicAlreadyExistsException;
+import org.apache.gravitino.exceptions.ViewAlreadyExistsException;
import org.apache.gravitino.file.Fileset;
import org.apache.gravitino.file.FilesetCatalog;
import org.apache.gravitino.file.FilesetChange;
@@ -313,12 +314,147 @@ public class TestCatalogOperations
String defaultCatalog,
String defaultSchema,
Map<String, String> properties) {
- throw new UnsupportedOperationException("createView not implemented in
test");
+ if (views.containsKey(ident)) {
+ throw new ViewAlreadyExistsException("View %s already exists", ident);
+ }
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ TestView view =
+ new TestView(
+ ident.name(),
+ comment,
+ columns == null ? new Column[0] : columns,
+ representations,
+ defaultCatalog,
+ defaultSchema,
+ properties == null ? ImmutableMap.of() :
ImmutableMap.copyOf(properties),
+ auditInfo);
+ views.put(ident, view);
+ return view;
}
@Override
public View alterView(NameIdentifier ident, ViewChange... changes) {
- throw new UnsupportedOperationException("alterView not implemented in
test");
+ if (!views.containsKey(ident)) {
+ throw new NoSuchViewException("View %s does not exist", ident);
+ }
+ TestView view = (TestView) views.get(ident);
+ String name = view.name();
+ String comment = view.comment();
+ String defaultCatalog = view.defaultCatalog();
+ String defaultSchema = view.defaultSchema();
+ Column[] columns = view.columns();
+ Representation[] representations = view.representations();
+ Map<String, String> properties = new HashMap<>(view.properties());
+
+ NameIdentifier newIdent = ident;
+ for (ViewChange change : changes) {
+ if (change instanceof ViewChange.RenameView) {
+ name = ((ViewChange.RenameView) change).getNewName();
+ newIdent = NameIdentifier.of(ident.namespace(), name);
+ if (views.containsKey(newIdent)) {
+ throw new ViewAlreadyExistsException("View %s already exists",
newIdent);
+ }
+ } else if (change instanceof ViewChange.SetProperty) {
+ ViewChange.SetProperty sp = (ViewChange.SetProperty) change;
+ properties.put(sp.getProperty(), sp.getValue());
+ } else if (change instanceof ViewChange.RemoveProperty) {
+ properties.remove(((ViewChange.RemoveProperty) change).getProperty());
+ } else if (change instanceof ViewChange.ReplaceView) {
+ ViewChange.ReplaceView rv = (ViewChange.ReplaceView) change;
+ columns = rv.getColumns();
+ representations = rv.getRepresentations();
+ defaultCatalog = rv.getDefaultCatalog();
+ defaultSchema = rv.getDefaultSchema();
+ comment = rv.getComment();
+ } else {
+ throw new IllegalArgumentException("Unsupported view change: " +
change);
+ }
+ }
+
+ TestView updated =
+ new TestView(
+ name,
+ comment,
+ columns,
+ representations,
+ defaultCatalog,
+ defaultSchema,
+ ImmutableMap.copyOf(properties),
+ view.auditInfo());
+ views.remove(ident);
+ views.put(newIdent, updated);
+ return updated;
+ }
+
+ private static final class TestView implements View {
+ private final String name;
+ private final String comment;
+ private final Column[] columns;
+ private final Representation[] representations;
+ private final String defaultCatalog;
+ private final String defaultSchema;
+ private final Map<String, String> properties;
+ private final AuditInfo auditInfo;
+
+ TestView(
+ String name,
+ String comment,
+ Column[] columns,
+ Representation[] representations,
+ String defaultCatalog,
+ String defaultSchema,
+ Map<String, String> properties,
+ AuditInfo auditInfo) {
+ this.name = name;
+ this.comment = comment;
+ this.columns = columns;
+ this.representations = representations;
+ this.defaultCatalog = defaultCatalog;
+ this.defaultSchema = defaultSchema;
+ this.properties = properties;
+ this.auditInfo = auditInfo;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public String comment() {
+ return comment;
+ }
+
+ @Override
+ public Column[] columns() {
+ return columns;
+ }
+
+ @Override
+ public Representation[] representations() {
+ return representations;
+ }
+
+ @Override
+ public String defaultCatalog() {
+ return defaultCatalog;
+ }
+
+ @Override
+ public String defaultSchema() {
+ return defaultSchema;
+ }
+
+ @Override
+ public Map<String, String> properties() {
+ return properties;
+ }
+
+ @Override
+ public AuditInfo auditInfo() {
+ return auditInfo;
+ }
}
@Override