This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new f6dfdb1cc6 [#11011] feat(catalog-glue): Add native Iceberg table
support via Glue SDK (#11012)
f6dfdb1cc6 is described below
commit f6dfdb1cc6b398f76427965cd9dc6c367ffeea68
Author: Yuhui <[email protected]>
AuthorDate: Tue May 12 21:17:09 2026 +0800
[#11011] feat(catalog-glue): Add native Iceberg table support via Glue SDK
(#11012)
### What changes were proposed in this pull request?
Route Iceberg `createTable`/`alterTable` through AWS Glue native
OpenTableFormat APIs,
add `GlueIcebergHelper` for type conversion and schema evolution, fix
`matchesFormatFilter`
for `default-table-format=iceberg` tables, and upgrade AWS SDK to
2.31.73.
### Why are the changes needed?
Without native Iceberg API routing, Glue skips writing `metadata.json`,
making tables
unreadable by Iceberg engines, and `alterTable` causes metadata drift.
Fix: #11011
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Added `TestGlueIceberg` with 30 unit tests covering routing, type
conversion, schema evolution, and error paths.
---
.../catalog/glue/GlueCatalogOperations.java | 178 +++++-
.../gravitino/catalog/glue/GlueClientProvider.java | 2 +-
.../gravitino/catalog/glue/GlueConstants.java | 24 +
.../gravitino/catalog/glue/GlueIcebergHelper.java | 435 +++++++++++++++
.../gravitino/catalog/glue/GlueTypeConverter.java | 2 +
.../gravitino/catalog/glue/TestGlueIceberg.java | 597 +++++++++++++++++++++
gradle/libs.versions.toml | 2 +-
7 files changed, 1218 insertions(+), 22 deletions(-)
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
index 70f1d5278d..b356b903ed 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
@@ -30,6 +30,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
@@ -79,12 +80,19 @@ import
software.amazon.awssdk.services.glue.model.GetTableRequest;
import software.amazon.awssdk.services.glue.model.GetTablesRequest;
import software.amazon.awssdk.services.glue.model.GetTablesResponse;
import software.amazon.awssdk.services.glue.model.GlueException;
+import software.amazon.awssdk.services.glue.model.IcebergInput;
+import software.amazon.awssdk.services.glue.model.IcebergTableUpdate;
+import software.amazon.awssdk.services.glue.model.MetadataOperation;
+import software.amazon.awssdk.services.glue.model.OpenTableFormatInput;
import software.amazon.awssdk.services.glue.model.Order;
import software.amazon.awssdk.services.glue.model.SerDeInfo;
import software.amazon.awssdk.services.glue.model.StorageDescriptor;
import software.amazon.awssdk.services.glue.model.Table;
import software.amazon.awssdk.services.glue.model.TableInput;
import software.amazon.awssdk.services.glue.model.UpdateDatabaseRequest;
+import software.amazon.awssdk.services.glue.model.UpdateIcebergInput;
+import software.amazon.awssdk.services.glue.model.UpdateIcebergTableInput;
+import software.amazon.awssdk.services.glue.model.UpdateOpenTableFormatInput;
import software.amazon.awssdk.services.glue.model.UpdateTableRequest;
/**
@@ -117,6 +125,8 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
/** Nullable — when null all table formats are exposed. */
@VisibleForTesting Set<String> tableFormatFilter;
+ @VisibleForTesting String defaultTableFormat;
+
private final GlueTypeConverter typeConverter = new GlueTypeConverter();
@Override
@@ -125,6 +135,9 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
throws RuntimeException {
this.glueClient = GlueClientProvider.buildClient(config);
this.catalogId = config.get(GlueConstants.AWS_GLUE_CATALOG_ID);
+ this.defaultTableFormat =
+ config.getOrDefault(
+ GlueConstants.DEFAULT_TABLE_FORMAT,
GlueConstants.DEFAULT_TABLE_FORMAT_VALUE);
String filterProp =
config.getOrDefault(
GlueConstants.TABLE_FORMAT_FILTER,
GlueConstants.DEFAULT_TABLE_FORMAT_FILTER);
@@ -375,24 +388,52 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
String dbName = schemaName(ident.namespace());
Map<String, String> props = properties != null ? properties :
Collections.emptyMap();
+ String tableFormat = props.getOrDefault(GlueConstants.TABLE_FORMAT,
defaultTableFormat);
+ boolean isIceberg =
GlueConstants.ICEBERG_TABLE_TYPE_VALUE.equalsIgnoreCase(tableFormat);
+
+ // For Iceberg tables, stamp table_type=ICEBERG into the Glue parameters
so that
+ // isIcebergTable() detection works consistently for future
alterTable/listTable calls.
+ Map<String, String> finalProps = props;
+ if (isIceberg) {
+ finalProps = new HashMap<>(props);
+ finalProps.put(GlueConstants.TABLE_TYPE_PARAM,
GlueConstants.ICEBERG_TABLE_TYPE_VALUE);
+ }
+
TableInput input =
buildTableInput(
- ident.name(), comment, columns, props, partitions, distribution,
sortOrders);
+ ident.name(), comment, columns, finalProps, partitions,
distribution, sortOrders);
CreateTableRequest.Builder req =
CreateTableRequest.builder().databaseName(dbName).tableInput(input);
- applyCatalogId(catalogId, req::catalogId);
- try {
- glueClient.createTable(req.build());
- } catch (EntityNotFoundException e) {
- throw new NoSuchSchemaException(e, "Schema %s does not exist", dbName);
- } catch (GlueException e) {
- throw GlueExceptionConverter.toTableException(e, "table " +
ident.name());
+ if (isIceberg) {
+ // Register mode: metadata_location points to existing Iceberg metadata.
+ // Create mode: new table; Glue writes metadata.json at the given
location.
+ boolean registerMode =
props.containsKey(GlueConstants.METADATA_LOCATION);
+ if (!registerMode) {
+ Preconditions.checkArgument(
+ props.containsKey(GlueConstants.LOCATION),
+ "Either '%s' (register existing table) or '%s' (create new table)
is required",
+ GlueConstants.METADATA_LOCATION,
+ GlueConstants.LOCATION);
+ req.openTableFormatInput(
+ OpenTableFormatInput.builder()
+ .icebergInput(
+ IcebergInput.builder()
+ .metadataOperation(MetadataOperation.CREATE)
+ .version(GlueConstants.ICEBERG_FORMAT_VERSION)
+ .build())
+ .build());
+ }
}
- LOG.info("Created Glue table {}.{}", dbName, ident.name());
+ executeCreateTable(dbName, ident, req);
+ LOG.info("Created {} table {}.{}", isIceberg ? "Iceberg" : "Glue", dbName,
ident.name());
+ if (isIceberg) {
+ // Load from Glue to pick up any server-set parameters (e.g.
current-schema-id).
+ return loadTable(ident);
+ }
GlueTable created =
GlueTable.builder()
.withName(ident.name())
@@ -416,9 +457,25 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
public GlueTable alterTable(NameIdentifier ident, TableChange... changes)
throws NoSuchTableException, IllegalArgumentException {
- GlueTable current = loadTable(ident);
String dbName = schemaName(ident.namespace());
+ GetTableRequest.Builder rawReq =
+ GetTableRequest.builder().databaseName(dbName).name(ident.name());
+ applyCatalogId(catalogId, rawReq::catalogId);
+ Table rawGlueTable;
+ try {
+ rawGlueTable = glueClient.getTable(rawReq.build()).table();
+ } catch (GlueException e) {
+ throw GlueExceptionConverter.toTableException(e, "table " +
ident.name());
+ }
+
+ if (GlueIcebergHelper.isIcebergTable(rawGlueTable)) {
+ return alterIcebergTable(ident, dbName, rawGlueTable, changes);
+ }
+
+ GlueTable current = GlueTable.fromGlueTable(rawGlueTable, typeConverter);
+ current.initOpsContext(glueClient, catalogId, dbName);
+
String newName = current.name();
String newComment = current.comment();
Map<String, String> newProps = new HashMap<>(current.properties());
@@ -470,16 +527,7 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
current.distribution(),
current.sortOrder());
- UpdateTableRequest.Builder req =
- UpdateTableRequest.builder().databaseName(dbName).tableInput(input);
- applyCatalogId(catalogId, req::catalogId);
-
- try {
- glueClient.updateTable(req.build());
- } catch (GlueException e) {
- throw GlueExceptionConverter.toTableException(e, "table " +
ident.name());
- }
-
+ executeUpdateTable(ident,
UpdateTableRequest.builder().databaseName(dbName).tableInput(input));
LOG.info("Altered Glue table {}.{}", dbName, ident.name());
GlueTable altered =
@@ -497,6 +545,55 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
return altered;
}
+ private GlueTable alterIcebergTable(
+ NameIdentifier ident, String dbName, Table rawGlueTable, TableChange...
changes) {
+
+ GlueIcebergHelper.validateChanges(changes);
+
+ Optional<IcebergTableUpdate> schemaUpdate =
+ GlueIcebergHelper.buildSchemaUpdate(rawGlueTable, changes);
+ Map<String, String> propUpdates =
GlueIcebergHelper.extractSetProperties(changes);
+
+ if (schemaUpdate.isEmpty() && propUpdates.isEmpty()) {
+ LOG.debug("No-op alterIcebergTable for {}.{}", dbName, ident.name());
+ return loadTable(ident);
+ }
+
+ if (schemaUpdate.isPresent()) {
+ UpdateIcebergTableInput icebergTableInput =
+
UpdateIcebergTableInput.builder().updates(schemaUpdate.get()).build();
+ UpdateIcebergInput icebergInput =
+
UpdateIcebergInput.builder().updateIcebergTableInput(icebergTableInput).build();
+ UpdateOpenTableFormatInput openFormatInput =
+
UpdateOpenTableFormatInput.builder().updateIcebergInput(icebergInput).build();
+ executeUpdateTable(
+ ident,
+ UpdateTableRequest.builder()
+ .databaseName(dbName)
+ .updateOpenTableFormatInput(openFormatInput));
+ LOG.info("Altered Iceberg table {}.{} schema via Glue native API",
dbName, ident.name());
+ // Re-fetch to pick up server-side parameter changes (e.g.,
current-schema-id update)
+ // before using rawGlueTable.parameters() for the property update below.
+ GetTableRequest.Builder rawReq =
+ GetTableRequest.builder().databaseName(dbName).name(ident.name());
+ applyCatalogId(catalogId, rawReq::catalogId);
+ rawGlueTable = glueClient.getTable(rawReq.build()).table();
+ }
+
+ if (!propUpdates.isEmpty()) {
+ Map<String, String> newParams = new HashMap<>(rawGlueTable.parameters());
+ newParams.putAll(propUpdates);
+ executeUpdateTable(
+ ident,
+ UpdateTableRequest.builder()
+ .databaseName(dbName)
+ .tableInput(tableInputFromRaw(rawGlueTable, newParams)));
+ LOG.info("Altered Iceberg table {}.{} properties directly", dbName,
ident.name());
+ }
+
+ return loadTable(ident);
+ }
+
@Override
public boolean dropTable(NameIdentifier ident) {
String dbName = schemaName(ident.namespace());
@@ -527,11 +624,52 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
private boolean matchesFormatFilter(Table table) {
if (tableFormatFilter == null) return true;
String fmt = table.hasParameters() ?
table.parameters().get(GlueConstants.TABLE_FORMAT) : null;
+ // Fall back to checking native Glue table_type for tables created without
the Gravitino
+ // table-format property (e.g. created via default-table-format config or
external tooling).
+ if (fmt == null && GlueIcebergHelper.isIcebergTable(table)) {
+ fmt = GlueConstants.ICEBERG_TABLE_TYPE_VALUE;
+ }
String normalized =
fmt != null ? fmt.toLowerCase(Locale.ROOT) :
GlueConstants.DEFAULT_TABLE_FORMAT_VALUE;
return tableFormatFilter.contains(normalized);
}
+ private void executeCreateTable(
+ String dbName, NameIdentifier ident, CreateTableRequest.Builder req) {
+ applyCatalogId(catalogId, req::catalogId);
+ try {
+ glueClient.createTable(req.build());
+ } catch (EntityNotFoundException e) {
+ throw new NoSuchSchemaException(e, "Schema %s does not exist", dbName);
+ } catch (GlueException e) {
+ throw GlueExceptionConverter.toTableException(e, "table " +
ident.name());
+ }
+ }
+
+ private void executeUpdateTable(NameIdentifier ident,
UpdateTableRequest.Builder req) {
+ applyCatalogId(catalogId, req::catalogId);
+ try {
+ glueClient.updateTable(req.build());
+ } catch (GlueException e) {
+ throw GlueExceptionConverter.toTableException(e, "table " +
ident.name());
+ }
+ }
+
+ /** Copies fields from {@code rawGlueTable} into a {@link TableInput} with
updated parameters. */
+ private static TableInput tableInputFromRaw(Table rawGlueTable, Map<String,
String> newParams) {
+ TableInput.Builder b =
+ TableInput.builder()
+ .name(rawGlueTable.name())
+ .description(rawGlueTable.description())
+ .tableType(rawGlueTable.tableType())
+ .parameters(newParams);
+ if (rawGlueTable.storageDescriptor() != null) {
+ b.storageDescriptor(rawGlueTable.storageDescriptor())
+ .partitionKeys(rawGlueTable.partitionKeys());
+ }
+ return b.build();
+ }
+
private TableInput buildTableInput(
String name,
String comment,
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
index 536a3955bc..8f4aa666c2 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
@@ -88,7 +88,7 @@ public final class GlueClientProvider {
builder.credentialsProvider(
StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey,
secretKey)));
} else {
- builder.credentialsProvider(DefaultCredentialsProvider.create());
+
builder.credentialsProvider(DefaultCredentialsProvider.builder().build());
}
// Optional custom endpoint override for VPC endpoints or LocalStack
testing.
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
index 2244f7552a..f919f27899 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
@@ -78,6 +78,30 @@ public final class GlueConstants {
/** Iceberg table metadata location stored in Glue {@code
Table.parameters()}. */
public static final String METADATA_LOCATION = "metadata_location";
+ /**
+ * Key of the {@code table_type} entry in {@code Table.parameters()} that
identifies the table
+ * format (e.g., {@code "ICEBERG"}).
+ */
+ public static final String TABLE_TYPE_PARAM = "table_type";
+
+ /** Value of {@link #TABLE_TYPE_PARAM} that identifies an Iceberg table. */
+ public static final String ICEBERG_TABLE_TYPE_VALUE = "ICEBERG";
+
+ /** Default Iceberg spec version used when creating Iceberg tables via Glue.
*/
+ public static final String ICEBERG_FORMAT_VERSION = "2";
+
+ /**
+ * Key of the {@code current-schema-id} entry in {@code Table.parameters()}
that holds the active
+ * Iceberg schema ID.
+ */
+ public static final String CURRENT_SCHEMA_ID_PARAM = "current-schema-id";
+
+ /** Glue column parameter key for the Iceberg field ID (assigned at table
creation). */
+ public static final String ICEBERG_FIELD_ID = "iceberg.field.id";
+
+ /** Glue column parameter key indicating whether the Iceberg field is
optional (nullable). */
+ public static final String ICEBERG_FIELD_OPTIONAL = "iceberg.field.optional";
+
// -------------------------------------------------------------------------
// StorageDescriptor-derived table properties (stored in Gravitino
properties map)
// -------------------------------------------------------------------------
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueIcebergHelper.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueIcebergHelper.java
new file mode 100644
index 0000000000..2bc64e843a
--- /dev/null
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueIcebergHelper.java
@@ -0,0 +1,435 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.catalog.glue;
+
+import static
org.apache.gravitino.catalog.glue.GlueConstants.CURRENT_SCHEMA_ID_PARAM;
+import static org.apache.gravitino.catalog.glue.GlueConstants.ICEBERG_FIELD_ID;
+import static
org.apache.gravitino.catalog.glue.GlueConstants.ICEBERG_FIELD_OPTIONAL;
+import static org.apache.gravitino.catalog.glue.GlueConstants.TABLE_TYPE_PARAM;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.BIGINT;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.BINARY;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.BOOLEAN;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.CHAR;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.DATE;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.DECIMAL;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.DOUBLE;
+import static
org.apache.gravitino.catalog.glue.GlueTypeConverter.DOUBLE_PRECISION;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.FLOAT;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.INT;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.INTEGER;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.LONG;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.SMALLINT;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.STRING;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.TIMESTAMP;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.TINYINT;
+import static org.apache.gravitino.catalog.glue.GlueTypeConverter.VARCHAR;
+
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.UnaryOperator;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.types.Type;
+import org.apache.gravitino.rel.types.Types;
+import software.amazon.awssdk.core.document.Document;
+import software.amazon.awssdk.services.glue.model.Column;
+import software.amazon.awssdk.services.glue.model.IcebergSchema;
+import software.amazon.awssdk.services.glue.model.IcebergStructField;
+import software.amazon.awssdk.services.glue.model.IcebergStructTypeEnum;
+import software.amazon.awssdk.services.glue.model.IcebergTableUpdate;
+import software.amazon.awssdk.services.glue.model.StorageDescriptor;
+import software.amazon.awssdk.services.glue.model.Table;
+
+/**
+ * Utility methods for building Iceberg-specific Glue API structures used when
creating and
+ * modifying Iceberg-format tables via the AWS Glue Data Catalog.
+ */
+final class GlueIcebergHelper {
+
+ private GlueIcebergHelper() {}
+
+ /**
+ * Returns true if the Glue table is an Iceberg-format table.
+ *
+ * <p>Checks for {@code table_type=ICEBERG} in {@code Table.parameters()}.
+ */
+ static boolean isIcebergTable(Table glueTable) {
+ if (!glueTable.hasParameters()) return false;
+ return GlueConstants.ICEBERG_TABLE_TYPE_VALUE.equalsIgnoreCase(
+ glueTable.parameters().get(TABLE_TYPE_PARAM));
+ }
+
+ /**
+ * Validates that all {@code changes} are supported for Iceberg tables.
Throws {@link
+ * IllegalArgumentException} for unsupported change types, including {@link
+ * TableChange.RemoveProperty} (not supported via the Glue SDK) and
non-column/non-property
+ * changes (e.g., {@code RenameTable}, {@code UpdateComment}).
+ */
+ static void validateChanges(TableChange... changes) {
+ for (TableChange change : changes) {
+ if (change instanceof TableChange.ColumnChange || change instanceof
TableChange.SetProperty) {
+ continue;
+ }
+ if (change instanceof TableChange.RemoveProperty) {
+ throw new IllegalArgumentException(
+ "Removing properties from Iceberg tables is not supported via the
Glue SDK."
+ + " Use SetProperty to override values instead.");
+ }
+ throw new IllegalArgumentException(
+ "Unsupported table change for Iceberg table: " +
change.getClass().getSimpleName());
+ }
+ }
+
+ /**
+ * Filters {@code changes} to column-schema changes and builds a single
{@link IcebergTableUpdate}
+ * carrying the updated schema. Returns empty if there are no column changes.
+ */
+ static Optional<IcebergTableUpdate> buildSchemaUpdate(
+ Table rawGlueTable, TableChange... changes) {
+ List<TableChange> schemaChanges = new ArrayList<>();
+ for (TableChange c : changes) {
+ if (c instanceof TableChange.ColumnChange) schemaChanges.add(c);
+ }
+ if (schemaChanges.isEmpty()) return Optional.empty();
+ return Optional.of(
+ IcebergTableUpdate.builder()
+ .schema(buildUpdatedSchema(rawGlueTable, schemaChanges))
+ .build());
+ }
+
+ /**
+ * Extracts {@link TableChange.SetProperty} entries from {@code changes}
into a key-value map.
+ * Returns an empty map if there are no property-set changes.
+ */
+ static Map<String, String> extractSetProperties(TableChange... changes) {
+ Map<String, String> props = new HashMap<>();
+ for (TableChange change : changes) {
+ if (change instanceof TableChange.SetProperty) {
+ TableChange.SetProperty sp = (TableChange.SetProperty) change;
+ props.put(sp.getProperty(), sp.getValue());
+ }
+ }
+ return props;
+ }
+
+ /**
+ * Reads the current Iceberg schema from a Glue table's StorageDescriptor
columns (preserving
+ * {@code iceberg.field.id} from column parameters), applies the given
column changes, and returns
+ * the resulting {@link IcebergSchema} with an incremented schema ID.
+ */
+ static IcebergSchema buildUpdatedSchema(Table rawGlueTable,
List<TableChange> schemaChanges) {
+ List<IcebergStructField> fields = currentFields(rawGlueTable);
+ int maxId =
fields.stream().mapToInt(IcebergStructField::id).max().orElse(0);
+
+ for (TableChange change : schemaChanges) {
+ if (change instanceof TableChange.AddColumn) {
+ TableChange.AddColumn add = (TableChange.AddColumn) change;
+ Preconditions.checkArgument(
+ add.fieldName().length == 1, "Nested column additions are not
supported");
+ fields.add(
+ IcebergStructField.builder()
+ .id(++maxId)
+ .name(add.fieldName()[0])
+ .type(gravitinoTypeToIcebergType(add.getDataType()))
+ .required(!add.isNullable())
+ .doc(add.getComment())
+ .build());
+
+ } else if (change instanceof TableChange.DeleteColumn) {
+ TableChange.DeleteColumn del = (TableChange.DeleteColumn) change;
+ Preconditions.checkArgument(
+ del.fieldName().length == 1, "Nested column deletions are not
supported");
+ String name = del.fieldName()[0];
+ boolean removed = fields.removeIf(f -> f.name().equals(name));
+ if (!removed && !del.getIfExists()) {
+ throw new IllegalArgumentException(
+ "Column '" + name + "' not found in Iceberg table schema");
+ }
+
+ } else if (change instanceof TableChange.RenameColumn) {
+ TableChange.RenameColumn rename = (TableChange.RenameColumn) change;
+ Preconditions.checkArgument(
+ rename.fieldName().length == 1, "Nested column renames are not
supported");
+ updateField(fields, rename.fieldName()[0], b ->
b.name(rename.getNewName()));
+
+ } else if (change instanceof TableChange.UpdateColumnType) {
+ TableChange.UpdateColumnType upd = (TableChange.UpdateColumnType)
change;
+ Preconditions.checkArgument(
+ upd.fieldName().length == 1, "Nested column type updates are not
supported");
+ Document newTypeDoc = gravitinoTypeToIcebergType(upd.getNewDataType());
+ updateField(fields, upd.fieldName()[0], b -> b.type(newTypeDoc));
+
+ } else if (change instanceof TableChange.UpdateColumnComment) {
+ TableChange.UpdateColumnComment upd =
(TableChange.UpdateColumnComment) change;
+ Preconditions.checkArgument(
+ upd.fieldName().length == 1, "Nested column comment updates are
not supported");
+ updateField(fields, upd.fieldName()[0], b ->
b.doc(upd.getNewComment()));
+
+ } else if (change instanceof TableChange.UpdateColumnNullability) {
+ TableChange.UpdateColumnNullability upd =
(TableChange.UpdateColumnNullability) change;
+ Preconditions.checkArgument(
+ upd.fieldName().length == 1, "Nested column nullability updates
are not supported");
+ updateField(fields, upd.fieldName()[0], b ->
b.required(!upd.nullable()));
+
+ } else {
+ throw new IllegalArgumentException(
+ "Unsupported column change: " + change.getClass().getSimpleName());
+ }
+ }
+
+ int currentSchemaId = parseSchemaId(rawGlueTable);
+ return IcebergSchema.builder()
+ .schemaId(currentSchemaId + 1)
+ .type(IcebergStructTypeEnum.STRUCT)
+ .fields(fields)
+ .build();
+ }
+
+ //
---------------------------------------------------------------------------
+ // Private helpers
+ //
---------------------------------------------------------------------------
+
+ /**
+ * Finds the first field with the given {@code name} in {@code fields} and
replaces it in-place
+ * with the result of applying {@code updater} to its builder. Throws if the
field is not found.
+ */
+ private static void updateField(
+ List<IcebergStructField> fields,
+ String name,
+ UnaryOperator<IcebergStructField.Builder> updater) {
+ for (int i = 0; i < fields.size(); i++) {
+ if (fields.get(i).name().equals(name)) {
+ fields.set(i, updater.apply(fields.get(i).toBuilder()).build());
+ return;
+ }
+ }
+ throw new IllegalArgumentException("Column '" + name + "' not found in
Iceberg table schema");
+ }
+
+ /** Reads current Iceberg fields from the Glue table's StorageDescriptor
columns. */
+ private static List<IcebergStructField> currentFields(Table rawGlueTable) {
+ List<IcebergStructField> fields = new ArrayList<>();
+ StorageDescriptor sd = rawGlueTable.storageDescriptor();
+ if (sd == null || !sd.hasColumns()) return fields;
+
+ int fallbackId = 0;
+ for (Column col : sd.columns()) {
+ int fieldId = parseFieldId(col, ++fallbackId);
+ boolean required = parseRequired(col);
+ fields.add(
+ IcebergStructField.builder()
+ .id(fieldId)
+ .name(col.name())
+ .type(hiveTypeToIcebergType(col.type()))
+ .required(required)
+ .doc(col.comment())
+ .build());
+ }
+
+ Set<Integer> seen = new HashSet<>();
+ for (IcebergStructField f : fields) {
+ if (!seen.add(f.id())) {
+ throw new IllegalStateException(
+ String.format(
+ "Iceberg table '%s' has duplicate field ID %d in column
metadata."
+ + " The Glue metadata may be corrupt. Aborting schema
update.",
+ rawGlueTable.name(), f.id()));
+ }
+ }
+ return fields;
+ }
+
+ /** Parses {@code iceberg.field.id} from column parameters; falls back to
{@code fallbackId}. */
+ private static int parseFieldId(Column col, int fallbackId) {
+ if (col.hasParameters()) {
+ String raw = col.parameters().get(ICEBERG_FIELD_ID);
+ if (raw != null) {
+ try {
+ return Integer.parseInt(raw);
+ } catch (NumberFormatException e) {
+ throw new IllegalStateException(
+ String.format(
+ "Column '%s' has non-numeric iceberg.field.id '%s'."
+ + " The Iceberg metadata may be corrupt. Aborting schema
update.",
+ col.name(), raw),
+ e);
+ }
+ }
+ }
+ return fallbackId;
+ }
+
+ /**
+ * Parses {@code iceberg.field.optional}; defaults to {@code false} (i.e.,
not required /
+ * optional).
+ */
+ private static boolean parseRequired(Column col) {
+ if (col.hasParameters()) {
+ String optional = col.parameters().get(ICEBERG_FIELD_OPTIONAL);
+ if (optional != null) {
+ return !Boolean.parseBoolean(optional);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Reads the current schema ID from {@code
Table.parameters()["current-schema-id"]}; returns 0 if
+ * absent. Throws {@link IllegalStateException} if the value is present but
non-numeric, since
+ * proceeding with a fabricated schema ID risks corrupting the Iceberg
schema version chain.
+ */
+ private static int parseSchemaId(Table rawGlueTable) {
+ if (!rawGlueTable.hasParameters()) return 0;
+ String raw = rawGlueTable.parameters().get(CURRENT_SCHEMA_ID_PARAM);
+ if (raw == null) return 0;
+ try {
+ return Integer.parseInt(raw);
+ } catch (NumberFormatException e) {
+ throw new IllegalStateException(
+ String.format(
+ "Iceberg table '%s' has non-numeric current-schema-id '%s'."
+ + " The Iceberg metadata may be corrupt. Aborting schema
update.",
+ rawGlueTable.name(), raw),
+ e);
+ }
+ }
+
+ /**
+ * Converts a Glue/Hive column type string to an Iceberg type {@link
+ * software.amazon.awssdk.core.document.Document}.
+ *
+ * <p>Iceberg REST spec type names are used for primitives (e.g. {@code
"long"} for Hive {@code
+ * bigint}). Complex types (array, map, struct) are not supported and will
throw {@link
+ * IllegalStateException}.
+ */
+ static Document hiveTypeToIcebergType(String hiveType) {
+ if (hiveType == null) return Document.fromString(STRING);
+ String t = hiveType.toLowerCase(Locale.ROOT).trim();
+
+ switch (t) {
+ case STRING:
+ return Document.fromString(STRING);
+ case BIGINT:
+ case LONG: // Iceberg type name stored by Glue native Iceberg API
+ return Document.fromString(LONG);
+ case INT:
+ case INTEGER:
+ case SMALLINT:
+ case TINYINT:
+ return Document.fromString(INT);
+ case FLOAT:
+ return Document.fromString(FLOAT);
+ case DOUBLE:
+ case DOUBLE_PRECISION:
+ return Document.fromString(DOUBLE);
+ case BOOLEAN:
+ return Document.fromString(BOOLEAN);
+ case BINARY:
+ return Document.fromString(BINARY);
+ case DATE:
+ return Document.fromString(DATE);
+ case TIMESTAMP:
+ return Document.fromString(TIMESTAMP);
+ default:
+ break;
+ }
+
+ if (t.startsWith(DECIMAL + "(")) {
+ try {
+ String inner =
+ t.substring((DECIMAL + "(").length(), t.endsWith(")") ? t.length()
- 1 : t.length());
+ String[] parts = inner.split(",");
+ int precision = Integer.parseInt(parts[0].trim());
+ int scale = parts.length > 1 ? Integer.parseInt(parts[1].trim()) : 0;
+ return Document.fromMap(
+ Map.of(
+ "type", Document.fromString(DECIMAL),
+ "precision", Document.fromNumber(precision),
+ "scale", Document.fromNumber(scale)));
+ } catch (NumberFormatException e) {
+ throw new IllegalStateException(
+ "Malformed Hive decimal type '"
+ + hiveType
+ + "' in existing Iceberg table schema."
+ + " Cannot safely build schema update.",
+ e);
+ }
+ }
+ if (t.startsWith(VARCHAR + "(") || t.startsWith(CHAR + "(")) {
+ return Document.fromString(STRING);
+ }
+ throw new IllegalStateException(
+ "Unsupported Hive column type '"
+ + hiveType
+ + "' in existing Iceberg table schema."
+ + " Cannot safely build schema update."
+ + " Complex types (array, map, struct) are not supported by the
Glue Iceberg schema update API.");
+ }
+
+ /**
+ * Converts a Gravitino {@link Type} to an Iceberg type {@link
+ * software.amazon.awssdk.core.document.Document} ({@code "Doc"} in the
method name refers to the
+ * AWS SDK {@code Document} type used to represent Iceberg type descriptors
in Glue API payloads).
+ *
+ * <p>Supports primitive types. Complex types (list, map, struct) are not
supported and will throw
+ * {@link UnsupportedOperationException}.
+ */
+ static Document gravitinoTypeToIcebergType(Type type) {
+ if (type instanceof Types.StringType
+ || type instanceof Types.VarCharType
+ || type instanceof Types.FixedCharType) {
+ return Document.fromString("string");
+ }
+ if (type instanceof Types.LongType) return Document.fromString("long");
+ if (type instanceof Types.IntegerType
+ || type instanceof Types.ShortType
+ || type instanceof Types.ByteType) {
+ return Document.fromString("int");
+ }
+ if (type instanceof Types.FloatType) return Document.fromString("float");
+ if (type instanceof Types.DoubleType) return Document.fromString("double");
+ if (type instanceof Types.BooleanType) return
Document.fromString("boolean");
+ if (type instanceof Types.BinaryType || type instanceof Types.FixedType) {
+ return Document.fromString("binary");
+ }
+ if (type instanceof Types.DateType) return Document.fromString("date");
+ if (type instanceof Types.TimeType) return Document.fromString("time");
+ if (type instanceof Types.TimestampType) {
+ return Document.fromString(
+ ((Types.TimestampType) type).hasTimeZone() ? "timestamptz" :
"timestamp");
+ }
+ if (type instanceof Types.UUIDType) return Document.fromString("uuid");
+ if (type instanceof Types.DecimalType) {
+ Types.DecimalType dt = (Types.DecimalType) type;
+ return Document.fromMap(
+ Map.of(
+ "type", Document.fromString("decimal"),
+ "precision", Document.fromNumber(dt.precision()),
+ "scale", Document.fromNumber(dt.scale())));
+ }
+ throw new UnsupportedOperationException(
+ "Iceberg Glue catalog does not support type: " + type.simpleString());
+ }
+}
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTypeConverter.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTypeConverter.java
index a35beb51d5..ff82962848 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTypeConverter.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTypeConverter.java
@@ -47,8 +47,10 @@ public class GlueTypeConverter implements
DataTypeConverter<String, String> {
static final String INT = "int";
static final String INTEGER = "integer";
static final String BIGINT = "bigint";
+ static final String LONG = "long";
static final String FLOAT = "float";
static final String DOUBLE = "double";
+ static final String DOUBLE_PRECISION = "double precision";
static final String STRING = "string";
static final String DATE = "date";
static final String TIMESTAMP = "timestamp";
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueIceberg.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueIceberg.java
new file mode 100644
index 0000000000..89b5cd7e42
--- /dev/null
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueIceberg.java
@@ -0,0 +1,597 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.catalog.glue;
+
+import static
org.apache.gravitino.catalog.glue.GlueConstants.CURRENT_SCHEMA_ID_PARAM;
+import static
org.apache.gravitino.catalog.glue.GlueConstants.ICEBERG_TABLE_TYPE_VALUE;
+import static org.apache.gravitino.catalog.glue.GlueConstants.TABLE_TYPE_PARAM;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+import java.util.Set;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.indexes.Indexes;
+import org.apache.gravitino.rel.types.Types;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.model.Column;
+import software.amazon.awssdk.services.glue.model.CreateTableRequest;
+import software.amazon.awssdk.services.glue.model.CreateTableResponse;
+import software.amazon.awssdk.services.glue.model.GetTableRequest;
+import software.amazon.awssdk.services.glue.model.GetTableResponse;
+import software.amazon.awssdk.services.glue.model.IcebergSchema;
+import software.amazon.awssdk.services.glue.model.IcebergStructField;
+import software.amazon.awssdk.services.glue.model.StorageDescriptor;
+import software.amazon.awssdk.services.glue.model.Table;
+import software.amazon.awssdk.services.glue.model.UpdateTableRequest;
+import software.amazon.awssdk.services.glue.model.UpdateTableResponse;
+
+class TestGlueIceberg {
+
+ private static final String DB = "mydb";
+ private static final String TABLE = "ice1";
+ private static final String LOCATION = "s3://my-bucket/warehouse/ice1";
+
+ private GlueClient mockClient;
+ private GlueCatalogOperations ops;
+
+ @BeforeEach
+ void setup() {
+ mockClient = mock(GlueClient.class);
+ ops = new GlueCatalogOperations();
+ ops.glueClient = mockClient;
+ ops.catalogId = null;
+ ops.tableFormatFilter = null;
+ ops.defaultTableFormat = GlueConstants.DEFAULT_TABLE_FORMAT_VALUE;
+ }
+
+ //
---------------------------------------------------------------------------
+ // isIcebergTable
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void testIsIcebergTable_withIcebergType() {
+ Table t =
+ Table.builder().parameters(Map.of(TABLE_TYPE_PARAM,
ICEBERG_TABLE_TYPE_VALUE)).build();
+ assertTrue(GlueIcebergHelper.isIcebergTable(t));
+ }
+
+ @Test
+ void testIsIcebergTable_caseInsensitive() {
+ Table t = Table.builder().parameters(Map.of("table_type",
"iceberg")).build();
+ assertTrue(GlueIcebergHelper.isIcebergTable(t));
+ }
+
+ @Test
+ void testIsIcebergTable_hiveTable() {
+ Table t = Table.builder().parameters(Map.of("table_type", "HIVE")).build();
+ assertFalse(GlueIcebergHelper.isIcebergTable(t));
+ }
+
+ @Test
+ void testIsIcebergTable_noParameters() {
+ Table t = Table.builder().build();
+ assertFalse(GlueIcebergHelper.isIcebergTable(t));
+ }
+
+ //
---------------------------------------------------------------------------
+ // hiveTypeToIcebergType
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void testHiveTypeToDoc_primitives() {
+ assertEquals("long",
GlueIcebergHelper.hiveTypeToIcebergType("bigint").asString());
+ assertEquals("int",
GlueIcebergHelper.hiveTypeToIcebergType("int").asString());
+ assertEquals("int",
GlueIcebergHelper.hiveTypeToIcebergType("smallint").asString());
+ assertEquals("float",
GlueIcebergHelper.hiveTypeToIcebergType("float").asString());
+ assertEquals("double",
GlueIcebergHelper.hiveTypeToIcebergType("double").asString());
+ assertEquals("boolean",
GlueIcebergHelper.hiveTypeToIcebergType("boolean").asString());
+ assertEquals("binary",
GlueIcebergHelper.hiveTypeToIcebergType("binary").asString());
+ assertEquals("date",
GlueIcebergHelper.hiveTypeToIcebergType("date").asString());
+ assertEquals("timestamp",
GlueIcebergHelper.hiveTypeToIcebergType("timestamp").asString());
+ assertEquals("string",
GlueIcebergHelper.hiveTypeToIcebergType("string").asString());
+ }
+
+ @Test
+ void testHiveTypeToDoc_decimal() {
+ var doc = GlueIcebergHelper.hiveTypeToIcebergType("decimal(18,2)");
+ var m = doc.asMap();
+ assertEquals("decimal", m.get("type").asString());
+ assertEquals(18, m.get("precision").asNumber().intValue());
+ assertEquals(2, m.get("scale").asNumber().intValue());
+ }
+
+ @Test
+ void testHiveTypeToDoc_varchar() {
+ assertEquals("string",
GlueIcebergHelper.hiveTypeToIcebergType("varchar(255)").asString());
+ }
+
+ //
---------------------------------------------------------------------------
+ // gravitinoTypeToIcebergType
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void testGravitinoTypeToDoc_primitives() {
+ assertEquals(
+ "long",
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.LongType.get()).asString());
+ assertEquals(
+ "int",
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.IntegerType.get()).asString());
+ assertEquals(
+ "float",
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.FloatType.get()).asString());
+ assertEquals(
+ "double",
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.DoubleType.get()).asString());
+ assertEquals(
+ "boolean",
+
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.BooleanType.get()).asString());
+ assertEquals(
+ "string",
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.StringType.get()).asString());
+ assertEquals(
+ "date",
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.DateType.get()).asString());
+ assertEquals(
+ "timestamp",
+
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.TimestampType.withoutTimeZone())
+ .asString());
+ assertEquals(
+ "timestamptz",
+
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.TimestampType.withTimeZone())
+ .asString());
+ assertEquals(
+ "uuid",
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.UUIDType.get()).asString());
+ }
+
+ @Test
+ void testGravitinoTypeToDoc_decimal() {
+ var doc =
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.DecimalType.of(10, 3));
+ var m = doc.asMap();
+ assertEquals("decimal", m.get("type").asString());
+ assertEquals(10, m.get("precision").asNumber().intValue());
+ assertEquals(3, m.get("scale").asNumber().intValue());
+ }
+
+ @Test
+ void testGravitinoTypeToDoc_unsupported() {
+ assertThrows(
+ UnsupportedOperationException.class,
+ () ->
GlueIcebergHelper.gravitinoTypeToIcebergType(Types.NullType.get()));
+ }
+
+ //
---------------------------------------------------------------------------
+ // buildSchemaUpdate / extractSetProperties / validateChanges
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void testBuildSchemaUpdate_addColumn() {
+ Table raw = icebergTable(icebergColumn("id", "long", 1, false));
+
+ TableChange add = TableChange.addColumn(new String[] {"score"},
Types.FloatType.get(), true);
+ IcebergSchema schema = GlueIcebergHelper.buildSchemaUpdate(raw,
add).orElseThrow().schema();
+
+ assertEquals(2, schema.fields().size());
+ IcebergStructField newField = schema.fields().get(1);
+ assertEquals("score", newField.name());
+ assertEquals("float", newField.type().asString());
+ assertEquals(2, newField.id());
+ assertFalse(newField.required());
+ }
+
+ @Test
+ void testBuildSchemaUpdate_deleteColumn() {
+ Table raw =
+ icebergTable(
+ icebergColumn("id", "long", 1, false), icebergColumn("name",
"string", 2, true));
+
+ TableChange delete = TableChange.deleteColumn(new String[] {"name"}, true);
+ IcebergSchema schema = GlueIcebergHelper.buildSchemaUpdate(raw,
delete).orElseThrow().schema();
+
+ assertEquals(1, schema.fields().size());
+ assertEquals("id", schema.fields().get(0).name());
+ }
+
+ @Test
+ void testBuildSchemaUpdate_renameColumn() {
+ Table raw = icebergTable(icebergColumn("old_name", "string", 1, true));
+
+ TableChange rename = TableChange.renameColumn(new String[] {"old_name"},
"new_name");
+ IcebergSchema schema = GlueIcebergHelper.buildSchemaUpdate(raw,
rename).orElseThrow().schema();
+
+ assertEquals("new_name", schema.fields().get(0).name());
+ assertEquals(1, schema.fields().get(0).id()); // ID preserved
+ }
+
+ @Test
+ void testExtractSetProperties_singleProperty() {
+ TableChange set = TableChange.setProperty("write.format.default",
"parquet");
+ Map<String, String> props = GlueIcebergHelper.extractSetProperties(set);
+ assertEquals(1, props.size());
+ assertEquals("parquet", props.get("write.format.default"));
+ }
+
+ @Test
+ void testExtractSetProperties_empty() {
+ Map<String, String> props =
+ GlueIcebergHelper.extractSetProperties(
+ TableChange.addColumn(new String[] {"col"}, Types.LongType.get()));
+ assertTrue(props.isEmpty());
+ }
+
+ @Test
+ void testValidateChanges_removePropertyThrows() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
GlueIcebergHelper.validateChanges(TableChange.removeProperty("some.prop")));
+ }
+
+ @Test
+ void testBuildSchemaUpdate_schemaIdIncrement() {
+ Table raw =
+ Table.builder()
+ .parameters(
+ Map.of(TABLE_TYPE_PARAM, ICEBERG_TABLE_TYPE_VALUE,
CURRENT_SCHEMA_ID_PARAM, "3"))
+ .storageDescriptor(StorageDescriptor.builder().build())
+ .build();
+
+ TableChange add = TableChange.addColumn(new String[] {"ts"},
Types.DateType.get(), true);
+ IcebergSchema schema = GlueIcebergHelper.buildSchemaUpdate(raw,
add).orElseThrow().schema();
+ assertEquals(4, schema.schemaId());
+ }
+
+ @Test
+ void testBuildSchemaUpdate_andExtractSetProperties_mixedChanges() {
+ Table raw = icebergTable(icebergColumn("id", "long", 1, false));
+ TableChange colChange = TableChange.addColumn(new String[] {"ts"},
Types.DateType.get(), true);
+ TableChange propChange =
TableChange.setProperty("write.target-file-size-bytes", "134217728");
+
+ IcebergSchema schema =
+ GlueIcebergHelper.buildSchemaUpdate(raw, colChange,
propChange).orElseThrow().schema();
+ assertEquals(2, schema.fields().size());
+ assertEquals("ts", schema.fields().get(1).name());
+
+ Map<String, String> props =
GlueIcebergHelper.extractSetProperties(colChange, propChange);
+ assertEquals("134217728", props.get("write.target-file-size-bytes"));
+ }
+
+ @Test
+ void testBuildSchemaUpdate_updateColumnType() {
+ Table raw = icebergTable(icebergColumn("id", "int", 1, false));
+
+ IcebergSchema schema =
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.updateColumnType(new String[] {"id"},
Types.LongType.get()))
+ .orElseThrow()
+ .schema();
+ assertEquals("long", schema.fields().get(0).type().asString());
+ assertEquals(1, schema.fields().get(0).id()); // field ID preserved
+ }
+
+ @Test
+ void testBuildSchemaUpdate_emptyChanges() {
+ Table raw = icebergTable(icebergColumn("id", "long", 1, false));
+ assertFalse(GlueIcebergHelper.buildSchemaUpdate(raw).isPresent());
+ }
+
+ @Test
+ void testBuildSchemaUpdate_nonSequentialFieldIds() {
+ Table raw =
+ icebergTable(
+ icebergColumn("a", "long", 1, false),
+ icebergColumn("b", "string", 5, true),
+ icebergColumn("c", "date", 10, true));
+
+ IcebergSchema schema =
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.addColumn(new String[] {"d"},
Types.IntegerType.get(), true))
+ .orElseThrow()
+ .schema();
+ IcebergStructField newField = schema.fields().get(3);
+ assertEquals("d", newField.name());
+ assertEquals(11, newField.id()); // max(1,5,10) + 1 = 11, not
fields.size()+1 = 4
+ }
+
+ @Test
+ void testBuildSchemaUpdate_columnNotFoundThrows() {
+ Table raw = icebergTable(icebergColumn("id", "long", 1, false));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.renameColumn(new String[] {"nonexistent"},
"new_name")));
+ }
+
+ @Test
+ void testHiveTypeToDoc_unknownTypeThrows() {
+ // Complex types (array, map, struct) in existing Iceberg schema must
abort, not silently retype
+ assertThrows(
+ IllegalStateException.class,
+ () -> GlueIcebergHelper.hiveTypeToIcebergType("array<string>"));
+ }
+
+ @Test
+ void testHiveTypeToDoc_malformedDecimalThrows() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> GlueIcebergHelper.hiveTypeToIcebergType("decimal(abc,2)"));
+ }
+
+ //
---------------------------------------------------------------------------
+ // createTable Iceberg routing
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void testCreateTable_icebergRoutesOpenTableFormatInput() {
+ Table created =
+ Table.builder()
+ .name(TABLE)
+ .parameters(
+ Map.of(
+ "table_type",
+ "ICEBERG",
+ "metadata_location",
+ LOCATION + "/metadata/00000.metadata.json"))
+ .storageDescriptor(StorageDescriptor.builder().build())
+ .build();
+
+ when(mockClient.createTable(any(CreateTableRequest.class)))
+ .thenReturn(CreateTableResponse.builder().build());
+ when(mockClient.getTable(any(GetTableRequest.class)))
+ .thenReturn(GetTableResponse.builder().table(created).build());
+
+ NameIdentifier ident = NameIdentifier.of("cat", "ns", DB, TABLE);
+ GlueColumn[] cols = {
+
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(false).build()
+ };
+
+ ops.createTable(
+ ident,
+ cols,
+ "iceberg table",
+ Map.of(GlueConstants.TABLE_FORMAT, "iceberg", GlueConstants.LOCATION,
LOCATION),
+ new Transform[0],
+ Distributions.NONE,
+ null,
+ Indexes.EMPTY_INDEXES);
+
+ ArgumentCaptor<CreateTableRequest> captor =
ArgumentCaptor.forClass(CreateTableRequest.class);
+ verify(mockClient).createTable(captor.capture());
+ CreateTableRequest req = captor.getValue();
+ assertNotNull(
+ req.openTableFormatInput(), "openTableFormatInput must be set for
Iceberg tables");
+ assertNotNull(req.openTableFormatInput().icebergInput());
+ }
+
+ //
---------------------------------------------------------------------------
+ // alterTable Iceberg routing
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void testAlterTable_icebergRoutesUpdateOpenTableFormatInput() {
+ Table rawTable = icebergTable(icebergColumn("id", "long", 1, false));
+
+ // getTable is called multiple times: initial load, re-fetch after schema
update, loadTable
+ when(mockClient.getTable(any(GetTableRequest.class)))
+ .thenReturn(GetTableResponse.builder().table(rawTable).build());
+ when(mockClient.updateTable(any(UpdateTableRequest.class)))
+ .thenReturn(UpdateTableResponse.builder().build());
+
+ NameIdentifier ident = NameIdentifier.of("cat", "ns", DB, TABLE);
+ ops.alterTable(
+ ident, TableChange.addColumn(new String[] {"score"},
Types.DoubleType.get(), true));
+
+ ArgumentCaptor<UpdateTableRequest> captor =
ArgumentCaptor.forClass(UpdateTableRequest.class);
+ verify(mockClient).updateTable(captor.capture());
+ UpdateTableRequest req = captor.getValue();
+ assertNotNull(
+ req.updateOpenTableFormatInput(),
+ "updateOpenTableFormatInput must be set for Iceberg alter");
+ assertNotNull(req.updateOpenTableFormatInput().updateIcebergInput());
+ }
+
+ @Test
+ void testCreateTable_icebergMissingLocationThrows() {
+ NameIdentifier ident = NameIdentifier.of("cat", "ns", DB, TABLE);
+ GlueColumn[] cols = {
+
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(false).build()
+ };
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ ops.createTable(
+ ident,
+ cols,
+ "iceberg table",
+ Map.of(GlueConstants.TABLE_FORMAT, "iceberg"), // missing
location
+ new Transform[0],
+ Distributions.NONE,
+ null,
+ Indexes.EMPTY_INDEXES));
+ }
+
+ @Test
+ void testAlterTable_icebergRenameThrows() {
+ Table rawTable = icebergTable(icebergColumn("id", "long", 1, false));
+
+ when(mockClient.getTable(any(GetTableRequest.class)))
+ .thenReturn(GetTableResponse.builder().table(rawTable).build());
+
+ NameIdentifier ident = NameIdentifier.of("cat", "ns", DB, TABLE);
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> ops.alterTable(ident, TableChange.rename("new_table_name")));
+ }
+
+ @Test
+ void testHiveTypeToDoc_nullFallsBackToString() {
+ assertEquals("string",
GlueIcebergHelper.hiveTypeToIcebergType(null).asString());
+ }
+
+ @Test
+ void testBuildSchemaUpdate_updateColumnNullability() {
+ Table raw = icebergTable(icebergColumn("id", "long", 1, true));
+
+ IcebergSchema schema =
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.updateColumnNullability(new String[] {"id"},
false))
+ .orElseThrow()
+ .schema();
+ assertTrue(schema.fields().get(0).required());
+ }
+
+ @Test
+ void testBuildSchemaUpdate_updateColumnComment() {
+ Table raw = icebergTable(icebergColumn("id", "long", 1, false));
+
+ IcebergSchema schema =
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.updateColumnComment(new String[] {"id"},
"primary key"))
+ .orElseThrow()
+ .schema();
+ assertEquals("primary key", schema.fields().get(0).doc());
+ }
+
+ @Test
+ void testCreateTable_defaultTableFormatIcebergRoutesOpenTableFormatInput() {
+ Table created =
+ Table.builder()
+ .name(TABLE)
+ .parameters(
+ Map.of(TABLE_TYPE_PARAM, ICEBERG_TABLE_TYPE_VALUE,
"metadata_location", LOCATION))
+ .storageDescriptor(StorageDescriptor.builder().build())
+ .build();
+ when(mockClient.createTable(any(CreateTableRequest.class)))
+ .thenReturn(CreateTableResponse.builder().build());
+ when(mockClient.getTable(any(GetTableRequest.class)))
+ .thenReturn(GetTableResponse.builder().table(created).build());
+
+ // Set defaultTableFormat to iceberg so createTable without table-format
property routes
+ // through OpenTableFormatInput
+ ops.defaultTableFormat = "iceberg";
+ NameIdentifier ident = NameIdentifier.of("cat", "ns", DB, TABLE);
+ GlueColumn[] cols = {
+
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(false).build()
+ };
+ ops.createTable(
+ ident,
+ cols,
+ "iceberg table",
+ Map.of(GlueConstants.LOCATION, LOCATION), // no table-format; relies
on defaultTableFormat
+ new Transform[0],
+ Distributions.NONE,
+ null,
+ Indexes.EMPTY_INDEXES);
+
+ ArgumentCaptor<CreateTableRequest> captor =
ArgumentCaptor.forClass(CreateTableRequest.class);
+ verify(mockClient).createTable(captor.capture());
+ assertNotNull(
+ captor.getValue().openTableFormatInput(),
+ "defaultTableFormat=iceberg should route through
openTableFormatInput");
+ }
+
+ @Test
+ void testMatchesFormatFilter_icebergFallbackViaTableType() {
+ // Table has table_type=ICEBERG but no table-format property (e.g. created
by external tooling)
+ Table externalIceberg =
+ Table.builder()
+ .name(TABLE)
+ .parameters(Map.of(TABLE_TYPE_PARAM, ICEBERG_TABLE_TYPE_VALUE))
+ .build();
+
+ ops.tableFormatFilter = Set.of("iceberg");
+ // matchesFormatFilter is private; test via listTables by mocking getTable
+ // Instead verify the helper logic directly via isIcebergTable + manual
check
+ assertTrue(
+ GlueIcebergHelper.isIcebergTable(externalIceberg),
+ "Table with table_type=ICEBERG should be recognized as Iceberg");
+
+ // A hive table should not match iceberg filter
+ Table hiveTable =
+ Table.builder().name("hive_t").parameters(Map.of("table_type",
"HIVE")).build();
+ assertFalse(GlueIcebergHelper.isIcebergTable(hiveTable));
+ }
+
+ @Test
+ void testBuildSchemaUpdate_nestedFieldThrows() {
+ Table raw = icebergTable(icebergColumn("id", "long", 1, false));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.deleteColumn(new String[] {"nested",
"field"}, false)));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.renameColumn(new String[] {"nested",
"field"}, "new_name")));
+ }
+
+ @Test
+ void testCurrentFields_malformedFieldIdThrows() {
+ Column col =
+ Column.builder()
+ .name("id")
+ .type("long")
+ .parameters(Map.of(GlueConstants.ICEBERG_FIELD_ID, "not-a-number"))
+ .build();
+ Table raw = icebergTable(col);
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ GlueIcebergHelper.buildSchemaUpdate(
+ raw, TableChange.deleteColumn(new String[] {"id"}, false)));
+ }
+
+ //
---------------------------------------------------------------------------
+ // Helpers
+ //
---------------------------------------------------------------------------
+
+ private static Column icebergColumn(String name, String type, int fieldId,
boolean optional) {
+ return Column.builder()
+ .name(name)
+ .type(type)
+ .parameters(
+ Map.of(
+ GlueConstants.ICEBERG_FIELD_ID,
+ String.valueOf(fieldId),
+ GlueConstants.ICEBERG_FIELD_OPTIONAL,
+ String.valueOf(optional)))
+ .build();
+ }
+
+ private static Table icebergTable(Column... columns) {
+ return Table.builder()
+ .name(TABLE)
+ .parameters(Map.of(TABLE_TYPE_PARAM, ICEBERG_TABLE_TYPE_VALUE))
+
.storageDescriptor(StorageDescriptor.builder().columns(columns).build())
+ .build();
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 94f6196eb8..d206525a61 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,7 +17,7 @@
# under the License.
#
[versions]
-awssdk = "2.29.52"
+awssdk = "2.31.73"
azure-identity = "1.13.1"
azure-storage-file-datalake = "12.20.0"
reactor-netty-http = "1.2.1"