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 bb9e0387a6 [#11003] feat(catalog-lakehouse-paimon): add paimon view 
CRUD support and interoperability tests (#11013)
bb9e0387a6 is described below

commit bb9e0387a6fbe7b87d25b14225e2356c360f3365
Author: mchades <[email protected]>
AuthorDate: Thu May 21 22:13:24 2026 +0800

    [#11003] feat(catalog-lakehouse-paimon): add paimon view CRUD support and 
interoperability tests (#11013)
    
    ### What changes were proposed in this pull request?
    
    - Added Paimon view CRUD support in Gravitino
    (`list/load/create/alter/rename/drop`).
    - Added Paimon view metadata conversion and extracted view logic into a
    dedicated composed ops class.
    - Exposed Paimon catalog as `ViewCatalog`.
    - Added tests:
      - unit tests for view ops/wiring
      - integration tests for:
        - Spark create view -> Gravitino load view
        - Gravitino create view -> Spark query view
    
    ### Why are the changes needed?
    
    Paimon catalog did not fully support view CRUD in Gravitino. This PR
    completes Paimon view capability and adds interoperability coverage with
    Spark.
    
    Fix: #11003
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    - Paimon catalog now supports `ViewCatalog` operations in Gravitino.
    - Added internal reserved hidden properties for view default
    catalog/schema handling.
    
    ### How was this patch tested?
    
    - `./gradlew :catalogs:catalog-lakehouse-paimon:spotlessApply`
    - `./gradlew :catalogs:catalog-lakehouse-paimon:test -PskipITs`
    - `SKIP_DOCKER_TESTS=false ./gradlew
    :catalogs:catalog-lakehouse-paimon:test -PskipDockerTests=false --tests
    
org.apache.gravitino.catalog.lakehouse.paimon.integration.test.CatalogPaimonHiveIT.testSparkCreateViewAndLoadByGravitino
    --tests
    
org.apache.gravitino.catalog.lakehouse.paimon.integration.test.CatalogPaimonHiveIT.testGravitinoCreateViewAndReadBySpark`
---
 catalogs/catalog-lakehouse-paimon/build.gradle.kts |  34 +-
 .../catalog/lakehouse/paimon/PaimonCatalog.java    |   6 +
 .../lakehouse/paimon/PaimonCatalogOperations.java  |  91 +++-
 .../paimon/PaimonTablePropertiesMetadata.java      |   6 +-
 .../catalog/lakehouse/paimon/PaimonView.java       | 221 +++++++++
 .../lakehouse/paimon/PaimonViewCatalogOps.java     | 310 ++++++++++++
 .../lakehouse/paimon/ops/PaimonCatalogOps.java     |  41 ++
 .../lakehouse/paimon/TestPaimonCatalog.java        |  29 ++
 .../lakehouse/paimon/TestPaimonViewCatalogOps.java | 526 +++++++++++++++++++++
 .../integration/test/CatalogPaimonBaseIT.java      |  75 +++
 .../integration/test/CatalogPaimonHiveIT.java      |  91 ++++
 .../lakehouse/paimon/ops/TestPaimonCatalogOps.java | 103 +++-
 .../paimon/utils/PaimonViewTestCatalogHelper.java  | 178 +++++++
 13 files changed, 1706 insertions(+), 5 deletions(-)

diff --git a/catalogs/catalog-lakehouse-paimon/build.gradle.kts 
b/catalogs/catalog-lakehouse-paimon/build.gradle.kts
index ab9eb42986..4ef2ad0584 100644
--- a/catalogs/catalog-lakehouse-paimon/build.gradle.kts
+++ b/catalogs/catalog-lakehouse-paimon/build.gradle.kts
@@ -125,6 +125,39 @@ dependencies {
     exclude("*")
   }
 
+  // Required by Paimon HiveCatalog#createView, which calls hive ql metadata 
Table APIs.
+  runtimeOnly(libs.hive2.exec) {
+    // Use the lightweight core artifact instead of the shaded hive-exec fat 
jar.
+    artifact {
+      classifier = "core"
+    }
+    // Keep hive-exec footprint minimal for catalog runtime packaging.
+    exclude("com.google.code.findbugs", "jsr305")
+    exclude("com.google.protobuf")
+    exclude("org.apache.ant")
+    exclude("org.apache.avro")
+    exclude("org.apache.calcite")
+    exclude("org.apache.calcite.avatica")
+    exclude("org.apache.curator")
+    exclude("org.apache.hadoop")
+    exclude("org.apache.hive", "hive-llap-tez")
+    exclude("org.apache.hive", "hive-vector-code-gen")
+    exclude("org.apache.ivy")
+    exclude("org.apache.logging.log4j")
+    exclude("org.apache.zookeeper")
+    exclude("org.codehaus.groovy")
+    exclude("org.datanucleus")
+    exclude("org.eclipse.jetty.aggregate", "jetty-all")
+    exclude("org.eclipse.jetty.orbit", "javax.servlet")
+    exclude("org.openjdk.jol")
+    // Avoid resolving non-Central transitive artifacts (e.g. 
pentaho-aggdesigner-algorithm).
+    exclude("org.pentaho")
+    exclude("org.codehaus.janino")
+    exclude("net.hydromatic", "eigenbase-properties")
+    exclude("org.slf4j")
+    exclude("stax", "stax-api")
+  }
+
   annotationProcessor(libs.lombok)
 
   testImplementation(project(":api"))
@@ -153,7 +186,6 @@ dependencies {
     exclude("org.rocksdb")
   }
   testImplementation(libs.awaitility)
-  testImplementation(libs.awaitility)
   testImplementation(libs.bundles.log4j)
   testImplementation(libs.h2db)
   testImplementation(libs.junit.jupiter.api)
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalog.java
 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalog.java
index ba69cbfc0b..34c327bc41 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalog.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalog.java
@@ -31,6 +31,7 @@ import org.apache.gravitino.connector.PropertiesMetadata;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.JdbcCredential;
+import org.apache.gravitino.rel.ViewCatalog;
 
 /**
  * Implementation of {@link Catalog} that represents an Apache Paimon catalog 
in Apache Gravitino.
@@ -65,6 +66,11 @@ public class PaimonCatalog extends 
BaseCatalog<PaimonCatalog> {
     return new PaimonCatalogOperations();
   }
 
+  @Override
+  public ViewCatalog asViewCatalog() {
+    return (ViewCatalog) ops();
+  }
+
   @Override
   public Capability newCapability() {
     return new PaimonCatalogCapability();
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
index aa2a298a62..8e9f31e79b 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
@@ -49,14 +49,20 @@ import 
org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.exceptions.NoSuchColumnException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NoSuchViewException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
+import org.apache.gravitino.exceptions.ViewAlreadyExistsException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Representation;
 import org.apache.gravitino.rel.TableCatalog;
 import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.TableChange.RenameTable;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.ViewCatalog;
+import org.apache.gravitino.rel.ViewChange;
 import org.apache.gravitino.rel.expressions.NamedReference;
 import org.apache.gravitino.rel.expressions.distributions.Distribution;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
@@ -77,11 +83,13 @@ import org.slf4j.LoggerFactory;
  * Implementation of {@link CatalogOperations} that represents operations for 
interacting with the
  * Apache Paimon catalog in Apache Gravitino.
  */
-public class PaimonCatalogOperations implements CatalogOperations, 
SupportsSchemas, TableCatalog {
+public class PaimonCatalogOperations
+    implements CatalogOperations, SupportsSchemas, TableCatalog, ViewCatalog {
 
   public static final Logger LOG = 
LoggerFactory.getLogger(PaimonCatalogOperations.class);
 
   @VisibleForTesting public PaimonCatalogOps paimonCatalogOps;
+  @VisibleForTesting PaimonViewCatalogOps paimonViewCatalogOps;
 
   private static final String NO_SUCH_SCHEMA_EXCEPTION =
       "Paimon schema (database) %s does not exist.";
@@ -117,6 +125,9 @@ public class PaimonCatalogOperations implements 
CatalogOperations, SupportsSchem
     resultConf.putAll(gravitinoConfig);
 
     this.paimonCatalogOps = new PaimonCatalogOps(new PaimonConfig(resultConf));
+    this.paimonViewCatalogOps =
+        new PaimonViewCatalogOps(
+            paimonCatalogOps, this::buildPaimonNameIdentifier, 
this::schemaExists);
   }
 
   /**
@@ -465,6 +476,84 @@ public class PaimonCatalogOperations implements 
CatalogOperations, SupportsSchem
     return true;
   }
 
+  /**
+   * Lists all views under the specified namespace.
+   *
+   * @param namespace The namespace to list views for.
+   * @return An array of {@link NameIdentifier} representing the views in the 
namespace.
+   * @throws NoSuchSchemaException If the schema with the provided namespace 
does not exist.
+   */
+  @Override
+  public NameIdentifier[] listViews(Namespace namespace) throws 
NoSuchSchemaException {
+    return paimonViewCatalogOps.listViews(namespace);
+  }
+
+  /**
+   * Loads the view with the provided identifier.
+   *
+   * @param identifier The identifier of the view to load.
+   * @return The loaded {@link View} instance representing the view metadata.
+   * @throws NoSuchViewException If the view with the provided identifier does 
not exist.
+   */
+  @Override
+  public View loadView(NameIdentifier identifier) throws NoSuchViewException {
+    return paimonViewCatalogOps.loadView(identifier);
+  }
+
+  /**
+   * Creates a new view with the provided metadata.
+   *
+   * @param identifier The identifier of the view to create.
+   * @param comment The view comment.
+   * @param columns The output columns of the view.
+   * @param representations The view SQL representations.
+   * @param defaultCatalog The default catalog for unqualified identifiers.
+   * @param defaultSchema The default schema for unqualified identifiers.
+   * @param properties The view properties.
+   * @return The created view metadata.
+   * @throws NoSuchSchemaException If the target schema does not exist.
+   * @throws ViewAlreadyExistsException If the view already exists.
+   */
+  @Override
+  public View createView(
+      NameIdentifier identifier,
+      String comment,
+      Column[] columns,
+      Representation[] representations,
+      String defaultCatalog,
+      String defaultSchema,
+      Map<String, String> properties)
+      throws NoSuchSchemaException, ViewAlreadyExistsException {
+    return paimonViewCatalogOps.createView(
+        identifier, comment, columns, representations, defaultCatalog, 
defaultSchema, properties);
+  }
+
+  /**
+   * Alters an existing view according to the provided changes.
+   *
+   * @param identifier The identifier of the view to alter.
+   * @param changes The changes to apply.
+   * @return The updated view metadata.
+   * @throws NoSuchViewException If the view does not exist.
+   * @throws IllegalArgumentException If any change type is unsupported by 
Paimon.
+   */
+  @Override
+  public View alterView(NameIdentifier identifier, ViewChange... changes)
+      throws NoSuchViewException, IllegalArgumentException {
+    return paimonViewCatalogOps.alterView(identifier, changes);
+  }
+
+  /**
+   * Drops the view with the provided identifier.
+   *
+   * @param identifier The identifier of the view to drop.
+   * @return true if the view is successfully dropped, false if the view does 
not exist.
+   */
+  @Override
+  public boolean dropView(NameIdentifier identifier) {
+    return paimonViewCatalogOps.dropView(identifier);
+  }
+
   @Override
   public void close() {
     if (paimonCatalogOps != null) {
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonTablePropertiesMetadata.java
 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonTablePropertiesMetadata.java
index e56e6ea6f3..b9f0139519 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonTablePropertiesMetadata.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonTablePropertiesMetadata.java
@@ -61,7 +61,11 @@ public class PaimonTablePropertiesMetadata extends 
BasePropertiesMetadata {
             stringImmutablePropertyEntry(
                 ROWKIND_FIELD, "The table rowkind field", false, null, false, 
false),
             stringReservedPropertyEntry(PRIMARY_KEY, "The table primary key", 
false),
-            stringReservedPropertyEntry(PARTITION, "The table partition", 
false));
+            stringReservedPropertyEntry(PARTITION, "The table partition", 
false),
+            stringReservedPropertyEntry(
+                PaimonView.DEFAULT_CATALOG_PROPERTY, "The view default 
catalog", true),
+            stringReservedPropertyEntry(
+                PaimonView.DEFAULT_SCHEMA_PROPERTY, "The view default schema", 
true));
     PROPERTIES_METADATA = Maps.uniqueIndex(propertyEntries, 
PropertyEntry::getName);
   }
 
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonView.java
 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonView.java
new file mode 100644
index 0000000000..0d0f22d656
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonView.java
@@ -0,0 +1,221 @@
+/*
+ * 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.lakehouse.paimon;
+
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.meta.AuditInfo;
+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.paimon.catalog.Identifier;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.view.ViewImpl;
+
+/** Represents a Gravitino view object converted from Apache Paimon view 
metadata. */
+final class PaimonView implements View {
+
+  static final String DEFAULT_CATALOG_PROPERTY = 
"gravitino.view.default-catalog";
+  static final String DEFAULT_SCHEMA_PROPERTY = 
"gravitino.view.default-schema";
+
+  private static final String PAIMON_VIEW_QUERY = "query";
+
+  private final String name;
+  @Nullable private final String comment;
+  private final Column[] columns;
+  private final Representation[] representations;
+  @Nullable private final String defaultCatalog;
+  @Nullable private final String defaultSchema;
+  private final Map<String, String> properties;
+  private final AuditInfo auditInfo;
+
+  private PaimonView(
+      String name,
+      @Nullable String comment,
+      Column[] columns,
+      Representation[] representations,
+      @Nullable String defaultCatalog,
+      @Nullable String defaultSchema,
+      Map<String, String> properties,
+      AuditInfo auditInfo) {
+    this.name = name;
+    this.comment = comment;
+    this.columns = Arrays.copyOf(columns, columns.length);
+    this.representations = Arrays.copyOf(representations, 
representations.length);
+    this.defaultCatalog = defaultCatalog;
+    this.defaultSchema = defaultSchema;
+    this.properties = new HashMap<>(properties);
+    this.auditInfo = auditInfo;
+  }
+
+  static PaimonView fromPaimonView(org.apache.paimon.view.View view) {
+    Map<String, String> options =
+        view.options() == null ? new HashMap<>() : new 
HashMap<>(view.options());
+
+    String defaultCatalog = options.remove(DEFAULT_CATALOG_PROPERTY);
+    String defaultSchema = options.remove(DEFAULT_SCHEMA_PROPERTY);
+
+    return new PaimonView(
+        view.name(),
+        view.comment().orElse(null),
+        GravitinoPaimonColumn.fromPaimonRowType(view.rowType()).toArray(new 
Column[0]),
+        toRepresentations(view.query(), view.dialects()),
+        defaultCatalog,
+        defaultSchema,
+        options,
+        AuditInfo.EMPTY);
+  }
+
+  static org.apache.paimon.view.View toPaimonView(
+      NameIdentifier ident,
+      @Nullable String comment,
+      @Nullable Column[] columns,
+      Representation[] representations,
+      @Nullable String defaultCatalog,
+      @Nullable String defaultSchema,
+      Map<String, String> properties) {
+    Preconditions.checkArgument(ident != null, "View identifier must not be 
null");
+    Preconditions.checkArgument(
+        representations != null && representations.length > 0,
+        "representations must not be null or empty");
+
+    Column[] safeColumns = columns == null ? new Column[0] : columns;
+    List<DataField> fields = new ArrayList<>(safeColumns.length);
+    for (int index = 0; index < safeColumns.length; index++) {
+      fields.add(GravitinoPaimonColumn.toPaimonColumn(index, 
safeColumns[index]));
+    }
+
+    Map<String, String> dialectQueries = new HashMap<>();
+    String query = null;
+    for (Representation representation : representations) {
+      Preconditions.checkArgument(
+          representation instanceof SQLRepresentation, "Paimon only supports 
SQL representations");
+      SQLRepresentation sqlRepresentation = (SQLRepresentation) representation;
+      if (PAIMON_VIEW_QUERY.equalsIgnoreCase(sqlRepresentation.dialect())) {
+        Preconditions.checkArgument(
+            query == null,
+            "Only one representation with dialect '%s' is allowed",
+            PAIMON_VIEW_QUERY);
+        query = sqlRepresentation.sql();
+      } else {
+        String normalizedDialect = 
sqlRepresentation.dialect().toLowerCase(Locale.ROOT);
+        Preconditions.checkArgument(
+            !dialectQueries.containsKey(normalizedDialect),
+            "Only one representation per dialect is allowed 
(case-insensitive). Found duplicate: %s",
+            sqlRepresentation.dialect());
+        dialectQueries.put(normalizedDialect, sqlRepresentation.sql());
+      }
+    }
+
+    Preconditions.checkArgument(
+        query != null && !query.isEmpty(),
+        "View representation with dialect '%s' must not be null or empty",
+        PAIMON_VIEW_QUERY);
+
+    Map<String, String> options = properties == null ? new HashMap<>() : new 
HashMap<>(properties);
+    if (defaultCatalog != null) {
+      options.put(DEFAULT_CATALOG_PROPERTY, defaultCatalog);
+    } else {
+      options.remove(DEFAULT_CATALOG_PROPERTY);
+    }
+
+    if (defaultSchema != null) {
+      options.put(DEFAULT_SCHEMA_PROPERTY, defaultSchema);
+    } else {
+      options.remove(DEFAULT_SCHEMA_PROPERTY);
+    }
+
+    String[] namespaceLevels = ident.namespace().levels();
+    Preconditions.checkArgument(namespaceLevels.length > 0, "View namespace 
must not be empty");
+    Identifier paimonIdentifier =
+        Identifier.create(namespaceLevels[namespaceLevels.length - 1], 
ident.name());
+
+    return new ViewImpl(paimonIdentifier, fields, query, dialectQueries, 
comment, options);
+  }
+
+  @Override
+  public String name() {
+    return name;
+  }
+
+  @Override
+  public String comment() {
+    return comment;
+  }
+
+  @Override
+  public Column[] columns() {
+    return Arrays.copyOf(columns, columns.length);
+  }
+
+  @Override
+  public Representation[] representations() {
+    return Arrays.copyOf(representations, representations.length);
+  }
+
+  @Override
+  public String defaultCatalog() {
+    return defaultCatalog;
+  }
+
+  @Override
+  public String defaultSchema() {
+    return defaultSchema;
+  }
+
+  @Override
+  public Map<String, String> properties() {
+    return Collections.unmodifiableMap(properties);
+  }
+
+  @Override
+  public AuditInfo auditInfo() {
+    return auditInfo;
+  }
+
+  private static Representation[] toRepresentations(String query, Map<String, 
String> dialects) {
+    Preconditions.checkArgument(
+        query != null && !query.isEmpty(), "Paimon view query must not be null 
or empty");
+
+    Map<String, String> safeDialects =
+        dialects == null ? Collections.emptyMap() : new HashMap<>(dialects);
+    List<Representation> sqlRepresentations = new 
ArrayList<>(safeDialects.size() + 1);
+    sqlRepresentations.add(
+        
SQLRepresentation.builder().withDialect(PAIMON_VIEW_QUERY).withSql(query).build());
+
+    safeDialects.forEach(
+        (dialect, sql) -> {
+          if (!PAIMON_VIEW_QUERY.equalsIgnoreCase(dialect)) {
+            sqlRepresentations.add(
+                
SQLRepresentation.builder().withDialect(dialect).withSql(sql).build());
+          }
+        });
+
+    return sqlRepresentations.toArray(new Representation[0]);
+  }
+}
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonViewCatalogOps.java
 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonViewCatalogOps.java
new file mode 100644
index 0000000000..179a9ff09a
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonViewCatalogOps.java
@@ -0,0 +1,310 @@
+/*
+ * 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.lakehouse.paimon;
+
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.catalog.lakehouse.paimon.ops.PaimonCatalogOps;
+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;
+import org.apache.paimon.catalog.Catalog;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Encapsulates Paimon view operations so {@link PaimonCatalogOperations} can 
compose view logic.
+ */
+final class PaimonViewCatalogOps {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(PaimonViewCatalogOps.class);
+
+  private static final String NO_SUCH_SCHEMA_EXCEPTION =
+      "Paimon schema (database) %s does not exist.";
+  private static final String NO_SUCH_VIEW_EXCEPTION = "Paimon view %s does 
not exist.";
+  private static final String VIEW_ALREADY_EXISTS_EXCEPTION = "Paimon view %s 
already exists.";
+
+  private final PaimonCatalogOps paimonCatalogOps;
+  private final Function<NameIdentifier, NameIdentifier> 
paimonIdentifierBuilder;
+  private final Predicate<NameIdentifier> schemaExistsChecker;
+
+  PaimonViewCatalogOps(
+      PaimonCatalogOps paimonCatalogOps,
+      Function<NameIdentifier, NameIdentifier> paimonIdentifierBuilder,
+      Predicate<NameIdentifier> schemaExistsChecker) {
+    Preconditions.checkArgument(paimonCatalogOps != null, "Paimon catalog ops 
must not be null");
+    Preconditions.checkArgument(
+        paimonIdentifierBuilder != null, "Paimon identifier builder must not 
be null");
+    Preconditions.checkArgument(
+        schemaExistsChecker != null, "Schema exists checker must not be null");
+
+    this.paimonCatalogOps = paimonCatalogOps;
+    this.paimonIdentifierBuilder = paimonIdentifierBuilder;
+    this.schemaExistsChecker = schemaExistsChecker;
+  }
+
+  NameIdentifier[] listViews(Namespace namespace) throws NoSuchSchemaException 
{
+    String[] levels = namespace.levels();
+    NameIdentifier schemaIdentifier = NameIdentifier.of(levels[levels.length - 
1]);
+
+    List<String> views;
+    try {
+      views = paimonCatalogOps.listViews(schemaIdentifier.name());
+    } catch (Catalog.DatabaseNotExistException e) {
+      throw new NoSuchSchemaException(e, NO_SUCH_SCHEMA_EXCEPTION, 
namespace.toString());
+    }
+
+    return views.stream()
+        .map(
+            viewIdentifier -> 
NameIdentifier.of(ArrayUtils.add(namespace.levels(), viewIdentifier)))
+        .toArray(NameIdentifier[]::new);
+  }
+
+  View loadView(NameIdentifier identifier) throws NoSuchViewException {
+    org.apache.paimon.view.View view;
+    try {
+      NameIdentifier viewIdentifier = 
paimonIdentifierBuilder.apply(identifier);
+      view = paimonCatalogOps.loadView(viewIdentifier.toString());
+    } catch (Catalog.ViewNotExistException e) {
+      throw new NoSuchViewException(e, NO_SUCH_VIEW_EXCEPTION, identifier);
+    }
+    return PaimonView.fromPaimonView(view);
+  }
+
+  View createView(
+      NameIdentifier identifier,
+      String comment,
+      Column[] columns,
+      Representation[] representations,
+      String defaultCatalog,
+      String defaultSchema,
+      Map<String, String> properties)
+      throws NoSuchSchemaException, ViewAlreadyExistsException {
+    NameIdentifier nameIdentifier = paimonIdentifierBuilder.apply(identifier);
+    NameIdentifier schemaIdentifier = 
NameIdentifier.of(nameIdentifier.namespace().levels());
+    if (!schemaExistsChecker.test(schemaIdentifier)) {
+      throw new NoSuchSchemaException(NO_SUCH_SCHEMA_EXCEPTION, 
schemaIdentifier);
+    }
+
+    org.apache.paimon.view.View paimonView =
+        PaimonView.toPaimonView(
+            identifier,
+            comment,
+            columns,
+            representations,
+            defaultCatalog,
+            defaultSchema,
+            properties);
+
+    try {
+      paimonCatalogOps.createView(nameIdentifier.toString(), paimonView);
+    } catch (Catalog.DatabaseNotExistException e) {
+      throw new NoSuchSchemaException(e, NO_SUCH_SCHEMA_EXCEPTION, 
schemaIdentifier);
+    } catch (Catalog.ViewAlreadyExistException e) {
+      throw new ViewAlreadyExistsException(e, VIEW_ALREADY_EXISTS_EXCEPTION, 
identifier);
+    }
+
+    return PaimonView.fromPaimonView(paimonView);
+  }
+
+  View alterView(NameIdentifier identifier, ViewChange... changes)
+      throws NoSuchViewException, IllegalArgumentException {
+    if (changes == null || changes.length == 0) {
+      return loadView(identifier);
+    }
+
+    List<ViewChange.RenameView> renameViewChanges =
+        Arrays.stream(changes)
+            .filter(viewChange -> viewChange instanceof ViewChange.RenameView)
+            .map(viewChange -> (ViewChange.RenameView) viewChange)
+            .collect(Collectors.toList());
+    if (!renameViewChanges.isEmpty()) {
+      Preconditions.checkArgument(
+          renameViewChanges.size() == 1,
+          "Only one rename operation is allowed, but found: %s",
+          renameViewChanges.size());
+
+      List<String> otherChanges =
+          Arrays.stream(changes)
+              .filter(viewChange -> !(viewChange instanceof 
ViewChange.RenameView))
+              .map(String::valueOf)
+              .collect(Collectors.toList());
+      Preconditions.checkArgument(
+          otherChanges.isEmpty(),
+          "The operation to change the view name cannot be performed together 
with other operations. "
+              + "The list of operations that you cannot perform includes: 
\n%s",
+          String.join("\n", otherChanges));
+      return renameView(identifier, renameViewChanges.get(0));
+    }
+
+    if (containsReplaceView(changes)) {
+      return replaceView(identifier, changes);
+    }
+
+    return alterViewWithoutReplace(identifier, changes);
+  }
+
+  boolean dropView(NameIdentifier identifier) {
+    try {
+      NameIdentifier viewIdentifier = 
paimonIdentifierBuilder.apply(identifier);
+      paimonCatalogOps.dropView(viewIdentifier.toString());
+    } catch (Catalog.ViewNotExistException e) {
+      return false;
+    }
+
+    return true;
+  }
+
+  private View renameView(NameIdentifier identifier, ViewChange.RenameView 
renameView)
+      throws NoSuchViewException, IllegalArgumentException {
+    NameIdentifier sourceIdentifier = 
paimonIdentifierBuilder.apply(identifier);
+    NameIdentifier renamedIdentifier =
+        NameIdentifier.of(identifier.namespace(), renameView.getNewName());
+    NameIdentifier targetIdentifier = 
paimonIdentifierBuilder.apply(renamedIdentifier);
+    try {
+      paimonCatalogOps.renameView(sourceIdentifier.toString(), 
targetIdentifier.toString());
+    } catch (Catalog.ViewNotExistException e) {
+      throw new NoSuchViewException(e, NO_SUCH_VIEW_EXCEPTION, identifier);
+    } catch (Catalog.ViewAlreadyExistException e) {
+      throw new ViewAlreadyExistsException(e, VIEW_ALREADY_EXISTS_EXCEPTION, 
renamedIdentifier);
+    }
+
+    try {
+      return loadView(renamedIdentifier);
+    } catch (NoSuchViewException e) {
+      throw new IllegalStateException(
+          String.format(
+              "Paimon view %s was renamed to %s, but loading the renamed view 
failed.",
+              identifier, renamedIdentifier),
+          e);
+    }
+  }
+
+  private View replaceView(NameIdentifier identifier, ViewChange... changes)
+      throws NoSuchViewException, IllegalArgumentException {
+    View existingView = loadView(identifier);
+    Map<String, String> finalProperties = new 
HashMap<>(existingView.properties());
+    ViewChange.ReplaceView replaceView = null;
+
+    for (ViewChange change : changes) {
+      if (change instanceof ViewChange.SetProperty) {
+        ViewChange.SetProperty setProperty = (ViewChange.SetProperty) change;
+        finalProperties.put(setProperty.getProperty(), setProperty.getValue());
+      } else if (change instanceof ViewChange.RemoveProperty) {
+        finalProperties.remove(((ViewChange.RemoveProperty) 
change).getProperty());
+      } else if (change instanceof ViewChange.ReplaceView) {
+        replaceView = (ViewChange.ReplaceView) change;
+      } else {
+        throw new IllegalArgumentException(
+            "Unsupported view change type: " + 
change.getClass().getSimpleName());
+      }
+    }
+
+    Preconditions.checkArgument(replaceView != null, "Replace view change is 
required");
+
+    org.apache.paimon.view.View paimonView =
+        PaimonView.toPaimonView(
+            identifier,
+            replaceView.getComment(),
+            replaceView.getColumns(),
+            replaceView.getRepresentations(),
+            replaceView.getDefaultCatalog(),
+            replaceView.getDefaultSchema(),
+            finalProperties);
+
+    NameIdentifier sourceIdentifier = 
paimonIdentifierBuilder.apply(identifier);
+
+    try {
+      // Paimon does not provide a native replace-view API. To align with 
Paimon Spark's
+      // CREATE OR REPLACE VIEW behavior, replace is executed as 
drop-then-create.
+      paimonCatalogOps.dropView(sourceIdentifier.toString());
+    } catch (Catalog.ViewNotExistException e) {
+      throw new NoSuchViewException(e, NO_SUCH_VIEW_EXCEPTION, identifier);
+    }
+
+    try {
+      paimonCatalogOps.createView(sourceIdentifier.toString(), paimonView);
+    } catch (Catalog.ViewAlreadyExistException e) {
+      LOG.error(
+          "Replacing Paimon view {} is non-atomic (drop-then-create). "
+              + "Create failed after drop and the original view may be lost.",
+          identifier,
+          e);
+      throw new IllegalArgumentException(
+          String.format(VIEW_ALREADY_EXISTS_EXCEPTION, identifier), e);
+    } catch (Catalog.DatabaseNotExistException e) {
+      LOG.error(
+          "Replacing Paimon view {} is non-atomic (drop-then-create). "
+              + "Create failed after drop and the original view may be lost.",
+          identifier,
+          e);
+      throw new IllegalArgumentException(
+          String.format(NO_SUCH_SCHEMA_EXCEPTION, identifier.namespace()), e);
+    }
+
+    return loadView(identifier);
+  }
+
+  private View alterViewWithoutReplace(NameIdentifier identifier, 
ViewChange... changes)
+      throws NoSuchViewException, IllegalArgumentException {
+    List<org.apache.paimon.view.ViewChange> paimonViewChanges = new 
ArrayList<>();
+
+    try {
+      for (ViewChange change : changes) {
+        if (change instanceof ViewChange.SetProperty) {
+          ViewChange.SetProperty setProperty = (ViewChange.SetProperty) change;
+          paimonViewChanges.add(
+              org.apache.paimon.view.ViewChange.setOption(
+                  setProperty.getProperty(), setProperty.getValue()));
+        } else if (change instanceof ViewChange.RemoveProperty) {
+          ViewChange.RemoveProperty removeProperty = 
(ViewChange.RemoveProperty) change;
+          paimonViewChanges.add(
+              
org.apache.paimon.view.ViewChange.removeOption(removeProperty.getProperty()));
+        } else {
+          throw new IllegalArgumentException(
+              "Unsupported view change type: " + 
change.getClass().getSimpleName());
+        }
+      }
+      paimonCatalogOps.alterView(
+          paimonIdentifierBuilder.apply(identifier).toString(), 
paimonViewChanges);
+    } catch (Catalog.ViewNotExistException e) {
+      throw new NoSuchViewException(e, NO_SUCH_VIEW_EXCEPTION, identifier);
+    }
+
+    return loadView(identifier);
+  }
+
+  private boolean containsReplaceView(ViewChange... changes) {
+    return Arrays.stream(changes).anyMatch(change -> change instanceof 
ViewChange.ReplaceView);
+  }
+}
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/PaimonCatalogOps.java
 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/PaimonCatalogOps.java
index 02996a2f5f..0851f2fa0b 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/PaimonCatalogOps.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/PaimonCatalogOps.java
@@ -32,11 +32,17 @@ import 
org.apache.paimon.catalog.Catalog.ColumnNotExistException;
 import org.apache.paimon.catalog.Catalog.DatabaseAlreadyExistException;
 import org.apache.paimon.catalog.Catalog.DatabaseNotEmptyException;
 import org.apache.paimon.catalog.Catalog.DatabaseNotExistException;
+import org.apache.paimon.catalog.Catalog.DialectAlreadyExistException;
+import org.apache.paimon.catalog.Catalog.DialectNotExistException;
 import org.apache.paimon.catalog.Catalog.TableAlreadyExistException;
 import org.apache.paimon.catalog.Catalog.TableNotExistException;
+import org.apache.paimon.catalog.Catalog.ViewAlreadyExistException;
+import org.apache.paimon.catalog.Catalog.ViewNotExistException;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.table.Table;
+import org.apache.paimon.view.View;
+import org.apache.paimon.view.ViewChange;
 
 /** Table operation proxy that handles table operations of an underlying 
Apache Paimon catalog. */
 public class PaimonCatalogOps implements AutoCloseable {
@@ -104,7 +110,42 @@ public class PaimonCatalogOps implements AutoCloseable {
     catalog.renameTable(tableIdentifier(fromTableName), 
tableIdentifier(toTableName), false);
   }
 
+  public List<String> listViews(String databaseName) throws 
DatabaseNotExistException {
+    return catalog.listViews(databaseName);
+  }
+
+  public View loadView(String viewName) throws ViewNotExistException {
+    return catalog.getView(viewIdentifier(viewName));
+  }
+
+  public void createView(String viewName, View view)
+      throws ViewAlreadyExistException, DatabaseNotExistException {
+    catalog.createView(viewIdentifier(viewName), view, false);
+  }
+
+  public void alterView(String viewName, List<ViewChange> changes) throws 
ViewNotExistException {
+    try {
+      catalog.alterView(viewIdentifier(viewName), changes, false);
+    } catch (DialectAlreadyExistException | DialectNotExistException e) {
+      throw new IllegalArgumentException(
+          String.format("Cannot alter view %s: %s", viewName, e.getMessage()), 
e);
+    }
+  }
+
+  public void renameView(String fromViewName, String toViewName)
+      throws ViewNotExistException, ViewAlreadyExistException {
+    catalog.renameView(viewIdentifier(fromViewName), 
viewIdentifier(toViewName), false);
+  }
+
+  public void dropView(String viewName) throws ViewNotExistException {
+    catalog.dropView(viewIdentifier(viewName), false);
+  }
+
   private Identifier tableIdentifier(String tableName) {
     return Identifier.fromString(tableName);
   }
+
+  private Identifier viewIdentifier(String viewName) {
+    return Identifier.fromString(viewName);
+  }
 }
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestPaimonCatalog.java
 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestPaimonCatalog.java
index e83f8c7d78..93510faf29 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestPaimonCatalog.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestPaimonCatalog.java
@@ -44,6 +44,7 @@ import org.apache.gravitino.credential.OSSSecretKeyCredential;
 import org.apache.gravitino.credential.S3SecretKeyCredential;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.CatalogEntity;
+import org.apache.gravitino.rel.ViewCatalog;
 import org.apache.gravitino.storage.AzureProperties;
 import org.apache.gravitino.storage.OSSProperties;
 import org.apache.gravitino.storage.S3Properties;
@@ -145,6 +146,14 @@ public class TestPaimonCatalog {
                 ImmutableMap.of()));
   }
 
+  @Test
+  void testAsViewCatalog() {
+    PaimonCatalog paimonCatalog = newPaimonCatalog("catalog_view");
+    ViewCatalog viewCatalog = paimonCatalog.asViewCatalog();
+    Assertions.assertNotNull(viewCatalog);
+    Assertions.assertSame(paimonCatalog.ops(), viewCatalog);
+  }
+
   @Test
   void testCatalogProperty() throws IOException {
     AuditInfo auditInfo =
@@ -403,4 +412,24 @@ public class TestPaimonCatalog {
     String credentialProviders = 
properties.get(CredentialConstants.CREDENTIAL_PROVIDERS);
     Assertions.assertEquals("custom-provider", credentialProviders);
   }
+
+  private PaimonCatalog newPaimonCatalog(String catalogName) {
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    CatalogEntity entity =
+        CatalogEntity.builder()
+            .withId(1L)
+            .withName(catalogName)
+            .withNamespace(Namespace.of("metalake"))
+            .withType(PaimonCatalog.Type.RELATIONAL)
+            .withProvider("lakehouse-paimon")
+            .withAuditInfo(auditInfo)
+            .build();
+
+    Map<String, String> conf = Maps.newHashMap();
+    conf.put(PaimonCatalogPropertiesMetadata.GRAVITINO_CATALOG_BACKEND, 
"filesystem");
+    conf.put(PaimonCatalogPropertiesMetadata.WAREHOUSE, tempDir + 
File.separator + catalogName);
+    return new PaimonCatalog().withCatalogConf(conf).withCatalogEntity(entity);
+  }
 }
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestPaimonViewCatalogOps.java
 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestPaimonViewCatalogOps.java
new file mode 100644
index 0000000000..640b98a7f6
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestPaimonViewCatalogOps.java
@@ -0,0 +1,526 @@
+/*
+ * 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.lakehouse.paimon;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import java.io.File;
+import java.util.Arrays;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.catalog.lakehouse.paimon.ops.PaimonCatalogOps;
+import 
org.apache.gravitino.catalog.lakehouse.paimon.utils.PaimonViewTestCatalogHelper;
+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.SQLRepresentation;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.ViewChange;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.Identifier;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Tests for {@link PaimonViewCatalogOps}. */
+public class TestPaimonViewCatalogOps {
+
+  private static final String DATABASE = "test_view_ops_database";
+  private static final String VIEW = "test_view_ops_view";
+  private static final NameIdentifier VIEW_IDENTIFIER =
+      NameIdentifier.of(Namespace.of(DATABASE), VIEW);
+
+  @TempDir private File warehouse;
+
+  private TestablePaimonCatalogOps paimonCatalogOps;
+  private PaimonViewCatalogOps paimonViewCatalogOps;
+
+  @BeforeEach
+  public void setUp() throws Exception {
+    paimonCatalogOps =
+        new TestablePaimonCatalogOps(
+            new PaimonConfig(
+                ImmutableMap.of(PaimonCatalogPropertiesMetadata.WAREHOUSE, 
warehouse.getPath())));
+    paimonCatalogOps.setCatalog(
+        
PaimonViewTestCatalogHelper.createViewSupportedCatalog(paimonCatalogOps.catalog()));
+    paimonCatalogOps.createDatabase(DATABASE, Maps.newHashMap());
+
+    paimonViewCatalogOps =
+        new PaimonViewCatalogOps(
+            paimonCatalogOps, this::buildPaimonNameIdentifier, 
this::schemaExists);
+  }
+
+  @AfterEach
+  public void tearDown() throws Exception {
+    if (paimonCatalogOps != null) {
+      paimonCatalogOps.close();
+    }
+  }
+
+  @Test
+  public void testRenameViewCannotBeCombinedWithOtherChanges() throws 
Exception {
+    createView(VIEW_IDENTIFIER);
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            paimonViewCatalogOps.alterView(
+                VIEW_IDENTIFIER,
+                ViewChange.rename(VIEW + "_renamed"),
+                ViewChange.setProperty("k1", "v1")));
+
+    View unchangedView = paimonViewCatalogOps.loadView(VIEW_IDENTIFIER);
+    assertEquals(VIEW, unchangedView.name());
+    assertFalse(unchangedView.properties().containsKey("k1"));
+  }
+
+  @Test
+  public void testAlterViewPropertyChangesAndRenameOnly() throws Exception {
+    createView(VIEW_IDENTIFIER);
+
+    paimonViewCatalogOps.alterView(
+        VIEW_IDENTIFIER,
+        ViewChange.setProperty("k1", "v1"),
+        ViewChange.setProperty("k2", "v2"),
+        ViewChange.removeProperty("k1"));
+
+    View alteredView = paimonViewCatalogOps.loadView(VIEW_IDENTIFIER);
+    assertFalse(alteredView.properties().containsKey("k1"));
+    assertEquals("v2", alteredView.properties().get("k2"));
+
+    String renamedViewName = VIEW + "_renamed";
+    NameIdentifier renamedIdentifier =
+        NameIdentifier.of(VIEW_IDENTIFIER.namespace(), renamedViewName);
+    View renamedView =
+        paimonViewCatalogOps.alterView(VIEW_IDENTIFIER, 
ViewChange.rename(renamedViewName));
+    assertEquals(renamedViewName, renamedView.name());
+    assertThrows(NoSuchViewException.class, () -> 
paimonViewCatalogOps.loadView(VIEW_IDENTIFIER));
+    assertEquals("v2", 
paimonViewCatalogOps.loadView(renamedIdentifier).properties().get("k2"));
+  }
+
+  @Test
+  public void testAlterViewReplaceUpdatesBodyAndPreservesProperties() throws 
Exception {
+    NameIdentifier replaceIdentifier = 
NameIdentifier.of(Namespace.of(DATABASE), VIEW + "_replace");
+    String originalQuery = "SELECT col_1 FROM source_table";
+
+    paimonViewCatalogOps.createView(
+        replaceIdentifier,
+        "original_view_comment",
+        new Column[] {Column.of("col_1", Types.IntegerType.get(), "col_1")},
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("query").withSql(originalQuery).build(),
+          
SQLRepresentation.builder().withDialect("spark").withSql(originalQuery).build()
+        },
+        "paimon",
+        DATABASE,
+        Maps.newHashMap(ImmutableMap.of("keep_key", "keep_value", 
"remove_key", "remove_value")));
+
+    String replacedQuery = "SELECT col_2, col_3 FROM source_table";
+    String replacedTrinoQuery = "SELECT CAST(col_2 AS BIGINT), col_3 FROM 
source_table";
+    paimonViewCatalogOps.alterView(
+        replaceIdentifier,
+        ViewChange.setProperty("add_key", "add_value"),
+        ViewChange.removeProperty("remove_key"),
+        ViewChange.replaceView(
+            new Column[] {
+              Column.of("col_2", Types.LongType.get(), "col_2"),
+              Column.of("col_3", Types.StringType.get(), "col_3")
+            },
+            new Representation[] {
+              
SQLRepresentation.builder().withDialect("query").withSql(replacedQuery).build(),
+              
SQLRepresentation.builder().withDialect("trino").withSql(replacedTrinoQuery).build()
+            },
+            "replaced_catalog",
+            "replaced_schema",
+            "replaced_view_comment"));
+
+    View replacedView = paimonViewCatalogOps.loadView(replaceIdentifier);
+    assertEquals("replaced_view_comment", replacedView.comment());
+    assertEquals(2, replacedView.columns().length);
+    assertEquals("col_2", replacedView.columns()[0].name());
+    assertEquals("col_3", replacedView.columns()[1].name());
+    assertEquals(2, replacedView.representations().length);
+    assertTrue(replacedView.sqlFor("query").isPresent());
+    assertEquals(replacedQuery, replacedView.sqlFor("query").get().sql());
+    assertTrue(replacedView.sqlFor("trino").isPresent());
+    assertEquals(replacedTrinoQuery, replacedView.sqlFor("trino").get().sql());
+    assertTrue(replacedView.sqlFor("spark").isEmpty());
+    assertEquals("replaced_catalog", replacedView.defaultCatalog());
+    assertEquals("replaced_schema", replacedView.defaultSchema());
+    assertEquals("keep_value", replacedView.properties().get("keep_key"));
+    assertFalse(replacedView.properties().containsKey("remove_key"));
+    assertEquals("add_value", replacedView.properties().get("add_key"));
+  }
+
+  @Test
+  public void testReplaceViewCreateFailureAfterDropLeavesViewDropped() throws 
Exception {
+    NameIdentifier replaceIdentifier =
+        NameIdentifier.of(Namespace.of(DATABASE), VIEW + 
"_replace_create_failure");
+    createView(replaceIdentifier);
+
+    NameIdentifier paimonReplaceIdentifier = 
buildPaimonNameIdentifier(replaceIdentifier);
+    paimonCatalogOps.failCreateViewWithDatabaseNotExist(
+        paimonReplaceIdentifier.toString(), DATABASE);
+
+    try {
+      IllegalArgumentException exception =
+          assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  paimonViewCatalogOps.alterView(
+                      replaceIdentifier,
+                      ViewChange.replaceView(
+                          new Column[] {Column.of("col_2", 
Types.IntegerType.get(), "col_2")},
+                          new Representation[] {
+                            SQLRepresentation.builder()
+                                .withDialect("query")
+                                .withSql("SELECT col_2 FROM source_table")
+                                .build()
+                          },
+                          "paimon",
+                          DATABASE,
+                          "replaced_view_comment")));
+
+      assertTrue(exception.getMessage().contains(DATABASE));
+    } finally {
+      paimonCatalogOps.clearFailCreateView();
+    }
+
+    assertThrows(NoSuchViewException.class, () -> 
paimonViewCatalogOps.loadView(replaceIdentifier));
+  }
+
+  @Test
+  public void testListAndDropViewOperations() throws Exception {
+    NameIdentifier firstIdentifier = NameIdentifier.of(Namespace.of(DATABASE), 
VIEW + "_list_1");
+    NameIdentifier secondIdentifier = 
NameIdentifier.of(Namespace.of(DATABASE), VIEW + "_list_2");
+    createView(firstIdentifier);
+    createView(secondIdentifier);
+
+    NameIdentifier[] listedViews = 
paimonViewCatalogOps.listViews(Namespace.of(DATABASE));
+    assertEquals(2, listedViews.length);
+    assertTrue(Arrays.asList(listedViews).contains(firstIdentifier));
+    assertTrue(Arrays.asList(listedViews).contains(secondIdentifier));
+
+    assertTrue(paimonViewCatalogOps.dropView(firstIdentifier));
+    assertFalse(paimonViewCatalogOps.dropView(firstIdentifier));
+    assertThrows(NoSuchViewException.class, () -> 
paimonViewCatalogOps.loadView(firstIdentifier));
+
+    NameIdentifier[] remainingViews = 
paimonViewCatalogOps.listViews(Namespace.of(DATABASE));
+    assertEquals(1, remainingViews.length);
+    assertEquals(secondIdentifier, remainingViews[0]);
+  }
+
+  @Test
+  public void testRenameAndReplaceCannotBeCombined() throws Exception {
+    createView(VIEW_IDENTIFIER);
+
+    IllegalArgumentException exception =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                paimonViewCatalogOps.alterView(
+                    VIEW_IDENTIFIER,
+                    ViewChange.rename(VIEW + "_renamed"),
+                    ViewChange.replaceView(
+                        new Column[] {Column.of("col_2", 
Types.IntegerType.get(), "col_2")},
+                        new Representation[] {
+                          SQLRepresentation.builder()
+                              .withDialect("query")
+                              .withSql("SELECT col_2 FROM source_table")
+                              .build()
+                        },
+                        "paimon",
+                        DATABASE,
+                        "replaced_view_comment")));
+
+    assertTrue(exception.getMessage().contains("cannot be performed 
together"));
+    assertEquals(VIEW, paimonViewCatalogOps.loadView(VIEW_IDENTIFIER).name());
+  }
+
+  @Test
+  public void testMultipleRenameChangesAreRejected() throws Exception {
+    createView(VIEW_IDENTIFIER);
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            paimonViewCatalogOps.alterView(
+                VIEW_IDENTIFIER, ViewChange.rename("rename_1"), 
ViewChange.rename("rename_2")));
+
+    assertEquals(VIEW, paimonViewCatalogOps.loadView(VIEW_IDENTIFIER).name());
+  }
+
+  @Test
+  public void testRenameViewFailsWithTypedExceptionWhenTargetExists() throws 
Exception {
+    NameIdentifier sourceIdentifier = 
NameIdentifier.of(Namespace.of(DATABASE), VIEW + "_source");
+    NameIdentifier targetIdentifier = 
NameIdentifier.of(Namespace.of(DATABASE), VIEW + "_target");
+    createView(sourceIdentifier);
+    createView(targetIdentifier);
+
+    ViewAlreadyExistsException exception =
+        assertThrows(
+            ViewAlreadyExistsException.class,
+            () ->
+                paimonViewCatalogOps.alterView(
+                    sourceIdentifier, 
ViewChange.rename(targetIdentifier.name())));
+    assertTrue(exception.getMessage().contains(targetIdentifier.name()));
+    assertEquals(sourceIdentifier.name(), 
paimonViewCatalogOps.loadView(sourceIdentifier).name());
+    assertEquals(targetIdentifier.name(), 
paimonViewCatalogOps.loadView(targetIdentifier).name());
+  }
+
+  @Test
+  public void testRenameViewReportsLoadFailureAfterSuccessfulRename() throws 
Exception {
+    NameIdentifier sourceIdentifier =
+        NameIdentifier.of(Namespace.of(DATABASE), VIEW + 
"_rename_load_source");
+    createView(sourceIdentifier);
+    NameIdentifier renamedIdentifier =
+        NameIdentifier.of(sourceIdentifier.namespace(), VIEW + 
"_rename_load_target");
+    NameIdentifier paimonRenamedIdentifier = 
buildPaimonNameIdentifier(renamedIdentifier);
+    paimonCatalogOps.failLoadView(paimonRenamedIdentifier.toString());
+
+    try {
+      IllegalStateException exception =
+          assertThrows(
+              IllegalStateException.class,
+              () ->
+                  paimonViewCatalogOps.alterView(
+                      sourceIdentifier, 
ViewChange.rename(renamedIdentifier.name())));
+      assertTrue(exception.getMessage().contains(sourceIdentifier.toString()));
+      
assertTrue(exception.getMessage().contains(renamedIdentifier.toString()));
+    } finally {
+      paimonCatalogOps.clearFailLoadView();
+    }
+
+    assertThrows(NoSuchViewException.class, () -> 
paimonViewCatalogOps.loadView(sourceIdentifier));
+    assertEquals(renamedIdentifier.name(), 
paimonViewCatalogOps.loadView(renamedIdentifier).name());
+  }
+
+  @Test
+  public void testCreateViewRequiresQueryRepresentation() {
+    String query = "SELECT col_1 FROM source_table";
+    Representation[] representations =
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("spark").withSql(query).build()
+        };
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            paimonViewCatalogOps.createView(
+                NameIdentifier.of(Namespace.of(DATABASE), 
"missing_query_dialect"),
+                "test_view_comment",
+                new Column[] {Column.of("col_1", Types.IntegerType.get(), 
"col_1")},
+                representations,
+                "paimon",
+                DATABASE,
+                Maps.newHashMap()));
+  }
+
+  @Test
+  public void testCreateViewAcceptsCaseInsensitiveQueryRepresentation() throws 
Exception {
+    String query = "SELECT col_1 FROM source_table";
+    NameIdentifier identifier = NameIdentifier.of(Namespace.of(DATABASE), 
"upper_case_query");
+    Representation[] representations =
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("QUERY").withSql(query).build(),
+          
SQLRepresentation.builder().withDialect("Spark").withSql(query).build()
+        };
+
+    paimonViewCatalogOps.createView(
+        identifier,
+        "test_view_comment",
+        new Column[] {Column.of("col_1", Types.IntegerType.get(), "col_1")},
+        representations,
+        "paimon",
+        DATABASE,
+        Maps.newHashMap());
+
+    View loadedView = paimonViewCatalogOps.loadView(identifier);
+    boolean hasQueryRepresentation =
+        Arrays.stream(loadedView.representations())
+            .anyMatch(
+                representation ->
+                    representation instanceof SQLRepresentation
+                        && "query".equals(((SQLRepresentation) 
representation).dialect())
+                        && query.equals(((SQLRepresentation) 
representation).sql()));
+    assertTrue(hasQueryRepresentation);
+
+    boolean hasNormalizedSparkRepresentation =
+        Arrays.stream(loadedView.representations())
+            .anyMatch(
+                representation ->
+                    representation instanceof SQLRepresentation
+                        && "spark".equals(((SQLRepresentation) 
representation).dialect())
+                        && query.equals(((SQLRepresentation) 
representation).sql()));
+    assertTrue(hasNormalizedSparkRepresentation);
+  }
+
+  @Test
+  public void testCreateViewRejectsCaseInsensitiveDuplicateDialects() {
+    String query = "SELECT col_1 FROM source_table";
+    Representation[] representations =
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("query").withSql(query).build(),
+          
SQLRepresentation.builder().withDialect("Spark").withSql(query).build(),
+          SQLRepresentation.builder()
+              .withDialect("spark")
+              .withSql(query + " WHERE col_1 > 1")
+              .build()
+        };
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            paimonViewCatalogOps.createView(
+                NameIdentifier.of(Namespace.of(DATABASE), 
"duplicate_dialect_case"),
+                "test_view_comment",
+                new Column[] {Column.of("col_1", Types.IntegerType.get(), 
"col_1")},
+                representations,
+                "paimon",
+                DATABASE,
+                Maps.newHashMap()));
+  }
+
+  @Test
+  public void 
testCreateViewDatabaseNotExistUsesSchemaIdentifierInErrorMessage() {
+    String query = "SELECT col_1 FROM source_table";
+    NameIdentifier identifier = 
NameIdentifier.of(Namespace.of("missing_schema"), "missing_view");
+    Representation[] representations =
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("query").withSql(query).build()
+        };
+
+    PaimonViewCatalogOps bypassedSchemaCheckOps =
+        new PaimonViewCatalogOps(
+            paimonCatalogOps, this::buildPaimonNameIdentifier, 
schemaIdentifier -> true);
+
+    NoSuchSchemaException exception =
+        assertThrows(
+            NoSuchSchemaException.class,
+            () ->
+                bypassedSchemaCheckOps.createView(
+                    identifier,
+                    "test_view_comment",
+                    new Column[] {Column.of("col_1", Types.IntegerType.get(), 
"col_1")},
+                    representations,
+                    "paimon",
+                    "missing_schema",
+                    Maps.newHashMap()));
+
+    assertTrue(exception.getMessage().contains("missing_schema"));
+    assertFalse(exception.getMessage().contains("missing_view"));
+  }
+
+  private void createView(NameIdentifier identifier) throws Exception {
+    String query = "SELECT col_1 FROM source_table";
+    Representation[] representations =
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("query").withSql(query).build(),
+          
SQLRepresentation.builder().withDialect("spark").withSql(query).build()
+        };
+
+    paimonViewCatalogOps.createView(
+        identifier,
+        "test_view_comment",
+        new Column[] {Column.of("col_1", Types.IntegerType.get(), "col_1")},
+        representations,
+        "paimon",
+        DATABASE,
+        Maps.newHashMap());
+  }
+
+  private boolean schemaExists(NameIdentifier identifier) {
+    try {
+      paimonCatalogOps.loadDatabase(identifier.name());
+      return true;
+    } catch (Catalog.DatabaseNotExistException e) {
+      return false;
+    }
+  }
+
+  private NameIdentifier buildPaimonNameIdentifier(NameIdentifier identifier) {
+    String[] levels = identifier.namespace().levels();
+    return NameIdentifier.of(levels[levels.length - 1], identifier.name());
+  }
+
+  private static class TestablePaimonCatalogOps extends PaimonCatalogOps {
+
+    private String failLoadViewName;
+    private String failCreateViewName;
+    private String failCreateViewDatabaseName;
+
+    TestablePaimonCatalogOps(PaimonConfig paimonConfig) {
+      super(paimonConfig);
+    }
+
+    Catalog catalog() {
+      return catalog;
+    }
+
+    void setCatalog(Catalog catalog) {
+      this.catalog = catalog;
+    }
+
+    void failLoadView(String viewName) {
+      this.failLoadViewName = viewName;
+    }
+
+    void clearFailLoadView() {
+      this.failLoadViewName = null;
+    }
+
+    void failCreateViewWithDatabaseNotExist(String viewName, String 
databaseName) {
+      this.failCreateViewName = viewName;
+      this.failCreateViewDatabaseName = databaseName;
+    }
+
+    void clearFailCreateView() {
+      this.failCreateViewName = null;
+      this.failCreateViewDatabaseName = null;
+    }
+
+    @Override
+    public void createView(String viewName, org.apache.paimon.view.View view)
+        throws Catalog.ViewAlreadyExistException, 
Catalog.DatabaseNotExistException {
+      if (failCreateViewName != null && failCreateViewName.equals(viewName)) {
+        throw new 
Catalog.DatabaseNotExistException(failCreateViewDatabaseName);
+      }
+
+      super.createView(viewName, view);
+    }
+
+    @Override
+    public org.apache.paimon.view.View loadView(String viewName)
+        throws Catalog.ViewNotExistException {
+      if (failLoadViewName != null && failLoadViewName.equals(viewName)) {
+        throw new 
Catalog.ViewNotExistException(Identifier.fromString(viewName));
+      }
+      return super.loadView(viewName);
+    }
+  }
+}
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonBaseIT.java
 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonBaseIT.java
