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

Reply via email to