This is an automated email from the ASF dual-hosted git repository.

diqiu50 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 6904c69be5 [#14293] fix(spark-connector): Resolve table names in a 
Hive view against the view's namespace (#14297)
6904c69be5 is described below

commit 6904c69be5506a5e9587b2ec9aaa55e9d0e99866
Author: YangJie <[email protected]>
AuthorDate: Mon Nov 2 22:09:27 2026 -0500

    [#14293] fix(spark-connector): Resolve table names in a Hive view against 
the view's namespace (#14297)
    
    ### What changes were proposed in this pull request?
    
    - `SparkHiveView` parses the view SQL and qualifies its table names
    before running it, with the new `ViewRelationQualifier`. As in Spark's
    own view resolution, a one-part name gets the catalog and namespace, and
    a name whose first part is not a registered catalog gets the catalog.
    CTE names are substituted first, and names inside subqueries and time
    travel clauses (`VERSION AS OF`, `TIMESTAMP AS OF (subquery)`) are
    qualified too.
    - The namespace is the one Spark recorded in
    `view.catalogAndNamespace.*` when it created the view, with
    `spark_catalog` mapped to the Gravitino catalog the view was loaded
    from. Without a recorded namespace, it is the view's own database.
    - When the view has no Spark SQL, the Hive fallback uses the view's
    expanded text, in which Hive qualifies the table names.
    - The session's current catalog and database are not changed.
    
    ### Why are the changes needed?
    
    The view SQL ran in the session's current catalog and database, so a
    view read the wrong table, or failed with `TABLE_OR_VIEW_NOT_FOUND`,
    whenever the session was not in the view's database.
    
    Fix: #14293
    
    ### Does this PR introduce _any_ user-facing change?
    
    Views return the rows of the tables they were defined on, regardless of
    the session's current database. Function names, names written with
    `IDENTIFIER(...)` and the SQL configs Spark records with a view are
    still taken from the session, as before.
    
    ### How was this patch tested?
    
    - New `TestViewRelationQualifier` covers one- and two-part names, names
    that start with a catalog, subqueries, CTE names, `VERSION AS OF` and a
    `TIMESTAMP AS OF` subquery, and nested `WITH` under the legacy CTE
    precedence policy.
    -
    `SparkHiveCatalogIT.testSelectViewWithUnqualifiedSqlResolvesInViewNamespace`
    switches the session to `spark_catalog.default` and reads three views
    whose SQL has no database name, with a same-named table in another
    database: one without a recorded namespace (its SQL has a CTE and a
    subquery), one with a recorded Spark namespace, and one created by `hive
    -e`.
---
 .../spark/connector/hive/GravitinoHiveCatalog.java |   2 +
 .../spark/connector/hive/SparkHiveView.java        |  66 ++++++++++-
 .../connector/hive/ViewRelationQualifier.java      | 126 +++++++++++++++++++++
 .../connector/hive/TestViewRelationQualifier.java  | 126 +++++++++++++++++++++
 .../integration/test/hive/SparkHiveCatalogIT.java  | 105 +++++++++++++++++
 5 files changed, 420 insertions(+), 5 deletions(-)

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 4179a79da2..e0275730e3 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
@@ -120,6 +120,8 @@ public class GravitinoHiveCatalog extends BaseCatalog {
       org.apache.spark.sql.connector.catalog.Table sparkTable) {
     return new SparkHiveView(
         gravitinoView,
+        name(),
+        getDatabase(ident),
         (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
index de93070535..39d0eb5123 100644
--- 
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
@@ -20,10 +20,12 @@
 package org.apache.gravitino.spark.connector.hive;
 
 import com.google.common.base.Preconditions;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.rel.Dialects;
 import org.apache.gravitino.rel.SQLRepresentation;
 import org.apache.gravitino.rel.View;
@@ -33,9 +35,13 @@ 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.catalyst.parser.ParseException;
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
+import org.apache.spark.sql.connector.catalog.CatalogManager;
 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.execution.CommandExecutionMode;
 import org.apache.spark.sql.types.DataTypes;
 import org.apache.spark.sql.types.Metadata;
 import org.apache.spark.sql.types.MetadataBuilder;
@@ -44,6 +50,7 @@ import org.apache.spark.sql.types.StructType;
 import org.apache.spark.sql.util.CaseInsensitiveStringMap;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import scala.collection.JavaConverters;
 
 /**
  * Wraps a Hive view stored in HMS with Gravitino view metadata. Overrides 
newScanBuilder to execute
@@ -63,22 +70,30 @@ public class SparkHiveView extends HiveTable {
 
   private final View gravitinoView;
   private final SparkTypeConverter sparkTypeConverter;
+  private final String catalogName;
+  private final String schemaName;
 
   /**
    * Creates a SparkHiveView that wraps a Gravitino view with Hive HMS backing.
    *
    * @param gravitinoView the Gravitino view metadata
+   * @param catalogName the Spark catalog name the view was loaded from
+   * @param schemaName the view's schema (database) name
    * @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,
+      String catalogName,
+      String schemaName,
       HiveTable hiveTable,
       HiveTableCatalog hiveTableCatalog,
       SparkTypeConverter sparkTypeConverter) {
     super(SparkSession.active(), hiveTable.catalogTable(), hiveTableCatalog);
     this.gravitinoView = gravitinoView;
+    this.catalogName = catalogName;
+    this.schemaName = schemaName;
     this.sparkTypeConverter = sparkTypeConverter;
   }
 
@@ -147,9 +162,36 @@ public class SparkHiveView extends HiveTable {
                       "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;
+                  // For a view created by Hive, the expanded text has its 
table names qualified.
+                  String expandedText = catalogTable().viewText().getOrElse(() 
-> "");
+                  return StringUtils.isNotBlank(expandedText) ? expandedText : 
hiveSql;
                 });
-    return new ViewScanBuilder(spark, viewSql, schema());
+    return new ViewScanBuilder(spark, viewSql, schema(), 
relationQualifier(spark));
+  }
+
+  /**
+   * Builds the qualifier for the namespace the view SQL resolves in: the 
catalog and namespace
+   * Spark recorded when it created the view, or else the view's own catalog 
and schema. A recorded
+   * {@code spark_catalog} is mapped to the catalog the view was loaded from. 
This assumes that the
+   * session catalog of the Spark session that created the view used the same 
Hive metastore as this
+   * catalog.
+   */
+  @SuppressWarnings("deprecation") // JavaConverters is deprecated on Scala 
2.13 but needed on 2.12
+  private ViewRelationQualifier relationQualifier(SparkSession spark) {
+    CatalogManager catalogManager = spark.sessionState().catalogManager();
+    List<String> recorded = 
JavaConverters.seqAsJavaList(catalogTable().viewCatalogAndNamespace());
+    String viewCatalog = catalogName;
+    List<String> viewNamespace = new ArrayList<>();
+    if (recorded.isEmpty()) {
+      viewNamespace.add(schemaName);
+    } else {
+      if 
(!CatalogManager.SESSION_CATALOG_NAME().equalsIgnoreCase(recorded.get(0))) {
+        viewCatalog = recorded.get(0);
+      }
+      viewNamespace.addAll(recorded.subList(1, recorded.size()));
+    }
+    return new ViewRelationQualifier(
+        viewCatalog, viewNamespace, catalogManager::isCatalogRegistered);
   }
 
   /**
@@ -161,11 +203,17 @@ public class SparkHiveView extends HiveTable {
     private final SparkSession spark;
     private final String viewSql;
     private final StructType schema;
+    private final ViewRelationQualifier relationQualifier;
 
-    ViewScanBuilder(SparkSession spark, String viewSql, StructType schema) {
+    ViewScanBuilder(
+        SparkSession spark,
+        String viewSql,
+        StructType schema,
+        ViewRelationQualifier relationQualifier) {
       this.spark = spark;
       this.viewSql = viewSql;
       this.schema = schema;
+      this.relationQualifier = relationQualifier;
     }
 
     @Override
@@ -182,8 +230,16 @@ public class SparkHiveView extends HiveTable {
     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) {
+        // Qualify the table names instead of switching the session's current 
namespace, so the
+        // session and other queries running on it are not affected.
+        LogicalPlan plan =
+            
relationQualifier.qualify(spark.sessionState().sqlParser().parsePlan(viewSql));
+        return spark
+            .sessionState()
+            .executePlan(plan, CommandExecutionMode.ALL())
+            .executedPlan()
+            .executeCollect();
+      } catch (ParseException | 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/main/java/org/apache/gravitino/spark/connector/hive/ViewRelationQualifier.java
 
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/ViewRelationQualifier.java
new file mode 100644
index 0000000000..c10aa40823
--- /dev/null
+++ 
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/ViewRelationQualifier.java
@@ -0,0 +1,126 @@
+/*
+ *  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 java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+import org.apache.spark.sql.catalyst.analysis.CTESubstitution;
+import org.apache.spark.sql.catalyst.analysis.RelationTimeTravel;
+import org.apache.spark.sql.catalyst.analysis.UnresolvedRelation;
+import org.apache.spark.sql.catalyst.expressions.Expression;
+import org.apache.spark.sql.catalyst.expressions.SubqueryExpression;
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
+import scala.collection.JavaConverters;
+
+/**
+ * Qualifies the table names in a parsed view query with the namespace the 
view resolves in, the way
+ * Spark expands table names inside a view: a one-part name gets the catalog 
and the namespace, and
+ * a name whose first part is not a registered catalog gets the catalog. 
Function names and names
+ * built with {@code IDENTIFIER(...)} are not qualified.
+ */
+@SuppressWarnings("deprecation") // JavaConverters is deprecated on Scala 2.13 
but required on 2.12
+final class ViewRelationQualifier {
+
+  // The analyzer applies CTESubstitution in a batch of at most 100 iterations 
by default.
+  private static final int MAX_CTE_SUBSTITUTIONS = 100;
+
+  private final String catalog;
+  private final List<String> namespace;
+  private final Predicate<String> isCatalog;
+
+  /**
+   * @param catalog the catalog that unqualified names resolve in
+   * @param namespace the namespace that one-part names resolve in
+   * @param isCatalog tests whether a name is a catalog registered in the 
session
+   */
+  ViewRelationQualifier(String catalog, List<String> namespace, 
Predicate<String> isCatalog) {
+    this.catalog = catalog;
+    this.namespace = namespace;
+    this.isCatalog = isCatalog;
+  }
+
+  /**
+   * Returns the plan with its table names qualified. CTE references are 
substituted first, so a CTE
+   * name is not taken for a table.
+   *
+   * @param plan the parsed, unresolved view query
+   * @return the plan with qualified table names
+   */
+  LogicalPlan qualify(LogicalPlan plan) {
+    return qualifyPlan(substituteCtes(plan));
+  }
+
+  // The legacy CTE precedence policy substitutes one level of nested WITH per 
run, so run it until
+  // nothing changes, as the analyzer does.
+  private static LogicalPlan substituteCtes(LogicalPlan plan) {
+    LogicalPlan current = plan;
+    for (int i = 0; i < MAX_CTE_SUBSTITUTIONS; i++) {
+      LogicalPlan next = CTESubstitution.apply(current);
+      if (next.fastEquals(current)) {
+        break;
+      }
+      current = next;
+    }
+    return current;
+  }
+
+  private LogicalPlan qualifyPlan(LogicalPlan plan) {
+    if (plan instanceof UnresolvedRelation) {
+      return qualifyRelation((UnresolvedRelation) plan);
+    }
+    if (plan instanceof RelationTimeTravel) {
+      // The relation is a field, not a child, of this leaf node.
+      RelationTimeTravel timeTravel = (RelationTimeTravel) plan;
+      return new RelationTimeTravel(
+              qualifyPlan(timeTravel.relation()), timeTravel.timestamp(), 
timeTravel.version())
+          .mapExpressions(this::qualifyExpression);
+    }
+    return 
plan.mapChildren(this::qualifyPlan).mapExpressions(this::qualifyExpression);
+  }
+
+  private Expression qualifyExpression(Expression expression) {
+    Expression qualified = expression;
+    if (expression instanceof SubqueryExpression) {
+      // Spark analyzes a subquery on its own, which substitutes the CTEs it 
defines.
+      SubqueryExpression subquery = (SubqueryExpression) expression;
+      qualified = subquery.withNewPlan(qualify(subquery.plan()));
+    }
+    return qualified.mapChildren(this::qualifyExpression);
+  }
+
+  private LogicalPlan qualifyRelation(UnresolvedRelation relation) {
+    List<String> parts = 
JavaConverters.seqAsJavaList(relation.multipartIdentifier());
+    List<String> qualified = new ArrayList<>();
+    if (parts.size() == 1) {
+      qualified.add(catalog);
+      qualified.addAll(namespace);
+    } else if (isCatalog.test(parts.get(0))) {
+      return relation;
+    } else {
+      qualified.add(catalog);
+    }
+    qualified.addAll(parts);
+    return new UnresolvedRelation(
+        JavaConverters.asScalaBuffer(qualified).toList(),
+        relation.options(),
+        relation.isStreaming());
+  }
+}
diff --git 
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/hive/TestViewRelationQualifier.java
 
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/hive/TestViewRelationQualifier.java
new file mode 100644
index 0000000000..9c6a5c8c8d
--- /dev/null
+++ 
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/hive/TestViewRelationQualifier.java
@@ -0,0 +1,126 @@
+/*
+ *  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 static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.spark.sql.catalyst.analysis.RelationTimeTravel;
+import org.apache.spark.sql.catalyst.analysis.UnresolvedRelation;
+import org.apache.spark.sql.catalyst.expressions.Expression;
+import org.apache.spark.sql.catalyst.expressions.SubqueryExpression;
+import org.apache.spark.sql.catalyst.parser.CatalystSqlParser;
+import org.apache.spark.sql.catalyst.parser.ParseException;
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
+import org.apache.spark.sql.internal.SQLConf;
+import org.junit.jupiter.api.Test;
+import scala.collection.JavaConverters;
+
+@SuppressWarnings("deprecation") // JavaConverters is deprecated on Scala 2.13 
but required on 2.12
+public class TestViewRelationQualifier {
+
+  private static final ViewRelationQualifier QUALIFIER =
+      new ViewRelationQualifier("hive", Arrays.asList("db1"), name -> 
name.equals("other"));
+
+  @Test
+  void testQualifiesOneAndTwoPartNames() throws ParseException {
+    assertEquals(
+        Arrays.asList("hive.db1.t", "hive.db2.u"),
+        relations("SELECT * FROM t JOIN db2.u ON t.id = u.id"));
+  }
+
+  @Test
+  void testKeepsNamesThatStartWithACatalog() throws ParseException {
+    assertEquals(Arrays.asList("other.db.t"), relations("SELECT * FROM 
other.db.t"));
+  }
+
+  @Test
+  void testQualifiesNamesInSubqueries() throws ParseException {
+    assertEquals(
+        Arrays.asList("hive.db1.a", "hive.db1.b"),
+        relations("SELECT * FROM a WHERE id IN (SELECT id FROM b)"));
+  }
+
+  @Test
+  void testDoesNotQualifyCteReferences() throws ParseException {
+    // The CTE named t shadows the table t; only the table inside the CTE body 
is qualified.
+    assertEquals(
+        Arrays.asList("hive.db1.base"),
+        relations("WITH t AS (SELECT * FROM base) SELECT * FROM t"));
+  }
+
+  @Test
+  void testQualifiesTimeTravelRelations() throws ParseException {
+    assertEquals(Arrays.asList("hive.db1.t"), relations("SELECT * FROM t 
VERSION AS OF 1"));
+    assertEquals(
+        Arrays.asList("hive.db1.t", "hive.db1.u"),
+        relations("SELECT * FROM t TIMESTAMP AS OF (SELECT max(ts) FROM u)"));
+  }
+
+  @Test
+  void testQualifiesNestedCtesUnderLegacyPolicy() throws ParseException {
+    SQLConf conf = SQLConf.get();
+    String key = SQLConf.LEGACY_CTE_PRECEDENCE_POLICY().key();
+    String previous = conf.getConfString(key, null);
+    conf.setConfString(key, "LEGACY");
+    try {
+      assertEquals(
+          Arrays.asList("hive.db1.t"),
+          relations("WITH a AS (WITH b AS (SELECT * FROM t) SELECT * FROM b) 
SELECT * FROM a"));
+      assertEquals(
+          Arrays.asList("hive.db1.a", "hive.db1.b"),
+          relations("SELECT * FROM a WHERE id IN (WITH c AS (SELECT id FROM b) 
SELECT id FROM c)"));
+    } finally {
+      if (previous == null) {
+        conf.unsetConf(key);
+      } else {
+        conf.setConfString(key, previous);
+      }
+    }
+  }
+
+  private static List<String> relations(String sql) throws ParseException {
+    List<String> names = new ArrayList<>();
+    collect(QUALIFIER.qualify(new CatalystSqlParser().parsePlan(sql)), names);
+    return names;
+  }
+
+  private static void collect(LogicalPlan plan, List<String> names) {
+    if (plan instanceof RelationTimeTravel) {
+      collect(((RelationTimeTravel) plan).relation(), names);
+    }
+    if (plan instanceof UnresolvedRelation) {
+      List<String> parts =
+          JavaConverters.seqAsJavaList(((UnresolvedRelation) 
plan).multipartIdentifier());
+      names.add(String.join(".", parts));
+    }
+    JavaConverters.seqAsJavaList(plan.children()).forEach(child -> 
collect(child, names));
+    JavaConverters.seqAsJavaList(plan.expressions()).forEach(e -> collect(e, 
names));
+  }
+
+  private static void collect(Expression expression, List<String> names) {
+    if (expression instanceof SubqueryExpression) {
+      collect(((SubqueryExpression) expression).plan(), names);
+    }
+    JavaConverters.seqAsJavaList(expression.children()).forEach(child -> 
collect(child, names));
+  }
+}
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 bca934c64e..83cdcfd6fc 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
@@ -24,12 +24,14 @@ import com.google.common.collect.Maps;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.integration.test.container.ContainerSuite;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.SQLRepresentation;
 import org.apache.gravitino.rel.ViewCatalog;
@@ -59,6 +61,7 @@ import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.testcontainers.containers.Container;
 import scala.collection.JavaConverters;
 
 @Tag("gravitino-docker-test")
@@ -634,6 +637,108 @@ public abstract class SparkHiveCatalogIT extends 
SparkCommonIT {
     }
   }
 
+  @Test
+  void testSelectViewWithUnqualifiedSqlResolvesInViewNamespace() {
+    String tableName = "view_base_table_unq";
+    String schemaName = getDefaultDatabase();
+    String otherDb = "db_view_unq_other";
+    String ownView = "test_hive_view_unq_own";
+    String recordedView = "test_hive_view_unq_recorded";
+
+    // The real table is in the default database; a same-named table in 
otherDb holds other rows.
+    dropTableIfExists(tableName);
+    createSimpleTable(tableName);
+    sql(String.format("INSERT INTO %s VALUES (1, '1', 1)", tableName));
+    createDatabaseIfNotExists(otherDb, getProvider());
+    String shadowTable = String.join(".", otherDb, tableName);
+    dropTableIfExists(shadowTable);
+    createSimpleTable(shadowTable);
+    sql(String.format("INSERT INTO %s VALUES (9, 'shadow', 9)", shadowTable));
+
+    ViewCatalog viewCatalog = getGravitinoCatalog().asViewCatalog();
+    NameIdentifier ownIdent = NameIdentifier.of(schemaName, ownView);
+    NameIdentifier recordedIdent = NameIdentifier.of(otherDb, recordedView);
+    // No recorded namespace: the SQL resolves in the view's own database. The 
CTE and the
+    // subquery check that their table names are qualified too.
+    createUnqualifiedView(
+        viewCatalog,
+        ownIdent,
+        String.format(
+            "WITH x AS (SELECT * FROM %1$s) SELECT * FROM x WHERE id IN 
(SELECT id FROM %1$s)",
+            tableName),
+        ImmutableMap.of());
+    // Like a Spark view created by "USE default_db; CREATE VIEW otherDb.v AS 
SELECT * FROM t".
+    createUnqualifiedView(
+        viewCatalog,
+        recordedIdent,
+        String.format("SELECT * FROM %s", tableName),
+        ImmutableMap.of(
+            "view.catalogAndNamespace.numParts", "2",
+            "view.catalogAndNamespace.part.0", "spark_catalog",
+            "view.catalogAndNamespace.part.1", schemaName));
+    // A view created by Hive itself, whose expanded text qualifies the table 
with default_db.
+    String hiveView = "test_hive_view_unq_hive";
+    NameIdentifier hiveIdent = NameIdentifier.of(otherDb, hiveView);
+    Container.ExecResult created =
+        ContainerSuite.getInstance()
+            .getHiveContainer()
+            .executeInContainer(
+                "hive",
+                "-e",
+                String.format(
+                    "USE %s; CREATE VIEW %s.%s AS SELECT * FROM %s",
+                    schemaName, otherDb, hiveView, tableName));
+    Assertions.assertEquals(0, created.getExitCode(), created.getStderr());
+
+    try {
+      // The session is in another catalog, so unqualified names in the 
session resolve elsewhere.
+      sql("USE spark_catalog.default");
+      for (NameIdentifier view : new NameIdentifier[] {ownIdent, 
recordedIdent, hiveIdent}) {
+        List<String> data =
+            getQueryData(
+                String.format(
+                    "SELECT * FROM %s.%s.%s",
+                    getCatalogName(), view.namespace().level(0), view.name()));
+        Assertions.assertEquals(Arrays.asList("1,1,1"), data, view.toString());
+      }
+      Assertions.assertEquals("spark_catalog", 
getSparkSession().catalog().currentCatalog());
+    } finally {
+      sql("USE " + String.join(".", getCatalogName(), getDefaultDatabase()));
+      viewCatalog.dropView(ownIdent);
+      viewCatalog.dropView(recordedIdent);
+      viewCatalog.dropView(hiveIdent);
+      dropTableIfExists(shadowTable);
+      dropDatabaseIfExists(otherDb);
+      dropTableIfExists(tableName);
+    }
+  }
+
+  private void createUnqualifiedView(
+      ViewCatalog viewCatalog,
+      NameIdentifier viewIdent,
+      String viewSql,
+      Map<String, String> extraProperties) {
+    if (viewCatalog.viewExists(viewIdent)) {
+      viewCatalog.dropView(viewIdent);
+    }
+    SQLRepresentation sparkRep =
+        
SQLRepresentation.builder().withDialect("spark").withSql(viewSql).build();
+    Map<String, String> properties = new HashMap<>(extraProperties);
+    properties.put("spark.sql.create.version", getSparkSession().version());
+    viewCatalog.createView(
+        viewIdent,
+        "unqualified 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},
+        null,
+        null,
+        properties);
+  }
+
   @Test
   void testTableExistsForView() {
     String viewName = "test_view_exists";

Reply via email to