index 677d8ca790..1e1a7a20d7 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonBaseIT.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonBaseIT.java
@@ -56,9 +56,12 @@ import org.apache.gravitino.integration.test.util.BaseIT;
 import org.apache.gravitino.integration.test.util.GravitinoITUtils;
 import org.apache.gravitino.integration.test.util.TestDatabaseName;
 import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Representation;
+import org.apache.gravitino.rel.SQLRepresentation;
 import org.apache.gravitino.rel.Table;
 import org.apache.gravitino.rel.TableCatalog;
 import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.ViewCatalog;
 import org.apache.gravitino.rel.expressions.NamedReference;
 import org.apache.gravitino.rel.expressions.distributions.Distribution;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
@@ -858,6 +861,55 @@ public abstract class CatalogPaimonBaseIT extends BaseIT {
     }
   }
 
+  @Test
+  void testSparkCreateViewAndLoadByGravitino() {
+    String viewName = GravitinoITUtils.genRandomName("spark_create_view");
+    String viewIdentifier = String.join(".", schemaName, viewName);
+
+    ViewCatalog viewCatalog = catalog.asViewCatalog();
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () -> {
+          spark.sql(
+              String.format(
+                  "CREATE VIEW paimon.%s AS SELECT 1 AS id, 'name_1' AS name", 
viewIdentifier));
+          viewCatalog.loadView(NameIdentifier.of(schemaName, viewName));
+        });
+  }
+
+  @Test
+  void testGravitinoCreateViewAndReadBySpark() {
+    String viewName = GravitinoITUtils.genRandomName("gravitino_create_view");
+    NameIdentifier viewIdentifier = NameIdentifier.of(schemaName, viewName);
+
+    String query = "SELECT 1 AS id, 'name_1' AS name";
+    Representation[] representations =
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("query").withSql(query).build(),
+          
SQLRepresentation.builder().withDialect("spark").withSql(query).build()
+        };
+    ViewCatalog viewCatalog = catalog.asViewCatalog();
+
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () -> {
+          viewCatalog.createView(
+              viewIdentifier,
+              "view_for_spark_read",
+              new Column[] {
+                Column.of("id", Types.IntegerType.get(), "id column"),
+                Column.of("name", Types.StringType.get(), "name column")
+              },
+              representations,
+              "paimon",
+              schemaName,
+              Collections.emptyMap());
+          spark
+              .sql(String.format("SELECT * FROM paimon.%s.%s ORDER BY id", 
schemaName, viewName))
+              .collectAsList();
+        });
+  }
+
   @Test
   void testTimeTypePrecision() throws 
