This is an automated email from the ASF dual-hosted git repository.
diqiu50 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 417b582a76 [Cherry-pick to branch-1.3] [#13010] fix(catalog-glue): Do
not expose Glue VIRTUAL_VIEW objects as tables (#13011) (#13039)
417b582a76 is described below
commit 417b582a7663a5af7ad56559e883a684c3e26777
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Sep 10 09:10:51 2026 +0800
[Cherry-pick to branch-1.3] [#13010] fix(catalog-glue): Do not expose Glue
VIRTUAL_VIEW objects as tables (#13011) (#13039)
**Cherry-pick Information:**
- Original commit: d648735761c3b46eb6c1c5d0f334cb293969edd0
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
---------
Co-authored-by: Yuhui <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: diqiu50 <[email protected]>
---
.../gravitino/catalog/glue/GlueConstants.java | 7 ++
.../catalog/glue/GlueCatalogOperations.java | 37 ++++++++-
.../glue/TestGlueCatalogOperationsForIceberg.java | 39 ++++++++++
.../glue/TestGlueCatalogTableOperations.java | 86 +++++++++++++++++++++
.../integration/test/AbstractGlueCatalogIT.java | 87 ++++++++++++++++++++++
5 files changed, 255 insertions(+), 1 deletion(-)
diff --git
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
index da78dfe24c..2cd1f32cf3 100644
---
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
+++
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
@@ -148,5 +148,12 @@ public final class GlueConstants {
/** Glue {@code tableType} value for external tables. */
public static final String EXTERNAL_TABLE_TYPE = "EXTERNAL_TABLE";
+ /**
+ * Glue {@code tableType} value for views. Both Hive-compatible views (whose
definition lives in
+ * {@code Table.viewOriginalText()}) and Glue multi-dialect views (whose
definition lives in
+ * {@code Table.viewDefinition()}) carry this type.
+ */
+ public static final String VIRTUAL_VIEW_TABLE_TYPE = "VIRTUAL_VIEW";
+
private GlueConstants() {}
}
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
index 67a8a80738..99f205e203 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
@@ -346,6 +346,7 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
if (nextToken != null) req.nextToken(nextToken);
GetTablesResponse resp = glueClient.getTables(req.build());
resp.tableList().stream()
+ .filter(t -> !isView(t))
.filter(this::matchesFormatFilter)
.map(t -> NameIdentifier.of(namespace, t.name()))
.forEach(result::add);
@@ -367,6 +368,7 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
try {
software.amazon.awssdk.services.glue.model.Table rawGlueTable =
glueClient.getTable(req.build()).table();
+ rejectIfView(rawGlueTable, ident, dbName);
GlueTable table = GlueTable.fromGlueTable(rawGlueTable, typeConverter);
// Recover Iceberg-specific partitioning and sort orders from the
Iceberg metadata.
@@ -509,6 +511,8 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
throw GlueExceptionConverter.toTableException(e, "table " +
ident.name());
}
+ rejectIfView(rawGlueTable, ident, dbName);
+
if (GlueIcebergTableHelper.isIcebergTable(rawGlueTable)) {
return alterIcebergTable(ident, dbName, rawGlueTable, changes);
}
@@ -588,7 +592,7 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
&&
rawGlueTable.parameters().containsKey(GlueConstants.METADATA_LOCATION);
boolean isSdkManaged =
rawGlueTable.hasParameters()
- && GlueConstants.ICEBERG_TABLE_TYPE_VALUE.equals(
+ && GlueConstants.ICEBERG_TABLE_TYPE_VALUE.equalsIgnoreCase(
rawGlueTable.parameters().get(GlueConstants.TABLE_TYPE_PARAM));
if (hasMetadataLocation && !isSdkManaged) {
return alterRegisterModeIcebergTable(ident, dbName, rawGlueTable,
changes);
@@ -641,6 +645,20 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
@Override
public boolean dropTable(NameIdentifier ident) {
String dbName = schemaName(ident.namespace());
+
+ // Glue stores views as objects of type VIRTUAL_VIEW in the same namespace
as tables, so the
+ // object has to be fetched first to avoid dropping a view through the
table API.
+ GetTableRequest.Builder getReq =
+ GetTableRequest.builder().databaseName(dbName).name(ident.name());
+ applyCatalogId(catalogId, getReq::catalogId);
+ try {
+ rejectIfView(glueClient.getTable(getReq.build()).table(), ident, dbName);
+ } catch (EntityNotFoundException e) {
+ return false;
+ } catch (GlueException e) {
+ throw GlueExceptionConverter.toTableException(e, "table " +
ident.name());
+ }
+
DeleteTableRequest.Builder req =
DeleteTableRequest.builder().databaseName(dbName).name(ident.name());
applyCatalogId(catalogId, req::catalogId);
@@ -662,6 +680,23 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
return levels[levels.length - 1];
}
+ /**
+ * Returns whether the Glue object is a view rather than a table. Glue keeps
views and tables in
+ * the same namespace and returns both from the table APIs, so every table
entry point has to
+ * screen them out.
+ */
+ private static boolean isView(Table table) {
+ return
GlueConstants.VIRTUAL_VIEW_TABLE_TYPE.equalsIgnoreCase(table.tableType());
+ }
+
+ /** Rejects a Glue object that is a view, so that it is not acted on through
the table API. */
+ private static void rejectIfView(Table table, NameIdentifier ident, String
dbName) {
+ if (isView(table)) {
+ throw new NoSuchTableException(
+ "No table named %s in schema %s (it is a view, not a table)",
ident.name(), dbName);
+ }
+ }
+
// NOTE: parameter type is the Glue SDK Table, not GlueTable (our domain
class).
// The Glue SDK's Column model is also referenced by FQN throughout this
class because its
// simple name conflicts with the imported org.apache.gravitino.rel.Column.
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
index 3fe0f11d96..96e950c3f2 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
@@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -55,6 +56,7 @@ import
software.amazon.awssdk.services.glue.model.GetPartitionsResponse;
import software.amazon.awssdk.services.glue.model.GetTableRequest;
import software.amazon.awssdk.services.glue.model.GetTableResponse;
import software.amazon.awssdk.services.glue.model.StorageDescriptor;
+import software.amazon.awssdk.services.glue.model.UpdateTableRequest;
class TestGlueCatalogOperationsForIceberg {
@@ -312,6 +314,43 @@ class TestGlueCatalogOperationsForIceberg {
verify(mockBuilder).create();
}
+ /**
+ * Glue stores table_type as a free-form string and different writers use
different casing, so the
+ * register-mode check must not treat a lower-case "iceberg" as an
externally registered table.
+ */
+ @Test
+ void testAlterIcebergTableWithLowerCaseTableTypeRoutesToIcebergSdk() {
+ String newName = "ice1_renamed";
+ software.amazon.awssdk.services.glue.model.Table rawTable =
+ software.amazon.awssdk.services.glue.model.Table.builder()
+ .name(TABLE)
+ .parameters(Map.of(TABLE_TYPE_PARAM, "iceberg",
"metadata_location", LOCATION))
+ .storageDescriptor(StorageDescriptor.builder().build())
+ .build();
+ software.amazon.awssdk.services.glue.model.Table renamedTable =
+ software.amazon.awssdk.services.glue.model.Table.builder()
+ .name(newName)
+ .parameters(Map.of(TABLE_TYPE_PARAM, "iceberg"))
+ .storageDescriptor(StorageDescriptor.builder().build())
+ .build();
+
+ when(mockClient.getTable(any(GetTableRequest.class)))
+ .thenReturn(GetTableResponse.builder().table(rawTable).build())
+ .thenReturn(GetTableResponse.builder().table(renamedTable).build());
+ when(mockIcebergCatalog.loadTable(TableIdentifier.of(DB, newName)))
+ .thenThrow(new RuntimeException("no iceberg metadata"));
+ when(mockClient.getPartitions(any(GetPartitionsRequest.class)))
+ .thenReturn(GetPartitionsResponse.builder().build());
+
+ NameIdentifier ident = NameIdentifier.of("cat", "ns", DB, TABLE);
+ GlueTable result = ops.alterTable(ident, TableChange.rename(newName));
+
+ verify(mockIcebergCatalog)
+ .renameTable(TableIdentifier.of(DB, TABLE), TableIdentifier.of(DB,
newName));
+ verify(mockClient, never()).updateTable(any(UpdateTableRequest.class));
+ assertEquals(newName, result.name());
+ }
+
@Test
void testMatchesFormatFilter_icebergFallbackViaTableType() {
// Table has table_type=ICEBERG but no table-format property (e.g. created
by external tooling)
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
index d2da93aafc..ffc9babbb7 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
@@ -563,6 +563,7 @@ class TestGlueCatalogTableOperations {
@Test
void testDropTableSuccess() {
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"t");
+
stubGetTable(Table.builder().name("t").tableType(GlueConstants.EXTERNAL_TABLE_TYPE).build());
boolean result = ops.dropTable(ident);
@@ -573,6 +574,18 @@ class TestGlueCatalogTableOperations {
@Test
void testDropTableNotFound() {
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"missing");
+ when(mockClient.getTable(any(GetTableRequest.class)))
+ .thenThrow(EntityNotFoundException.builder().message("not
found").build());
+
+ assertFalse(ops.dropTable(ident));
+ }
+
+ /** The table is dropped by another client between the type check and the
delete. */
+ @Test
+ void testDropTableVanishesBeforeDelete() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"missing");
+ stubGetTable(
+
Table.builder().name("missing").tableType(GlueConstants.EXTERNAL_TABLE_TYPE).build());
when(mockClient.deleteTable(any(DeleteTableRequest.class)))
.thenThrow(EntityNotFoundException.builder().message("not
found").build());
@@ -583,6 +596,7 @@ class TestGlueCatalogTableOperations {
void testDropTableWithCatalogId() {
ops.catalogId = "123456789012";
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"t");
+
stubGetTable(Table.builder().name("t").tableType(GlueConstants.EXTERNAL_TABLE_TYPE).build());
ArgumentCaptor<DeleteTableRequest> captor =
ArgumentCaptor.forClass(DeleteTableRequest.class);
ops.dropTable(ident);
@@ -592,4 +606,76 @@ class TestGlueCatalogTableOperations {
assertEquals("mydb", captor.getValue().databaseName());
assertEquals("t", captor.getValue().name());
}
+
+ // -------------------------------------------------------------------------
+ // views are not tables
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testListTablesExcludesViews() {
+ Namespace ns = Namespace.of("metalake", "catalog", "mydb");
+ Table table = Table.builder().name("iceberg_test").build();
+
+ when(mockClient.getTables(any(GetTablesRequest.class)))
+ .thenReturn(
+ GetTablesResponse.builder()
+ .tableList(table, viewObject("iceberg_view"))
+ .nextToken(null)
+ .build());
+
+ NameIdentifier[] result = ops.listTables(ns);
+
+ assertEquals(1, result.length);
+ assertEquals("iceberg_test", result[0].name());
+ }
+
+ @Test
+ void testLoadTableRejectsView() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"iceberg_view");
+ stubGetTable(viewObject("iceberg_view"));
+
+ NoSuchTableException e = assertThrows(NoSuchTableException.class, () ->
ops.loadTable(ident));
+ assertTrue(e.getMessage().contains("it is a view, not a table"));
+ }
+
+ @Test
+ void testAlterTableRejectsView() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"iceberg_view");
+ stubGetTable(viewObject("iceberg_view"));
+
+ assertThrows(
+ NoSuchTableException.class, () -> ops.alterTable(ident,
TableChange.updateComment("x")));
+ }
+
+ @Test
+ void testDropTableRejectsView() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"iceberg_view");
+ stubGetTable(viewObject("iceberg_view"));
+
+ assertThrows(NoSuchTableException.class, () -> ops.dropTable(ident));
+ verify(mockClient, never()).deleteTable(any(DeleteTableRequest.class));
+ }
+
+ /** Glue stores tableType as a free-form string; casing must not decide
whether it is a view. */
+ @Test
+ void testViewDetectionIsCaseInsensitive() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"v");
+ stubGetTable(Table.builder().name("v").tableType("virtual_view").build());
+
+ assertThrows(NoSuchTableException.class, () -> ops.loadTable(ident));
+ }
+
+ private void stubGetTable(Table table) {
+ when(mockClient.getTable(any(GetTableRequest.class)))
+ .thenReturn(GetTableResponse.builder().table(table).build());
+ }
+
+ private static Table viewObject(String name) {
+ return Table.builder()
+ .name(name)
+ .tableType(GlueConstants.VIRTUAL_VIEW_TABLE_TYPE)
+ .viewOriginalText("/* Presto View: abc */")
+ .parameters(Map.of("presto_view", "true"))
+ .build();
+ }
}
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
index 946502567a..55e0164b57 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
@@ -36,8 +36,10 @@ import org.apache.gravitino.Namespace;
import org.apache.gravitino.Schema;
import org.apache.gravitino.SchemaChange;
import org.apache.gravitino.catalog.glue.GlueCatalogOperations;
+import org.apache.gravitino.catalog.glue.GlueClientProvider;
import org.apache.gravitino.catalog.glue.GlueConstants;
import org.apache.gravitino.catalog.hive.HiveStorageConstants;
+import org.apache.gravitino.exceptions.NoSuchTableException;
import org.apache.gravitino.exceptions.NonEmptySchemaException;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.SupportsPartitions;
@@ -61,6 +63,11 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
+import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.model.CreateTableRequest;
+import software.amazon.awssdk.services.glue.model.GetTableRequest;
+import software.amazon.awssdk.services.glue.model.StorageDescriptor;
+import software.amazon.awssdk.services.glue.model.TableInput;
/**
* Abstract base class for Glue catalog integration tests.
@@ -73,6 +80,7 @@ abstract class AbstractGlueCatalogIT {
protected GlueCatalogOperations ops;
private String currentSchema;
+ private GlueClient rawGlueClient;
private static final Namespace SCHEMA_NS = Namespace.of("ml", "cat");
@@ -86,6 +94,9 @@ abstract class AbstractGlueCatalogIT {
@AfterAll
void closeOps() throws Exception {
+ if (rawGlueClient != null) {
+ rawGlueClient.close();
+ }
if (ops != null) {
ops.close();
}
@@ -173,6 +184,38 @@ abstract class AbstractGlueCatalogIT {
return ops.loadTable(tableIdent(schema, table)).supportPartitions();
}
+ private GlueClient glueClient() {
+ if (rawGlueClient == null) {
+ rawGlueClient = GlueClientProvider.buildClient(catalogConfig());
+ }
+ return rawGlueClient;
+ }
+
+ /** Creates a Presto/Athena style view directly through the Glue API. */
+ private void createGlueView(String schema, String name) {
+ glueClient()
+ .createTable(
+ CreateTableRequest.builder()
+ .databaseName(schema)
+ .tableInput(
+ TableInput.builder()
+ .name(name)
+ .tableType(GlueConstants.VIRTUAL_VIEW_TABLE_TYPE)
+ .viewOriginalText("/* Presto View: dGVzdA== */")
+ .viewExpandedText("/* Presto View */")
+ .parameters(Map.of("presto_view", "true"))
+ .storageDescriptor(
+ StorageDescriptor.builder()
+ .columns(
+
software.amazon.awssdk.services.glue.model.Column.builder()
+ .name("id")
+ .type("int")
+ .build())
+ .build())
+ .build())
+ .build());
+ }
+
private Partition identityPartition(String dateValue) {
return Partitions.identity(
"dt=" + dateValue,
@@ -332,6 +375,50 @@ abstract class AbstractGlueCatalogIT {
assertFalse(ops.dropTable(tableIdent(schema, "droptbl")));
}
+ // -------------------------------------------------------------------------
+ // View tests: Glue keeps views alongside tables, the table APIs must skip
them
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testListTablesExcludesViews() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createHiveTable(schema, "base_tbl");
+ createGlueView(schema, "a_view");
+
+ List<String> names =
+ Arrays.stream(ops.listTables(tableNs(schema)))
+ .map(NameIdentifier::name)
+ .collect(Collectors.toList());
+ assertTrue(names.contains("base_tbl"));
+ assertFalse(names.contains("a_view"));
+ }
+
+ @Test
+ void testLoadTableRejectsView() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createGlueView(schema, "load_view");
+
+ assertThrows(NoSuchTableException.class, () ->
ops.loadTable(tableIdent(schema, "load_view")));
+ }
+
+ @Test
+ void testDropTableRejectsView() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createGlueView(schema, "drop_view");
+
+ assertThrows(NoSuchTableException.class, () ->
ops.dropTable(tableIdent(schema, "drop_view")));
+ assertEquals(
+ GlueConstants.VIRTUAL_VIEW_TABLE_TYPE,
+ glueClient()
+
.getTable(GetTableRequest.builder().databaseName(schema).name("drop_view").build())
+ .table()
+ .tableType(),
+ "the view must survive the rejected drop");
+ }
+
// -------------------------------------------------------------------------
// Iceberg table tests
// -------------------------------------------------------------------------