This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 5b0c5ef1b5 [Cherry-pick to branch-1.3] [#11185] feat(spark-connector):
Add Glue catalog support for Spark connector (#11186) (#11428)
5b0c5ef1b5 is described below
commit 5b0c5ef1b5aeed6afa1f54b407a86dec709746af
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jun 5 11:29:36 2026 +0800
[Cherry-pick to branch-1.3] [#11185] feat(spark-connector): Add Glue
catalog support for Spark connector (#11186) (#11428)
**Cherry-pick Information:**
- Original commit: c49183662b7f3770ea84df336a481b162bbea6b1
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Yuhui <[email protected]>
---
.../gravitino/catalog/glue/GlueConstants.java | 3 +
.../catalog/glue/TestAwsGlueCatalogOperations.java | 4 +-
gradle/libs.versions.toml | 1 +
.../gravitino/integration/test/util/BaseIT.java | 14 +-
spark-connector/spark-common/build.gradle.kts | 49 ++
.../spark/connector/catalog/BaseCatalog.java | 65 ++-
.../connector/glue/GluePropertiesConverter.java | 223 ++++++++
.../spark/connector/glue/GravitinoGlueCatalog.java | 234 +++++++++
.../glue/GravitinoGlueCredentialsProvider.java | 74 +++
.../connector/hive/HivePropertiesConverter.java | 21 +-
.../connector/version/CatalogNameAdaptor.java | 58 ++-
.../glue/TestGluePropertiesConverter.java | 274 ++++++++++
.../connector/glue/TestGravitinoGlueCatalog.java | 217 ++++++++
.../glue/TestGravitinoGlueCredentialsProvider.java | 51 ++
.../connector/integration/test/SparkCommonIT.java | 13 +-
.../connector/integration/test/SparkEnvIT.java | 4 +-
.../integration/test/glue/SparkGlueCatalogIT.java | 573 +++++++++++++++++++++
.../integration/test/glue/SparkGlueEnvIT.java | 363 +++++++++++++
.../integration/test/util/SparkUtilIT.java | 56 +-
spark-connector/v3.3/spark/build.gradle.kts | 19 +
.../glue/GravitinoGlueCatalogSpark33.java | 22 +
.../test/glue/SparkAwsGlueCatalogIT33.java | 37 ++
spark-connector/v3.4/spark/build.gradle.kts | 19 +
.../glue/GravitinoGlueCatalogSpark34.java | 38 ++
.../test/glue/SparkAwsGlueCatalogIT34.java | 37 ++
spark-connector/v3.5/spark/build.gradle.kts | 19 +
.../glue/GravitinoGlueCatalogSpark35.java | 34 ++
.../test/glue/SparkAwsGlueCatalogIT35.java | 37 ++
28 files changed, 2482 insertions(+), 77 deletions(-)
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
similarity index 97%
rename from
catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
rename to
catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
index 24cc744a31..76de4483d5 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
+++
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
@@ -82,6 +82,9 @@ public final class GlueConstants {
*/
public static final String TABLE_FORMAT = "table-format";
+ /** Table-format value indicating an Iceberg table stored in Glue {@code
Table.parameters()}. */
+ public static final String TABLE_FORMAT_ICEBERG = "ICEBERG";
+
/**
* Table file format (lowercase, e.g. "parquet", "orc", "textfile") used
when creating Hive-format
* tables via Trino. Maps to input-format/output-format/serde-lib.
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueCatalogOperations.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueCatalogOperations.java
index aac40a92ac..2169eb418e 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueCatalogOperations.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestAwsGlueCatalogOperations.java
@@ -110,7 +110,7 @@ class TestAwsGlueCatalogOperations {
}
@Test
- void testRenameTableIsUnsupported() {
+ void testRenameIsUnsupported() {
String tableName = "rename_test_" + System.currentTimeMillis();
NameIdentifier ident = NameIdentifier.of(NS.level(0), NS.level(1),
TEST_SCHEMA, tableName);
@@ -119,7 +119,7 @@ class TestAwsGlueCatalogOperations {
ident,
new Column[] {col},
"test table",
- Map.of(GlueConstants.FORMAT, "parquet"),
+ Map.of(),
Transforms.EMPTY_TRANSFORM,
Distributions.NONE,
SortOrders.NONE,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7d3f7a00ae..a28060485e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -162,6 +162,7 @@ ql-expression = "4.0.3"
[libraries]
aspectj-aspectjrt = { group = "org.aspectj", name = "aspectjrt", version.ref =
"aspectj" }
+aws-dynamodb = { group = "software.amazon.awssdk", name = "dynamodb",
version.ref = "awssdk" }
aws-glue = { group = "software.amazon.awssdk", name = "glue", version.ref =
"awssdk" }
aws-iam = { group = "software.amazon.awssdk", name = "iam", version.ref =
"awssdk" }
aws-policy = { group = "software.amazon.awssdk", name = "iam-policy-builder",
version.ref = "awssdk" }
diff --git
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
index 5f328fb023..8c9cbfa510 100644
---
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
+++
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/BaseIT.java
@@ -332,8 +332,13 @@ public class BaseIT {
}
}
- @BeforeAll
- public void startIntegrationTest() throws Exception {
+ /**
+ * Starts the Gravitino server (embedded or external) and initialises the
admin client. Extracted
+ * from {@link #startIntegrationTest()} so that subclasses can call it
directly without triggering
+ * virtual-dispatch through overriding {@code @BeforeAll} overrides (e.g.
{@code
+ * SparkEnvIT.startIntegrationTest()} which is intentionally empty).
+ */
+ protected void startServer() throws Exception {
testMode =
System.getProperty(ITUtils.TEST_MODE) == null
? ITUtils.EMBEDDED_TEST_MODE
@@ -464,6 +469,11 @@ public class BaseIT {
}
}
+ @BeforeAll
+ public void startIntegrationTest() throws Exception {
+ startServer();
+ }
+
@AfterAll
public void stopIntegrationTest() throws IOException, InterruptedException {
if (testMode != null && testMode.equals(ITUtils.EMBEDDED_TEST_MODE) &&
miniGravitino != null) {
diff --git a/spark-connector/spark-common/build.gradle.kts
b/spark-connector/spark-common/build.gradle.kts
index afd0324df8..7211d6e3bb 100644
--- a/spark-connector/spark-common/build.gradle.kts
+++ b/spark-connector/spark-common/build.gradle.kts
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+import java.net.URI
+
plugins {
`maven-publish`
id("java")
@@ -51,6 +53,7 @@ dependencies {
compileOnly(project(":clients:client-java-runtime", configuration =
"shadow"))
compileOnly("org.apache.iceberg:iceberg-spark-runtime-${sparkMajorVersion}_$scalaVersion:$icebergVersion")
+ compileOnly(libs.aws.glue)
compileOnly("org.apache.kyuubi:kyuubi-spark-connector-hive_$scalaVersion:$kyuubiVersion")
compileOnly("org.apache.spark:spark-catalyst_$scalaVersion:$sparkVersion")
compileOnly("org.apache.spark:spark-core_$scalaVersion:$sparkVersion")
@@ -87,6 +90,12 @@ dependencies {
testImplementation(project(":server-common")) {
exclude("org.apache.logging.log4j")
}
+ testImplementation(project(":catalogs:catalog-glue")) {
+ exclude("org.apache.logging.log4j")
+ }
+ // Iceberg's GlueCatalog (in iceberg-spark-runtime) references AWS Glue SDK
classes at runtime.
+ // catalog-glue uses `implementation` (not `api`) so the SDK is not
transitively exposed here.
+ testImplementation(libs.aws.glue)
testImplementation(project(":integration-test-common", "testArtifacts"))
testImplementation(libs.hive2.common) {
@@ -124,6 +133,8 @@ dependencies {
testImplementation(libs.nimbus.jose.jwt)
testImplementation(libs.mysql.driver)
testImplementation(libs.postgresql.driver)
+ testImplementation(libs.mockito.core)
+ testImplementation(libs.mockito.inline)
testImplementation(libs.testcontainers)
testImplementation("org.apache.iceberg:iceberg-core:$icebergVersion")
@@ -150,6 +161,44 @@ dependencies {
testRuntimeOnly(libs.junit.jupiter.engine)
}
+// Jars for Spark's IsolatedClientLoader: patched Hive 2.3.10 + Glue
datacatalog client.
+// aws-java-sdk-glue 1.12.31 requires
PropertyNamingStrategy$PascalCaseStrategy which was
+// removed in Jackson 2.12. Jackson included here so the isolated classloader
uses a compatible
+// version instead of the app-level Jackson bundled with Spark (2.14+).
+// Only set when AWS_ACCESS_KEY_ID is present so version-specific modules can
skip the download
+// when Glue tests are not enabled.
+val glueHiveJarsDir: String? =
+ if (System.getenv("AWS_ACCESS_KEY_ID") != null)
"$buildDir/tmp/glue-hive-jars" else null
+extra["glueHiveJarsDir"] = glueHiveJarsDir
+val glueLibsApiUrl =
+
"https://api.github.com/repos/datastrato/spark-hive-glue-libs/contents/spark3/glue-3.4.0"
+
+val downloadGlueHiveJars by
+tasks.registering {
+ glueHiveJarsDir?.let { outputs.dir(it) }
+ doLast {
+ val outputDir = file(glueHiveJarsDir ?: return@doLast)
+ outputDir.mkdirs()
+ val response = URI(glueLibsApiUrl).toURL().readText()
+
+ @Suppress("UNCHECKED_CAST")
+ val entries = groovy.json.JsonSlurper().parseText(response) as
List<Map<String, Any>>
+ entries
+ .filter { (it["name"] as String).endsWith(".jar") }
+ .forEach { entry ->
+ val jarName = entry["name"] as String
+ val downloadUrl = entry["download_url"] as String
+ val dest = outputDir.resolve(jarName)
+ if (!dest.exists()) {
+ logger.lifecycle("Downloading $jarName ...")
+ URI(downloadUrl).toURL().openStream().use { input ->
+ dest.outputStream().use { output -> input.copyTo(output) }
+ }
+ }
+ }
+ }
+}
+
tasks.test {
val skipITs = project.hasProperty("skipITs")
if (skipITs) {
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
index 2f26beae32..6f93518ad5 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
@@ -197,34 +197,9 @@ public abstract class BaseCatalog implements TableCatalog,
SupportsNamespaces, F
public Table createTable(
Identifier ident, StructType schema, Transform[] transforms, Map<String,
String> properties)
throws TableAlreadyExistsException, NoSuchNamespaceException {
- NameIdentifier gravitinoIdentifier = NameIdentifier.of(getDatabase(ident),
ident.name());
- org.apache.gravitino.rel.Column[] gravitinoColumns =
- Arrays.stream(schema.fields())
- .map(structField -> createGravitinoColumn(structField))
- .toArray(org.apache.gravitino.rel.Column[]::new);
-
- Map<String, String> gravitinoProperties =
- propertiesConverter.toGravitinoTableProperties(properties);
- // Spark store comment in properties, we should retrieve it and pass to
Gravitino explicitly.
- String comment = gravitinoProperties.remove(ConnectorConstants.COMMENT);
-
- DistributionAndSortOrdersInfo distributionAndSortOrdersInfo =
-
sparkTransformConverter.toGravitinoDistributionAndSortOrders(transforms);
- org.apache.gravitino.rel.expressions.transforms.Transform[] partitionings =
- sparkTransformConverter.toGravitinoPartitionings(transforms);
-
try {
org.apache.gravitino.rel.Table gravitinoTable =
- gravitinoCatalogClient
- .asTableCatalog()
- .createTable(
- gravitinoIdentifier,
- gravitinoColumns,
- comment,
- gravitinoProperties,
- partitionings,
- distributionAndSortOrdersInfo.getDistribution(),
- distributionAndSortOrdersInfo.getSortOrders());
+ createGravitinoTable(ident, schema, transforms, properties);
org.apache.spark.sql.connector.catalog.Table sparkTable =
loadSparkTable(ident);
return createSparkTable(
ident,
@@ -241,6 +216,42 @@ public abstract class BaseCatalog implements TableCatalog,
SupportsNamespaces, F
}
}
+ /**
+ * Creates a table in Gravitino and returns the Gravitino table object.
Subclasses may call this
+ * to create via Gravitino without triggering the default {@code
loadSparkTable()} that follows in
+ * {@link #createTable}.
+ */
+ protected org.apache.gravitino.rel.Table createGravitinoTable(
+ Identifier ident, StructType schema, Transform[] transforms, Map<String,
String> properties)
+ throws NoSuchSchemaException,
org.apache.gravitino.exceptions.TableAlreadyExistsException {
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of(getDatabase(ident),
ident.name());
+ org.apache.gravitino.rel.Column[] gravitinoColumns =
+ Arrays.stream(schema.fields())
+ .map(structField -> createGravitinoColumn(structField))
+ .toArray(org.apache.gravitino.rel.Column[]::new);
+
+ Map<String, String> gravitinoProperties =
+ propertiesConverter.toGravitinoTableProperties(properties);
+ // Spark store comment in properties, we should retrieve it and pass to
Gravitino explicitly.
+ String comment = gravitinoProperties.remove(ConnectorConstants.COMMENT);
+
+ DistributionAndSortOrdersInfo distributionAndSortOrdersInfo =
+
sparkTransformConverter.toGravitinoDistributionAndSortOrders(transforms);
+ org.apache.gravitino.rel.expressions.transforms.Transform[] partitionings =
+ sparkTransformConverter.toGravitinoPartitionings(transforms);
+
+ return gravitinoCatalogClient
+ .asTableCatalog()
+ .createTable(
+ gravitinoIdentifier,
+ gravitinoColumns,
+ comment,
+ gravitinoProperties,
+ partitionings,
+ distributionAndSortOrdersInfo.getDistribution(),
+ distributionAndSortOrdersInfo.getSortOrders());
+ }
+
@Override
public Table loadTable(Identifier ident) throws NoSuchTableException {
org.apache.gravitino.rel.Table gravitinoTable;
@@ -655,7 +666,7 @@ public abstract class BaseCatalog implements TableCatalog,
SupportsNamespaces, F
throw new NoSuchFunctionException(ident);
}
- private Table loadSparkTable(Identifier ident) {
+ protected Table loadSparkTable(Identifier ident) {
try {
return sparkCatalog.loadTable(ident);
} catch (NoSuchTableException e) {
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GluePropertiesConverter.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GluePropertiesConverter.java
new file mode 100644
index 0000000000..a24e5f315f
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GluePropertiesConverter.java
@@ -0,0 +1,223 @@
+/*
+ * 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.glue;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.catalog.glue.GlueConstants;
+import org.apache.gravitino.spark.connector.PropertiesConverter;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.aws.glue.GlueCatalog;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Transform AWS Glue catalog properties between Apache Spark and Apache
Gravitino.
+ *
+ * <p>This converter handles the property mapping for:
+ *
+ * <ul>
+ * <li>Non-Iceberg tables: pass through AWS credentials and region to
HiveTableCatalog
+ * <li>Iceberg tables: map Gravitino Glue properties to Iceberg's
GlueCatalog configuration
+ * </ul>
+ */
+public class GluePropertiesConverter implements PropertiesConverter {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(GluePropertiesConverter.class);
+
+ /** Iceberg GlueCatalog implementation class name, set as {@code
catalog-impl}. */
+ public static final String GLUE_CATALOG_IMPL = GlueCatalog.class.getName();
+
+ /** Iceberg property key for the AWS Glue catalog ID (account-level
catalog). */
+ public static final String GLUE_ID = "glue.id";
+
+ /** Iceberg property key for a custom AWS Glue service endpoint URL. */
+ public static final String GLUE_ENDPOINT = "glue.endpoint";
+
+ /** Iceberg property key for the AWS region used by the Glue client. */
+ public static final String CLIENT_REGION = "client.region";
+
+ /**
+ * Iceberg property key for the credentials provider class. Set to {@link
+ * #GRAVITINO_GLUE_CREDENTIALS_PROVIDER} when static credentials are
configured.
+ */
+ public static final String CLIENT_CREDENTIALS_PROVIDER =
"client.credentials-provider";
+
+ /**
+ * Fully-qualified class name of {@link GravitinoGlueCredentialsProvider}.
Implements {@code
+ * AwsCredentialsProvider} with a {@code create(Map)} factory so that
Iceberg's {@code
+ * AwsClientProperties} can instantiate it dynamically via {@code
client.credentials-provider}.
+ */
+ public static final String GRAVITINO_GLUE_CREDENTIALS_PROVIDER =
+
"org.apache.gravitino.spark.connector.glue.GravitinoGlueCredentialsProvider";
+
+ /** Gravitino catalog property key for the AWS access key ID. */
+ public static final String AWS_ACCESS_KEY_ID = "aws-access-key-id";
+
+ /** Gravitino catalog property key for the AWS secret access key. */
+ public static final String AWS_SECRET_ACCESS_KEY = "aws-secret-access-key";
+
+ /** Gravitino catalog property key for the AWS region. */
+ public static final String AWS_REGION = "aws-region";
+
+ /** Gravitino catalog property key for the AWS Glue catalog ID. */
+ public static final String AWS_GLUE_CATALOG_ID = "aws-glue-catalog-id";
+
+ /** Gravitino catalog property key for a custom AWS Glue endpoint URL. */
+ public static final String AWS_GLUE_ENDPOINT = "aws-glue-endpoint";
+
+ private static final String SPARK_PROVIDER_ICEBERG = "iceberg";
+
+ /**
+ * Hive config key that activates the AWS Glue Data Catalog as the Hive
metastore client.
+ *
+ * <p>Note: the patched Hive 2.3.10 (HIVE-12679) uses the ConfVar {@code
+ * IMETASTORE_CLIENT_FACTORY_CLASS} whose string key is {@code
+ * hive.imetastoreclient.factory.class} — not the commonly documented {@code
+ * hive.metastore.client.factory.class}.
+ */
+ public static final String HIVE_METASTORE_CLIENT_FACTORY_CLASS =
+ "hive.imetastoreclient.factory.class";
+
+ /** AWS Glue Data Catalog Hive client factory class name. */
+ public static final String AWS_GLUE_HIVE_CLIENT_FACTORY_CLASS =
+
"com.amazonaws.glue.catalog.metastore.AWSGlueDataCatalogHiveClientFactory";
+
+ /** Maps Gravitino property keys to Spark HiveTableCatalog property keys. */
+ private static final Map<String, String> GRAVITINO_TO_SPARK_KEYS =
+ ImmutableMap.of(
+ AWS_REGION, "aws.region",
+ AWS_GLUE_CATALOG_ID, "aws.glue.catalog.id",
+ AWS_GLUE_ENDPOINT, "aws.glue.endpoint");
+
+ /** Maps Gravitino property keys to Iceberg GlueCatalog property keys. */
+ private static final Map<String, String> GRAVITINO_TO_ICEBERG_KEYS =
+ ImmutableMap.of(
+ AWS_REGION, CLIENT_REGION,
+ AWS_GLUE_CATALOG_ID, GLUE_ID,
+ AWS_GLUE_ENDPOINT, GLUE_ENDPOINT);
+
+ private static class GluePropertiesConverterHolder {
+ private static final GluePropertiesConverter INSTANCE = new
GluePropertiesConverter();
+ }
+
+ private GluePropertiesConverter() {}
+
+ /**
+ * Returns the singleton instance of {@link GluePropertiesConverter}.
+ *
+ * @return the singleton instance
+ */
+ public static GluePropertiesConverter getInstance() {
+ return GluePropertiesConverterHolder.INSTANCE;
+ }
+
+ /**
+ * Transform Gravitino Glue catalog properties to Spark catalog properties
for HiveTableCatalog.
+ *
+ * <p>Sets {@code hive.imetastoreclient.factory.class} to {@link
+ * #AWS_GLUE_HIVE_CLIENT_FACTORY_CLASS}, replacing the embedded Derby
metastore with a direct Glue
+ * API connection. This eliminates Derby and makes the catalog work
correctly across multiple
+ * Spark applications.
+ *
+ * <p>AWS credentials are read via the default AWS credential chain
(environment variables,
+ * instance profile, etc.) inside the isolated Hive classloader. The static
credentials in
+ * Gravitino catalog properties are used by the Iceberg GlueCatalog path
instead.
+ */
+ @Override
+ public Map<String, String> toSparkCatalogProperties(Map<String, String>
properties) {
+ Preconditions.checkArgument(properties != null, "Glue Catalog properties
should not be null");
+ HashMap<String, String> all = new HashMap<>();
+ all.put(HIVE_METASTORE_CLIENT_FACTORY_CLASS,
AWS_GLUE_HIVE_CLIENT_FACTORY_CLASS);
+ GRAVITINO_TO_SPARK_KEYS.forEach(
+ (gravitinoKey, sparkKey) -> {
+ String value = properties.get(gravitinoKey);
+ if (StringUtils.isNotBlank(value)) {
+ all.put(sparkKey, value);
+ }
+ });
+ return all;
+ }
+
+ /**
+ * Transform Gravitino Glue catalog properties to Iceberg SparkCatalog
properties for Iceberg
+ * tables stored in Glue.
+ *
+ * <p>This maps Gravitino's AWS Glue properties to Iceberg's GlueCatalog
configuration:
+ *
+ * <ul>
+ * <li>{@code aws-region} → {@code client.region} (optional; falls back to
SDK default chain)
+ * <li>{@code aws-glue-catalog-id} → {@code glue.id}
+ * <li>{@code aws-glue-endpoint} → {@code glue.endpoint} (optional)
+ * <li>{@code aws-access-key-id} + {@code aws-secret-access-key} → {@code
+ * client.credentials-provider=GravitinoGlueCredentialsProvider} with
credentials passed via
+ * {@code client.credentials-provider.*} properties
+ * </ul>
+ */
+ @VisibleForTesting
+ Map<String, String> toIcebergCatalogProperties(Map<String, String>
properties) {
+ Preconditions.checkArgument(properties != null, "Glue Catalog properties
should not be null");
+ HashMap<String, String> all = new HashMap<>();
+ all.put(CatalogProperties.CATALOG_IMPL, GLUE_CATALOG_IMPL);
+ GRAVITINO_TO_ICEBERG_KEYS.forEach(
+ (gravitinoKey, icebergKey) -> {
+ String value = properties.get(gravitinoKey);
+ if (StringUtils.isNotBlank(value)) {
+ all.put(icebergKey, value);
+ }
+ });
+ String accessKey = properties.get(AWS_ACCESS_KEY_ID);
+ String secretKey = properties.get(AWS_SECRET_ACCESS_KEY);
+ if (StringUtils.isNotBlank(accessKey) &&
StringUtils.isNotBlank(secretKey)) {
+ // Iceberg 1.10+ reads credentials via client.credentials-provider (a
class with create(Map)).
+ // The client.credentials-provider.* properties are stripped of their
prefix and passed to
+ // GravitinoGlueCredentialsProvider.create(Map) as {"access-key-id":
..., "secret-access-key":
+ // ...}.
+ all.put(CLIENT_CREDENTIALS_PROVIDER,
GRAVITINO_GLUE_CREDENTIALS_PROVIDER);
+ all.put("client.credentials-provider.access-key-id", accessKey);
+ all.put("client.credentials-provider.secret-access-key", secretKey);
+ } else {
+ LOG.debug(
+ "aws-access-key-id or aws-secret-access-key not configured; "
+ + "falling back to the default AWS credential chain (instance
profile, env vars, etc.).");
+ }
+ return all;
+ }
+
+ @Override
+ public Map<String, String> toGravitinoTableProperties(Map<String, String>
properties) {
+ HashMap<String, String> all = new HashMap<>(properties);
+ String provider = all.remove(TableCatalog.PROP_PROVIDER);
+ if (SPARK_PROVIDER_ICEBERG.equalsIgnoreCase(provider)) {
+ all.put(GlueConstants.TABLE_FORMAT, GlueConstants.TABLE_FORMAT_ICEBERG);
+ }
+ return all;
+ }
+
+ @Override
+ public Map<String, String> toSparkTableProperties(Map<String, String>
properties) {
+ return new HashMap<>(properties);
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalog.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalog.java
new file mode 100644
index 0000000000..fc7dbce568
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalog.java
@@ -0,0 +1,234 @@
+/*
+ * 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.glue;
+
+import com.google.common.base.Preconditions;
+import java.util.Map;
+import org.apache.gravitino.catalog.glue.GlueConstants;
+import org.apache.gravitino.spark.connector.PropertiesConverter;
+import org.apache.gravitino.spark.connector.SparkTransformConverter;
+import org.apache.gravitino.spark.connector.SparkTypeConverter;
+import org.apache.gravitino.spark.connector.catalog.BaseCatalog;
+import org.apache.gravitino.spark.connector.hive.SparkHiveTable;
+import org.apache.gravitino.spark.connector.hive.SparkHiveTypeConverter;
+import org.apache.gravitino.spark.connector.iceberg.SparkIcebergTable;
+import org.apache.iceberg.spark.SparkCatalog;
+import org.apache.iceberg.spark.source.SparkTable;
+import org.apache.kyuubi.spark.connector.hive.HiveTable;
+import org.apache.kyuubi.spark.connector.hive.HiveTableCatalog;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.util.CaseInsensitiveStringMap;
+
+/**
+ * Gravitino Glue catalog implementation for Apache Spark.
+ *
+ * <p>This catalog handles mixed table types stored in AWS Glue Data Catalog:
+ *
+ * <ul>
+ * <li>Non-Iceberg tables (Hive, Delta, Parquet): routed to {@link
HiveTableCatalog} backed by the
+ * AWS Glue Data Catalog Hive client ({@code
AWSGlueDataCatalogHiveClientFactory})
+ * <li>Iceberg tables: routed to Iceberg's {@link SparkCatalog}
(GlueCatalog) for I/O
+ * </ul>
+ *
+ * <p>Table routing is based on the {@code table-format} property in Glue
table parameters. Tables
+ * with {@code table-format=ICEBERG} are delegated to the Iceberg backend.
+ *
+ * <p>{@link HiveTableCatalog} is configured with {@code
hive.metastore.client.factory.class} set to
+ * {@code AWSGlueDataCatalogHiveClientFactory}, which replaces the embedded
Derby metastore with a
+ * direct AWS Glue API connection. This means no local Derby sync is required
and the catalog works
+ * correctly across multiple concurrent Spark applications sharing the same
Glue catalog.
+ */
+public class GravitinoGlueCatalog extends BaseCatalog {
+
+ // Lazily initialized Iceberg GlueCatalog for Iceberg tables
+ private volatile SparkCatalog icebergGlueCatalog;
+
+ // Store original config for Iceberg catalog initialization
+ private String catalogName;
+ private Map<String, String> catalogProperties;
+
+ /** Creates a new GravitinoGlueCatalog. */
+ public GravitinoGlueCatalog() {}
+
+ /**
+ * Creates a new HiveTableCatalog instance. Override in tests to inject mock
instances.
+ *
+ * @return a new HiveTableCatalog
+ */
+ protected HiveTableCatalog createHiveTableCatalog() {
+ return new HiveTableCatalog();
+ }
+
+ @Override
+ protected TableCatalog createAndInitSparkCatalog(
+ String name, CaseInsensitiveStringMap options, Map<String, String>
properties) {
+ this.catalogName = name;
+ this.catalogProperties = properties;
+
+ TableCatalog hiveCatalog = createHiveTableCatalog();
+ Map<String, String> all =
+ getPropertiesConverter().toSparkCatalogProperties(options, properties);
+ hiveCatalog.initialize(name, new CaseInsensitiveStringMap(all));
+ return hiveCatalog;
+ }
+
+ /**
+ * Routes Spark table loading to the correct backend.
+ *
+ * <p>Iceberg tables are loaded from the Iceberg GlueCatalog. Non-Iceberg
tables are loaded
+ * directly from {@link HiveTableCatalog}, which reads from Glue via the
factory client.
+ */
+ @Override
+ protected Table loadSparkTable(Identifier ident) {
+ try {
+ org.apache.gravitino.rel.Table gravitinoTable =
loadGravitinoTable(ident);
+ if (isIcebergTable(gravitinoTable)) {
+ return getOrCreateIcebergGlueCatalog().loadTable(ident);
+ }
+ return sparkCatalog.loadTable(ident);
+ } catch (NoSuchTableException e) {
+ throw new RuntimeException(
+ String.format("Failed to load spark table: %s.%s",
getDatabase(ident), ident.name()), e);
+ }
+ }
+
+ /**
+ * Routes table creation to the appropriate Spark wrapper based on the
Gravitino table type.
+ * Iceberg tables are wrapped in {@link SparkIcebergTable}; all others in
{@link SparkHiveTable}.
+ */
+ @Override
+ protected Table createSparkTable(
+ Identifier identifier,
+ org.apache.gravitino.rel.Table gravitinoTable,
+ Table sparkTable,
+ TableCatalog sparkHiveCatalog,
+ PropertiesConverter propertiesConverter,
+ SparkTransformConverter sparkTransformConverter,
+ SparkTypeConverter sparkTypeConverter) {
+
+ if (isIcebergTable(gravitinoTable)) {
+ Preconditions.checkArgument(
+ sparkTable instanceof SparkTable,
+ "Iceberg table %s expected SparkTable from Iceberg backend, got %s",
+ identifier,
+ sparkTable.getClass().getName());
+ return new SparkIcebergTable(
+ identifier,
+ gravitinoTable,
+ (SparkTable) sparkTable,
+ getOrCreateIcebergGlueCatalog(),
+ propertiesConverter,
+ sparkTransformConverter,
+ sparkTypeConverter);
+ }
+
+ return new SparkHiveTable(
+ identifier,
+ gravitinoTable,
+ (HiveTable) sparkTable,
+ (HiveTableCatalog) sparkHiveCatalog,
+ propertiesConverter,
+ sparkTransformConverter,
+ sparkTypeConverter);
+ }
+
+ /** {@inheritDoc} Returns the Glue-specific properties converter singleton.
*/
+ @Override
+ protected PropertiesConverter getPropertiesConverter() {
+ return GluePropertiesConverter.getInstance();
+ }
+
+ /** {@inheritDoc} Returns a transform converter with identity partition
support disabled. */
+ @Override
+ protected SparkTransformConverter getSparkTransformConverter() {
+ return new SparkTransformConverter(false);
+ }
+
+ /** {@inheritDoc} Returns the Hive-compatible type converter used for Glue
tables. */
+ @Override
+ protected SparkTypeConverter getSparkTypeConverter() {
+ return new SparkHiveTypeConverter();
+ }
+
+ /**
+ * Returns true if the Gravitino table is an Iceberg-format table based on
its properties.
+ *
+ * @param gravitinoTable the Gravitino table to inspect
+ * @return true for Iceberg tables, false otherwise
+ */
+ static boolean isIcebergTable(org.apache.gravitino.rel.Table gravitinoTable)
{
+ Map<String, String> properties = gravitinoTable.properties();
+ if (properties == null) {
+ return false;
+ }
+ // Gravitino convention: table-format=ICEBERG
+ String tableFormat = properties.get(GlueConstants.TABLE_FORMAT);
+ if (GlueConstants.TABLE_FORMAT_ICEBERG.equalsIgnoreCase(tableFormat)) {
+ return true;
+ }
+ // Iceberg Glue catalog convention: table_type=ICEBERG stored in Glue
table parameters
+ return GlueConstants.ICEBERG_TABLE_TYPE_VALUE.equalsIgnoreCase(
+ properties.get(GlueConstants.TABLE_TYPE_PARAM));
+ }
+
+ /**
+ * Gets or creates the Iceberg GlueCatalog using double-checked locking.
+ *
+ * @return the Iceberg SparkCatalog
+ */
+ private SparkCatalog getOrCreateIcebergGlueCatalog() {
+ if (icebergGlueCatalog == null) {
+ synchronized (this) {
+ if (icebergGlueCatalog == null) {
+ Preconditions.checkArgument(
+ catalogName != null && catalogProperties != null,
+ "Catalog name and properties must be set before accessing
Iceberg catalog");
+ try {
+ icebergGlueCatalog = createIcebergGlueCatalog();
+ } catch (Exception e) {
+ throw new RuntimeException(
+ String.format(
+ "Failed to initialize Iceberg GlueCatalog for catalog
'%s'. "
+ + "Check aws-region, aws-access-key-id, and
aws-secret-access-key properties.",
+ catalogName),
+ e);
+ }
+ }
+ }
+ }
+ return icebergGlueCatalog;
+ }
+
+ /**
+ * Creates a new Iceberg GlueCatalog with appropriate configuration.
+ *
+ * @return the configured Iceberg SparkCatalog
+ */
+ private SparkCatalog createIcebergGlueCatalog() {
+ GluePropertiesConverter converter = GluePropertiesConverter.getInstance();
+ Map<String, String> icebergProperties =
converter.toIcebergCatalogProperties(catalogProperties);
+ SparkCatalog catalog = new SparkCatalog();
+ catalog.initialize(catalogName + "_iceberg", new
CaseInsensitiveStringMap(icebergProperties));
+ return catalog;
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCredentialsProvider.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCredentialsProvider.java
new file mode 100644
index 0000000000..b805d40d54
--- /dev/null
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCredentialsProvider.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.spark.connector.glue;
+
+import com.google.common.base.Preconditions;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+
+/**
+ * AWS credentials provider for Iceberg {@code GlueCatalog} that reads static
credentials from a
+ * properties map.
+ *
+ * <p>Iceberg 1.10+ loads credentials via {@code client.credentials-provider}.
This class is
+ * instantiated dynamically by Iceberg's {@code AwsClientProperties} using the
{@code create(Map)}
+ * static factory. The properties map contains the {@code
client.credentials-provider.*} entries
+ * with their prefix stripped, i.e. {@code access-key-id} and {@code
secret-access-key}.
+ */
+public class GravitinoGlueCredentialsProvider implements
AwsCredentialsProvider {
+
+ private static final String ACCESS_KEY_ID = "access-key-id";
+ private static final String SECRET_ACCESS_KEY = "secret-access-key";
+
+ private final String accessKeyId;
+ private final String secretAccessKey;
+
+ /**
+ * Creates a credentials provider from the given properties map.
+ *
+ * @param properties map containing {@code access-key-id} and {@code
secret-access-key}
+ * @return a new {@link AwsCredentialsProvider} instance
+ */
+ public static AwsCredentialsProvider create(Map<String, String> properties) {
+ Preconditions.checkArgument(properties != null, "Credentials properties
must not be null");
+ String accessKeyId = properties.get(ACCESS_KEY_ID);
+ String secretAccessKey = properties.get(SECRET_ACCESS_KEY);
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(accessKeyId),
+ "Glue credentials provider requires 'access-key-id' in
client.credentials-provider.* properties");
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(secretAccessKey),
+ "Glue credentials provider requires 'secret-access-key' in
client.credentials-provider.* properties");
+ return new GravitinoGlueCredentialsProvider(accessKeyId, secretAccessKey);
+ }
+
+ GravitinoGlueCredentialsProvider(String accessKeyId, String secretAccessKey)
{
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ }
+
+ @Override
+ public AwsCredentials resolveCredentials() {
+ return AwsBasicCredentials.create(accessKeyId, secretAccessKey);
+ }
+}
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/HivePropertiesConverter.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/HivePropertiesConverter.java
index 77fb6f3f05..bf8d0bbc3a 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/HivePropertiesConverter.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/hive/HivePropertiesConverter.java
@@ -47,16 +47,17 @@ public class HivePropertiesConverter implements
PropertiesConverter {
// Transform Spark Hive file format to Gravitino hive file format
static final Map<String, String> fileFormatMap =
- ImmutableMap.of(
- "sequencefile",
HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_SEQUENCEFILE,
- "rcfile", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_RCFILE,
- "orc", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_ORC,
- "parquet", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_PARQUET,
- "textfile", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_TEXTFILE,
- "hive", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_TEXTFILE,
- "json", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_JSON,
- "csv", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_CSV,
- "avro", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_AVRO);
+ ImmutableMap.<String, String>builder()
+ .put("sequencefile",
HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_SEQUENCEFILE)
+ .put("rcfile", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_RCFILE)
+ .put("orc", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_ORC)
+ .put("parquet",
HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_PARQUET)
+ .put("textfile",
HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_TEXTFILE)
+ .put("hive", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_TEXTFILE)
+ .put("json", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_JSON)
+ .put("csv", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_CSV)
+ .put("avro", HivePropertiesConstants.GRAVITINO_HIVE_FORMAT_AVRO)
+ .build();
static final Map<String, String> sparkToGravitinoPropertyMap =
ImmutableMap.of(
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
index 8724941529..c5c4bbcc78 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/version/CatalogNameAdaptor.java
@@ -25,26 +25,44 @@ import org.apache.spark.package$;
import org.apache.spark.util.VersionUtils$;
public class CatalogNameAdaptor {
- private static final Map<String, String> catalogNames =
- ImmutableMap.of(
- "hive-3.3",
-
"org.apache.gravitino.spark.connector.hive.GravitinoHiveCatalogSpark33",
- "hive-3.4",
-
"org.apache.gravitino.spark.connector.hive.GravitinoHiveCatalogSpark34",
- "hive-3.5",
-
"org.apache.gravitino.spark.connector.hive.GravitinoHiveCatalogSpark35",
- "lakehouse-iceberg-3.3",
-
"org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalogSpark33",
- "lakehouse-iceberg-3.4",
-
"org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalogSpark34",
- "lakehouse-iceberg-3.5",
-
"org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalogSpark35",
- "lakehouse-paimon-3.3",
-
"org.apache.gravitino.spark.connector.paimon.GravitinoPaimonCatalogSpark33",
- "lakehouse-paimon-3.4",
-
"org.apache.gravitino.spark.connector.paimon.GravitinoPaimonCatalogSpark34",
- "lakehouse-paimon-3.5",
-
"org.apache.gravitino.spark.connector.paimon.GravitinoPaimonCatalogSpark35");
+ private static final Map<String, String> catalogNames;
+
+ static {
+ ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
+ catalogNames =
+ builder
+ .put(
+ "hive-3.3",
"org.apache.gravitino.spark.connector.hive.GravitinoHiveCatalogSpark33")
+ .put(
+ "hive-3.4",
"org.apache.gravitino.spark.connector.hive.GravitinoHiveCatalogSpark34")
+ .put(
+ "hive-3.5",
"org.apache.gravitino.spark.connector.hive.GravitinoHiveCatalogSpark35")
+ .put(
+ "lakehouse-iceberg-3.3",
+
"org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalogSpark33")
+ .put(
+ "lakehouse-iceberg-3.4",
+
"org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalogSpark34")
+ .put(
+ "lakehouse-iceberg-3.5",
+
"org.apache.gravitino.spark.connector.iceberg.GravitinoIcebergCatalogSpark35")
+ .put(
+ "lakehouse-paimon-3.3",
+
"org.apache.gravitino.spark.connector.paimon.GravitinoPaimonCatalogSpark33")
+ .put(
+ "lakehouse-paimon-3.4",
+
"org.apache.gravitino.spark.connector.paimon.GravitinoPaimonCatalogSpark34")
+ .put(
+ "lakehouse-paimon-3.5",
+
"org.apache.gravitino.spark.connector.paimon.GravitinoPaimonCatalogSpark35")
+ .put(
+ "glue-3.3",
"org.apache.gravitino.spark.connector.glue.GravitinoGlueCatalogSpark33")
+ .put(
+ "glue-3.4",
"org.apache.gravitino.spark.connector.glue.GravitinoGlueCatalogSpark34")
+ .put(
+ "glue-3.5",
"org.apache.gravitino.spark.connector.glue.GravitinoGlueCatalogSpark35")
+ .build();
+ }
private static final Map<String, String> jdbcCatalogNames =
ImmutableMap.of(
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGluePropertiesConverter.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGluePropertiesConverter.java
new file mode 100644
index 0000000000..1340a85935
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGluePropertiesConverter.java
@@ -0,0 +1,274 @@
+/*
+ * 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.glue;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+@TestInstance(Lifecycle.PER_CLASS)
+public class TestGluePropertiesConverter {
+ private final GluePropertiesConverter converter =
GluePropertiesConverter.getInstance();
+
+ // --- toIcebergCatalogProperties tests ---
+
+ @Test
+ void testToIcebergCatalogPropertiesWithRegion() {
+ Map<String, String> icebergProps =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(GluePropertiesConverter.AWS_REGION, "us-east-1"));
+ Assertions.assertEquals(
+ "org.apache.iceberg.aws.glue.GlueCatalog",
icebergProps.get("catalog-impl"));
+ Assertions.assertEquals("us-east-1",
icebergProps.get(GluePropertiesConverter.CLIENT_REGION));
+ Assertions.assertEquals(2, icebergProps.size());
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesWithAllProperties() {
+ Map<String, String> icebergProps =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(
+ GluePropertiesConverter.AWS_REGION, "us-west-2",
+ GluePropertiesConverter.AWS_GLUE_CATALOG_ID, "123456789012",
+ GluePropertiesConverter.AWS_GLUE_ENDPOINT,
"http://localhost:4566",
+ GluePropertiesConverter.AWS_ACCESS_KEY_ID,
"AKIAIOSFODNN7EXAMPLE",
+ GluePropertiesConverter.AWS_SECRET_ACCESS_KEY,
+ "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"));
+ Assertions.assertEquals(
+ "org.apache.iceberg.aws.glue.GlueCatalog",
icebergProps.get("catalog-impl"));
+ Assertions.assertEquals("us-west-2",
icebergProps.get(GluePropertiesConverter.CLIENT_REGION));
+ Assertions.assertEquals("123456789012",
icebergProps.get(GluePropertiesConverter.GLUE_ID));
+ Assertions.assertEquals(
+ "http://localhost:4566",
icebergProps.get(GluePropertiesConverter.GLUE_ENDPOINT));
+ Assertions.assertEquals(
+
"org.apache.gravitino.spark.connector.glue.GravitinoGlueCredentialsProvider",
+ icebergProps.get(GluePropertiesConverter.CLIENT_CREDENTIALS_PROVIDER));
+ Assertions.assertEquals(
+ "AKIAIOSFODNN7EXAMPLE",
icebergProps.get("client.credentials-provider.access-key-id"));
+ Assertions.assertEquals(
+ "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ icebergProps.get("client.credentials-provider.secret-access-key"));
+ Assertions.assertEquals(7, icebergProps.size());
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesWithOptionalCatalogId() {
+ Map<String, String> icebergProps =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(GluePropertiesConverter.AWS_REGION,
"eu-central-1"));
+ Assertions.assertEquals(
+ "eu-central-1",
icebergProps.get(GluePropertiesConverter.CLIENT_REGION));
+ Assertions.assertNull(icebergProps.get(GluePropertiesConverter.GLUE_ID));
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesWithOptionalEndpoint() {
+ Map<String, String> icebergProps =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(
+ GluePropertiesConverter.AWS_REGION,
+ "us-east-1",
+ GluePropertiesConverter.AWS_GLUE_ENDPOINT,
+ "http://localhost:4566"));
+ Assertions.assertEquals(
+ "http://localhost:4566",
icebergProps.get(GluePropertiesConverter.GLUE_ENDPOINT));
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesWithStaticCredentials() {
+ Map<String, String> icebergProps =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(
+ GluePropertiesConverter.AWS_REGION, "us-east-1",
+ GluePropertiesConverter.AWS_ACCESS_KEY_ID, "access-key",
+ GluePropertiesConverter.AWS_SECRET_ACCESS_KEY, "secret-key"));
+ Assertions.assertEquals(
+
"org.apache.gravitino.spark.connector.glue.GravitinoGlueCredentialsProvider",
+ icebergProps.get(GluePropertiesConverter.CLIENT_CREDENTIALS_PROVIDER));
+ Assertions.assertEquals(
+ "access-key",
icebergProps.get("client.credentials-provider.access-key-id"));
+ Assertions.assertEquals(
+ "secret-key",
icebergProps.get("client.credentials-provider.secret-access-key"));
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesMissingRegionOmitsClientRegion() {
+ // Region is optional; when absent the AWS SDK default region chain is
used.
+ Map<String, String> icebergProps =
converter.toIcebergCatalogProperties(ImmutableMap.of());
+
Assertions.assertNull(icebergProps.get(GluePropertiesConverter.CLIENT_REGION));
+
+ Map<String, String> blankRegion =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(GluePropertiesConverter.AWS_REGION, ""));
+
Assertions.assertNull(blankRegion.get(GluePropertiesConverter.CLIENT_REGION));
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesNullInputThrows() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
converter.toIcebergCatalogProperties(null));
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesOnlyAccessKeyOmitsCredentialsProvider() {
+ Map<String, String> props =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(GluePropertiesConverter.AWS_ACCESS_KEY_ID,
"AKID"));
+
Assertions.assertNull(props.get(GluePropertiesConverter.CLIENT_CREDENTIALS_PROVIDER));
+ }
+
+ @Test
+ void testToIcebergCatalogPropertiesOnlySecretKeyOmitsCredentialsProvider() {
+ Map<String, String> props =
+ converter.toIcebergCatalogProperties(
+ ImmutableMap.of(GluePropertiesConverter.AWS_SECRET_ACCESS_KEY,
"SECRET"));
+
Assertions.assertNull(props.get(GluePropertiesConverter.CLIENT_CREDENTIALS_PROVIDER));
+ }
+
+ // --- toSparkCatalogProperties tests (single-arg version) ---
+
+ @Test
+ void testToSparkCatalogPropertiesAlwaysContainsFactoryClass() {
+ // Factory class must always be set so HiveTableCatalog uses Glue instead
of Derby.
+ // The patched Hive 2.3.10 reads IMETASTORE_CLIENT_FACTORY_CLASS whose key
is
+ // "hive.imetastoreclient.factory.class" (not
"hive.metastore.client.factory.class").
+ Map<String, String> sparkProps =
converter.toSparkCatalogProperties(ImmutableMap.of());
+ Assertions.assertEquals(
+ GluePropertiesConverter.AWS_GLUE_HIVE_CLIENT_FACTORY_CLASS,
+
sparkProps.get(GluePropertiesConverter.HIVE_METASTORE_CLIENT_FACTORY_CLASS));
+ Assertions.assertEquals(
+ "hive.imetastoreclient.factory.class",
+ GluePropertiesConverter.HIVE_METASTORE_CLIENT_FACTORY_CLASS);
+ Assertions.assertEquals(1, sparkProps.size());
+ }
+
+ @Test
+ void testToSparkCatalogPropertiesWithRegion() {
+ Map<String, String> sparkProps =
+ converter.toSparkCatalogProperties(
+ ImmutableMap.of(GluePropertiesConverter.AWS_REGION, "us-east-1"));
+ Assertions.assertEquals("us-east-1", sparkProps.get("aws.region"));
+ Assertions.assertEquals(
+ GluePropertiesConverter.AWS_GLUE_HIVE_CLIENT_FACTORY_CLASS,
+
sparkProps.get(GluePropertiesConverter.HIVE_METASTORE_CLIENT_FACTORY_CLASS));
+ }
+
+ @Test
+ void testToSparkCatalogPropertiesWithCatalogId() {
+ Map<String, String> sparkProps =
+ converter.toSparkCatalogProperties(
+ ImmutableMap.of(
+ GluePropertiesConverter.AWS_REGION, "us-east-1",
+ GluePropertiesConverter.AWS_GLUE_CATALOG_ID, "123456789012"));
+ Assertions.assertEquals("123456789012",
sparkProps.get("aws.glue.catalog.id"));
+ Assertions.assertEquals("us-east-1", sparkProps.get("aws.region"));
+ }
+
+ @Test
+ void testToSparkCatalogPropertiesWithEndpoint() {
+ Map<String, String> sparkProps =
+ converter.toSparkCatalogProperties(
+ ImmutableMap.of(
+ GluePropertiesConverter.AWS_REGION, "us-east-1",
+ GluePropertiesConverter.AWS_GLUE_ENDPOINT,
"http://localhost:4566"));
+ Assertions.assertEquals("http://localhost:4566",
sparkProps.get("aws.glue.endpoint"));
+ }
+
+ @Test
+ void testToSparkCatalogPropertiesCredentialsNotForwarded() {
+ // Static credentials are NOT forwarded to HiveTableCatalog — the
+ // AWSGlueDataCatalogHiveClientFactory
+ // uses DefaultAWSCredentialsProviderChain (env vars, instance profile,
etc.) instead.
+ Map<String, String> sparkProps =
+ converter.toSparkCatalogProperties(
+ ImmutableMap.of(
+ GluePropertiesConverter.AWS_ACCESS_KEY_ID, "my-access-key",
+ GluePropertiesConverter.AWS_SECRET_ACCESS_KEY,
"my-secret-key"));
+
Assertions.assertNull(sparkProps.get(GluePropertiesConverter.AWS_ACCESS_KEY_ID));
+
Assertions.assertNull(sparkProps.get(GluePropertiesConverter.AWS_SECRET_ACCESS_KEY));
+ }
+
+ @Test
+ void testToSparkCatalogPropertiesNullInputThrows() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
converter.toSparkCatalogProperties(null));
+ }
+
+ // --- toGravitinoTableProperties tests ---
+
+ @Test
+ void testToGravitinoTableProperties() {
+ Map<String, String> tableProps =
+ converter.toGravitinoTableProperties(ImmutableMap.of("key1", "value1",
"key2", "value2"));
+ Assertions.assertEquals(ImmutableMap.of("key1", "value1", "key2",
"value2"), tableProps);
+ Assertions.assertNotSame(tableProps, ImmutableMap.of("key1", "value1",
"key2", "value2"));
+ }
+
+ @Test
+ void testToGravitinoTablePropertiesIcebergProviderMapped() {
+ Map<String, String> tableProps =
+ converter.toGravitinoTableProperties(
+ ImmutableMap.of("provider", "iceberg", "key1", "value1"));
+ Assertions.assertEquals("ICEBERG", tableProps.get("table-format"));
+ Assertions.assertFalse(tableProps.containsKey("provider"), "provider
should be removed");
+ Assertions.assertEquals("value1", tableProps.get("key1"));
+ }
+
+ @Test
+ void testToGravitinoTablePropertiesIcebergProviderCaseInsensitive() {
+ Map<String, String> tableProps =
+ converter.toGravitinoTableProperties(ImmutableMap.of("provider",
"ICEBERG"));
+ Assertions.assertEquals("ICEBERG", tableProps.get("table-format"));
+ Assertions.assertFalse(tableProps.containsKey("provider"));
+ }
+
+ @Test
+ void testToGravitinoTablePropertiesNonIcebergProviderPassthrough() {
+ Map<String, String> tableProps =
+ converter.toGravitinoTableProperties(ImmutableMap.of("provider",
"parquet"));
+ Assertions.assertFalse(tableProps.containsKey("table-format"));
+ Assertions.assertFalse(tableProps.containsKey("provider"), "provider
should be removed");
+ }
+
+ @Test
+ void testToGravitinoTablePropertiesEmpty() {
+ Map<String, String> tableProps =
converter.toGravitinoTableProperties(ImmutableMap.of());
+ Assertions.assertTrue(tableProps.isEmpty());
+ }
+
+ // --- toSparkTableProperties tests ---
+
+ @Test
+ void testToSparkTableProperties() {
+ Map<String, String> sparkTableProps =
+ converter.toSparkTableProperties(ImmutableMap.of("key1", "value1",
"key2", "value2"));
+ Assertions.assertEquals(ImmutableMap.of("key1", "value1", "key2",
"value2"), sparkTableProps);
+ Assertions.assertNotSame(sparkTableProps, ImmutableMap.of("key1",
"value1", "key2", "value2"));
+ }
+
+ @Test
+ void testToSparkTablePropertiesEmpty() {
+ Map<String, String> sparkTableProps =
converter.toSparkTableProperties(ImmutableMap.of());
+ Assertions.assertTrue(sparkTableProps.isEmpty());
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
new file mode 100644
index 0000000000..130ddee519
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCatalog.java
@@ -0,0 +1,217 @@
+/*
+ * 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.glue;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.HashMap;
+import org.apache.gravitino.catalog.glue.GlueConstants;
+import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.spark.connector.PropertiesConverter;
+import org.apache.gravitino.spark.connector.SparkTransformConverter;
+import org.apache.gravitino.spark.connector.SparkTypeConverter;
+import org.apache.gravitino.spark.connector.catalog.GravitinoCatalogManager;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+@TestInstance(Lifecycle.PER_CLASS)
+public class TestGravitinoGlueCatalog {
+
+ private GravitinoGlueCatalog gravitinoGlueCatalog;
+
+ @BeforeAll
+ void initCatalogManager() {
+ // GravitinoGlueCatalog extends BaseCatalog which calls
GravitinoCatalogManager.get()
+ // in its constructor, so we must initialize the manager first.
+ GravitinoClient mockClient = mock(GravitinoClient.class);
+ GravitinoCatalogManager.create(() -> mockClient);
+ }
+
+ @AfterAll
+ void cleanupCatalogManager() {
+ GravitinoCatalogManager.get().close();
+ }
+
+ @BeforeEach
+ void setUp() {
+ gravitinoGlueCatalog = new GravitinoGlueCatalog();
+ }
+
+ // -------------------------------------------------------------------------
+ // Test isIcebergTable (static package-private method)
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testIsIcebergTableWithIcebergFormat() {
+ Table mockTable =
+ createMockGravitinoTable(
+ ImmutableMap.of(GlueConstants.TABLE_FORMAT,
GlueConstants.TABLE_FORMAT_ICEBERG));
+ Assertions.assertTrue(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithIcebergFormatLowercase() {
+ Table mockTable =
+ createMockGravitinoTable(ImmutableMap.of(GlueConstants.TABLE_FORMAT,
"iceberg"));
+ Assertions.assertTrue(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithIcebergFormatMixedCase() {
+ Table mockTable =
+ createMockGravitinoTable(ImmutableMap.of(GlueConstants.TABLE_FORMAT,
"IcEbErG"));
+ Assertions.assertTrue(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithHiveFormat() {
+ Table mockTable =
createMockGravitinoTable(ImmutableMap.of(GlueConstants.TABLE_FORMAT, "HIVE"));
+ Assertions.assertFalse(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithDeltaFormat() {
+ Table mockTable =
+ createMockGravitinoTable(ImmutableMap.of(GlueConstants.TABLE_FORMAT,
"DELTA"));
+ Assertions.assertFalse(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithEmptyProperties() {
+ Table mockTable = createMockGravitinoTable(ImmutableMap.of());
+ Assertions.assertFalse(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithNullProperties() {
+ Table mockTable = mock(Table.class);
+ when(mockTable.properties()).thenReturn(null);
+ Assertions.assertFalse(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithNoTableFormatProperty() {
+ Table mockTable =
+ createMockGravitinoTable(ImmutableMap.of("aws-location",
"s3://bucket/table"));
+ Assertions.assertFalse(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithEmptyTableFormat() {
+ Table mockTable =
createMockGravitinoTable(ImmutableMap.of(GlueConstants.TABLE_FORMAT, ""));
+ Assertions.assertFalse(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithNativeIcebergTableType() {
+ // Tables created directly by Iceberg GlueCatalog use table_type=ICEBERG
(no table-format key).
+ Table mockTable = createMockGravitinoTable(ImmutableMap.of("table_type",
"ICEBERG"));
+ Assertions.assertTrue(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ @Test
+ void testIsIcebergTableWithNativeIcebergTableTypeLowercase() {
+ Table mockTable = createMockGravitinoTable(ImmutableMap.of("table_type",
"iceberg"));
+ Assertions.assertTrue(GravitinoGlueCatalog.isIcebergTable(mockTable));
+ }
+
+ // -------------------------------------------------------------------------
+ // Test loadSparkTable routes non-Iceberg tables to sparkCatalog directly
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testLoadSparkTableRoutesNonIcebergToSparkCatalog() throws Exception {
+ org.apache.spark.sql.connector.catalog.TableCatalog mockCatalog =
+ mock(org.apache.spark.sql.connector.catalog.TableCatalog.class);
+ org.apache.spark.sql.connector.catalog.Table mockSparkTable =
+ mock(org.apache.spark.sql.connector.catalog.Table.class);
+ Table mockGravitinoTable = createMockGravitinoTable(ImmutableMap.of());
+
+ Identifier ident = Identifier.of(new String[] {"db"}, "tbl");
+ when(mockCatalog.loadTable(any())).thenReturn(mockSparkTable);
+
+ GravitinoGlueCatalog catalog =
+ new GravitinoGlueCatalog() {
+ {
+ sparkCatalog = mockCatalog;
+ }
+
+ @Override
+ protected Table loadGravitinoTable(Identifier ident) throws
NoSuchTableException {
+ return mockGravitinoTable;
+ }
+ };
+
+ org.apache.spark.sql.connector.catalog.Table result =
catalog.loadSparkTable(ident);
+ Assertions.assertSame(mockSparkTable, result);
+ }
+
+ // -------------------------------------------------------------------------
+ // Test converter methods
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testGetSparkTypeConverter() {
+ SparkTypeConverter typeConverter =
gravitinoGlueCatalog.getSparkTypeConverter();
+ Assertions.assertNotNull(typeConverter);
+ Assertions.assertInstanceOf(
+
org.apache.gravitino.spark.connector.hive.SparkHiveTypeConverter.class,
typeConverter);
+ }
+
+ @Test
+ void testGetPropertiesConverter() {
+ PropertiesConverter converter =
gravitinoGlueCatalog.getPropertiesConverter();
+ Assertions.assertNotNull(converter);
+ Assertions.assertInstanceOf(GluePropertiesConverter.class, converter);
+ }
+
+ @Test
+ void testGetSparkTransformConverter() {
+ SparkTransformConverter transformer =
gravitinoGlueCatalog.getSparkTransformConverter();
+ Assertions.assertNotNull(transformer);
+ }
+
+ // -------------------------------------------------------------------------
+ // Helper methods
+ // -------------------------------------------------------------------------
+
+ /** Creates a mock Gravitino Table with the given properties. */
+ private Table createMockGravitinoTable(java.util.Map<String, String>
properties) {
+ Table mockTable = mock(Table.class);
+ when(mockTable.properties()).thenReturn(new HashMap<>(properties));
+ when(mockTable.name()).thenReturn("test_db.test_table");
+ when(mockTable.columns())
+ .thenReturn(new Column[] {Column.of("id", Types.IntegerType.get(), "id
column")});
+ return mockTable;
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCredentialsProvider.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCredentialsProvider.java
new file mode 100644
index 0000000000..5a5113f07b
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/glue/TestGravitinoGlueCredentialsProvider.java
@@ -0,0 +1,51 @@
+/*
+ * 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.glue;
+
+import java.util.Map;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.auth.credentials.AwsCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+
+public class TestGravitinoGlueCredentialsProvider {
+
+ @Test
+ void testCreateAndResolveValidCredentials() {
+ AwsCredentialsProvider provider =
+ GravitinoGlueCredentialsProvider.create(
+ Map.of("access-key-id", "AKID", "secret-access-key", "SECRET"));
+ AwsCredentials creds = provider.resolveCredentials();
+ Assertions.assertEquals("AKID", creds.accessKeyId());
+ Assertions.assertEquals("SECRET", creds.secretAccessKey());
+ }
+
+ @Test
+ void testCreateWithMissingKeysThrows() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
GravitinoGlueCredentialsProvider.create(Map.of()));
+ }
+
+ @Test
+ void testCreateWithNullPropertiesThrows() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
GravitinoGlueCredentialsProvider.create(null));
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkCommonIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkCommonIT.java
index 8a96f3ab44..7f637ff78b 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkCommonIT.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkCommonIT.java
@@ -222,7 +222,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
- void testLoadCatalogs() {
+ protected void testLoadCatalogs() {
Set<String> catalogs = getCatalogs();
Assertions.assertTrue(catalogs.contains(getCatalogName()));
}
@@ -281,7 +281,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
- void testDropSchema() {
+ protected void testDropSchema() {
String testDatabaseName = "t_drop";
dropDatabaseIfExists(testDatabaseName);
Set<String> databases = getDatabases();
@@ -381,7 +381,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
- void testRenameTable() {
+ protected void testRenameTable() {
String tableName = "rename1";
String newTableName = "rename2";
dropTableIfExists(tableName);
@@ -408,7 +408,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
- void testListTable() {
+ protected void testListTable() {
String table1 = "list1";
String table2 = "list2";
dropTableIfExists(table1);
@@ -472,6 +472,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
+ @EnabledIf("supportsSchemaEvolution")
void testAlterTableAddAndDeleteColumn() {
String tableName = "test_column";
dropTableIfExists(tableName);
@@ -491,6 +492,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
+ @EnabledIf("supportsSchemaEvolution")
void testAlterTableUpdateColumnType() {
String tableName = "test_column_type";
dropTableIfExists(tableName);
@@ -508,6 +510,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
+ @EnabledIf("supportsSchemaEvolution")
void testAlterTableRenameColumn() {
String tableName = "test_rename_column";
dropTableIfExists(tableName);
@@ -858,7 +861,7 @@ public abstract class SparkCommonIT extends SparkEnvIT {
}
@Test
- void testDropAndWriteTable() {
+ protected void testDropAndWriteTable() {
String tableName = "drop_then_create_write_table";
createSimpleTable(tableName);
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
index a9da4aaa8f..fcd2c2d819 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/SparkEnvIT.java
@@ -96,7 +96,7 @@ public abstract class SparkEnvIT extends SparkUtilIT {
}
@BeforeAll
- void startUp() throws Exception {
+ protected void startUp() throws Exception {
initHiveEnv();
// initialize the hiveMetastoreUri and warehouse at first to inject
properties to
// IcebergRestService
@@ -120,7 +120,7 @@ public abstract class SparkEnvIT extends SparkUtilIT {
}
@AfterAll
- void stop() throws IOException, InterruptedException {
+ protected void stop() throws IOException, InterruptedException {
if (hdfs != null) {
try {
hdfs.close();
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkGlueCatalogIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkGlueCatalogIT.java
new file mode 100644
index 0000000000..79b9ca4b33
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkGlueCatalogIT.java
@@ -0,0 +1,573 @@
+/*
+ * 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.integration.test.glue;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.gravitino.catalog.glue.GlueConstants;
+import
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfo;
+import
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfo.SparkColumnInfo;
+import
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfoChecker;
+import org.apache.spark.sql.types.DataTypes;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Integration test for GravitinoGlueCatalog in Spark connector.
+ *
+ * <p>Tests mixed table type support (Hive format + Iceberg format) in a
single Glue database. Uses
+ * Moto server to mock AWS Glue API, similar to MotoGlueCatalogIT in the
server module.
+ */
+public abstract class SparkGlueCatalogIT extends SparkGlueEnvIT {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(SparkGlueCatalogIT.class);
+
+ private String glueEndpoint;
+ private String awsRegion;
+ private String awsAccessKeyId;
+ private String awsSecretAccessKey;
+
+ @Override
+ protected String getCatalogName() {
+ return "glue";
+ }
+
+ @Override
+ protected String getProvider() {
+ return "glue";
+ }
+
+ @Override
+ protected Map<String, String> getCatalogConfigs() {
+ Preconditions.checkArgument(
+ awsRegion != null, "awsRegion must be set before getCatalogConfigs()");
+ Preconditions.checkArgument(
+ awsAccessKeyId != null, "awsAccessKeyId must be set before
getCatalogConfigs()");
+ Preconditions.checkArgument(
+ awsSecretAccessKey != null, "awsSecretAccessKey must be set before
getCatalogConfigs()");
+ Map<String, String> catalogProperties = new java.util.HashMap<>();
+ catalogProperties.put(GlueConstants.AWS_REGION, awsRegion);
+ catalogProperties.put(GlueConstants.AWS_ACCESS_KEY_ID, awsAccessKeyId);
+ catalogProperties.put(GlueConstants.AWS_SECRET_ACCESS_KEY,
awsSecretAccessKey);
+ catalogProperties.put(GlueConstants.WAREHOUSE, warehouse);
+ if (glueEndpoint != null) {
+ catalogProperties.put(GlueConstants.AWS_GLUE_ENDPOINT, glueEndpoint);
+ }
+ return catalogProperties;
+ }
+
+ @Override
+ protected boolean supportsSparkSQLClusteredBy() {
+ return true;
+ }
+
+ @Override
+ protected boolean supportsPartition() {
+ return true;
+ }
+
+ @Override
+ protected boolean supportsDelete() {
+ return false;
+ }
+
+ @Override
+ protected boolean supportsSchemaEvolution() {
+ return false;
+ }
+
+ @Override
+ protected boolean supportsReplaceColumns() {
+ return false;
+ }
+
+ @Override
+ protected boolean supportsSchemaAndTableProperties() {
+ return true;
+ }
+
+ @Override
+ protected boolean supportsComplexType() {
+ // Glue does not support Gravitino complex types (LIST, MAP, STRUCT) in
table columns.
+ return false;
+ }
+
+ @Override
+ protected boolean supportsUpdateColumnPosition() {
+ return false;
+ }
+
+ @Override
+ protected boolean supportsFunction() {
+ return false;
+ }
+
+ @BeforeAll
+ @Override
+ protected void startUp() throws Exception {
+ String accessKeyId = System.getenv("AWS_ACCESS_KEY_ID");
+ String secretAccessKey = System.getenv("AWS_SECRET_ACCESS_KEY");
+ setGlueEndpoint(null);
+ setAwsCredentials(accessKeyId, secretAccessKey);
+ setAwsRegion(System.getenv("AWS_DEFAULT_REGION"));
+ setS3Credentials(null, accessKeyId, secretAccessKey);
+ setS3BucketName(System.getenv("AWS_S3_TEST_BUCKET"));
+ super.startUp();
+ }
+
+ /**
+ * Sets the Glue endpoint for testing. Called by subclasses after Moto
container is started.
+ *
+ * @param endpoint the Glue API endpoint URL
+ */
+ protected void setGlueEndpoint(String endpoint) {
+ this.glueEndpoint = endpoint;
+ }
+
+ protected void setAwsRegion(String region) {
+ this.awsRegion = region;
+ }
+
+ /**
+ * Sets AWS credentials for testing.
+ *
+ * @param accessKeyId AWS access key ID
+ * @param secretAccessKey AWS secret access key
+ */
+ protected void setAwsCredentials(String accessKeyId, String secretAccessKey)
{
+ this.awsAccessKeyId = accessKeyId;
+ this.awsSecretAccessKey = secretAccessKey;
+ }
+
+ @Override
+ protected String getDefaultAwsRegion() {
+ return awsRegion;
+ }
+
+ @Override
+ protected String getGlueEndpoint() {
+ return glueEndpoint;
+ }
+
+ protected String getAwsRegion() {
+ return awsRegion;
+ }
+
+ /**
+ * Overrides to use CASCADE so that databases with stale tables (e.g., from
prior test runs) can
+ * be cleaned up. Glue tables persist across JVM restarts and may be left
behind after a crash.
+ */
+ @Override
+ protected void dropDatabaseIfExists(String database) {
+ sql("DROP DATABASE IF EXISTS " + database + " CASCADE");
+ }
+
+ /**
+ * Overrides to always use PARQUET format for Glue. Without an explicit
USING clause, Spark may
+ * route CREATE TABLE through the V1 Hive path, bypassing the
location-derivation logic in
+ * GravitinoGlueCatalog.createTable and leaving tables without a stored S3
location.
+ */
+ @Override
+ protected void createSimpleTable(String identifier) {
+ sql(getCreateSimpleTableString(identifier) + " USING PARQUET");
+ }
+
+ /**
+ * Overrides to recreate the database with the correct S3 location. The base
implementation uses
+ * CREATE DATABASE IF NOT EXISTS with a local HDFS path (/user/hive/db),
causing tables to inherit
+ * a local path as their default location. We drop and recreate to ensure
the S3 location is
+ * always set correctly, so stale data cleanup via dropTableIfExists works
reliably.
+ */
+ @Override
+ protected void createDatabaseIfNotExists(String database, String provider) {
+ String dbLocation = warehouse + "/" + database;
+ dropDatabaseIfExists(database);
+ // Delete S3 data directory so stale data files from prior runs don't
cause duplicate rows.
+ deleteDirIfExists(dbLocation);
+ sql(String.format("CREATE DATABASE %s LOCATION '%s'", database,
dbLocation));
+ }
+
+ /**
+ * Overrides to also delete the S3 data directory after dropping the table.
Unlike HDFS managed
+ * tables, Glue external tables do not delete S3 data on DROP TABLE. Stale
data files cause
+ * duplicate rows on the next create+insert cycle.
+ */
+ @Override
+ protected void dropTableIfExists(String tableName) {
+ String location = null;
+ try {
+ location = getTableInfo(tableName).getTableLocation();
+ } catch (Exception e) {
+ // Table may not exist yet — location stays null, nothing to delete.
+ LOG.debug("Could not get location for table {}: {}", tableName,
e.getMessage());
+ }
+ super.dropTableIfExists(tableName);
+ if (location != null) {
+ deleteDirIfExists(location);
+ }
+ }
+
+ /**
+ * Overrides base class: use USING PARQUET to ensure the table goes through
the Gravitino Glue
+ * catalog (V2 path).
+ */
+ @Test
+ @Override
+ protected void testDropAndWriteTable() {
+ String tableName = "drop_then_create_write_table";
+ dropTableIfExists(tableName);
+ sql(getCreateSimpleTableString(tableName) + " USING PARQUET");
+ SparkTableInfo info = getTableInfo(tableName);
+ checkTableReadWrite(info);
+
+ // External tables on S3 do not delete data on DROP TABLE; clean up
explicitly.
+ String location = info.getTableLocation();
+ dropTableIfExists(tableName);
+ if (location != null) {
+ deleteDirIfExists(location);
+ }
+
+ sql(getCreateSimpleTableString(tableName) + " USING PARQUET");
+ checkTableReadWrite(getTableInfo(tableName));
+ }
+
+ @Disabled("Glue does not support table rename for non-Iceberg tables")
+ @Test
+ @Override
+ protected void testRenameTable() {}
+
+ // -------------------------------------------------------------------------
+ // Test mixed table types (Hive format + Iceberg format)
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testCreateHiveFormatTable() {
+ String tableName = "test_hive_format_table";
+ dropTableIfExists(tableName);
+ String createTableSql = getCreateSimpleTableString(tableName);
+ createTableSql += " USING PARQUET";
+ sql(createTableSql);
+
+ SparkTableInfo tableInfo = getTableInfo(tableName);
+ SparkTableInfoChecker checker =
+
SparkTableInfoChecker.create().withName(tableName).withColumns(getSimpleTableColumn());
+ checker.check(tableInfo);
+ checkTableReadWrite(tableInfo);
+ }
+
+ @Test
+ void testCreateIcebergFormatTable() {
+ String tableName = "test_iceberg_format_table";
+ dropTableIfExists(tableName);
+ String createTableSql = getCreateSimpleTableString(tableName);
+ createTableSql += " USING iceberg";
+ sql(createTableSql);
+
+ SparkTableInfo tableInfo = getTableInfo(tableName);
+ SparkTableInfoChecker checker =
+
SparkTableInfoChecker.create().withName(tableName).withColumns(getSimpleTableColumn());
+ checker.check(tableInfo);
+ checkTableReadWrite(tableInfo);
+ }
+
+ @Test
+ void testMixedTableTypesInSameDatabase() {
+ String hiveTable = "mixed_hive_table";
+ String icebergTable = "mixed_iceberg_table";
+
+ // Create non-partitioned Hive format table
+ dropTableIfExists(hiveTable);
+ sql(getCreateSimpleTableString(hiveTable) + " USING PARQUET");
+
+ // Create non-partitioned Iceberg format table
+ dropTableIfExists(icebergTable);
+ sql(getCreateSimpleTableString(icebergTable) + " USING iceberg");
+
+ // Both tables should be accessible
+ SparkTableInfo hiveTableInfo = getTableInfo(hiveTable);
+ SparkTableInfoChecker hiveChecker =
+
SparkTableInfoChecker.create().withName(hiveTable).withColumns(getSimpleTableColumn());
+ hiveChecker.check(hiveTableInfo);
+ checkTableReadWrite(hiveTableInfo);
+
+ SparkTableInfo icebergTableInfo = getTableInfo(icebergTable);
+ SparkTableInfoChecker icebergChecker =
+
SparkTableInfoChecker.create().withName(icebergTable).withColumns(getSimpleTableColumn());
+ icebergChecker.check(icebergTableInfo);
+ checkTableReadWrite(icebergTableInfo);
+ }
+
+ @Test
+ void testHivePartitionedTable() {
+ String tableName = "test_hive_partitioned_table";
+ dropTableIfExists(tableName);
+ // Use existing columns as partition keys (datasource-style) so partition
columns stay in
+ // schema.
+ // Spark places partition columns last; columns = [id, age, name] with
name as partition.
+ sql(
+ "CREATE TABLE "
+ + tableName
+ + " (id INT COMMENT 'id comment', age INT, name STRING COMMENT '')
USING PARQUET"
+ + " PARTITIONED BY (name)");
+
+ SparkTableInfo tableInfo = getTableInfo(tableName);
+ SparkTableInfoChecker checker =
+ SparkTableInfoChecker.create()
+ .withName(tableName)
+ .withColumns(
+ Arrays.asList(
+ SparkColumnInfo.of("id", DataTypes.IntegerType, "id
comment"),
+ SparkColumnInfo.of("age", DataTypes.IntegerType, null),
+ SparkColumnInfo.of("name", DataTypes.StringType, "")))
+ .withIdentifyPartition(Arrays.asList("name"));
+ checker.check(tableInfo);
+ checkTableReadWrite(tableInfo);
+ }
+
+ @Test
+ void testIcebergPartitionedTable() {
+ String tableName = "test_iceberg_partitioned_table";
+ dropTableIfExists(tableName);
+ // Partition by an existing column (id). Iceberg stores partition columns
separately from
+ // schema.
+ sql(getCreateSimpleTableString(tableName) + " USING iceberg PARTITIONED BY
(id)");
+
+ SparkTableInfo tableInfo = getTableInfo(tableName);
+ SparkTableInfoChecker checker =
+
SparkTableInfoChecker.create().withName(tableName).withColumns(getSimpleTableColumn());
+ checker.check(tableInfo);
+ checkTableReadWrite(tableInfo);
+ }
+
+ @Test
+ void testInsertHiveTable() {
+ String tableName = "test_insert_hive_table";
+ dropTableIfExists(tableName);
+ String createTableSql = getCreateSimpleTableString(tableName);
+ createTableSql += " USING PARQUET";
+ sql(createTableSql);
+
+ sql(String.format("INSERT INTO %s VALUES (1, 'name1', 25)", tableName));
+ List<String> tableData = getTableData(tableName);
+ Assertions.assertFalse(tableData.isEmpty());
+ Assertions.assertEquals("1,name1,25", tableData.get(0));
+ }
+
+ @Test
+ void testInsertIcebergTable() {
+ String tableName = "test_insert_iceberg_table";
+ dropTableIfExists(tableName);
+ String createTableSql = getCreateSimpleTableString(tableName);
+ createTableSql += " USING iceberg";
+ sql(createTableSql);
+
+ sql(String.format("INSERT INTO %s VALUES (1, 'name1', 25)", tableName));
+ List<String> tableData = getTableData(tableName);
+ Assertions.assertFalse(tableData.isEmpty());
+ Assertions.assertEquals("1,name1,25", tableData.get(0));
+ }
+
+ @Test
+ void testCreateTableWithComment() {
+ String tableName = "test_table_with_comment";
+ dropTableIfExists(tableName);
+ String createTableSql = getCreateSimpleTableString(tableName);
+ createTableSql += " USING PARQUET COMMENT 'Test table comment'";
+ sql(createTableSql);
+
+ SparkTableInfo tableInfo = getTableInfo(tableName);
+ Assertions.assertEquals("Test table comment", tableInfo.getComment());
+ checkTableReadWrite(tableInfo);
+ }
+
+ @Test
+ void testExternalTableLocation() {
+ String tableName = "test_external_table";
+ dropTableIfExists(tableName);
+ String externalLocation = warehouse + "/external_glue_db/external_table";
+ deleteDirIfExists(externalLocation);
+
+ String createTableSql = getCreateSimpleTableString(tableName);
+ createTableSql += String.format(" USING PARQUET LOCATION '%s'",
externalLocation);
+ sql(createTableSql);
+
+ SparkTableInfo tableInfo = getTableInfo(tableName);
+ Assertions.assertEquals(externalLocation, tableInfo.getTableLocation());
+ checkTableReadWrite(tableInfo);
+ }
+
+ @Test
+ @Override
+ protected void testLoadCatalogs() {
+ // Glue catalog is not shown in SHOW CATALOGS output (Gravitino registers
it lazily via Spark
+ // plugin). Verify accessibility by listing databases instead.
+ Assertions.assertDoesNotThrow(() -> sql("SHOW DATABASES IN " +
getCatalogName()));
+ }
+
+ /**
+ * Overrides base: skips S3 directory verification when no explicit LOCATION
was given. Glue does
+ * not store the auto-assigned warehouse location in table properties, so we
cannot reconstruct
+ * the exact S3 path. Data read/write correctness is already validated by
{@link
+ * #checkTableReadWrite}.
+ */
+ @Override
+ protected void checkPartitionDirExists(SparkTableInfo table) {
+ if (table.getTableLocation() == null) {
+ return;
+ }
+ super.checkPartitionDirExists(table);
+ }
+
+ // -------------------------------------------------------------------------
+ // Override exception-assertion tests (Glue throws different exception types)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Override: Glue does not support local filesystem paths for database
locations; use S3 path.
+ * Also, Glue does not return Owner as "anonymous".
+ */
+ @Test
+ @Override
+ protected void testCreateAndLoadSchema() {
+ String testDatabaseName = "t_create1";
+ dropDatabaseIfExists(testDatabaseName);
+ sql("CREATE DATABASE " + testDatabaseName + " WITH DBPROPERTIES
(ID=001);");
+ Map<String, String> databaseMeta = getDatabaseMetadata(testDatabaseName);
+ // Glue does not auto-assign a location when none is specified, so no
"Location" row appears
+ String properties = databaseMeta.get("Properties");
+ Assertions.assertTrue(properties.contains("(ID,001)"));
+
+ testDatabaseName = "t_create2";
+ dropDatabaseIfExists(testDatabaseName);
+ String testDatabaseLocation = warehouse + "/" + testDatabaseName;
+ sql(
+ String.format(
+ "CREATE DATABASE %s COMMENT 'comment' LOCATION '%s' WITH
DBPROPERTIES (ID=002);",
+ testDatabaseName, testDatabaseLocation));
+ databaseMeta = getDatabaseMetadata(testDatabaseName);
+ String comment = databaseMeta.get("Comment");
+ Assertions.assertEquals("comment", comment);
+
Assertions.assertTrue(databaseMeta.get("Location").contains(testDatabaseName));
+ properties = databaseMeta.get("Properties");
+ Assertions.assertTrue(properties.contains("(ID,002)"));
+ }
+
+ /**
+ * Override: Glue may throw AnalysisException instead of
NoSuchNamespaceException when listing
+ * tables in a nonexistent schema.
+ */
+ @Test
+ void testListTables() {
+ String tableName = "t_list";
+ Set<String> tableNames = listTableNames();
+ Assertions.assertFalse(tableNames.contains(tableName));
+ createSimpleTable(tableName);
+ tableNames = listTableNames();
+ Assertions.assertTrue(tableNames.contains(tableName));
+ // Glue throws AnalysisException or other runtime exception through
Spark's Hive catalog adapter
+ Assertions.assertThrows(Exception.class, () -> sql("SHOW TABLES IN
nonexistent_schema"));
+ }
+
+ /**
+ * Override: Glue may throw AnalysisException instead of
NoSuchNamespaceException when altering a
+ * nonexistent schema.
+ */
+ @Test
+ @Override
+ protected void testAlterSchema() {
+ String testDatabaseName = "t_alter";
+ dropDatabaseIfExists(testDatabaseName);
+ sql("CREATE DATABASE " + testDatabaseName + " WITH DBPROPERTIES
(ID=001);");
+ Assertions.assertTrue(
+
getDatabaseMetadata(testDatabaseName).get("Properties").contains("(ID,001)"));
+
+ sql(String.format("ALTER DATABASE %s SET DBPROPERTIES ('ID'='002')",
testDatabaseName));
+ Assertions.assertFalse(
+
getDatabaseMetadata(testDatabaseName).get("Properties").contains("(ID,001)"));
+ Assertions.assertTrue(
+
getDatabaseMetadata(testDatabaseName).get("Properties").contains("(ID,002)"));
+
+ // Glue may throw AnalysisException instead of NoSuchNamespaceException
+ Assertions.assertThrows(
+ Exception.class, () -> sql("ALTER DATABASE notExists SET DBPROPERTIES
('ID'='001')"));
+ }
+
+ /**
+ * Override: Glue may throw AnalysisException instead of
NoSuchNamespaceException when dropping a
+ * nonexistent schema.
+ */
+ @Test
+ @Override
+ protected void testDropSchema() {
+ String testDatabaseName = "t_drop";
+ dropDatabaseIfExists(testDatabaseName);
+ Set<String> databases = getDatabases();
+ Assertions.assertFalse(databases.contains(testDatabaseName));
+
+ sql("CREATE DATABASE " + testDatabaseName);
+ databases = getDatabases();
+ Assertions.assertTrue(databases.contains(testDatabaseName));
+
+ sql("DROP DATABASE " + testDatabaseName);
+ databases = getDatabases();
+ Assertions.assertFalse(databases.contains(testDatabaseName));
+
+ // Glue may throw AnalysisException instead of NoSuchNamespaceException
+ Assertions.assertThrows(Exception.class, () -> sql("DROP DATABASE
notExists"));
+ }
+
+ /**
+ * Override: Glue may throw AnalysisException instead of
NoSuchNamespaceException when listing
+ * tables from a nonexistent database.
+ */
+ @Test
+ @Override
+ protected void testListTable() {
+ String table1 = "list1";
+ String table2 = "list2";
+ createSimpleTable(table1);
+ createSimpleTable(table2);
+ Set<String> tables = listTableNames();
+ Assertions.assertTrue(tables.contains(table1));
+ Assertions.assertTrue(tables.contains(table2));
+
+ String database = "db_list";
+ String table3 = "list3";
+ String table4 = "list4";
+ createDatabaseIfNotExists(database, getProvider());
+ dropTableIfExists(String.join(".", database, table3));
+ dropTableIfExists(String.join(".", database, table4));
+ createSimpleTable(String.join(".", database, table3));
+ createSimpleTable(String.join(".", database, table4));
+ tables = listTableNames(database);
+
+ Assertions.assertTrue(tables.contains(table3));
+ Assertions.assertTrue(tables.contains(table4));
+
+ // Glue may throw AnalysisException instead of NoSuchNamespaceException
+ Assertions.assertThrows(Exception.class, () ->
listTableNames("not_exists_db"));
+ }
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkGlueEnvIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkGlueEnvIT.java
new file mode 100644
index 0000000000..a502826776
--- /dev/null
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkGlueEnvIT.java
@@ -0,0 +1,363 @@
+/*
+ * 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.integration.test.glue;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.util.Collections;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.spark.connector.GravitinoSparkConfig;
+import org.apache.gravitino.spark.connector.integration.test.SparkCommonIT;
+import org.apache.gravitino.spark.connector.plugin.GravitinoSparkPlugin;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.spark.SparkConf;
+import org.apache.spark.sql.SparkSession;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Base environment setup for Spark Glue connector integration tests.
+ *
+ * <p>Replaces the Hive/HDFS initialization chain from SparkEnvIT with
Glue-appropriate setup. Glue
+ * does not require Hive metastore or HDFS — it uses AWS Glue API (mocked by
LocalStack) and S3
+ * storage.
+ *
+ * <p>Subclasses must:
+ *
+ * <ul>
+ * <li>Start LocalStack container in their {@link #startUp()} and call {@link
+ * SparkGlueCatalogIT#setGlueEndpoint(String)}
+ * <li>Set AWS credentials via {@link
SparkGlueCatalogIT#setAwsCredentials(String, String)}
+ * <li>Set S3 credentials via {@link #setS3Credentials(String, String,
String)}
+ * <li>Call {@code super.startUp()} after configuring Glue
+ * </ul>
+ */
+public abstract class SparkGlueEnvIT extends SparkCommonIT {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(SparkGlueEnvIT.class);
+
+ private SparkSession sparkSession;
+
+ private String s3AccessKey;
+ private String s3SecretKey;
+ private String s3Endpoint;
+ private String s3BucketName;
+
+ @Override
+ protected SparkSession getSparkSession() {
+ return sparkSession;
+ }
+
+ /**
+ * Starts the test environment. Subclasses must call super.startUp() after
configuring the Glue
+ * endpoint and credentials.
+ */
+ @BeforeAll
+ protected void startUp() throws Exception {
+ Preconditions.checkArgument(s3BucketName != null, "s3BucketName must be
set before startUp()");
+ Preconditions.checkArgument(s3AccessKey != null, "s3AccessKey must be set
before startUp()");
+ Preconditions.checkArgument(s3SecretKey != null, "s3SecretKey must be set
before startUp()");
+ warehouse = "s3a://" + s3BucketName + "/warehouse";
+ hiveMetastoreUri = null;
+ hdfs = null;
+
+ // Start the embedded Gravitino server. SparkEnvIT.startIntegrationTest()
is an empty
+ // @BeforeAll override that prevents JUnit from auto-invoking
BaseIT.startIntegrationTest().
+ // We call startServer() directly (a non-@BeforeAll method) to avoid the
virtual-dispatch
+ // problem that would occur with reflection + Method.invoke().
+ startServer();
+ int gravitinoPort = getGravitinoServerPort();
+ String gravitinoUri = String.format("http://127.0.0.1:%d", gravitinoPort);
+ initMetalakeAndCatalogs();
+ initSparkEnv(gravitinoUri);
+ // Create the default database in the Glue catalog so that tests can USE
it.
+ // Note: SparkCommonIT.initDefaultDatabase() is package-private and cannot
be @Override'd
+ // from this package, so we call this class's own implementation directly.
+ initDefaultDatabase();
+
+ LOG.info(
+ "Startup Spark env for Glue successfully, Gravitino uri: {},
Warehouse: {}",
+ gravitinoUri,
+ warehouse);
+ }
+
+ @BeforeEach
+ protected void init() {
+ // Spark's Hive-style catalog integration treats USE <name> as switching
databases,
+ // not catalogs. We must use SET to set the default catalog instead.
+ sql("SET spark.sql.defaultCatalog=" + getCatalogName());
+ sql("USE " + getDefaultDatabase());
+ }
+
+ protected String getDefaultAwsRegion() {
+ return "us-east-1";
+ }
+
+ /**
+ * Skips the Hive container initialization. Glue uses AWS Glue API (catalog)
and S3 (storage), not
+ * a Hive metastore or HDFS. This method shadows the private {@link
SparkEnvIT#initHiveEnv()} from
+ * the grandparent class — no @Override since the parent method is private
(not inheritable).
+ */
+ protected void initHiveEnv() {
+ // Skip Hive container startup. Glue uses AWS Glue API + S3, not HMS.
+ // warehouse and hiveMetastoreUri were already set in startUp():
+ // - warehouse = "s3a://bucket/warehouse" (already set)
+ // - hiveMetastoreUri = null (already set)
+ }
+
+ /**
+ * Overrides HDFS filesystem initialization. Glue uses S3, not HDFS, so this
is a no-op. Note:
+ * no @Override because the parent method is private (not inheritable).
+ */
+ protected void initHdfsFileSystem() {
+ // Glue uses S3, not HDFS. hdfs is already set to null in startUp().
+ }
+
+ /**
+ * Shadows parent's HDFS-based database initialization. The parent {@link
+ * org.apache.gravitino.spark.connector.integration.test.SparkCommonIT}
creates a database with an
+ * HDFS location ('/user/hive/{db}') which is wrong for Glue. This
implementation drops and
+ * recreates the database with the correct S3 location to remove any stale
tables left by prior
+ * test runs (e.g., when multiple Spark versions share the same Glue
catalog).
+ *
+ * <p>Called explicitly from {@link #startUp()}, not via JUnit lifecycle
inheritance. JUnit 5
+ * treats this protected method as hiding the package-private {@code
@BeforeAll
+ * initDefaultDatabase()} from {@code SparkCommonIT}, so the parent's
drop-and-recreate logic
+ * would not run — this override must perform the same cleanup itself.
+ */
+ protected void initDefaultDatabase() {
+ String defaultDbName = getDefaultDatabase();
+ String dbLocation = warehouse + "/" + defaultDbName;
+ sql("SET spark.sql.defaultCatalog=" + getCatalogName());
+ sql("DROP DATABASE IF EXISTS " + defaultDbName + " CASCADE");
+ deleteDirIfExists(dbLocation);
+ sql(String.format("CREATE DATABASE %s LOCATION '%s'", defaultDbName,
dbLocation));
+ sql("USE " + defaultDbName);
+ }
+
+ /**
+ * Overrides HDFS-based directory check. Glue uses S3 storage, not HDFS.
Uses S3A filesystem to
+ * verify directory existence on S3.
+ */
+ @Override
+ protected void checkDirExists(Path dir) {
+ try {
+ Configuration conf = newS3Config();
+ FileSystem fs = FileSystem.get(dir.toUri(), conf);
+ boolean exists = fs.exists(dir);
+ fs.close();
+ org.junit.jupiter.api.Assertions.assertTrue(exists, "S3 directory not
exists: " + dir);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Overrides HDFS-based file existence check. Glue uses S3 storage, not
HDFS. Uses S3A filesystem
+ * to verify that at least one data file exists in the partition directory.
+ */
+ @Override
+ protected void checkDataFileExists(Path dir) {
+ try {
+ Configuration conf = newS3Config();
+ FileSystem fs = FileSystem.get(dir.toUri(), conf);
+ FileStatus[] files = fs.listStatus(dir);
+ boolean hasFile = false;
+ for (FileStatus file : files) {
+ if (file.isFile()) {
+ hasFile = true;
+ break;
+ }
+ }
+ fs.close();
+ org.junit.jupiter.api.Assertions.assertTrue(hasFile, "No data file found
in: " + dir);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @AfterAll
+ protected void stop() throws IOException, InterruptedException {
+ if (sparkSession != null) {
+ sparkSession.close();
+ }
+ super.stopIntegrationTest();
+ }
+
+ /**
+ * Deletes a directory (file or directory) from S3 if it exists. Overrides
the HDFS-based
+ * implementation in SparkCommonIT since Glue uses S3 storage instead of
HDFS.
+ */
+ @Override
+ protected void deleteDirIfExists(String path) {
+ try {
+ Configuration conf = newS3Config();
+ Path dir = new Path(path);
+ FileSystem fs = FileSystem.get(dir.toUri(), conf);
+ if (fs.exists(dir)) {
+ fs.delete(dir, true);
+ }
+ fs.close();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Creates a Hadoop Configuration configured for S3A access with the test
credentials. */
+ private Configuration newS3Config() {
+ Configuration conf = new Configuration();
+ conf.set("fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem");
+ conf.set("fs.s3a.access.key", s3AccessKey);
+ conf.set("fs.s3a.secret.key", s3SecretKey);
+ conf.set(
+ "fs.s3a.aws.credentials.provider",
"org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider");
+ if (s3Endpoint != null) {
+ conf.set("fs.s3a.endpoint", s3Endpoint);
+ conf.set("fs.s3a.path.style.access", "true");
+ conf.set("fs.s3a.connection.ssl.enabled", "false");
+ }
+ return conf;
+ }
+
+ private void initMetalakeAndCatalogs() {
+ client.createMetalake(metalakeName, "", Collections.emptyMap());
+ GravitinoMetalake metalake = client.loadMetalake(metalakeName);
+ metalake.createCatalog(
+ getCatalogName(), Catalog.Type.RELATIONAL, getProvider(), "",
getCatalogConfigs());
+ }
+
+ /**
+ * Returns the Glue API endpoint override, or null to use the default AWS
endpoint. Subclasses
+ * that use a mock Glue service (e.g., Moto/LocalStack) should override this
to return the mock
+ * URL.
+ */
+ protected String getGlueEndpoint() {
+ return null;
+ }
+
+ private void initSparkEnv(String gravitinoUri) {
+ String awsRegion = getDefaultAwsRegion();
+ // Set AWS region as system properties so the AWS SDK's
DefaultAwsRegionProviderChain
+ // can find it. Without this, S3A operations fail with "Unable to find a
region".
+ // aws.region is checked first by DefaultAwsRegionProviderChain;
AWS_DEFAULT_REGION is
+ // fallback for env-var-based provider.
+ if (awsRegion != null && !awsRegion.isEmpty()) {
+ System.setProperty("AWS_DEFAULT_REGION", awsRegion);
+ System.setProperty("aws.region", awsRegion);
+ }
+
+ String glueEndpoint = getGlueEndpoint();
+ SparkConf sparkConf =
+ new SparkConf()
+ .set("spark.plugins", GravitinoSparkPlugin.class.getName())
+ .set(GravitinoSparkConfig.GRAVITINO_URI, gravitinoUri)
+ .set(GravitinoSparkConfig.GRAVITINO_METALAKE, metalakeName)
+ .set(GravitinoSparkConfig.GRAVITINO_ENABLE_ICEBERG_SUPPORT, "true")
+ .set(GravitinoSparkConfig.GRAVITINO_ENABLE_PAIMON_SUPPORT, "true")
+ .set("hive.exec.dynamic.partition.mode", "nonstrict")
+ .set("spark.sql.warehouse.dir", warehouse)
+ .set("spark.sql.session.timeZone", TIME_ZONE_UTC)
+ // S3A filesystem configuration
+ .set("spark.hadoop.fs.s3a.impl",
"org.apache.hadoop.fs.s3a.S3AFileSystem")
+ .set("spark.hadoop.fs.s3a.access.key", s3AccessKey)
+ .set("spark.hadoop.fs.s3a.secret.key", s3SecretKey)
+ .set(
+ "spark.hadoop.fs.s3a.aws.credentials.provider",
+ "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider");
+
+ if (awsRegion != null && !awsRegion.isEmpty()) {
+ sparkConf.set("spark.hadoop.aws.region", awsRegion);
+ }
+ if (glueEndpoint != null) {
+ // For mock Glue services (Moto/LocalStack), override the Glue API
endpoint.
+ sparkConf.set("spark.hadoop.aws.glue.endpoint", glueEndpoint);
+ }
+ // Only set endpoint for custom S3 services (e.g., LocalStack).
+ // For real AWS, omit this to use the default AWS endpoint.
+ if (s3Endpoint != null) {
+ sparkConf.set("spark.hadoop.fs.s3a.endpoint", s3Endpoint);
+ // LocalStack uses HTTP with path-style access
+ sparkConf.set("spark.hadoop.fs.s3a.path.style.access", "true");
+ sparkConf.set("spark.hadoop.fs.s3a.connection.ssl.enabled", "false");
+ } else if (awsRegion != null && !awsRegion.isEmpty()) {
+ // For real AWS S3 (no custom endpoint), configure the region so S3A can
+ // determine the correct AWS partition and endpoint without an explicit
URL.
+ sparkConf.set("spark.hadoop.fs.s3a.endpoint.region", awsRegion);
+ }
+ String glueHiveJarsDir = System.getProperty("glue.hive-jars-dir");
+ Preconditions.checkNotNull(
+ glueHiveJarsDir,
+ "System property glue.hive-jars-dir not set. "
+ + "Download jars from
https://github.com/datastrato/spark-hive-glue-libs "
+ + "and set -Dglue.hive-jars-dir=<path> or run via Gradle test
task.");
+ String sharedPrefixes =
+ String.join(
+ ",",
+ "com.mysql.jdbc",
+ "org.postgresql",
+ "com.microsoft.sqlserver.jdbc",
+ "org.apache.thrift",
+ "org.slf4j",
+ "org.apache.log4j",
+ "com.google.protobuf",
+ "com.google.common",
+ "javax.jdo",
+ "org.apache.derby",
+ "org.antlr");
+ sparkConf
+ .set("spark.sql.hive.metastore.version", "2.3.10")
+ .set("spark.sql.hive.metastore.jars", "path")
+ .set("spark.sql.hive.metastore.jars.path", glueHiveJarsDir + "/*")
+ .set("spark.sql.hive.metastore.sharedPrefixes", sharedPrefixes);
+
+ sparkSession =
+ SparkSession.builder()
+ .master("local[1]")
+ .appName("Spark connector Glue integration test")
+ .config(sparkConf)
+ .enableHiveSupport()
+ .getOrCreate();
+ }
+
+ /**
+ * Sets S3 credentials for Spark S3A filesystem access.
+ *
+ * @param endpoint S3 endpoint URL (e.g., http://localhost:4566)
+ * @param accessKey S3 access key
+ * @param secretKey S3 secret key
+ */
+ public void setS3Credentials(String endpoint, String accessKey, String
secretKey) {
+ this.s3Endpoint = endpoint;
+ this.s3AccessKey = accessKey;
+ this.s3SecretKey = secretKey;
+ }
+
+ public void setS3BucketName(String bucketName) {
+ this.s3BucketName = bucketName;
+ }
+
+ private final String metalakeName = "test";
+}
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/util/SparkUtilIT.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/util/SparkUtilIT.java
index 0ea383e406..8bf9548768 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/util/SparkUtilIT.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/util/SparkUtilIT.java
@@ -30,12 +30,13 @@ import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.apache.gravitino.integration.test.util.BaseIT;
import org.apache.spark.sql.AnalysisException;
-import org.apache.spark.sql.Dataset;
import org.apache.spark.sql.Row;
import org.apache.spark.sql.SparkSession;
-import org.apache.spark.sql.catalyst.analysis.ResolvedTable;
-import org.apache.spark.sql.catalyst.plans.logical.CommandResult;
-import org.apache.spark.sql.catalyst.plans.logical.DescribeRelation;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.connector.catalog.CatalogManager;
+import org.apache.spark.sql.connector.catalog.CatalogPlugin;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
import org.junit.jupiter.api.Assertions;
/**
@@ -134,12 +135,45 @@ public abstract class SparkUtilIT extends BaseIT {
}
// Create SparkTableInfo from SparkBaseTable retrieved from LogicalPlan.
+ // In Spark 3.3/3.5: DESC TABLE EXTENDED returns DescribeRelation.
+ // In Spark 3.4: DESC TABLE EXTENDED returns DescribeTableCommand (different
class hierarchy).
+ // Use the v2 Catalog API (CatalogManager + TableCatalog.loadTable) for
cross-version
+ // compatibility.
protected SparkTableInfo getTableInfo(String tableName) {
- Dataset ds = getSparkSession().sql("DESC TABLE EXTENDED " + tableName);
- CommandResult result = (CommandResult) ds.logicalPlan();
- DescribeRelation relation = (DescribeRelation) result.commandLogicalPlan();
- ResolvedTable table = (ResolvedTable) relation.child();
- return SparkTableInfo.create(table.table());
+ CatalogManager catalogManager =
getSparkSession().sessionState().catalogManager();
+
+ // Parse tableName: could be short (tbl), partially-qualified (db.tbl),
+ // or fully-qualified (cat.db.tbl).
+ String[] parts = tableName.split("\\.");
+ Identifier identifier;
+ TableCatalog tableCatalog;
+ if (parts.length == 1) {
+ // Short table name: use current catalog + current V2 namespace.
+ // catalog().currentDatabase() returns the V1 Hive session catalog
database and is NOT
+ // updated when USE <db> is issued against a V2 catalog (e.g. Glue) in
Spark 3.3.
+ // catalogManager.currentNamespace() reflects the V2 namespace correctly.
+ CatalogPlugin currentCatalog = catalogManager.currentCatalog();
+ String[] currentNamespace = catalogManager.currentNamespace();
+ identifier = Identifier.of(currentNamespace, parts[0]);
+ tableCatalog = (TableCatalog) currentCatalog;
+ } else if (parts.length == 2) {
+ // Partially qualified: db.table
+ identifier = Identifier.of(new String[] {parts[0]}, parts[1]);
+ CatalogPlugin currentCatalog = catalogManager.currentCatalog();
+ tableCatalog = (TableCatalog) currentCatalog;
+ } else if (parts.length == 3) {
+ // Fully qualified: cat.db.table — namespace is only the db part, not
the catalog
+ identifier = Identifier.of(new String[] {parts[1]}, parts[2]);
+ CatalogPlugin catalog = catalogManager.catalog(parts[0]);
+ tableCatalog = (TableCatalog) catalog;
+ } else {
+ throw new IllegalArgumentException("Invalid table name format: " +
tableName);
+ }
+ try {
+ return SparkTableInfo.create(tableCatalog.loadTable(identifier));
+ } catch (NoSuchTableException e) {
+ throw new RuntimeException(e);
+ }
}
protected List<Object[]> getTablePartitions(String tableName) {
@@ -159,6 +193,10 @@ public abstract class SparkUtilIT extends BaseIT {
if (e instanceof AnalysisException) {
return false;
}
+ // getTableInfo() wraps NoSuchTableException (extends AnalysisException)
in RuntimeException
+ if (e.getCause() instanceof AnalysisException) {
+ return false;
+ }
throw e;
}
}
diff --git a/spark-connector/v3.3/spark/build.gradle.kts
b/spark-connector/v3.3/spark/build.gradle.kts
index 2fbd6218c2..3581801337 100644
--- a/spark-connector/v3.3/spark/build.gradle.kts
+++ b/spark-connector/v3.3/spark/build.gradle.kts
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+evaluationDependsOn(":spark-connector:spark-common")
+
plugins {
`maven-publish`
id("java")
@@ -143,6 +145,16 @@ dependencies {
testImplementation(libs.mysql.driver)
testImplementation(libs.postgresql.driver)
testImplementation(libs.testcontainers)
+ testImplementation(libs.hadoop3.aws)
+ // hadoop-aws declares hadoop-client-api as provided; add it explicitly so
S3AFileSystem can load
+ // org.apache.hadoop.fs.impl.prefetch.PrefetchingStatistics (added in 3.3.5)
at runtime.
+ testImplementation(libs.hadoop3.client.api)
+ // Iceberg's GlueCatalog references several AWS SDK modules at runtime; must
be on test classpath
+ testImplementation(libs.aws.dynamodb)
+ testImplementation(libs.aws.glue)
+ testImplementation(libs.aws.sts)
+ testImplementation(libs.aws.s3)
+ testImplementation(libs.aws.kms)
// org.apache.iceberg.rest.RESTSerializers#registerAll(ObjectMapper) has
different method signature for iceberg-core and iceberg-spark-runtime package,
we must make sure iceberg-core is in front to start up MiniGravitino server.
testImplementation("org.apache.iceberg:iceberg-core:$icebergVersion")
@@ -172,6 +184,9 @@ dependencies {
testRuntimeOnly(libs.junit.jupiter.engine)
}
+val glueHiveJarsDir: String? =
+ project(":spark-connector:spark-common").extra["glueHiveJarsDir"] as String?
+
tasks.test {
val skipITs = project.hasProperty("skipITs")
val enableSparkSQLITs = project.hasProperty("enableSparkSQLITs")
@@ -182,6 +197,10 @@ tasks.test {
// Exclude integration tests
exclude("**/integration/test/**")
} else {
+ if (glueHiveJarsDir != null) {
+ dependsOn(":spark-connector:spark-common:downloadGlueHiveJars")
+ jvmArgs("-Dglue.hive-jars-dir=$glueHiveJarsDir")
+ }
dependsOn(tasks.jar)
dependsOn(":catalogs:catalog-lakehouse-iceberg:jar")
dependsOn(":catalogs:catalog-hive:jar")
diff --git
a/spark-connector/v3.3/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark33.java
b/spark-connector/v3.3/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark33.java
new file mode 100644
index 0000000000..4a8075682d
--- /dev/null
+++
b/spark-connector/v3.3/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark33.java
@@ -0,0 +1,22 @@
+/*
+ * 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.glue;
+
+/** Spark 3.3 specific Gravitino Glue catalog implementation. */
+public class GravitinoGlueCatalogSpark33 extends GravitinoGlueCatalog {}
diff --git
a/spark-connector/v3.3/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT33.java
b/spark-connector/v3.3/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT33.java
new file mode 100644
index 0000000000..17b8a78ea7
--- /dev/null
+++
b/spark-connector/v3.3/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT33.java
@@ -0,0 +1,37 @@
+/*
+ * 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.integration.test.glue;
+
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+/**
+ * Integration test for Spark 3.3 Glue catalog connector 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}
+ * <li>{@code AWS_S3_TEST_BUCKET}
+ * </ul>
+ */
+@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".+")
+public class SparkAwsGlueCatalogIT33 extends SparkGlueCatalogIT {}
diff --git a/spark-connector/v3.4/spark/build.gradle.kts
b/spark-connector/v3.4/spark/build.gradle.kts
index d271ba3f94..d4da6062fc 100644
--- a/spark-connector/v3.4/spark/build.gradle.kts
+++ b/spark-connector/v3.4/spark/build.gradle.kts
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+evaluationDependsOn(":spark-connector:spark-common")
+
plugins {
`maven-publish`
id("java")
@@ -96,6 +98,16 @@ dependencies {
}
testImplementation(libs.awaitility)
+ // Iceberg's GlueCatalog references several AWS SDK modules at runtime; must
be on test classpath
+ testImplementation(libs.aws.dynamodb)
+ testImplementation(libs.aws.glue)
+ testImplementation(libs.aws.kms)
+ testImplementation(libs.aws.s3)
+ testImplementation(libs.aws.sts)
+ testImplementation(libs.hadoop3.aws)
+ // hadoop-aws declares hadoop-client-api as provided; add it explicitly so
S3AFileSystem can load
+ // org.apache.hadoop.fs.impl.prefetch.PrefetchingStatistics (added in 3.3.5)
at runtime.
+ testImplementation(libs.hadoop3.client.api)
testImplementation(libs.hive2.common) {
exclude("com.sun.jersey")
exclude("org.apache.curator")
@@ -163,6 +175,9 @@ dependencies {
testRuntimeOnly(libs.junit.jupiter.engine)
}
+val glueHiveJarsDir: String? =
+ project(":spark-connector:spark-common").extra["glueHiveJarsDir"] as String?
+
tasks.test {
val skipITs = project.hasProperty("skipITs")
val enableSparkSQLITs = project.hasProperty("enableSparkSQLITs")
@@ -173,6 +188,10 @@ tasks.test {
// Exclude integration tests
exclude("**/integration/test/**")
} else {
+ if (glueHiveJarsDir != null) {
+ dependsOn(":spark-connector:spark-common:downloadGlueHiveJars")
+ jvmArgs("-Dglue.hive-jars-dir=$glueHiveJarsDir")
+ }
dependsOn(tasks.jar)
dependsOn(":catalogs:catalog-lakehouse-iceberg:jar")
dependsOn(":catalogs:catalog-hive:jar")
diff --git
a/spark-connector/v3.4/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark34.java
b/spark-connector/v3.4/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark34.java
new file mode 100644
index 0000000000..3ca04141be
--- /dev/null
+++
b/spark-connector/v3.4/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark34.java
@@ -0,0 +1,38 @@
+/*
+ * 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.glue;
+
+import org.apache.gravitino.spark.connector.SparkTableChangeConverter;
+import org.apache.gravitino.spark.connector.SparkTableChangeConverter34;
+import org.apache.gravitino.spark.connector.SparkTypeConverter;
+import org.apache.gravitino.spark.connector.hive.SparkHiveTypeConverter34;
+
+/** Spark 3.4 specific Gravitino Glue catalog implementation. */
+public class GravitinoGlueCatalogSpark34 extends GravitinoGlueCatalog {
+ @Override
+ protected SparkTypeConverter getSparkTypeConverter() {
+ return new SparkHiveTypeConverter34();
+ }
+
+ @Override
+ protected SparkTableChangeConverter getSparkTableChangeConverter(
+ SparkTypeConverter sparkTypeConverter) {
+ return new SparkTableChangeConverter34(sparkTypeConverter);
+ }
+}
diff --git
a/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT34.java
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT34.java
new file mode 100644
index 0000000000..c6a18f4827
--- /dev/null
+++
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT34.java
@@ -0,0 +1,37 @@
+/*
+ * 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.integration.test.glue;
+
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+/**
+ * Integration test for Spark 3.4 Glue catalog connector 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}
+ * <li>{@code AWS_S3_TEST_BUCKET}
+ * </ul>
+ */
+@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".+")
+public class SparkAwsGlueCatalogIT34 extends SparkGlueCatalogIT {}
diff --git a/spark-connector/v3.5/spark/build.gradle.kts
b/spark-connector/v3.5/spark/build.gradle.kts
index c17a21d394..4b2a7b7402 100644
--- a/spark-connector/v3.5/spark/build.gradle.kts
+++ b/spark-connector/v3.5/spark/build.gradle.kts
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+evaluationDependsOn(":spark-connector:spark-common")
+
plugins {
`maven-publish`
id("java")
@@ -27,6 +29,9 @@ repositories {
mavenCentral()
}
+val glueHiveJarsDir: String? =
+ project(":spark-connector:spark-common").extra["glueHiveJarsDir"] as String?
+
val scalaVersion: String = project.properties["scalaVersion"] as? String ?:
extra["defaultScalaVersion"].toString()
val sparkVersion: String = libs.versions.spark35.get()
val sparkMajorVersion: String = sparkVersion.substringBeforeLast(".")
@@ -98,6 +103,16 @@ dependencies {
}
testImplementation(libs.awaitility)
+ // Iceberg's GlueCatalog references several AWS SDK modules at runtime; must
be on test classpath
+ testImplementation(libs.aws.dynamodb)
+ testImplementation(libs.aws.glue)
+ testImplementation(libs.aws.kms)
+ testImplementation(libs.aws.s3)
+ testImplementation(libs.aws.sts)
+ testImplementation(libs.hadoop3.aws)
+ // hadoop-aws declares hadoop-client-api as provided; add it explicitly so
S3AFileSystem can load
+ // org.apache.hadoop.fs.impl.prefetch.PrefetchingStatistics (added in 3.3.5)
at runtime.
+ testImplementation(libs.hadoop3.client.api)
testImplementation(libs.hive2.common) {
exclude("com.sun.jersey")
exclude("org.apache.curator")
@@ -175,6 +190,10 @@ tasks.test {
// Exclude integration tests
exclude("**/integration/test/**")
} else {
+ if (glueHiveJarsDir != null) {
+ dependsOn(":spark-connector:spark-common:downloadGlueHiveJars")
+ jvmArgs("-Dglue.hive-jars-dir=$glueHiveJarsDir")
+ }
dependsOn(tasks.jar)
dependsOn(":catalogs:catalog-lakehouse-iceberg:jar")
dependsOn(":catalogs:catalog-hive:jar")
diff --git
a/spark-connector/v3.5/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark35.java
b/spark-connector/v3.5/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark35.java
new file mode 100644
index 0000000000..0203ef9793
--- /dev/null
+++
b/spark-connector/v3.5/spark/src/main/java/org/apache/gravitino/spark/connector/glue/GravitinoGlueCatalogSpark35.java
@@ -0,0 +1,34 @@
+/*
+ * 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.glue;
+
+import java.util.Set;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.TableWritePrivilege;
+
+/** Spark 3.5 specific Gravitino Glue catalog implementation. */
+public class GravitinoGlueCatalogSpark35 extends GravitinoGlueCatalogSpark34 {
+ @Override
+ public Table loadTable(Identifier ident, Set<TableWritePrivilege>
writePrivileges)
+ throws NoSuchTableException {
+ return loadTableForWriting(ident);
+ }
+}
diff --git
a/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT35.java
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT35.java
new file mode 100644
index 0000000000..449e371765
--- /dev/null
+++
b/spark-connector/v3.5/spark/src/test/java/org/apache/gravitino/spark/connector/integration/test/glue/SparkAwsGlueCatalogIT35.java
@@ -0,0 +1,37 @@
+/*
+ * 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.integration.test.glue;
+
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+/**
+ * Integration test for Spark 3.5 Glue catalog connector 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}
+ * <li>{@code AWS_S3_TEST_BUCKET}
+ * </ul>
+ */
+@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".+")
+public class SparkAwsGlueCatalogIT35 extends SparkGlueCatalogIT {}