org.apache.paimon.catalog.Catalog.TableNotExistException {
     String tableName = GravitinoITUtils.genRandomName("test_time_precision");
@@ -1040,6 +1092,29 @@ public abstract class CatalogPaimonBaseIT extends BaseIT 
{
     return values;
   }
 
+  protected NameIdentifier createSimplePaimonTableForViewInterop(String 
tablePrefix) {
+    Preconditions.checkNotNull(
+        spark, "Spark session is required for Spark view interoperability 
tests, but it is null.");
+
+    String tableName = GravitinoITUtils.genRandomName(tablePrefix);
+    NameIdentifier tableIdentifier = NameIdentifier.of(schemaName, tableName);
+    catalog
+        .asTableCatalog()
+        .createTable(
+            tableIdentifier,
+            new Column[] {
+              Column.of("id", Types.IntegerType.get(), "id column"),
+              Column.of("name", Types.StringType.get(), "name column")
+            },
+            table_comment,
+            createProperties());
+
+    spark.sql(
+        String.format(
+            "INSERT INTO paimon.%s.%s VALUES (1, 'name_1'), (2, 'name_2')", 
schemaName, tableName));
+    return tableIdentifier;
+  }
+
   private void clearTableAndSchema() {
     SupportsSchemas supportsSchema = catalog.asSchemas();
     Arrays.stream(supportsSchema.listSchemas())
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonHiveIT.java
 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonHiveIT.java
index fdebf6839f..f943d971dc 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonHiveIT.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/integration/test/CatalogPaimonHiveIT.java
@@ -19,6 +19,8 @@
 package org.apache.gravitino.catalog.lakehouse.paimon.integration.test;
 
 import com.google.common.collect.Maps;
+import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Schema;
@@ -26,7 +28,16 @@ import org.apache.gravitino.SupportsSchemas;
 import 
org.apache.gravitino.catalog.lakehouse.paimon.PaimonCatalogPropertiesMetadata;
 import org.apache.gravitino.integration.test.container.HiveContainer;
 import org.apache.gravitino.integration.test.util.GravitinoITUtils;
+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.ViewCatalog;
+import org.apache.gravitino.rel.types.Types;
 import org.apache.paimon.catalog.Catalog;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
@@ -61,6 +72,86 @@ public class CatalogPaimonHiveIT extends CatalogPaimonBaseIT 
{
     return catalogProperties;
   }
 
+  @Override
+  protected void initSparkEnv() {
+    spark =
+        SparkSession.builder()
+            .master("local[1]")
+            .appName("Paimon Catalog integration test")
+            .config("spark.sql.catalog.paimon", 
"org.apache.paimon.spark.SparkCatalog")
+            .config("spark.sql.catalog.paimon.metastore", "hive")
+            .config("spark.sql.catalog.paimon.uri", URI)
+            .config("spark.sql.catalog.paimon.warehouse", WAREHOUSE)
+            .config("spark.sql.catalog.paimon.cache-enabled", "false")
+            .config(
+                "spark.sql.extensions",
+                
"org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions")
+            .enableHiveSupport()
+            .getOrCreate();
+  }
+
+  @Test
+  @Override
+  void testSparkCreateViewAndLoadByGravitino() {
+    NameIdentifier baseTableIdentifier =
+        
createSimplePaimonTableForViewInterop("spark_create_view_source_table");
+    String viewName = GravitinoITUtils.genRandomName("spark_create_view");
+    String viewIdentifier = String.join(".", schemaName, viewName);
+    String tableIdentifier = String.join(".", schemaName, 
baseTableIdentifier.name());
+
+    ViewCatalog viewCatalog = catalog.asViewCatalog();
+    spark.sql(
+        String.format(
+            "CREATE VIEW paimon.%s AS SELECT id, name FROM paimon.%s",
+            viewIdentifier, tableIdentifier));
+
+    View loadedView = viewCatalog.loadView(NameIdentifier.of(schemaName, 
viewName));
+    Assertions.assertEquals(viewName, loadedView.name());
+    Assertions.assertEquals(2, loadedView.columns().length);
+    Assertions.assertEquals("id", loadedView.columns()[0].name());
+    Assertions.assertEquals("name", loadedView.columns()[1].name());
+    Assertions.assertTrue(loadedView.representations().length > 0);
+  }
+
+  @Test
+  @Override
+  void testGravitinoCreateViewAndReadBySpark() {
+    NameIdentifier baseTableIdentifier =
+        
createSimplePaimonTableForViewInterop("gravitino_create_view_source_table");
+    String viewName = GravitinoITUtils.genRandomName("gravitino_create_view");
+    NameIdentifier viewIdentifier = NameIdentifier.of(schemaName, viewName);
+
+    String query =
+        String.format("SELECT id, name FROM paimon.%s.%s", schemaName, 
baseTableIdentifier.name());
+    Representation[] representations =
+        new Representation[] {
+          
SQLRepresentation.builder().withDialect("query").withSql(query).build(),
+          
SQLRepresentation.builder().withDialect("spark").withSql(query).build()
+        };
+    ViewCatalog viewCatalog = catalog.asViewCatalog();
+
+    viewCatalog.createView(
+        viewIdentifier,
+        "view_for_spark_read",
+        new Column[] {
+          Column.of("id", Types.IntegerType.get(), "id column"),
+          Column.of("name", Types.StringType.get(), "name column")
+        },
+        representations,
+        "paimon",
+        schemaName,
+        Collections.emptyMap());
+
+    Dataset<Row> rows =
+        spark.sql(String.format("SELECT * FROM paimon.%s.%s ORDER BY id", 
schemaName, viewName));
+    List<Row> results = rows.collectAsList();
+    Assertions.assertEquals(2, results.size());
+    Assertions.assertEquals(1, results.get(0).getInt(0));
+    Assertions.assertEquals("name_1", results.get(0).getString(1));
+    Assertions.assertEquals(2, results.get(1).getInt(0));
+    Assertions.assertEquals("name_2", results.get(1).getString(1));
+  }
+
   @Test
   void testPaimonSchemaProperties() throws Catalog.DatabaseNotExistException {
     SupportsSchemas schemas = catalog.asSchemas();
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/TestPaimonCatalogOps.java
 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/TestPaimonCatalogOps.java
index ffdf2d5980..26095464c0 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/TestPaimonCatalogOps.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/ops/TestPaimonCatalogOps.java
@@ -38,6 +38,7 @@ import static 
org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -52,6 +53,7 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import 
org.apache.gravitino.catalog.lakehouse.paimon.PaimonCatalogPropertiesMetadata;
 import org.apache.gravitino.catalog.lakehouse.paimon.PaimonConfig;
+import 
org.apache.gravitino.catalog.lakehouse.paimon.utils.PaimonViewTestCatalogHelper;
 import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.TableChange.ColumnPosition;
 import org.apache.gravitino.rel.TableChange.UpdateColumnComment;
@@ -63,6 +65,7 @@ import 
org.apache.paimon.catalog.Catalog.ColumnAlreadyExistException;
 import org.apache.paimon.catalog.Catalog.ColumnNotExistException;
 import org.apache.paimon.catalog.Catalog.DatabaseNotExistException;
 import org.apache.paimon.catalog.Catalog.TableAlreadyExistException;
+import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.schema.SchemaChange.AddColumn;
@@ -76,6 +79,8 @@ import org.apache.paimon.types.MapType;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.types.TimestampType;
 import org.apache.paimon.types.VarCharType;
+import org.apache.paimon.view.ViewChange;
+import org.apache.paimon.view.ViewImpl;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
@@ -85,19 +90,22 @@ import org.junit.jupiter.api.io.TempDir;
 /** Tests for {@link 
org.apache.gravitino.catalog.lakehouse.paimon.ops.PaimonCatalogOps}. */
 public class TestPaimonCatalogOps {
 
-  private PaimonCatalogOps paimonCatalogOps;
+  private TestablePaimonCatalogOps paimonCatalogOps;
   @TempDir private File warehouse;
 
   private static final String DATABASE = "test_table_ops_database";
   private static final String TABLE = "test_table_ops_table";
+  private static final String VIEW = "test_table_ops_view";
   private static final String COMMENT = "table_ops_table_comment";
   private static final NameIdentifier IDENTIFIER = 
NameIdentifier.of(Namespace.of(DATABASE), TABLE);
+  private static final NameIdentifier VIEW_IDENTIFIER =
+      NameIdentifier.of(Namespace.of(DATABASE), VIEW);
   private static final Map<String, String> OPTIONS = 
ImmutableMap.of(BUCKET.key(), "10");
 
   @BeforeEach
   public void setUp() throws Exception {
     paimonCatalogOps =
-        new PaimonCatalogOps(
+        new TestablePaimonCatalogOps(
             new PaimonConfig(
                 ImmutableMap.of(PaimonCatalogPropertiesMetadata.WAREHOUSE, 
warehouse.getPath())));
     createDatabase();
@@ -167,6 +175,82 @@ public class TestPaimonCatalogOps {
     
Assertions.assertNotNull(paimonCatalogOps.loadTable(IDENTIFIER.toString()));
   }
 
+  @Test
+  void testCreateLoadAlterRenameAndDropViewOperations() throws Exception {
+    paimonCatalogOps.setCatalog(
+        
PaimonViewTestCatalogHelper.createViewSupportedCatalog(paimonCatalogOps.catalog()));
+
+    org.apache.paimon.view.View createdView =
+        new ViewImpl(
+            Identifier.create(DATABASE, VIEW),
+            List.of(new DataField(0, "col_1", DataTypes.INT(), "col_1")),
+            "SELECT col_1 FROM " + TABLE,
+            ImmutableMap.of("spark", "SELECT col_1 FROM " + TABLE),
+            "test_view_comment",
+            Maps.newHashMap());
+    paimonCatalogOps.createView(VIEW_IDENTIFIER.toString(), createdView);
+
+    org.apache.paimon.view.View loadedView = 
paimonCatalogOps.loadView(VIEW_IDENTIFIER.toString());
+    assertEquals("test_view_comment", loadedView.comment().orElse(null));
+    assertEquals("SELECT col_1 FROM " + TABLE, loadedView.query());
+    assertTrue(paimonCatalogOps.listViews(DATABASE).contains(VIEW));
+
+    paimonCatalogOps.alterView(
+        VIEW_IDENTIFIER.toString(),
+        List.of(
+            ViewChange.setOption("k1", "v1"),
+            ViewChange.updateComment("updated_view_comment"),
+            ViewChange.updateDialect("spark", "SELECT col_1 FROM " + TABLE + " 
WHERE col_1 > 1")));
+
+    org.apache.paimon.view.View alteredView = 
paimonCatalogOps.loadView(VIEW_IDENTIFIER.toString());
+    assertEquals("updated_view_comment", alteredView.comment().orElse(null));
+    assertEquals("v1", alteredView.options().get("k1"));
+    assertEquals("SELECT col_1 FROM " + TABLE, alteredView.query());
+    assertEquals(
+        "SELECT col_1 FROM " + TABLE + " WHERE col_1 > 1", 
alteredView.dialects().get("spark"));
+
+    NameIdentifier renamedViewIdentifier =
+        NameIdentifier.of(VIEW_IDENTIFIER.namespace(), VIEW + "_renamed");
+    paimonCatalogOps.renameView(VIEW_IDENTIFIER.toString(), 
renamedViewIdentifier.toString());
+
+    assertThrowsExactly(
+        Catalog.ViewNotExistException.class,
+        () -> paimonCatalogOps.loadView(VIEW_IDENTIFIER.toString()));
+    assertNotNull(paimonCatalogOps.loadView(renamedViewIdentifier.toString()));
+
+    paimonCatalogOps.dropView(renamedViewIdentifier.toString());
+    assertThrowsExactly(
+        Catalog.ViewNotExistException.class,
+        () -> paimonCatalogOps.loadView(renamedViewIdentifier.toString()));
+  }
+
+  @Test
+  void testAlterViewExceptionMessageContainsContext() throws Exception {
+    paimonCatalogOps.setCatalog(
+        
PaimonViewTestCatalogHelper.createViewSupportedCatalog(paimonCatalogOps.catalog()));
+
+    org.apache.paimon.view.View createdView =
+        new ViewImpl(
+            Identifier.create(DATABASE, VIEW),
+            List.of(new DataField(0, "col_1", DataTypes.INT(), "col_1")),
+            "SELECT col_1 FROM " + TABLE,
+            ImmutableMap.of("spark", "SELECT col_1 FROM " + TABLE),
+            "test_view_comment",
+            Maps.newHashMap());
+    paimonCatalogOps.createView(VIEW_IDENTIFIER.toString(), createdView);
+
+    IllegalArgumentException exception =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                paimonCatalogOps.alterView(
+                    VIEW_IDENTIFIER.toString(),
+                    List.of(ViewChange.updateDialect("trino", "SELECT col_1 
FROM " + TABLE))));
+
+    assertTrue(exception.getMessage().contains("Cannot alter view " + 
VIEW_IDENTIFIER + ": "));
+    assertTrue(exception.getCause() instanceof 
Catalog.DialectNotExistException);
+  }
+
   @Test
   void testAddColumn() throws Exception {
     // Test AddColumn after column.
@@ -433,4 +517,19 @@ public class TestPaimonCatalogOps {
     paimonCatalogOps.dropDatabase(DATABASE, true);
     Assertions.assertTrue(paimonCatalogOps.listDatabases().isEmpty());
   }
+
+  private static class TestablePaimonCatalogOps extends PaimonCatalogOps {
+
+    TestablePaimonCatalogOps(PaimonConfig paimonConfig) {
+      super(paimonConfig);
+    }
+
+    Catalog catalog() {
+      return catalog;
+    }
+
+    void setCatalog(Catalog catalog) {
+      this.catalog = catalog;
+    }
+  }
 }
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/utils/PaimonViewTestCatalogHelper.java
 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/utils/PaimonViewTestCatalogHelper.java
new file mode 100644
index 0000000000..0dfce90d48
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/utils/PaimonViewTestCatalogHelper.java
@@ -0,0 +1,178 @@
+/*
+ * 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.lakehouse.paimon.utils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.CatalogLoader;
+import org.apache.paimon.catalog.DelegateCatalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.view.View;
+import org.apache.paimon.view.ViewChange;
+import org.apache.paimon.view.ViewImpl;
+
+/** Utilities for creating in-memory view-capable catalog wrappers in Paimon 
unit tests. */
+public final class PaimonViewTestCatalogHelper {
+
+  private PaimonViewTestCatalogHelper() {}
+
+  public static Catalog createViewSupportedCatalog(Catalog wrappedCatalog) {
+    Map<Identifier, View> viewStore = new HashMap<>();
+    return new DelegateCatalog(wrappedCatalog) {
+      @Override
+      public CatalogLoader catalogLoader() {
+        return wrapped().catalogLoader();
+      }
+
+      @Override
+      public List<String> listViews(String databaseName) {
+        return viewStore.keySet().stream()
+            .filter(identifier -> 
identifier.getDatabaseName().equals(databaseName))
+            .map(Identifier::getObjectName)
+            .collect(Collectors.toList());
+      }
+
+      @Override
+      public View getView(Identifier identifier) throws 
Catalog.ViewNotExistException {
+        View storedView = viewStore.get(identifier);
+        if (storedView == null) {
+          throw new Catalog.ViewNotExistException(identifier);
+        }
+        return storedView;
+      }
+
+      @Override
+      public void createView(Identifier identifier, View view, boolean 
ignoreIfExists)
+          throws Catalog.ViewAlreadyExistException, 
Catalog.DatabaseNotExistException {
+        if (viewStore.containsKey(identifier)) {
+          if (!ignoreIfExists) {
+            throw new Catalog.ViewAlreadyExistException(identifier);
+          }
+          return;
+        }
+
+        if (!listDatabases().contains(identifier.getDatabaseName())) {
+          throw new 
Catalog.DatabaseNotExistException(identifier.getDatabaseName());
+        }
+
+        viewStore.put(identifier, copyView(identifier, view));
+      }
+
+      @Override
+      public void alterView(
+          Identifier identifier, List<ViewChange> changes, boolean 
ignoreIfNotExists)
+          throws Catalog.ViewNotExistException, 
Catalog.DialectAlreadyExistException,
+              Catalog.DialectNotExistException {
+        View storedView = viewStore.get(identifier);
+        if (storedView == null) {
+          if (!ignoreIfNotExists) {
+            throw new Catalog.ViewNotExistException(identifier);
+          }
+          return;
+        }
+
+        Map<String, String> updatedOptions = new 
HashMap<>(storedView.options());
+        Map<String, String> updatedDialects = new 
HashMap<>(storedView.dialects());
+        String updatedComment = storedView.comment().orElse(null);
+
+        for (ViewChange change : changes) {
+          if (change instanceof ViewChange.SetViewOption) {
+            ViewChange.SetViewOption setViewOption = 
(ViewChange.SetViewOption) change;
+            updatedOptions.put(setViewOption.key(), setViewOption.value());
+          } else if (change instanceof ViewChange.RemoveViewOption) {
+            ViewChange.RemoveViewOption removeViewOption = 
(ViewChange.RemoveViewOption) change;
+            updatedOptions.remove(removeViewOption.key());
+          } else if (change instanceof ViewChange.UpdateViewComment) {
+            ViewChange.UpdateViewComment updateViewComment = 
(ViewChange.UpdateViewComment) change;
+            updatedComment = updateViewComment.comment();
+          } else if (change instanceof ViewChange.AddDialect) {
+            ViewChange.AddDialect addDialect = (ViewChange.AddDialect) change;
+            if (updatedDialects.containsKey(addDialect.dialect())) {
+              throw new Catalog.DialectAlreadyExistException(identifier, 
addDialect.dialect());
+            }
+            updatedDialects.put(addDialect.dialect(), addDialect.query());
+          } else if (change instanceof ViewChange.UpdateDialect) {
+            ViewChange.UpdateDialect updateDialect = 
(ViewChange.UpdateDialect) change;
+            if (!updatedDialects.containsKey(updateDialect.dialect())) {
+              throw new Catalog.DialectNotExistException(identifier, 
updateDialect.dialect());
+            }
+            updatedDialects.put(updateDialect.dialect(), 
updateDialect.query());
+          } else if (change instanceof ViewChange.DropDialect) {
+            ViewChange.DropDialect dropDialect = (ViewChange.DropDialect) 
change;
+            if (!updatedDialects.containsKey(dropDialect.dialect())) {
+              throw new Catalog.DialectNotExistException(identifier, 
dropDialect.dialect());
+            }
+            updatedDialects.remove(dropDialect.dialect());
+          }
+        }
+
+        viewStore.put(
+            identifier,
+            new ViewImpl(
+                identifier,
+                storedView.rowType().getFields(),
+                storedView.query(),
+                updatedDialects,
+                updatedComment,
+                updatedOptions));
+      }
+
+      @Override
+      public void renameView(
+          Identifier fromIdentifier, Identifier toIdentifier, boolean 
ignoreIfNotExists)
+          throws Catalog.ViewNotExistException, 
Catalog.ViewAlreadyExistException {
+        View storedView = viewStore.remove(fromIdentifier);
+        if (storedView == null) {
+          if (!ignoreIfNotExists) {
+            throw new Catalog.ViewNotExistException(fromIdentifier);
+          }
+          return;
+        }
+
+        if (viewStore.containsKey(toIdentifier)) {
+          viewStore.put(fromIdentifier, storedView);
+          throw new Catalog.ViewAlreadyExistException(toIdentifier);
+        }
+
+        viewStore.put(toIdentifier, copyView(toIdentifier, storedView));
+      }
+
+      @Override
+      public void dropView(Identifier identifier, boolean ignoreIfNotExists)
+          throws Catalog.ViewNotExistException {
+        if (viewStore.remove(identifier) == null && !ignoreIfNotExists) {
+          throw new Catalog.ViewNotExistException(identifier);
+        }
+      }
+    };
+  }
+
+  private static View copyView(Identifier identifier, View view) {
+    return new ViewImpl(
+        identifier,
+        view.rowType().getFields(),
+        view.query(),
+        new HashMap<>(view.dialects()),
+        view.comment().orElse(null),
+        new HashMap<>(view.options()));
+  }
+}


Reply via email to