This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 0528fd0ccc [Cherry-pick to branch-1.3] [#11287] feat(spark-connector):
Add view support for Hive catalog (#11288) (#11410)
0528fd0ccc is described below
commit 0528fd0ccc0a072f3c13ecefe76368cd2ec832dd
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Jun 4 11:26:54 2026 +0800
[Cherry-pick to branch-1.3] [#11287] feat(spark-connector): Add view
support for Hive catalog (#11288) (#11410)
**Cherry-pick Information:**
- Original commit: 64e8faf4ac8dcdc83b29ab9afbe996f35bf9fdca
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Yuhui <[email protected]>
---
.../apache/gravitino/catalog/hive/HiveView.java | 6 +-
.../catalog/hive/HiveViewCatalogOperations.java | 90 +++++++---
.../catalog/hive/TestHiveCatalogOperations.java | 37 ++--
.../spark/connector/catalog/BaseCatalog.java | 81 +++++++--
.../spark/connector/hive/GravitinoHiveCatalog.java | 13 ++
.../spark/connector/hive/SparkHiveView.java | 192 +++++++++++++++++++++
.../connector/integration/test/SparkEnvIT.java | 5 +
.../integration/test/hive/SparkHiveCatalogIT.java | 165 ++++++++++++++++++
8 files changed, 532 insertions(+), 57 deletions(-)
diff --git
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveView.java
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveView.java
index bae828e7f6..a2aeee7a0b 100644
---
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveView.java
+++
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveView.java
@@ -52,7 +52,8 @@ import org.apache.gravitino.rel.View;
@ToString
public class HiveView implements View {
- private static final String SPARK_VERSION_KEY = "spark.sql.create.version";
+ static final String SPARK_VERSION_KEY = "spark.sql.create.version";
+ static final String FLINK_PROPERTY_PREFIX = "flink.";
private static final String TRINO_VIEW_MARKER_KEY = "presto_view";
private static final String TRINO_VIEW_PREFIX = "/* Presto View:";
@@ -124,7 +125,8 @@ public class HiveView implements View {
if (parameters != null && parameters.containsKey(SPARK_VERSION_KEY)) {
return Dialects.SPARK;
}
- if (parameters != null && parameters.keySet().stream().anyMatch(k ->
k.startsWith("flink."))) {
+ if (parameters != null
+ && parameters.keySet().stream().anyMatch(k ->
k.startsWith(FLINK_PROPERTY_PREFIX))) {
return Dialects.FLINK;
}
return Dialects.HIVE;
diff --git
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveViewCatalogOperations.java
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveViewCatalogOperations.java
index 1200391bbc..f3d078ff2e 100644
---
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveViewCatalogOperations.java
+++
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveViewCatalogOperations.java
@@ -27,6 +27,7 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import java.time.Instant;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.function.Predicate;
import java.util.function.Supplier;
@@ -112,12 +113,13 @@ class HiveViewCatalogOperations implements ViewCatalog {
if (!schemaExistsChecker.test(schemaIdent)) {
throw new NoSuchSchemaException("Schema %s does not exist", schemaIdent);
}
+ Map<String, String> safeProperties = properties == null ?
ImmutableMap.of() : properties;
SQLRepresentation sqlRepresentation =
- validateSQLRepresentation(representations, defaultCatalog,
defaultSchema, ident);
+ validateSQLRepresentation(
+ representations, defaultCatalog, defaultSchema, safeProperties,
ident);
try {
- Map<String, String> params =
- Maps.newHashMap(properties == null ? ImmutableMap.of() : properties);
+ Map<String, String> params = Maps.newHashMap(safeProperties);
params.put(TABLE_TYPE, TableType.VIRTUAL_VIEW.name());
String viewOriginalText = toHmsViewOriginalText(sqlRepresentation,
ident);
@@ -212,6 +214,7 @@ class HiveViewCatalogOperations implements ViewCatalog {
replace.getRepresentations(),
replace.getDefaultCatalog(),
replace.getDefaultSchema(),
+ updatedProperties,
ident);
updatedColumns = copyColumns(replace.getColumns());
updatedComment = replace.getComment();
@@ -363,14 +366,17 @@ class HiveViewCatalogOperations implements ViewCatalog {
Maps.newHashMap(properties != null ? properties : ImmutableMap.of());
String representationSql = viewOriginalText;
String detectedDialect = HiveView.detectDialect(representationSql, params);
- if (!Dialects.HIVE.equalsIgnoreCase(detectedDialect)
- && !Dialects.FLINK.equalsIgnoreCase(detectedDialect)) {
- // TODO(design-docs/gravitino-logical-view-management.md): support
loading trino/spark HMS
- // views.
- throw new UnsupportedOperationException(
- String.format(
- "Hive catalog currently supports only '%s' and '%s' view
dialects, but found '%s' for view %s",
- Dialects.HIVE, Dialects.FLINK, detectedDialect, ident));
+ switch (detectedDialect.toLowerCase(Locale.ROOT)) {
+ case Dialects.HIVE:
+ case Dialects.FLINK:
+ case Dialects.SPARK:
+ break;
+ default:
+ // TODO(design-docs/gravitino-logical-view-management.md): support
loading trino HMS views.
+ throw new UnsupportedOperationException(
+ String.format(
+ "Hive catalog currently supports only '%s', '%s' and '%s' view
dialects, but found '%s' for view %s",
+ Dialects.HIVE, Dialects.FLINK, Dialects.SPARK,
detectedDialect, ident));
}
SQLRepresentation rep =
@@ -389,10 +395,18 @@ class HiveViewCatalogOperations implements ViewCatalog {
.build();
}
+ /**
+ * Validates that {@code representations} contains exactly one {@link
SQLRepresentation} with a
+ * supported dialect, and that dialect-specific constraints are satisfied.
+ *
+ * @param properties view properties used to verify dialect marker keys are
present (e.g. {@code
+ * spark.sql.create.version} for Spark, a {@code flink.*} key for Flink)
+ */
private SQLRepresentation validateSQLRepresentation(
Representation[] representations,
String defaultCatalog,
String defaultSchema,
+ Map<String, String> properties,
NameIdentifier ident) {
int representationCount = representations == null ? 0 :
representations.length;
Representation firstRepresentation =
@@ -406,8 +420,17 @@ class HiveViewCatalogOperations implements ViewCatalog {
firstRepresentation == null ? "null" :
firstRepresentation.getClass().getSimpleName());
SQLRepresentation selected = (SQLRepresentation) firstRepresentation;
- switch (selected.dialect().toLowerCase(java.util.Locale.ROOT)) {
+ switch (selected.dialect().toLowerCase(Locale.ROOT)) {
case Dialects.HIVE:
+ Preconditions.checkArgument(
+ defaultCatalog == null && defaultSchema == null,
+ "Dialect '%s' does not support non-null
defaultCatalog/defaultSchema, but got "
+ + "defaultCatalog=%s, defaultSchema=%s for view %s",
+ selected.dialect(),
+ defaultCatalog,
+ defaultSchema,
+ ident);
+ return selected;
case Dialects.FLINK:
Preconditions.checkArgument(
defaultCatalog == null && defaultSchema == null,
@@ -417,28 +440,45 @@ class HiveViewCatalogOperations implements ViewCatalog {
defaultCatalog,
defaultSchema,
ident);
+ Preconditions.checkArgument(
+ properties.keySet().stream()
+ .anyMatch(k -> k.startsWith(HiveView.FLINK_PROPERTY_PREFIX)),
+ "Flink dialect view '%s' requires at least one property with
prefix '%s' to be set; "
+ + "without it the view silently round-trips as Hive dialect on
reload",
+ ident,
+ HiveView.FLINK_PROPERTY_PREFIX);
+ return selected;
+ case Dialects.SPARK:
+ Preconditions.checkArgument(
+ properties.containsKey(HiveView.SPARK_VERSION_KEY),
+ "Spark dialect view '%s' requires property '%s' to be set; "
+ + "without it the view silently round-trips as Hive dialect on
reload",
+ ident,
+ HiveView.SPARK_VERSION_KEY);
return selected;
default:
- // TODO(design-docs/gravitino-logical-view-management.md): support
creating trino/spark HMS
- // views.
+ // TODO(design-docs/gravitino-logical-view-management.md): support
creating trino HMS views.
throw new UnsupportedOperationException(
String.format(
- "Hive catalog currently supports only '%s' and '%s' view
dialects, but got '%s' for view %s",
- Dialects.HIVE, Dialects.FLINK, selected.dialect(), ident));
+ "Hive catalog currently supports only '%s', '%s' and '%s' view
dialects, but got '%s' for view %s",
+ Dialects.HIVE, Dialects.FLINK, Dialects.SPARK,
selected.dialect(), ident));
}
}
private String toHmsViewOriginalText(SQLRepresentation representation,
NameIdentifier ident) {
- if (!Dialects.HIVE.equalsIgnoreCase(representation.dialect())
- && !Dialects.FLINK.equalsIgnoreCase(representation.dialect())) {
- // TODO(design-docs/gravitino-logical-view-management.md): support
serializing trino/spark HMS
- // view definitions.
- throw new UnsupportedOperationException(
- String.format(
- "Hive catalog currently supports only '%s' and '%s' view
dialects, but got '%s' for view %s",
- Dialects.HIVE, Dialects.FLINK, representation.dialect(), ident));
+ switch (representation.dialect().toLowerCase(Locale.ROOT)) {
+ case Dialects.HIVE:
+ case Dialects.FLINK:
+ case Dialects.SPARK:
+ return representation.sql();
+ default:
+ // TODO(design-docs/gravitino-logical-view-management.md): support
serializing trino HMS
+ // view definitions.
+ throw new UnsupportedOperationException(
+ String.format(
+ "Hive catalog currently supports only '%s', '%s' and '%s' view
dialects, but got '%s' for view %s",
+ Dialects.HIVE, Dialects.FLINK, Dialects.SPARK,
representation.dialect(), ident));
}
- return representation.sql();
}
private String extractRenameTargetName(String originalName, ViewChange[]
changes) {
diff --git
a/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/TestHiveCatalogOperations.java
b/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/TestHiveCatalogOperations.java
index a8ab3142d9..fbc1e8c54b 100644
---
a/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/TestHiveCatalogOperations.java
+++
b/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/TestHiveCatalogOperations.java
@@ -373,7 +373,7 @@ class TestHiveCatalogOperations {
}
@Test
- void testCreateViewRejectsSparkDialect() throws Exception {
+ void testCreateViewAcceptsSparkDialect() throws Exception {
HiveCatalogOperations op = new HiveCatalogOperations();
op.initialize(Maps.newHashMap(), null, HIVE_PROPERTIES_METADATA);
@@ -381,6 +381,7 @@ class TestHiveCatalogOperations {
HiveClient hiveClient = mock(HiveClient.class);
HiveSchema schema =
HiveSchema.builder().withCatalogName("hive").withName("db").build();
when(hiveClient.getDatabase(anyString(), anyString())).thenReturn(schema);
+ doNothing().when(hiveClient).createTable(any());
when(clientPool.run(any()))
.thenAnswer(
invocation -> {
@@ -389,21 +390,25 @@ class TestHiveCatalogOperations {
});
op.clientPool = clientPool;
- UnsupportedOperationException exception =
- Assertions.assertThrows(
- UnsupportedOperationException.class,
- () ->
- op.createView(
- NameIdentifier.of("db", "v_spark"),
- null,
- new Column[0],
- new SQLRepresentation[] {
-
SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
- },
- null,
- null,
- Maps.newHashMap()));
- Assertions.assertTrue(exception.getMessage().contains("supports only"));
+ // Callers must supply spark.sql.create.version so HMS detectDialect
identifies the view as
+ // Spark
+ View view =
+ op.createView(
+ NameIdentifier.of("db", "v_spark"),
+ null,
+ new Column[0],
+ new SQLRepresentation[] {
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT
1").build()
+ },
+ null,
+ null,
+ Maps.newHashMap(ImmutableMap.of("spark.sql.create.version",
"3.5.3")));
+
+ Assertions.assertNotNull(view);
+ Assertions.assertEquals(1, view.representations().length);
+ SQLRepresentation rep = (SQLRepresentation) view.representations()[0];
+ Assertions.assertEquals("spark", rep.dialect());
+ Assertions.assertEquals("SELECT 1", rep.sql());
}
@Test
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
index 24a75bd4a9..2f26beae32 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
@@ -34,12 +34,14 @@ import org.apache.gravitino.SchemaChange;
import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.apache.gravitino.exceptions.NoSuchViewException;
import org.apache.gravitino.exceptions.NonEmptySchemaException;
import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
import org.apache.gravitino.function.Function;
import org.apache.gravitino.function.FunctionDefinition;
import org.apache.gravitino.function.FunctionImpl;
import org.apache.gravitino.function.JavaImpl;
+import org.apache.gravitino.rel.View;
import org.apache.gravitino.spark.connector.ConnectorConstants;
import org.apache.gravitino.spark.connector.PropertiesConverter;
import org.apache.gravitino.spark.connector.SparkTableChangeConverter;
@@ -241,21 +243,22 @@ public abstract class BaseCatalog implements
TableCatalog, SupportsNamespaces, F
@Override
public Table loadTable(Identifier ident) throws NoSuchTableException {
+ org.apache.gravitino.rel.Table gravitinoTable;
try {
- org.apache.gravitino.rel.Table gravitinoTable =
loadGravitinoTable(ident);
- org.apache.spark.sql.connector.catalog.Table sparkTable =
loadSparkTable(ident);
- // Will create a catalog specific table
- return createSparkTable(
- ident,
- gravitinoTable,
- sparkTable,
- sparkCatalog,
- propertiesConverter,
- sparkTransformConverter,
- sparkTypeConverter);
- } catch (org.apache.gravitino.exceptions.NoSuchTableException e) {
- throw new NoSuchTableException(ident);
+ gravitinoTable = loadGravitinoTable(ident);
+ } catch (NoSuchTableException e) {
+ // Not a table in Gravitino; try as a view.
+ return loadViewAsTable(ident);
}
+ Table sparkTable = loadSparkTable(ident);
+ return createSparkTable(
+ ident,
+ gravitinoTable,
+ sparkTable,
+ sparkCatalog,
+ propertiesConverter,
+ sparkTransformConverter,
+ sparkTypeConverter);
}
@Override
@@ -312,11 +315,22 @@ public abstract class BaseCatalog implements
TableCatalog, SupportsNamespaces, F
loadGravitinoTable(ident);
return true;
} catch (NoSuchTableException e) {
- return false;
+ // fall through to view check
} catch (ForbiddenException e) {
// User lacks LOAD_TABLE privilege, return false to allow CREATE TABLE
IF NOT EXISTS
return false;
}
+ try {
+ return gravitinoCatalogClient
+ .asViewCatalog()
+ .viewExists(NameIdentifier.of(getDatabase(ident), ident.name()));
+ } catch (UnsupportedOperationException e) {
+ return false;
+ } catch (ForbiddenException e) {
+ // Same rationale as the table check above: lacking LOAD_VIEW privilege
is treated as
+ // non-existence so that CREATE TABLE IF NOT EXISTS can proceed.
+ return false;
+ }
}
@Override
@@ -421,6 +435,45 @@ public abstract class BaseCatalog implements TableCatalog,
SupportsNamespaces, F
}
}
+ /**
+ * Loads a Gravitino view as a Spark Table when {@link
#loadTable(Identifier)} fails because the
+ * object is a view. Subclasses can override {@link #createSparkView} to
enable view support.
+ *
+ * @param ident the identifier to load
+ * @return Spark Table wrapping the view
+ * @throws NoSuchTableException if no view exists or views are not supported
by this catalog
+ */
+ protected Table loadViewAsTable(Identifier ident) throws
NoSuchTableException {
+ View gravitinoView;
+ try {
+ gravitinoView =
+ gravitinoCatalogClient
+ .asViewCatalog()
+ .loadView(NameIdentifier.of(getDatabase(ident), ident.name()));
+ } catch (NoSuchViewException | UnsupportedOperationException |
ForbiddenException e) {
+ throw new NoSuchTableException(ident);
+ }
+ // Call sparkCatalog.loadTable directly to surface NoSuchTableException as
checked,
+ // avoiding the RuntimeException wrapping in loadSparkTable.
+ Table sparkTable = sparkCatalog.loadTable(ident);
+ return createSparkView(ident, gravitinoView, sparkTable);
+ }
+
+ /**
+ * Creates a catalog-specific Spark Table wrapping a Gravitino view.
Subclasses that support views
+ * must override this method.
+ *
+ * @param ident the identifier
+ * @param gravitinoView the Gravitino view metadata
+ * @param sparkTable the underlying Spark table for IO (view expansion)
+ * @return a Spark Table representing the view
+ * @throws UnsupportedOperationException if views are not supported by this
catalog
+ */
+ protected Table createSparkView(Identifier ident, View gravitinoView, Table
sparkTable) {
+ throw new UnsupportedOperationException(
+ "View not supported by catalog: " + getDatabase(ident) + "." +
ident.name());
+ }
+
protected org.apache.gravitino.rel.Table loadGravitinoTable(Identifier ident)
throws NoSuchTableException {
try {
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/GravitinoHiveCatalog.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/GravitinoHiveCatalog.java
index b1df7ad4b4..dd7851828c 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/GravitinoHiveCatalog.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/GravitinoHiveCatalog.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.spark.connector.hive;
import java.util.Map;
import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.View;
import org.apache.gravitino.spark.connector.PropertiesConverter;
import org.apache.gravitino.spark.connector.SparkTransformConverter;
import org.apache.gravitino.spark.connector.SparkTypeConverter;
@@ -77,4 +78,16 @@ public class GravitinoHiveCatalog extends BaseCatalog {
protected SparkTypeConverter getSparkTypeConverter() {
return new SparkHiveTypeConverter();
}
+
+ @Override
+ protected org.apache.spark.sql.connector.catalog.Table createSparkView(
+ Identifier ident,
+ View gravitinoView,
+ org.apache.spark.sql.connector.catalog.Table sparkTable) {
+ return new SparkHiveView(
+ gravitinoView,
+ (HiveTable) sparkTable,
+ (HiveTableCatalog) sparkCatalog,
+ getSparkTypeConverter());
+ }
}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/SparkHiveView.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/SparkHiveView.java
new file mode 100644
index 0000000000..de93070535
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/SparkHiveView.java
@@ -0,0 +1,192 @@
+/*
+ * 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.spark.connector.hive;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.gravitino.rel.Dialects;
+import org.apache.gravitino.rel.SQLRepresentation;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.spark.connector.ConnectorConstants;
+import org.apache.gravitino.spark.connector.SparkTypeConverter;
+import org.apache.kyuubi.spark.connector.hive.HiveTable;
+import org.apache.kyuubi.spark.connector.hive.HiveTableCatalog;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.read.LocalScan;
+import org.apache.spark.sql.connector.read.Scan;
+import org.apache.spark.sql.connector.read.ScanBuilder;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.MetadataBuilder;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.sql.util.CaseInsensitiveStringMap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Wraps a Hive view stored in HMS with Gravitino view metadata. Overrides
newScanBuilder to execute
+ * the view's SQL (spark dialect preferred, hive as fallback) instead of using
Kyuubi's file-based
+ * scan, which returns empty results for VIRTUAL_VIEW entries.
+ *
+ * <p>Uses {@link LocalScan} to materialize view results on the driver. This
is a known limitation:
+ * the proper fix (expanding the view SQL into the query plan at analysis
time) requires Spark's V2
+ * catalog framework to route view operations to named catalogs, which Spark
does not yet support —
+ * {@code ResolveSessionCatalog} unconditionally throws for any non-session
catalog view. Until
+ * Spark adds that support, driver-side materialization via {@code
executeCollect()} is the only
+ * viable approach. <b>This implementation is suitable only for small/bounded
views.</b>
+ */
+public class SparkHiveView extends HiveTable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(SparkHiveView.class);
+
+ private final View gravitinoView;
+ private final SparkTypeConverter sparkTypeConverter;
+
+ /**
+ * Creates a SparkHiveView that wraps a Gravitino view with Hive HMS backing.
+ *
+ * @param gravitinoView the Gravitino view metadata
+ * @param hiveTable the backing Kyuubi HiveTable from HMS
+ * @param hiveTableCatalog the Kyuubi HiveTableCatalog for HMS access
+ * @param sparkTypeConverter converter for Gravitino-to-Spark type mapping
+ */
+ public SparkHiveView(
+ View gravitinoView,
+ HiveTable hiveTable,
+ HiveTableCatalog hiveTableCatalog,
+ SparkTypeConverter sparkTypeConverter) {
+ super(SparkSession.active(), hiveTable.catalogTable(), hiveTableCatalog);
+ this.gravitinoView = gravitinoView;
+ this.sparkTypeConverter = sparkTypeConverter;
+ }
+
+ @Override
+ public String name() {
+ return gravitinoView.name();
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public StructType schema() {
+ org.apache.gravitino.rel.Column[] columns = gravitinoView.columns();
+ if (columns.length == 0) {
+ // View API allows an empty column array when the output schema is
unknown;
+ // fall back to the HMS-derived schema from the parent HiveTable.
+ return super.schema();
+ }
+ List<StructField> fields =
+ Arrays.stream(columns)
+ .map(
+ column -> {
+ String comment = column.comment();
+ Metadata metadata =
+ comment != null
+ ? new MetadataBuilder()
+ .putString(ConnectorConstants.COMMENT, comment)
+ .build()
+ : Metadata.empty();
+ return StructField.apply(
+ column.name(),
+ sparkTypeConverter.toSparkType(column.dataType()),
+ column.nullable(),
+ metadata);
+ })
+ .collect(Collectors.toList());
+ return DataTypes.createStructType(fields);
+ }
+
+ @Override
+ public Map<String, String> properties() {
+ return gravitinoView.properties();
+ }
+
+ @Override
+ public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) {
+ SparkSession spark = SparkSession.active();
+ Preconditions.checkState(
+ spark != null && !spark.sparkContext().isStopped(),
+ "No active SparkSession available to execute view '%s'",
+ gravitinoView.name());
+ String viewSql =
+ gravitinoView
+ .sqlFor(Dialects.SPARK)
+ .map(SQLRepresentation::sql)
+ .orElseGet(
+ () -> {
+ String hiveSql =
+ gravitinoView
+ .sqlFor(Dialects.HIVE)
+ .map(SQLRepresentation::sql)
+ .orElseThrow(
+ () ->
+ new UnsupportedOperationException(
+ "No SQL representation for view: " +
gravitinoView.name()));
+ LOG.warn(
+ "View '{}' has no Spark SQL representation; falling back
to Hive SQL. "
+ + "Results may differ if the SQL uses Hive-specific
syntax.",
+ gravitinoView.name());
+ return hiveSql;
+ });
+ return new ViewScanBuilder(spark, viewSql, schema());
+ }
+
+ /**
+ * A ScanBuilder that executes the view's SQL via SparkSession using {@link
LocalScan}. Spark
+ * treats LocalScan results as driver-local data and never serializes rows
to executors.
+ */
+ private static class ViewScanBuilder implements ScanBuilder, Scan, LocalScan
{
+
+ private final SparkSession spark;
+ private final String viewSql;
+ private final StructType schema;
+
+ ViewScanBuilder(SparkSession spark, String viewSql, StructType schema) {
+ this.spark = spark;
+ this.viewSql = viewSql;
+ this.schema = schema;
+ }
+
+ @Override
+ public Scan build() {
+ return this;
+ }
+
+ @Override
+ public StructType readSchema() {
+ return schema;
+ }
+
+ @Override
+ public InternalRow[] rows() {
+ // executeCollect() pulls all rows to driver memory — see class-level
Javadoc for why.
+ try {
+ return
spark.sql(viewSql).queryExecution().executedPlan().executeCollect();
+ } catch (RuntimeException e) {
+ throw new RuntimeException(
+ String.format("Failed to execute view SQL [%s]: %s", viewSql,
e.getMessage()), e);
+ }
+ }
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
index d9c4e8a247..a9da4aaa8f 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
@@ -84,6 +84,11 @@ public abstract class SparkEnvIT extends SparkUtilIT {
return true;
}
+ /** Returns the Gravitino {@link Catalog} for the catalog under test. */
+ protected Catalog getGravitinoCatalog() {
+ return client.loadMetalake(metalakeName).loadCatalog(getCatalogName());
+ }
+
@Override
protected SparkSession getSparkSession() {
Assertions.assertNotNull(sparkSession);
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
index c0ae42727d..e5393b525c 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
@@ -22,8 +22,15 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.SQLRepresentation;
+import org.apache.gravitino.rel.ViewCatalog;
+import org.apache.gravitino.rel.types.Types;
import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
import org.apache.gravitino.spark.connector.hive.HivePropertiesConstants;
import org.apache.gravitino.spark.connector.integration.test.SparkCommonIT;
@@ -31,6 +38,7 @@ import
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfo
import
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfo.SparkColumnInfo;
import
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfoChecker;
import org.apache.hadoop.fs.Path;
+import org.apache.spark.sql.AnalysisException;
import org.apache.spark.sql.connector.catalog.TableCatalog;
import org.apache.spark.sql.types.DataTypes;
import org.junit.jupiter.api.Assertions;
@@ -528,4 +536,161 @@ public abstract class SparkHiveCatalogIT extends
SparkCommonIT {
SparkColumnInfo.of("ts", DataTypes.TimestampType));
checkTableColumns(tableName, expectedSparkInfo, tableInfo);
}
+
+ @Test
+ void testCreateViewViaSql() {
+ String tableName = "cv_base_table";
+ String viewName = "cv_test_view";
+ String schemaName = getDefaultDatabase();
+ dropTableIfExists(tableName);
+ createSimpleTable(tableName);
+ try {
+ // CREATE VIEW via SQL is not supported for V2 named catalogs. Spark's
+ // ResolveSessionCatalog unconditionally throws for any non-session
catalog
+ // (see the "case CreateView(ResolvedIdentifier(catalog, _), ...)"
branch), regardless of
+ // whether the catalog implements ViewCatalog. Views must be created
through the
+ // Gravitino API (ViewCatalog.createView) and are readable via SELECT.
+ AnalysisException ex =
+ Assertions.assertThrows(
+ AnalysisException.class,
+ () ->
+ sql(
+ String.format(
+ "CREATE VIEW %s.%s.%s AS SELECT * FROM %s.%s.%s",
+ getCatalogName(),
+ schemaName,
+ viewName,
+ getCatalogName(),
+ schemaName,
+ tableName)));
+ Assertions.assertTrue(
+ ex.getMessage().contains("does not support views"),
+ "Expected error about view support, got: " + ex.getMessage());
+ } finally {
+ dropTableIfExists(tableName);
+ }
+ }
+
+ @Test
+ void testSelectHiveView() {
+ String tableName = "view_base_table";
+ String viewName = "test_hive_view";
+ String schemaName = getDefaultDatabase();
+
+ dropTableIfExists(tableName);
+ createSimpleTable(tableName);
+ sql(String.format("INSERT INTO %s VALUES (1, '1', 1),(2, '2', 2),(3, '3',
3)", tableName));
+
+ ViewCatalog viewCatalog = getGravitinoCatalog().asViewCatalog();
+ NameIdentifier viewIdent = NameIdentifier.of(schemaName, viewName);
+ if (viewCatalog.viewExists(viewIdent)) {
+ viewCatalog.dropView(viewIdent);
+ }
+
+ SQLRepresentation sparkRep =
+ SQLRepresentation.builder()
+ .withDialect("spark")
+ .withSql(
+ String.format("SELECT * FROM %s.%s.%s", getCatalogName(),
schemaName, tableName))
+ .build();
+ viewCatalog.createView(
+ viewIdent,
+ "test view",
+ new Column[] {
+ Column.of("id", Types.IntegerType.get(), null),
+ Column.of("name", Types.StringType.get(), null),
+ Column.of("age", Types.IntegerType.get(), null)
+ },
+ new SQLRepresentation[] {sparkRep},
+ getCatalogName(),
+ schemaName,
+ ImmutableMap.of("spark.sql.create.version",
getSparkSession().version()));
+
+ try {
+ List<String> data =
+ getQueryData(
+ String.format("SELECT * FROM %s.%s.%s", getCatalogName(),
schemaName, viewName));
+ data.sort(Comparator.naturalOrder());
+ Assertions.assertEquals(3, data.size());
+ Assertions.assertEquals("1,1,1", data.get(0));
+ Assertions.assertEquals("2,2,2", data.get(1));
+ Assertions.assertEquals("3,3,3", data.get(2));
+ } finally {
+ viewCatalog.dropView(viewIdent);
+ dropTableIfExists(tableName);
+ }
+ }
+
+ @Test
+ void testTableExistsForView() {
+ String viewName = "test_view_exists";
+ String schemaName = getDefaultDatabase();
+
+ ViewCatalog viewCatalog = getGravitinoCatalog().asViewCatalog();
+ NameIdentifier viewIdent = NameIdentifier.of(schemaName, viewName);
+ if (viewCatalog.viewExists(viewIdent)) {
+ viewCatalog.dropView(viewIdent);
+ }
+
+ SQLRepresentation sparkRep =
+ SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1 AS
id").build();
+ viewCatalog.createView(
+ viewIdent,
+ null,
+ new Column[] {Column.of("id", Types.IntegerType.get(), null)},
+ new SQLRepresentation[] {sparkRep},
+ null,
+ null,
+ ImmutableMap.of("spark.sql.create.version",
getSparkSession().version()));
+
+ try {
+ Assertions.assertDoesNotThrow(
+ () ->
+ sql(
+ String.format(
+ "DESCRIBE TABLE %s.%s.%s", getCatalogName(), schemaName,
viewName)));
+ } finally {
+ viewCatalog.dropView(viewIdent);
+ }
+ }
+
+ @Test
+ void testShowTablesExcludesViews() {
+ String tableName = "show_tables_base_table";
+ String viewName = "show_tables_view";
+ String schemaName = getDefaultDatabase();
+
+ dropTableIfExists(tableName);
+ createSimpleTable(tableName);
+
+ ViewCatalog viewCatalog = getGravitinoCatalog().asViewCatalog();
+ NameIdentifier viewIdent = NameIdentifier.of(schemaName, viewName);
+ if (viewCatalog.viewExists(viewIdent)) {
+ viewCatalog.dropView(viewIdent);
+ }
+
+ SQLRepresentation sparkRep =
+ SQLRepresentation.builder()
+ .withDialect("spark")
+ .withSql(
+ String.format("SELECT * FROM %s.%s.%s", getCatalogName(),
schemaName, tableName))
+ .build();
+ viewCatalog.createView(
+ viewIdent,
+ null,
+ new Column[0],
+ new SQLRepresentation[] {sparkRep},
+ getCatalogName(),
+ schemaName,
+ ImmutableMap.of("spark.sql.create.version",
getSparkSession().version()));
+
+ try {
+ Set<String> tableNames = listTableNames(schemaName);
+ Assertions.assertTrue(tableNames.contains(tableName));
+ Assertions.assertFalse(tableNames.contains(viewName));
+ } finally {
+ viewCatalog.dropView(viewIdent);
+ dropTableIfExists(tableName);
+ }
+ }
}