This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 5a0d21ed21 [#10910] feat(catalog-glue): Add integration tests for glue
catalog (#10917)
5a0d21ed21 is described below
commit 5a0d21ed2136a4206f1892a3e142e9748eb4a714
Author: Yuhui <[email protected]>
AuthorDate: Fri May 8 15:55:35 2026 +0800
[#10910] feat(catalog-glue): Add integration tests for glue catalog (#10917)
### What changes were proposed in this pull request?
Add integration test coverage for table-level features in catalog-glue,
including Hive table distribution/sort/comment/types and Iceberg table
operations.
### Why are the changes needed?
Verify table-level features (Hive distribution, sort columns, comments,
various types, Iceberg operations) work correctly with the AWS Glue
backend.
Fix: #10910
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Unit tests and integration tests.
---
catalogs/catalog-glue/build.gradle.kts | 3 +
.../apache/gravitino/catalog/glue/GlueCatalog.java | 3 -
...GlueSchemaTest.java => GlueSchemaTestBase.java} | 2 +-
...ctGlueTableTest.java => GlueTableTestBase.java} | 2 +-
...chema.java => TestAwsGlueSchemaConversion.java} | 4 +-
...eTable.java => TestAwsGlueTableConversion.java} | 4 +-
.../glue/TestGlueCatalogSchemaOperations.java | 314 ++++++++++
...ma.java => TestGlueCatalogTableOperations.java} | 279 +--------
...tions.java => TestGluePartitionOperations.java} | 2 +-
...ueSchema.java => TestGlueSchemaConversion.java} | 4 +-
...GlueTable.java => TestGlueTableConversion.java} | 4 +-
.../integration/test/AbstractGlueCatalogIT.java | 661 +++++++++++++++++++++
.../glue/integration/test/AwsGlueCatalogIT.java | 57 ++
.../glue/integration/test/MotoGlueCatalogIT.java | 80 +++
.../test/container/GravitinoMotoContainer.java | 74 +++
15 files changed, 1215 insertions(+), 278 deletions(-)
diff --git a/catalogs/catalog-glue/build.gradle.kts
b/catalogs/catalog-glue/build.gradle.kts
index b7787263c5..b37ae580c6 100644
--- a/catalogs/catalog-glue/build.gradle.kts
+++ b/catalogs/catalog-glue/build.gradle.kts
@@ -52,6 +52,9 @@ dependencies {
testImplementation(libs.slf4j.api)
testRuntimeOnly(libs.junit.jupiter.engine)
+
+ testImplementation(libs.testcontainers)
+ testImplementation(project(":integration-test-common", "testArtifacts"))
}
tasks {
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
index f016121cfa..471bfccbc5 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
@@ -32,7 +32,6 @@ import org.apache.gravitino.connector.capability.Capability;
*/
public class GlueCatalog extends BaseCatalog<GlueCatalog> {
- // TODO PR-02: replace stubs with real implementations
static final GlueCatalogPropertiesMetadata CATALOG_PROPERTIES_METADATA =
new GlueCatalogPropertiesMetadata();
@@ -60,13 +59,11 @@ public class GlueCatalog extends BaseCatalog<GlueCatalog> {
*/
@Override
protected CatalogOperations newOps(Map<String, String> config) {
- // TODO PR-04: initialize GlueClient and wire up real operations
return new GlueCatalogOperations();
}
@Override
public Capability newCapability() {
- // TODO PR-02: implement GlueCatalogCapability
return new GlueCatalogCapability();
}
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/AbstractGlueSchemaTest.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/GlueSchemaTestBase.java
similarity index 99%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/AbstractGlueSchemaTest.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/GlueSchemaTestBase.java
index 6e29b2df6f..198a5a946f 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/AbstractGlueSchemaTest.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/GlueSchemaTestBase.java
@@ -34,7 +34,7 @@ import software.amazon.awssdk.services.glue.model.Database;
* <p>Subclasses provide a {@link Database} object however they like (SDK
builder, real AWS API,
* etc.). The test scenarios are defined once here and shared across all
implementations.
*/
-abstract class AbstractGlueSchemaTest {
+abstract class GlueSchemaTestBase {
/**
* Returns a Glue {@link Database} with the given fields. Subclasses may
create this via the SDK
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/AbstractGlueTableTest.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/GlueTableTestBase.java
similarity index 99%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/AbstractGlueTableTest.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/GlueTableTestBase.java
index 22f3d6c130..f51262f745 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/AbstractGlueTableTest.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/GlueTableTestBase.java
@@ -37,7 +37,7 @@ import software.amazon.awssdk.services.glue.model.Table;
* <p>Subclasses supply the {@link Table} object — either via SDK builder
(synthetic) or via the
* real Glue API — while the test scenarios are defined once here.
*/
-abstract class AbstractGlueTableTest {
+abstract class GlueTableTestBase {
private static final GlueTypeConverter TYPE_CONVERTER = new
GlueTypeConverter();
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueSchema.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueSchemaConversion.java
similarity index 96%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueSchema.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueSchemaConversion.java
index 4e07d6b35b..34a6040339 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueSchema.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueSchemaConversion.java
@@ -32,7 +32,7 @@ import
software.amazon.awssdk.services.glue.model.GetDatabaseRequest;
import software.amazon.awssdk.services.glue.model.GlueException;
/**
- * Runs {@link AbstractGlueSchemaTest} scenarios against a real AWS Glue
endpoint.
+ * Runs {@link GlueSchemaTestBase} scenarios against a real AWS Glue endpoint.
*
* <p>This test is <b>skipped by default</b> and only runs when {@code
AWS_ACCESS_KEY_ID} is set. To
* run it, set the following environment variables:
@@ -49,7 +49,7 @@ import
software.amazon.awssdk.services.glue.model.GlueException;
* deleted in {@link #cleanup} regardless of test outcome.
*/
@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".+")
-class TestAwsGlueSchema extends AbstractGlueSchemaTest {
+class TestAwsGlueSchemaConversion extends GlueSchemaTestBase {
private static GlueClient glueClient;
private static String catalogId;
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueTable.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueTableConversion.java
similarity index 98%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueTable.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueTableConversion.java
index 303d96786c..234f678fd3 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueTable.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueTableConversion.java
@@ -38,7 +38,7 @@ import software.amazon.awssdk.services.glue.model.Table;
import software.amazon.awssdk.services.glue.model.TableInput;
/**
- * Runs {@link AbstractGlueTableTest} scenarios against a real AWS Glue
endpoint.
+ * Runs {@link GlueTableTestBase} scenarios against a real AWS Glue endpoint.
*
* <p>This test is <b>skipped by default</b> and only runs when {@code
AWS_ACCESS_KEY_ID} is set. To
* run it, set the following environment variables:
@@ -55,7 +55,7 @@ import software.amazon.awssdk.services.glue.model.TableInput;
* deleted in {@link #cleanup} regardless of test outcome.
*/
@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".+")
-class TestAwsGlueTable extends AbstractGlueTableTest {
+class TestAwsGlueTableConversion extends GlueTableTestBase {
private static GlueClient glueClient;
private static String catalogId;
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
new file mode 100644
index 0000000000..89e0163e55
--- /dev/null
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
@@ -0,0 +1,314 @@
+/*
+ * 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.glue;
+
+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 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;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.SchemaChange;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.apache.gravitino.exceptions.NonEmptySchemaException;
+import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
+import software.amazon.awssdk.services.glue.model.CreateDatabaseRequest;
+import software.amazon.awssdk.services.glue.model.Database;
+import software.amazon.awssdk.services.glue.model.DeleteDatabaseRequest;
+import software.amazon.awssdk.services.glue.model.EntityNotFoundException;
+import software.amazon.awssdk.services.glue.model.GetDatabaseRequest;
+import software.amazon.awssdk.services.glue.model.GetDatabaseResponse;
+import software.amazon.awssdk.services.glue.model.GetDatabasesRequest;
+import software.amazon.awssdk.services.glue.model.GetDatabasesResponse;
+import software.amazon.awssdk.services.glue.model.GetTablesRequest;
+import software.amazon.awssdk.services.glue.model.GetTablesResponse;
+import software.amazon.awssdk.services.glue.model.Table;
+import software.amazon.awssdk.services.glue.model.UpdateDatabaseRequest;
+import software.amazon.awssdk.services.glue.model.UpdateDatabaseResponse;
+
+class TestGlueCatalogSchemaOperations {
+
+ private GlueCatalogOperations ops;
+ private GlueClient mockClient;
+
+ @BeforeEach
+ void setup() {
+ mockClient = mock(GlueClient.class);
+ ops = new GlueCatalogOperations();
+ ops.glueClient = mockClient;
+ // catalogId is null by default (caller's AWS account)
+ }
+
+ // -------------------------------------------------------------------------
+ // listSchemas
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testListSchemasPaginated() {
+ Namespace ns = Namespace.of("metalake", "catalog");
+ Database db1 = Database.builder().name("db1").build();
+ Database db2 = Database.builder().name("db2").build();
+ Database db3 = Database.builder().name("db3").build();
+ Database db4 = Database.builder().name("db4").build();
+
+ when(mockClient.getDatabases(any(GetDatabasesRequest.class)))
+ .thenReturn(
+ GetDatabasesResponse.builder().databaseList(db1,
db2).nextToken("token1").build())
+ .thenReturn(GetDatabasesResponse.builder().databaseList(db3,
db4).nextToken(null).build());
+
+ NameIdentifier[] result = ops.listSchemas(ns);
+
+ assertEquals(4, result.length);
+ assertEquals("db1", result[0].name());
+ assertEquals("db4", result[3].name());
+ }
+
+ @Test
+ void testListSchemasEmpty() {
+ Namespace ns = Namespace.of("metalake", "catalog");
+ when(mockClient.getDatabases(any(GetDatabasesRequest.class)))
+ .thenReturn(
+ GetDatabasesResponse.builder()
+ .databaseList(Collections.emptyList())
+ .nextToken(null)
+ .build());
+
+ NameIdentifier[] result = ops.listSchemas(ns);
+
+ assertEquals(0, result.length);
+ }
+
+ // -------------------------------------------------------------------------
+ // createSchema
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testCreateSchemaSuccess() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ Map<String, String> props = Map.of("k", "v");
+
+ GlueSchema schema = ops.createSchema(ident, "my comment", props);
+
+ verify(mockClient).createDatabase(any(CreateDatabaseRequest.class));
+ assertEquals("mydb", schema.name());
+ assertEquals("my comment", schema.comment());
+ assertEquals(props, schema.properties());
+ }
+
+ @Test
+ void testCreateSchemaAlreadyExists() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ when(mockClient.createDatabase(any(CreateDatabaseRequest.class)))
+ .thenThrow(AlreadyExistsException.builder().message("exists").build());
+
+ assertThrows(
+ SchemaAlreadyExistsException.class,
+ () -> ops.createSchema(ident, null, Collections.emptyMap()));
+ }
+
+ // -------------------------------------------------------------------------
+ // loadSchema
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testLoadSchemaSuccess() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ Database db =
+ Database.builder()
+ .name("mydb")
+ .description("desc")
+ .parameters(Map.of("k", "v"))
+ .createTime(Instant.now())
+ .build();
+ when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+ .thenReturn(GetDatabaseResponse.builder().database(db).build());
+
+ GlueSchema schema = ops.loadSchema(ident);
+
+ assertEquals("mydb", schema.name());
+ assertEquals("desc", schema.comment());
+ assertEquals(Map.of("k", "v"), schema.properties());
+ }
+
+ @Test
+ void testLoadSchemaNotFound() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "missing");
+ when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+ .thenThrow(EntityNotFoundException.builder().message("not
found").build());
+
+ assertThrows(NoSuchSchemaException.class, () -> ops.loadSchema(ident));
+ }
+
+ // -------------------------------------------------------------------------
+ // alterSchema
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testAlterSchemaSetProperty() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ Database db =
+ Database.builder()
+ .name("mydb")
+ .parameters(Map.of("existing", "val"))
+ .createTime(Instant.now())
+ .build();
+ when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+ .thenReturn(GetDatabaseResponse.builder().database(db).build());
+ when(mockClient.updateDatabase(any(UpdateDatabaseRequest.class)))
+ .thenReturn(UpdateDatabaseResponse.builder().build());
+
+ ArgumentCaptor<UpdateDatabaseRequest> captor =
+ ArgumentCaptor.forClass(UpdateDatabaseRequest.class);
+
+ GlueSchema result = ops.alterSchema(ident,
SchemaChange.setProperty("newKey", "newVal"));
+
+ verify(mockClient).updateDatabase(captor.capture());
+ Map<String, String> sentParams =
captor.getValue().databaseInput().parameters();
+ assertEquals("val", sentParams.get("existing"));
+ assertEquals("newVal", sentParams.get("newKey"));
+ assertEquals("newVal", result.properties().get("newKey"));
+ }
+
+ @Test
+ void testAlterSchemaRemoveProperty() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ Database db =
+ Database.builder()
+ .name("mydb")
+ .parameters(Map.of("toRemove", "v"))
+ .createTime(Instant.now())
+ .build();
+ when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+ .thenReturn(GetDatabaseResponse.builder().database(db).build());
+ when(mockClient.updateDatabase(any(UpdateDatabaseRequest.class)))
+ .thenReturn(UpdateDatabaseResponse.builder().build());
+
+ ArgumentCaptor<UpdateDatabaseRequest> captor =
+ ArgumentCaptor.forClass(UpdateDatabaseRequest.class);
+
+ ops.alterSchema(ident, SchemaChange.removeProperty("toRemove"));
+
+ verify(mockClient).updateDatabase(captor.capture());
+
assertFalse(captor.getValue().databaseInput().parameters().containsKey("toRemove"));
+ }
+
+ @Test
+ void testAlterSchemaUnsupportedChange() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ Database db =
+
Database.builder().name("mydb").parameters(Map.of()).createTime(Instant.now()).build();
+ when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+ .thenReturn(GetDatabaseResponse.builder().database(db).build());
+
+ SchemaChange unsupported = mock(SchemaChange.class);
+
+ assertThrows(IllegalArgumentException.class, () -> ops.alterSchema(ident,
unsupported));
+ }
+
+ @Test
+ void testAlterSchemaNotFound() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "missing");
+ when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+ .thenThrow(EntityNotFoundException.builder().message("not
found").build());
+
+ assertThrows(
+ NoSuchSchemaException.class,
+ () -> ops.alterSchema(ident, SchemaChange.setProperty("k", "v")));
+ }
+
+ // -------------------------------------------------------------------------
+ // dropSchema
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testDropSchemaCascadeFalseEmpty() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ when(mockClient.getTables(any(GetTablesRequest.class)))
+ .thenReturn(GetTablesResponse.builder().tableList(List.of()).build());
+
+ boolean dropped = ops.dropSchema(ident, false);
+
+ verify(mockClient).deleteDatabase(any(DeleteDatabaseRequest.class));
+ assertTrue(dropped);
+ }
+
+ @Test
+ void testDropSchemaCascadeFalseNonEmpty() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ Table t = Table.builder().name("t1").build();
+ when(mockClient.getTables(any(GetTablesRequest.class)))
+ .thenReturn(GetTablesResponse.builder().tableList(t).build());
+
+ assertThrows(NonEmptySchemaException.class, () -> ops.dropSchema(ident,
false));
+ verify(mockClient,
never()).deleteDatabase(any(DeleteDatabaseRequest.class));
+ }
+
+ @Test
+ void testDropSchemaCascadeTrue() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+
+ boolean dropped = ops.dropSchema(ident, true);
+
+ verify(mockClient, never()).getTables(any(GetTablesRequest.class));
+ verify(mockClient).deleteDatabase(any(DeleteDatabaseRequest.class));
+ assertTrue(dropped);
+ }
+
+ @Test
+ void testDropSchemaNotFound() {
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "missing");
+ when(mockClient.deleteDatabase(any(DeleteDatabaseRequest.class)))
+ .thenThrow(EntityNotFoundException.builder().message("not
found").build());
+
+ boolean dropped = ops.dropSchema(ident, true);
+
+ assertFalse(dropped);
+ }
+
+ @Test
+ void testDropSchemaWithCatalogId() {
+ ops.catalogId = "123456789012";
+ NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
+ when(mockClient.getTables(any(GetTablesRequest.class)))
+ .thenReturn(GetTablesResponse.builder().tableList(List.of()).build());
+
+ ArgumentCaptor<DeleteDatabaseRequest> captor =
+ ArgumentCaptor.forClass(DeleteDatabaseRequest.class);
+
+ ops.dropSchema(ident, false);
+
+ verify(mockClient).deleteDatabase(captor.capture());
+ assertEquals("123456789012", captor.getValue().catalogId());
+ }
+}
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchema.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
similarity index 58%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchema.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
index 487d120684..4e390f1144 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchema.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
@@ -24,7 +24,6 @@ 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;
@@ -32,19 +31,18 @@ import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
-import org.apache.gravitino.SchemaChange;
import org.apache.gravitino.exceptions.NoSuchSchemaException;
import org.apache.gravitino.exceptions.NoSuchTableException;
-import org.apache.gravitino.exceptions.NonEmptySchemaException;
-import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
import org.apache.gravitino.exceptions.TableAlreadyExistsException;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.TableChange;
import org.apache.gravitino.rel.expressions.distributions.Distributions;
import org.apache.gravitino.rel.expressions.sorts.SortOrders;
import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.indexes.Index;
import org.apache.gravitino.rel.indexes.Indexes;
import org.apache.gravitino.rel.types.Types;
import org.junit.jupiter.api.BeforeEach;
@@ -52,28 +50,19 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import software.amazon.awssdk.services.glue.GlueClient;
import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
-import software.amazon.awssdk.services.glue.model.CreateDatabaseRequest;
import software.amazon.awssdk.services.glue.model.CreateTableRequest;
-import software.amazon.awssdk.services.glue.model.Database;
-import software.amazon.awssdk.services.glue.model.DeleteDatabaseRequest;
import software.amazon.awssdk.services.glue.model.DeleteTableRequest;
import software.amazon.awssdk.services.glue.model.EntityNotFoundException;
-import software.amazon.awssdk.services.glue.model.GetDatabaseRequest;
-import software.amazon.awssdk.services.glue.model.GetDatabaseResponse;
-import software.amazon.awssdk.services.glue.model.GetDatabasesRequest;
-import software.amazon.awssdk.services.glue.model.GetDatabasesResponse;
import software.amazon.awssdk.services.glue.model.GetTableRequest;
import software.amazon.awssdk.services.glue.model.GetTableResponse;
import software.amazon.awssdk.services.glue.model.GetTablesRequest;
import software.amazon.awssdk.services.glue.model.GetTablesResponse;
import software.amazon.awssdk.services.glue.model.StorageDescriptor;
import software.amazon.awssdk.services.glue.model.Table;
-import software.amazon.awssdk.services.glue.model.UpdateDatabaseRequest;
-import software.amazon.awssdk.services.glue.model.UpdateDatabaseResponse;
import software.amazon.awssdk.services.glue.model.UpdateTableRequest;
import software.amazon.awssdk.services.glue.model.UpdateTableResponse;
-class TestGlueCatalogSchema {
+class TestGlueCatalogTableOperations {
private GlueCatalogOperations ops;
private GlueClient mockClient;
@@ -85,250 +74,12 @@ class TestGlueCatalogSchema {
ops.glueClient = mockClient;
}
- // -------------------------------------------------------------------------
- // listSchemas
- // -------------------------------------------------------------------------
-
- @Test
- void testListSchemas() {
- Namespace ns = Namespace.of("metalake", "catalog");
- Database db1 = Database.builder().name("db1").build();
- Database db2 = Database.builder().name("db2").build();
- Database db3 = Database.builder().name("db3").build();
- Database db4 = Database.builder().name("db4").build();
-
- when(mockClient.getDatabases(any(GetDatabasesRequest.class)))
- .thenReturn(
- GetDatabasesResponse.builder().databaseList(db1,
db2).nextToken("token1").build())
- .thenReturn(GetDatabasesResponse.builder().databaseList(db3,
db4).nextToken(null).build());
-
- NameIdentifier[] result = ops.listSchemas(ns);
-
- assertEquals(4, result.length);
- assertEquals("db1", result[0].name());
- assertEquals("db4", result[3].name());
- }
-
- @Test
- void testListSchemasReturnsEmptyArray() {
- Namespace ns = Namespace.of("metalake", "catalog");
- when(mockClient.getDatabases(any(GetDatabasesRequest.class)))
- .thenReturn(
- GetDatabasesResponse.builder()
- .databaseList(Collections.emptyList())
- .nextToken(null)
- .build());
-
- assertEquals(0, ops.listSchemas(ns).length);
- }
-
- // -------------------------------------------------------------------------
- // createSchema
- // -------------------------------------------------------------------------
-
- @Test
- void testCreateSchema() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- Map<String, String> props = Map.of("k", "v");
-
- GlueSchema schema = ops.createSchema(ident, "my comment", props);
-
- verify(mockClient).createDatabase(any(CreateDatabaseRequest.class));
- assertEquals("mydb", schema.name());
- assertEquals("my comment", schema.comment());
- assertEquals(props, schema.properties());
- }
-
- @Test
- void testCreateSchemaAlreadyExists() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- when(mockClient.createDatabase(any(CreateDatabaseRequest.class)))
- .thenThrow(AlreadyExistsException.builder().message("exists").build());
-
- assertThrows(
- SchemaAlreadyExistsException.class,
- () -> ops.createSchema(ident, null, Collections.emptyMap()));
- }
-
- // -------------------------------------------------------------------------
- // loadSchema
- // -------------------------------------------------------------------------
-
- @Test
- void testLoadSchema() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- Database db =
- Database.builder()
- .name("mydb")
- .description("desc")
- .parameters(Map.of("k", "v"))
- .createTime(Instant.now())
- .build();
- when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
- .thenReturn(GetDatabaseResponse.builder().database(db).build());
-
- GlueSchema schema = ops.loadSchema(ident);
-
- assertEquals("mydb", schema.name());
- assertEquals("desc", schema.comment());
- assertEquals(Map.of("k", "v"), schema.properties());
- }
-
- @Test
- void testLoadSchemaNotFound() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "missing");
- when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
- .thenThrow(EntityNotFoundException.builder().message("not
found").build());
-
- assertThrows(NoSuchSchemaException.class, () -> ops.loadSchema(ident));
- }
-
- // -------------------------------------------------------------------------
- // alterSchema
- // -------------------------------------------------------------------------
-
- @Test
- void testAlterSchemaSetProperty() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- Database db =
- Database.builder()
- .name("mydb")
- .parameters(Map.of("existing", "val"))
- .createTime(Instant.now())
- .build();
- when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
- .thenReturn(GetDatabaseResponse.builder().database(db).build());
- when(mockClient.updateDatabase(any(UpdateDatabaseRequest.class)))
- .thenReturn(UpdateDatabaseResponse.builder().build());
-
- ArgumentCaptor<UpdateDatabaseRequest> captor =
- ArgumentCaptor.forClass(UpdateDatabaseRequest.class);
-
- GlueSchema result = ops.alterSchema(ident,
SchemaChange.setProperty("newKey", "newVal"));
-
- verify(mockClient).updateDatabase(captor.capture());
- Map<String, String> sentParams =
captor.getValue().databaseInput().parameters();
- assertEquals("val", sentParams.get("existing"));
- assertEquals("newVal", sentParams.get("newKey"));
- assertEquals("newVal", result.properties().get("newKey"));
- }
-
- @Test
- void testAlterSchemaRemoveProperty() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- Database db =
- Database.builder()
- .name("mydb")
- .parameters(Map.of("toRemove", "v"))
- .createTime(Instant.now())
- .build();
- when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
- .thenReturn(GetDatabaseResponse.builder().database(db).build());
- when(mockClient.updateDatabase(any(UpdateDatabaseRequest.class)))
- .thenReturn(UpdateDatabaseResponse.builder().build());
-
- ArgumentCaptor<UpdateDatabaseRequest> captor =
- ArgumentCaptor.forClass(UpdateDatabaseRequest.class);
-
- ops.alterSchema(ident, SchemaChange.removeProperty("toRemove"));
-
- verify(mockClient).updateDatabase(captor.capture());
-
assertFalse(captor.getValue().databaseInput().parameters().containsKey("toRemove"));
- }
-
- @Test
- void testAlterSchemaUnsupportedChange() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- Database db =
-
Database.builder().name("mydb").parameters(Map.of()).createTime(Instant.now()).build();
- when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
- .thenReturn(GetDatabaseResponse.builder().database(db).build());
-
- SchemaChange unsupported = mock(SchemaChange.class);
-
- assertThrows(IllegalArgumentException.class, () -> ops.alterSchema(ident,
unsupported));
- }
-
- @Test
- void testAlterSchemaNotFound() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "missing");
- when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
- .thenThrow(EntityNotFoundException.builder().message("not
found").build());
-
- assertThrows(
- NoSuchSchemaException.class,
- () -> ops.alterSchema(ident, SchemaChange.setProperty("k", "v")));
- }
-
- // -------------------------------------------------------------------------
- // dropSchema
- // -------------------------------------------------------------------------
-
- @Test
- void testDropSchema() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- when(mockClient.getTables(any(GetTablesRequest.class)))
- .thenReturn(GetTablesResponse.builder().tableList(List.of()).build());
-
- boolean dropped = ops.dropSchema(ident, false);
-
- verify(mockClient).deleteDatabase(any(DeleteDatabaseRequest.class));
- assertTrue(dropped);
- }
-
- @Test
- void testDropSchemaNonEmpty() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- Table t = Table.builder().name("t1").build();
- when(mockClient.getTables(any(GetTablesRequest.class)))
- .thenReturn(GetTablesResponse.builder().tableList(t).build());
-
- assertThrows(NonEmptySchemaException.class, () -> ops.dropSchema(ident,
false));
- verify(mockClient,
never()).deleteDatabase(any(DeleteDatabaseRequest.class));
- }
-
- @Test
- void testDropSchemaCascadeTrue() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
-
- boolean dropped = ops.dropSchema(ident, true);
-
- verify(mockClient, never()).getTables(any(GetTablesRequest.class));
- verify(mockClient).deleteDatabase(any(DeleteDatabaseRequest.class));
- assertTrue(dropped);
- }
-
- @Test
- void testDropSchemaNotFound() {
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "missing");
- when(mockClient.deleteDatabase(any(DeleteDatabaseRequest.class)))
- .thenThrow(EntityNotFoundException.builder().message("not
found").build());
-
- assertFalse(ops.dropSchema(ident, true));
- }
-
- @Test
- void testDropSchemaWithCatalogId() {
- ops.catalogId = "123456789012";
- NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb");
- when(mockClient.getTables(any(GetTablesRequest.class)))
- .thenReturn(GetTablesResponse.builder().tableList(List.of()).build());
-
- ArgumentCaptor<DeleteDatabaseRequest> captor =
- ArgumentCaptor.forClass(DeleteDatabaseRequest.class);
-
- ops.dropSchema(ident, false);
-
- verify(mockClient).deleteDatabase(captor.capture());
- assertEquals("123456789012", captor.getValue().catalogId());
- }
-
// -------------------------------------------------------------------------
// listTables
// -------------------------------------------------------------------------
@Test
- void testListTables() {
+ void testListTablesPaginated() {
Namespace ns = Namespace.of("metalake", "catalog", "mydb");
Table t1 = Table.builder().name("t1").build();
Table t2 = Table.builder().name("t2").build();
@@ -355,8 +106,8 @@ class TestGlueCatalogSchema {
}
@Test
- void testListTablesWithFormatFilter() {
- ops.tableFormatFilter = java.util.Set.of("iceberg");
+ void testListTablesFormatFilter() {
+ ops.tableFormatFilter = Set.of("iceberg");
Namespace ns = Namespace.of("metalake", "catalog", "mydb");
Table icebergTable =
@@ -381,7 +132,7 @@ class TestGlueCatalogSchema {
// -------------------------------------------------------------------------
@Test
- void testLoadTable() {
+ void testLoadTableSuccess() {
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"mytable");
Table glueTable =
Table.builder()
@@ -422,7 +173,7 @@ class TestGlueCatalogSchema {
// -------------------------------------------------------------------------
@Test
- void testCreateTable() {
+ void testCreateTableSuccess() {
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"mytable");
Column[] columns = {
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(true).build(),
@@ -471,7 +222,7 @@ class TestGlueCatalogSchema {
}
@Test
- void testCreateTableRejectsIndexes() {
+ void testCreateTableIndexesRejected() {
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"mytable");
assertThrows(
@@ -485,9 +236,7 @@ class TestGlueCatalogSchema {
Transforms.EMPTY_TRANSFORM,
Distributions.NONE,
SortOrders.NONE,
- new org.apache.gravitino.rel.indexes.Index[] {
- mock(org.apache.gravitino.rel.indexes.Index.class)
- }));
+ new Index[] {mock(Index.class)}));
}
@Test
@@ -526,7 +275,7 @@ class TestGlueCatalogSchema {
// -------------------------------------------------------------------------
@Test
- void testAlterTable() {
+ void testAlterTableRenameAndComment() {
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"old");
Table glueTable =
Table.builder()
@@ -616,11 +365,13 @@ class TestGlueCatalogSchema {
// -------------------------------------------------------------------------
@Test
- void testDropTable() {
+ void testDropTableSuccess() {
NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb",
"t");
- assertTrue(ops.dropTable(ident));
+ boolean result = ops.dropTable(ident);
+
verify(mockClient).deleteTable(any(DeleteTableRequest.class));
+ assertTrue(result);
}
@Test
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueTableOperations.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGluePartitionOperations.java
similarity index 99%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueTableOperations.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGluePartitionOperations.java
index 14e294eef5..0099b39512 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueTableOperations.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGluePartitionOperations.java
@@ -47,7 +47,7 @@ import
software.amazon.awssdk.services.glue.model.GetPartitionResponse;
import software.amazon.awssdk.services.glue.model.GetPartitionsRequest;
import software.amazon.awssdk.services.glue.model.GetPartitionsResponse;
-class TestGlueTableOperations {
+class TestGluePartitionOperations {
private GlueClient mockClient;
private GlueTableOperations ops;
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestSyntheticGlueSchema.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueSchemaConversion.java
similarity index 90%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestSyntheticGlueSchema.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueSchemaConversion.java
index a1c0643d8c..be790425bc 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestSyntheticGlueSchema.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueSchemaConversion.java
@@ -23,13 +23,13 @@ import java.util.Map;
import software.amazon.awssdk.services.glue.model.Database;
/**
- * Runs {@link AbstractGlueSchemaTest} scenarios using AWS SDK builders to
create {@link Database}
+ * Runs {@link GlueSchemaTestBase} scenarios using AWS SDK builders to create
{@link Database}
* objects directly — no network or AWS credentials required.
*
* <p>This verifies that the {@link GlueSchema#fromGlueDatabase} conversion
logic works correctly
* for typical Glue API response shapes.
*/
-class TestSyntheticGlueSchema extends AbstractGlueSchemaTest {
+class TestGlueSchemaConversion extends GlueSchemaTestBase {
@Override
protected Database provideDatabase(String name, String description,
Map<String, String> params) {
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestSyntheticGlueTable.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueTableConversion.java
similarity index 95%
rename from
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestSyntheticGlueTable.java
rename to
catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueTableConversion.java
index d32eb80025..085b7f7d9a 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestSyntheticGlueTable.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueTableConversion.java
@@ -27,10 +27,10 @@ import
software.amazon.awssdk.services.glue.model.StorageDescriptor;
import software.amazon.awssdk.services.glue.model.Table;
/**
- * Runs {@link AbstractGlueTableTest} scenarios using AWS SDK builders — no
network or credentials
+ * Runs {@link GlueTableTestBase} scenarios using AWS SDK builders — no
network or credentials
* required.
*/
-class TestSyntheticGlueTable extends AbstractGlueTableTest {
+class TestGlueTableConversion extends GlueTableTestBase {
private static final String INPUT_FMT =
"org.apache.hadoop.mapred.TextInputFormat";
private static final String OUTPUT_FMT =
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
new file mode 100644
index 0000000000..93e25d9b0d
--- /dev/null
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
@@ -0,0 +1,661 @@
+/*
+ * 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.glue.integration.test;
+
+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.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.gravitino.NameIdentifier;
+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.GlueConstants;
+import org.apache.gravitino.exceptions.NonEmptySchemaException;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.SupportsPartitions;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.expressions.NamedReference;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.literals.Literal;
+import org.apache.gravitino.rel.expressions.literals.Literals;
+import org.apache.gravitino.rel.expressions.sorts.SortDirection;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.sorts.SortOrders;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.indexes.Index;
+import org.apache.gravitino.rel.partitions.Partition;
+import org.apache.gravitino.rel.partitions.Partitions;
+import org.apache.gravitino.rel.types.Types;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+/**
+ * Abstract base class for Glue catalog integration tests.
+ *
+ * <p>Subclasses provide backend-specific configuration via {@link
#catalogConfig()}. All test
+ * scenarios are defined here and shared between backend implementations.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+abstract class AbstractGlueCatalogIT {
+
+ protected GlueCatalogOperations ops;
+ private String currentSchema;
+
+ private static final Namespace SCHEMA_NS = Namespace.of("ml", "cat");
+ private static final String INPUT_FMT =
"org.apache.hadoop.mapred.TextInputFormat";
+ private static final String OUTPUT_FMT =
+ "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat";
+ private static final String SERDE =
"org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe";
+
+ protected abstract Map<String, String> catalogConfig();
+
+ @BeforeAll
+ void initOps() {
+ ops = new GlueCatalogOperations();
+ ops.initialize(catalogConfig(), null, null);
+ }
+
+ @AfterAll
+ void closeOps() throws Exception {
+ if (ops != null) {
+ ops.close();
+ }
+ }
+
+ @AfterEach
+ void cleanupSchema() {
+ if (currentSchema != null) {
+ try {
+ ops.dropSchema(schemaIdent(currentSchema), true);
+ } catch (Exception ignored) {
+ }
+ currentSchema = null;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private String newSchema() {
+ String name = "glue_it_" + System.nanoTime();
+ currentSchema = name;
+ return name;
+ }
+
+ private NameIdentifier schemaIdent(String name) {
+ return NameIdentifier.of("ml", "cat", name);
+ }
+
+ private Namespace tableNs(String schema) {
+ return Namespace.of("ml", "cat", schema);
+ }
+
+ private NameIdentifier tableIdent(String schema, String table) {
+ return NameIdentifier.of("ml", "cat", schema, table);
+ }
+
+ private Column[] hiveColumns() {
+ return new Column[] {
+ Column.of("id", Types.LongType.get(), "primary key", false, false, null),
+ Column.of("name", Types.StringType.get(), null),
+ };
+ }
+
+ private Map<String, String> hiveTableProps() {
+ Map<String, String> props = new HashMap<>();
+ props.put(GlueConstants.TABLE_TYPE, "EXTERNAL_TABLE");
+ props.put(GlueConstants.INPUT_FORMAT, INPUT_FMT);
+ props.put(GlueConstants.OUTPUT_FORMAT, OUTPUT_FMT);
+ props.put(GlueConstants.SERDE_LIB, SERDE);
+ return props;
+ }
+
+ private Table createHiveTable(String schema, String table) {
+ return ops.createTable(
+ tableIdent(schema, table),
+ hiveColumns(),
+ null,
+ hiveTableProps(),
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+ }
+
+ private Column[] partitionedColumns() {
+ return new Column[] {
+ Column.of("id", Types.LongType.get(), null, false, false, null),
+ Column.of("dt", Types.DateType.get(), null),
+ };
+ }
+
+ private SupportsPartitions createPartitionedTable(String schema, String
table) {
+ Map<String, String> props = hiveTableProps();
+ props.put(GlueConstants.LOCATION, "s3://gravitino-test-bucket/" + schema +
"/" + table);
+ ops.createTable(
+ tableIdent(schema, table),
+ partitionedColumns(),
+ null,
+ props,
+ new Transform[] {Transforms.identity("dt")},
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+ return ops.loadTable(tableIdent(schema, table)).supportPartitions();
+ }
+
+ private Partition identityPartition(String dateValue) {
+ return Partitions.identity(
+ "dt=" + dateValue,
+ new String[][] {{"dt"}},
+ new Literal[] {Literals.stringLiteral(dateValue)},
+ Collections.emptyMap());
+ }
+
+ // -------------------------------------------------------------------------
+ // Schema tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testSchemaProperties() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), "test comment", Map.of("k1", "v1"));
+
+ Schema loaded = ops.loadSchema(schemaIdent(schema));
+ assertEquals(schema, loaded.name());
+ assertEquals("test comment", loaded.comment());
+ assertTrue(loaded.properties().containsKey("k1"));
+ assertEquals("v1", loaded.properties().get("k1"));
+ }
+
+ @Test
+ void testListSchemas() {
+ String schema1 = newSchema();
+ String schema2 = "glue_it_" + System.nanoTime() + "_b";
+ ops.createSchema(schemaIdent(schema1), null, Collections.emptyMap());
+ ops.createSchema(schemaIdent(schema2), null, Collections.emptyMap());
+ try {
+ List<String> names =
+ Arrays.stream(ops.listSchemas(SCHEMA_NS))
+ .map(NameIdentifier::name)
+ .collect(Collectors.toList());
+ assertTrue(names.contains(schema1));
+ assertTrue(names.contains(schema2));
+ } finally {
+ try {
+ ops.dropSchema(schemaIdent(schema2), false);
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ @Test
+ void testAlterSchema() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ ops.alterSchema(schemaIdent(schema), SchemaChange.setProperty("newKey",
"newVal"));
+ Schema loaded = ops.loadSchema(schemaIdent(schema));
+ assertEquals("newVal", loaded.properties().get("newKey"));
+
+ ops.alterSchema(schemaIdent(schema),
SchemaChange.removeProperty("newKey"));
+ Schema reloaded = ops.loadSchema(schemaIdent(schema));
+ assertFalse(reloaded.properties().containsKey("newKey"));
+ }
+
+ @Test
+ void testDropSchemaEmpty() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ boolean dropped = ops.dropSchema(schemaIdent(schema), false);
+ assertTrue(dropped);
+ currentSchema = null;
+ }
+
+ @Test
+ void testDropSchemaNonEmpty() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createHiveTable(schema, "tbl");
+
+ try {
+ assertThrows(NonEmptySchemaException.class, () ->
ops.dropSchema(schemaIdent(schema), false));
+
+ assertTrue(ops.dropTable(tableIdent(schema, "tbl")));
+ assertTrue(ops.dropSchema(schemaIdent(schema), false));
+ currentSchema = null;
+ } finally {
+ if (currentSchema != null) {
+ try {
+ ops.dropTable(tableIdent(schema, "tbl"));
+ } catch (Exception ignored) {
+ }
+ try {
+ ops.dropSchema(schemaIdent(schema), false);
+ } catch (Exception ignored) {
+ }
+ currentSchema = null;
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Hive table tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testCreateHiveTable() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createHiveTable(schema, "hive_tbl");
+
+ Table loaded = ops.loadTable(tableIdent(schema, "hive_tbl"));
+ assertEquals("hive_tbl", loaded.name());
+ assertEquals(2, loaded.columns().length);
+ assertEquals("id", loaded.columns()[0].name());
+ assertEquals(Types.LongType.get(), loaded.columns()[0].dataType());
+ assertEquals("EXTERNAL_TABLE",
loaded.properties().get(GlueConstants.TABLE_TYPE));
+ assertNotNull(loaded.properties().get(GlueConstants.INPUT_FORMAT));
+ assertNotNull(loaded.properties().get(GlueConstants.OUTPUT_FORMAT));
+ assertNotNull(loaded.properties().get(GlueConstants.SERDE_LIB));
+ }
+
+ @Test
+ void testListTables() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createHiveTable(schema, "tbl1");
+ createHiveTable(schema, "tbl2");
+
+ List<String> names =
+ Arrays.stream(ops.listTables(tableNs(schema)))
+ .map(NameIdentifier::name)
+ .collect(Collectors.toList());
+ assertTrue(names.contains("tbl1"));
+ assertTrue(names.contains("tbl2"));
+ }
+
+ @Test
+ void testAlterTable() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createHiveTable(schema, "altertbl");
+
+ ops.alterTable(
+ tableIdent(schema, "altertbl"),
+ TableChange.addColumn(new String[] {"email"}, Types.StringType.get()));
+ Table altered = ops.loadTable(tableIdent(schema, "altertbl"));
+ assertEquals(3, altered.columns().length);
+ assertEquals("email", altered.columns()[2].name());
+ }
+
+ @Test
+ void testDropTable() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ createHiveTable(schema, "droptbl");
+
+ assertTrue(ops.dropTable(tableIdent(schema, "droptbl")));
+ assertFalse(ops.dropTable(tableIdent(schema, "droptbl")));
+ }
+
+ // -------------------------------------------------------------------------
+ // Iceberg table tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testCreateIcebergTable() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ Map<String, String> props = new HashMap<>();
+ props.put(GlueConstants.TABLE_FORMAT, "ICEBERG");
+ props.put(GlueConstants.METADATA_LOCATION,
"s3://bucket/path/metadata/v1.metadata.json");
+ ops.createTable(
+ tableIdent(schema, "iceberg_tbl"),
+ new Column[0],
+ null,
+ props,
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ Table loaded = ops.loadTable(tableIdent(schema, "iceberg_tbl"));
+ assertEquals("ICEBERG",
loaded.properties().get(GlueConstants.TABLE_FORMAT));
+ assertEquals(
+ "s3://bucket/path/metadata/v1.metadata.json",
+ loaded.properties().get(GlueConstants.METADATA_LOCATION));
+ assertEquals(0, loaded.columns().length);
+ }
+
+ // -------------------------------------------------------------------------
+ // Partition tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testAddPartition() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ SupportsPartitions sp = createPartitionedTable(schema, "ptbl");
+
+ sp.addPartition(identityPartition("2024-01-01"));
+
+ List<String> names = Arrays.asList(sp.listPartitionNames());
+ assertTrue(names.contains("dt=2024-01-01"));
+ }
+
+ @Test
+ void testListPartitions() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ SupportsPartitions sp = createPartitionedTable(schema, "ptbl");
+
+ sp.addPartition(identityPartition("2024-02-01"));
+
+ Partition[] parts = sp.listPartitions();
+ assertEquals(1, parts.length);
+ assertEquals("dt=2024-02-01", parts[0].name());
+ }
+
+ @Test
+ void testGetPartition() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ SupportsPartitions sp = createPartitionedTable(schema, "ptbl");
+
+ sp.addPartition(identityPartition("2024-03-01"));
+
+ Partition p = sp.getPartition("dt=2024-03-01");
+ assertNotNull(p);
+ assertEquals("dt=2024-03-01", p.name());
+ }
+
+ @Test
+ void testDropPartition() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+ SupportsPartitions sp = createPartitionedTable(schema, "ptbl");
+
+ sp.addPartition(identityPartition("2024-04-01"));
+
+ assertTrue(sp.dropPartition("dt=2024-04-01"));
+ assertFalse(sp.dropPartition("dt=2024-04-01"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Hive table feature tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testHashDistribution() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ ops.createTable(
+ tableIdent(schema, "bucketed"),
+ hiveColumns(),
+ null,
+ hiveTableProps(),
+ new Transform[0],
+ Distributions.hash(4, NamedReference.field("id")),
+ new SortOrder[0],
+ new Index[0]);
+
+ Table loaded = ops.loadTable(tableIdent(schema, "bucketed"));
+ assertEquals(4, loaded.distribution().number());
+ assertEquals(1, loaded.distribution().expressions().length);
+ assertEquals(NamedReference.field("id"),
loaded.distribution().expressions()[0]);
+ }
+
+ @Test
+ void testSortOrders() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ ops.createTable(
+ tableIdent(schema, "sorted"),
+ hiveColumns(),
+ null,
+ hiveTableProps(),
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[] {
+ SortOrders.ascending(NamedReference.field("id")),
+ SortOrders.descending(NamedReference.field("name"))
+ },
+ new Index[0]);
+
+ Table loaded = ops.loadTable(tableIdent(schema, "sorted"));
+ assertEquals(2, loaded.sortOrder().length);
+ assertEquals(SortDirection.ASCENDING, loaded.sortOrder()[0].direction());
+ assertEquals(NamedReference.field("id"),
loaded.sortOrder()[0].expression());
+ assertEquals(SortDirection.DESCENDING, loaded.sortOrder()[1].direction());
+ assertEquals(NamedReference.field("name"),
loaded.sortOrder()[1].expression());
+ }
+
+ @Test
+ void testTableComment() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ ops.createTable(
+ tableIdent(schema, "commented"),
+ hiveColumns(),
+ "my table comment",
+ hiveTableProps(),
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ Table loaded = ops.loadTable(tableIdent(schema, "commented"));
+ assertEquals("my table comment", loaded.comment());
+ }
+
+ @Test
+ void testUnsupportedTransformRejected() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ ops.createTable(
+ tableIdent(schema, "bad"),
+ hiveColumns(),
+ null,
+ hiveTableProps(),
+ new Transform[] {Transforms.year("dt")},
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]));
+ }
+
+ @Test
+ void testDecimalAndVarcharColumns() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ Column[] cols =
+ new Column[] {
+ Column.of("price", Types.DecimalType.of(10, 2), null),
+ Column.of("label", Types.VarCharType.of(255), null),
+ };
+ ops.createTable(
+ tableIdent(schema, "typed"),
+ cols,
+ null,
+ hiveTableProps(),
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ Table loaded = ops.loadTable(tableIdent(schema, "typed"));
+ assertEquals(Types.DecimalType.of(10, 2), loaded.columns()[0].dataType());
+ assertEquals(Types.VarCharType.of(255), loaded.columns()[1].dataType());
+ }
+
+ @Test
+ void testComplexColumnTypes() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ Column[] cols =
+ new Column[] {
+ Column.of("tags", Types.ExternalType.of("array<string>"), null),
+ Column.of("info",
Types.ExternalType.of("struct<name:string,age:int>"), null),
+ };
+ ops.createTable(
+ tableIdent(schema, "complex"),
+ cols,
+ null,
+ hiveTableProps(),
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ Table loaded = ops.loadTable(tableIdent(schema, "complex"));
+ // GlueTypeConverter.toGravitino converts known complex types to native
types.
+ assertEquals(Types.ListType.class,
loaded.columns()[0].dataType().getClass());
+ assertEquals(Types.StructType.class,
loaded.columns()[1].dataType().getClass());
+ }
+
+ @Test
+ void testSerDeParameters() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ Map<String, String> props = hiveTableProps();
+ props.put("serde.parameter.serialization.format", "1");
+ ops.createTable(
+ tableIdent(schema, "serde_tbl"),
+ hiveColumns(),
+ null,
+ props,
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ Table loaded = ops.loadTable(tableIdent(schema, "serde_tbl"));
+ assertEquals("1",
loaded.properties().get("serde.parameter.serialization.format"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Iceberg table tests (additional)
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testDropIcebergTable() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ Map<String, String> props = new HashMap<>();
+ props.put(GlueConstants.TABLE_FORMAT, "ICEBERG");
+ props.put(GlueConstants.METADATA_LOCATION,
"s3://bucket/path/metadata/v1.metadata.json");
+ ops.createTable(
+ tableIdent(schema, "iceberg_drop"),
+ new Column[0],
+ null,
+ props,
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ assertTrue(ops.dropTable(tableIdent(schema, "iceberg_drop")));
+ assertFalse(ops.dropTable(tableIdent(schema, "iceberg_drop")));
+ }
+
+ @Test
+ void testAlterIcebergMetadata() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ Map<String, String> props = new HashMap<>();
+ props.put(GlueConstants.TABLE_FORMAT, "ICEBERG");
+ props.put(GlueConstants.METADATA_LOCATION,
"s3://bucket/path/metadata/v1.metadata.json");
+ ops.createTable(
+ tableIdent(schema, "iceberg_alter"),
+ new Column[0],
+ null,
+ props,
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ ops.alterTable(
+ tableIdent(schema, "iceberg_alter"),
+ TableChange.setProperty(
+ GlueConstants.METADATA_LOCATION,
"s3://bucket/path/metadata/v2.metadata.json"));
+
+ Table loaded = ops.loadTable(tableIdent(schema, "iceberg_alter"));
+ assertEquals(
+ "s3://bucket/path/metadata/v2.metadata.json",
+ loaded.properties().get(GlueConstants.METADATA_LOCATION));
+ }
+
+ @Test
+ void testListTablesIncludesIceberg() {
+ String schema = newSchema();
+ ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+
+ createHiveTable(schema, "hive_mixed");
+
+ Map<String, String> icebergProps = new HashMap<>();
+ icebergProps.put(GlueConstants.TABLE_FORMAT, "ICEBERG");
+ icebergProps.put(GlueConstants.METADATA_LOCATION,
"s3://bucket/path/metadata/v1.metadata.json");
+ ops.createTable(
+ tableIdent(schema, "iceberg_mixed"),
+ new Column[0],
+ null,
+ icebergProps,
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ new Index[0]);
+
+ List<String> names =
+ Arrays.stream(ops.listTables(tableNs(schema)))
+ .map(NameIdentifier::name)
+ .collect(Collectors.toList());
+ assertTrue(names.contains("hive_mixed"));
+ assertTrue(names.contains("iceberg_mixed"));
+ }
+}
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AwsGlueCatalogIT.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AwsGlueCatalogIT.java
new file mode 100644
index 0000000000..7f8e1afa53
--- /dev/null
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AwsGlueCatalogIT.java
@@ -0,0 +1,57 @@
+/*
+ * 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.glue.integration.test;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.catalog.glue.GlueConstants;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+/**
+ * Runs {@link AbstractGlueCatalogIT} scenarios against a real AWS Glue
endpoint.
+ *
+ * <p>This test is <b>skipped by default</b> and only runs when {@code
AWS_ACCESS_KEY_ID} is set.
+ * Required environment variables:
+ *
+ * <ul>
+ * <li>{@code AWS_ACCESS_KEY_ID}
+ * <li>{@code AWS_SECRET_ACCESS_KEY}
+ * <li>{@code AWS_DEFAULT_REGION} (e.g. {@code us-east-1})
+ * <li>{@code GLUE_CATALOG_ID} (12-digit AWS account ID; optional)
+ * </ul>
+ */
+@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".+")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class AwsGlueCatalogIT extends AbstractGlueCatalogIT {
+
+ @Override
+ protected Map<String, String> catalogConfig() {
+ Map<String, String> config = new HashMap<>();
+ config.put(
+ GlueConstants.AWS_REGION,
System.getenv().getOrDefault("AWS_DEFAULT_REGION", "us-east-1"));
+ config.put(GlueConstants.AWS_ACCESS_KEY_ID,
System.getenv("AWS_ACCESS_KEY_ID"));
+ config.put(GlueConstants.AWS_SECRET_ACCESS_KEY,
System.getenv("AWS_SECRET_ACCESS_KEY"));
+ String catalogId = System.getenv("GLUE_CATALOG_ID");
+ if (catalogId != null) {
+ config.put(GlueConstants.AWS_GLUE_CATALOG_ID, catalogId);
+ }
+ return config;
+ }
+}
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/MotoGlueCatalogIT.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/MotoGlueCatalogIT.java
new file mode 100644
index 0000000000..979affbcb2
--- /dev/null
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/MotoGlueCatalogIT.java
@@ -0,0 +1,80 @@
+/*
+ * 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.glue.integration.test;
+
+import java.util.Map;
+import org.apache.gravitino.catalog.glue.GlueConstants;
+import org.apache.gravitino.integration.test.container.GravitinoMotoContainer;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.TestInstance;
+
+/**
+ * Runs {@link AbstractGlueCatalogIT} scenarios against a Moto server (free
AWS mock).
+ *
+ * <p>Requires Docker. Skipped by default when the {@code
gravitino-docker-test} tag is excluded.
+ * Override the container image with the {@code
GRAVITINO_CI_MOTO_DOCKER_IMAGE} environment
+ * variable.
+ */
+@Tag("gravitino-docker-test")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class MotoGlueCatalogIT extends AbstractGlueCatalogIT {
+
+ private static final String FALLBACK_IMAGE = "motoserver/moto:5.1.4";
+
+ private GravitinoMotoContainer moto;
+
+ @Override
+ @BeforeAll
+ void initOps() {
+ String image = GravitinoMotoContainer.DEFAULT_IMAGE;
+ if (image == null || image.isBlank()) {
+ image = FALLBACK_IMAGE;
+ }
+ moto = GravitinoMotoContainer.builder().withImage(image).build();
+ moto.start();
+ super.initOps();
+ }
+
+ @AfterAll
+ void stopContainer() {
+ if (moto != null) {
+ moto.close();
+ }
+ }
+
+ @Override
+ protected Map<String, String> catalogConfig() {
+ String endpoint =
+ "http://"
+ + moto.getContainer().getHost()
+ + ":"
+ + moto.getMappedPort(GravitinoMotoContainer.PORT);
+ return Map.of(
+ GlueConstants.AWS_REGION,
+ "us-east-1",
+ GlueConstants.AWS_ACCESS_KEY_ID,
+ "test",
+ GlueConstants.AWS_SECRET_ACCESS_KEY,
+ "test",
+ GlueConstants.AWS_GLUE_ENDPOINT,
+ endpoint);
+ }
+}
diff --git
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/GravitinoMotoContainer.java
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/GravitinoMotoContainer.java
new file mode 100644
index 0000000000..efaf29a524
--- /dev/null
+++
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/container/GravitinoMotoContainer.java
@@ -0,0 +1,74 @@
+/*
+ * 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.integration.test.container;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.testcontainers.containers.Network;
+
+/** Container wrapping {@code motoserver/moto} — a free AWS mock that supports
Glue and more. */
+public class GravitinoMotoContainer extends BaseContainer {
+
+ public static final String DEFAULT_IMAGE =
System.getenv("GRAVITINO_CI_MOTO_DOCKER_IMAGE");
+ public static final String HOST_NAME = "gravitino-ci-moto";
+ public static final int PORT = 5000;
+
+ public GravitinoMotoContainer(
+ String image,
+ String hostName,
+ Set<Integer> ports,
+ Map<String, String> extraHosts,
+ Map<String, String> filesToMount,
+ Map<String, String> envVars,
+ Optional<Network> network) {
+ super(image, hostName, ports, extraHosts, filesToMount, envVars, network);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ protected boolean checkContainerStatus(int retryLimit) {
+ return true;
+ }
+
+ public static class Builder
+ extends BaseContainer.Builder<GravitinoMotoContainer.Builder,
GravitinoMotoContainer> {
+ public Builder() {
+ super();
+ this.image = DEFAULT_IMAGE;
+ this.hostName = HOST_NAME;
+ this.exposePorts = ImmutableSet.of(PORT);
+ }
+
+ @Override
+ public GravitinoMotoContainer build() {
+ if (image == null || image.isBlank()) {
+ throw new IllegalStateException(
+ "GravitinoMotoContainer image must be set. "
+ + "Provide a non-blank image via withImage() or set the
GRAVITINO_CI_MOTO_DOCKER_IMAGE environment variable.");
+ }
+ return new GravitinoMotoContainer(
+ image, hostName, exposePorts, extraHosts, filesToMount, envVars,
network);
+ }
+ }
+}