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

roryqi 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 b94de1e1e1 [Cherry-pick to branch-1.3] [#14545] fix(iceberg-rest): 
assign missing partition field ids and refuse decimals Gravitino cannot import 
(#14547)
b94de1e1e1 is described below

commit b94de1e1e1d04a0b46f1feba935ea69d459052c4
Author: Mark Hoerth <[email protected]>
AuthorDate: Mon Nov 2 22:07:47 2026 -0800

    [Cherry-pick to branch-1.3] [#14545] fix(iceberg-rest): assign missing 
partition field ids and refuse decimals Gravitino cannot import (#14547)
    
    Backport to branch-1.3 of the fix for #14545, without the v3 date to
    timestamp and timestamp_ns promotions, which are 2.0 only.
    
    ### What changes were proposed in this pull request?
    
    - `IcebergCommitValidation.assignPartitionFieldIds`, called from
    `CatalogWrapperForREST.updateTable`: an `add-spec` field without a
    `field-id` gets the id of the same field (source and transform) in an
    earlier spec, or the next id above `last-partition-id`, from the table's
    current metadata. Creates and requests whose fields all carry ids are
    unchanged and load no metadata. The commit validation still checks the
    result on every retry, so a concurrent change that takes the id is
    refused.
    - The schema check allows `date` to `timestamp` and `timestamp_ns`
    (without time zone) on v3 tables, as the v3 table spec does.
    - `IcebergColumnFieldValidator` checks every decimal type, nested ones
    included, with Gravitino's own `Types.DecimalType` rule before a create,
    register or schema commit, so nothing is created that cannot be
    imported.
    
    ### Why are the changes needed?
    
    A valid `add-spec` was refused, valid v3 promotions were refused, and an
    invalid decimal left a created, unowned table behind a 400.
    
    Fixes #14545
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. `add-spec` without field ids succeeds with correctly numbered ids;
    v3 date promotions are accepted; a create with a decimal Gravitino
    cannot represent is refused with 400 before anything is created.
    
    ### How was this patch tested?
    
    -
    `TestIcebergCommitValidation#testAddSpecWithoutFieldIdsGetsTheTablesIds`,
    `#testAddSpecWithFieldIdsIsUnchanged`,
    `#testDatePromotionsAreAllowedOnlyFromV3`.
    -
    
`TestIcebergTableOperationExecutor#testRejectsUnrepresentableDecimalBeforeCreate`.
    - The issue's curl reproduction, which fails before this change.
    
    `./gradlew :iceberg:iceberg-rest-server:test -PskipITs`
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    https://claude.ai/code/session_01Eu6fb9pLfmrSJzGQDVLi1g
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../iceberg/service/CatalogWrapperForREST.java     |  15 ++-
 .../iceberg/service/IcebergCommitValidation.java   | 132 +++++++++++++++++++++
 .../dispatcher/IcebergColumnFieldValidator.java    |  25 ++++
 .../service/TestIcebergCommitValidation.java       |  38 ++++++
 .../TestIcebergTableOperationExecutor.java         |  31 +++++
 5 files changed, 240 insertions(+), 1 deletion(-)

diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index b9f7be6fa3..723ab46a45 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -34,6 +34,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Stream;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
 import org.apache.gravitino.credential.CatalogCredentialManager;
@@ -51,6 +52,7 @@ import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.iceberg.DeleteFile;
 import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.HasTableOperations;
 import org.apache.iceberg.IncrementalAppendScan;
 import org.apache.iceberg.Scan;
 import org.apache.iceberg.Table;
@@ -166,10 +168,21 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
   @Override
   public LoadTableResponse updateTable(
       TableIdentifier tableIdentifier, UpdateTableRequest updateTableRequest) {
+    UpdateTableRequest request =
+        IcebergCommitValidation.assignPartitionFieldIds(
+            updateTableRequest, () -> currentMetadata(tableIdentifier));
     return super.updateTable(
         tableIdentifier,
         IcebergCommitValidation.withValidation(
-            updateTableRequest, () -> 
getCatalog().loadTable(tableIdentifier).io()));
+            request, () -> getCatalog().loadTable(tableIdentifier).io()));
+  }
+
+  @Nullable
+  private TableMetadata currentMetadata(TableIdentifier tableIdentifier) {
+    Table table = getCatalog().loadTable(tableIdentifier);
+    return table instanceof HasTableOperations
+        ? ((HasTableOperations) table).operations().current()
+        : null;
   }
 
   public LoadTableResponse registerTable(
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCommitValidation.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCommitValidation.java
index d59271d6ab..6d72c96d20 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCommitValidation.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergCommitValidation.java
@@ -18,9 +18,13 @@
  */
 package org.apache.gravitino.iceberg.service;
 
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -34,6 +38,7 @@ import org.apache.iceberg.ManifestFile;
 import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.PartitionField;
 import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.PartitionSpecParser;
 import org.apache.iceberg.RetryableValidationException;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.Snapshot;
@@ -42,6 +47,7 @@ import org.apache.iceberg.SnapshotRefType;
 import org.apache.iceberg.SortField;
 import org.apache.iceberg.SortOrder;
 import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.UnboundPartitionSpec;
 import org.apache.iceberg.UpdateRequirement;
 import org.apache.iceberg.exceptions.CommitFailedException;
 import org.apache.iceberg.exceptions.ValidationException;
@@ -50,6 +56,7 @@ import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.TypeUtil;
 import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.JsonUtil;
 
 /**
  * Checks a table commit against the metadata it is applied to, before Iceberg 
applies it.
@@ -125,6 +132,131 @@ public final class IcebergCommitValidation implements 
UpdateRequirement {
     return new UpdateTableRequest(requirements.build(), request.updates());
   }
 
+  /**
+   * Gives partition fields that an add-spec sends without a {@code field-id} 
the id the table
+   * assigns: the id of the same field (source and transform) in an earlier 
spec, or the next id
+   * above {@code last-partition-id}. The REST spec makes {@code field-id} 
optional, but Iceberg's
+   * metadata builder numbers such fields from 1000 again, so they would take 
the ids of existing
+   * partition fields.
+   *
+   * @param request the table commit request
+   * @param current supplies the table's current metadata, or null if it 
cannot be loaded; called
+   *     only when a field needs an id
+   * @return the request with every added partition field carrying an id
+   */
+  public static UpdateTableRequest assignPartitionFieldIds(
+      UpdateTableRequest request, Supplier<TableMetadata> current) {
+    if (request == null
+        || request.updates() == null
+        || request.requirements().stream()
+            
.anyMatch(UpdateRequirement.AssertTableDoesNotExist.class::isInstance)) {
+      // A create starts from no partition fields, so numbering from 1000 is 
right.
+      return request;
+    }
+    boolean missingIds = false;
+    for (MetadataUpdate update : request.updates()) {
+      if (update instanceof MetadataUpdate.AddPartitionSpec) {
+        for (Object field : fields(((MetadataUpdate.AddPartitionSpec) 
update).spec())) {
+          missingIds |= partitionId(field) == null;
+        }
+      }
+    }
+    if (!missingIds) {
+      return request;
+    }
+    TableMetadata base = current.get();
+    if (base == null || base.formatVersion() < 2) {
+      return request;
+    }
+
+    Map<String, Integer> knownIds = new HashMap<>();
+    for (PartitionSpec spec : base.specs()) {
+      for (PartitionField field : spec.fields()) {
+        knownIds.putIfAbsent(
+            partitionFieldKey(field.sourceId(), field.transform().toString()), 
field.fieldId());
+      }
+    }
+    int lastAssigned = Math.max(base.lastAssignedPartitionId(), 
PARTITION_DATA_ID_START - 1);
+    for (MetadataUpdate update : request.updates()) {
+      if (update instanceof MetadataUpdate.AddPartitionSpec) {
+        for (Object field : fields(((MetadataUpdate.AddPartitionSpec) 
update).spec())) {
+          Integer id = partitionId(field);
+          if (id != null) {
+            knownIds.putIfAbsent(partitionFieldKey(field), id);
+            lastAssigned = Math.max(lastAssigned, id);
+          }
+        }
+      }
+    }
+
+    List<MetadataUpdate> updates = new ArrayList<>();
+    for (MetadataUpdate update : request.updates()) {
+      if (!(update instanceof MetadataUpdate.AddPartitionSpec)) {
+        updates.add(update);
+        continue;
+      }
+      UnboundPartitionSpec spec = ((MetadataUpdate.AddPartitionSpec) 
update).spec();
+      ObjectNode specNode = JsonUtil.mapper().createObjectNode();
+      specNode.put("spec-id", spec.specId());
+      ArrayNode fields = specNode.putArray("fields");
+      for (Object field : fields(spec)) {
+        Integer id = partitionId(field);
+        if (id == null) {
+          String key = partitionFieldKey(field);
+          id = knownIds.get(key);
+          if (id == null) {
+            id = ++lastAssigned;
+            knownIds.put(key, id);
+          }
+        }
+        fields
+            .addObject()
+            .put("name", (String) fieldValue(field, "name"))
+            .put("transform", (String) fieldValue(field, "transformAsString"))
+            .put("source-id", (Integer) fieldValue(field, "sourceId"))
+            .put("field-id", id);
+      }
+      updates.add(new 
MetadataUpdate.AddPartitionSpec(PartitionSpecParser.fromJson(specNode)));
+    }
+    return request.identifier() == null
+        ? new UpdateTableRequest(request.requirements(), updates)
+        : UpdateTableRequest.create(request.identifier(), 
request.requirements(), updates);
+  }
+
+  private static List<?> fields(UnboundPartitionSpec spec) {
+    return spec.fields();
+  }
+
+  @Nullable
+  private static Integer partitionId(Object unboundField) {
+    return (Integer) fieldValue(unboundField, "partitionId");
+  }
+
+  private static String partitionFieldKey(Object unboundField) {
+    return partitionFieldKey(
+        (Integer) fieldValue(unboundField, "sourceId"),
+        (String) fieldValue(unboundField, "transformAsString"));
+  }
+
+  /**
+   * Reads an accessor of {@code UnboundPartitionSpec.UnboundPartitionField}. 
Its accessors are
+   * public but the class is package-private in Iceberg 1.11, and no public 
API exposes whether a
+   * field was sent without a {@code field-id}.
+   */
+  private static Object fieldValue(Object unboundField, String accessor) {
+    try {
+      Method method = unboundField.getClass().getMethod(accessor);
+      method.setAccessible(true);
+      return method.invoke(unboundField);
+    } catch (ReflectiveOperationException e) {
+      throw new IllegalStateException("Cannot read partition field " + 
accessor, e);
+    }
+  }
+
+  private static String partitionFieldKey(int sourceId, String transform) {
+    return sourceId + ":" + transform;
+  }
+
   @Override
   public void validate(@Nullable TableMetadata base) {
     TableMetadata updated = validate(base, updates);
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergColumnFieldValidator.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergColumnFieldValidator.java
index 5c4d78a4ef..70206c9f21 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergColumnFieldValidator.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergColumnFieldValidator.java
@@ -21,9 +21,13 @@ package org.apache.gravitino.iceberg.service.dispatcher;
 import static org.apache.gravitino.Entity.EntityType.COLUMN;
 
 import org.apache.gravitino.meta.ColumnEntity;
+import org.apache.gravitino.rel.types.Types;
 import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types.DecimalType;
+import org.apache.iceberg.types.Types.NestedField;
 
 final class IcebergColumnFieldValidator {
 
@@ -35,6 +39,27 @@ final class IcebergColumnFieldValidator {
               ColumnEntity.NAME.validate(column.name(), COLUMN);
               ColumnEntity.COMMENT.validate(column.doc(), COLUMN);
             });
+    TypeUtil.indexById(schema.asStruct())
+        .values()
+        .forEach(IcebergColumnFieldValidator::validateType);
+  }
+
+  /**
+   * Refuses a column type that Iceberg accepts but Gravitino cannot 
represent, before the table is
+   * created. Otherwise the table is created and then fails to import into 
Gravitino.
+   */
+  private static void validateType(NestedField field) {
+    if (field.type() instanceof DecimalType) {
+      DecimalType decimal = (DecimalType) field.type();
+      try {
+        Types.DecimalType.of(decimal.precision(), decimal.scale());
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException(
+            String.format(
+                "Invalid type %s for column %s: %s", decimal, field.name(), 
e.getMessage()),
+            e);
+      }
+    }
   }
 
   static void validateUpdate(UpdateTableRequest request) {
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCommitValidation.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCommitValidation.java
index 238941d98f..b53c0b12bc 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCommitValidation.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergCommitValidation.java
@@ -23,6 +23,7 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import java.util.List;
 import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.HasTableOperations;
 import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.MetadataUpdateParser;
 import org.apache.iceberg.PartitionSpec;
@@ -380,6 +381,43 @@ public class TestIcebergCommitValidation {
     Assertions.assertNotNull(partitioned);
   }
 
+  @Test
+  public void testAddSpecWithoutFieldIdsGetsTheTablesIds() {
+    createPartitionedTable("noid");
+    TableIdentifier ident = TableIdentifier.of(Namespace.of("db"), "noid");
+    // Spec 0 has 1000: s identity(2). Neither field below carries a field-id.
+    UpdateTableRequest request =
+        IcebergCommitValidation.assignPartitionFieldIds(
+            request(
+                addSpec(
+                    
"{\"name\":\"s\",\"transform\":\"identity\",\"source-id\":2},"
+                        + 
"{\"name\":\"id_b\",\"transform\":\"bucket[4]\",\"source-id\":1}"),
+                new MetadataUpdate.SetDefaultPartitionSpec(-1)),
+            () -> ((HasTableOperations) 
catalog.loadTable(ident)).operations().current());
+
+    CatalogHandlers.updateTable(catalog, ident, 
IcebergCommitValidation.withValidation(request));
+
+    PartitionSpec spec = catalog.loadTable(ident).spec();
+    // The field already partitioned on keeps its id; the new field gets the 
next one.
+    Assertions.assertEquals(1000, spec.fields().get(0).fieldId());
+    Assertions.assertEquals(1001, spec.fields().get(1).fieldId());
+  }
+
+  @Test
+  public void testAddSpecWithFieldIdsIsUnchanged() {
+    UpdateTableRequest request =
+        request(
+            addSpec(
+                
"{\"name\":\"id\",\"transform\":\"identity\",\"source-id\":1,\"field-id\":1000}"));
+    Assertions.assertSame(
+        request,
+        IcebergCommitValidation.assignPartitionFieldIds(
+            request,
+            () -> {
+              throw new AssertionError("metadata must not be loaded");
+            }));
+  }
+
   @Test
   public void testRemovingASpecWithLiveManifestsIsRefused() {
     Table partitioned = createPartitionedTable("rm");
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
index a8c7f7b230..043121f2c3 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableOperationExecutor.java
@@ -44,6 +44,7 @@ import org.apache.iceberg.rest.requests.CreateTableRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.types.Types;
 import org.apache.iceberg.types.Types.NestedField;
 import org.apache.iceberg.types.Types.StringType;
 import org.junit.jupiter.api.Assertions;
@@ -269,6 +270,36 @@ public class TestIcebergTableOperationExecutor {
     verifyNoInteractions(mockCatalogWrapper);
   }
 
+  @Test
+  public void testRejectsUnrepresentableDecimalBeforeCreate() {
+    // Iceberg accepts decimal(10, 11); Gravitino cannot import it, so it must 
be refused before the
+    // table is created rather than after.
+    Schema schema =
+        new Schema(
+            NestedField.required(1, "id", StringType.get()),
+            NestedField.optional(
+                2,
+                "s",
+                Types.StructType.of(
+                    NestedField.optional(3, "amount", Types.DecimalType.of(10, 
11)))));
+    CreateTableRequest request =
+        
CreateTableRequest.builder().withName("test_table").withSchema(schema).build();
+
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> executor.createTable(mockContext, 
Namespace.of("test_namespace"), request));
+    Assertions.assertTrue(exception.getMessage().contains("amount"), 
exception.getMessage());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            executor.updateTable(
+                mockContext,
+                TableIdentifier.of("test_namespace", "test_table"),
+                updateRequest(schema)));
+    verifyNoInteractions(mockCatalogWrapper);
+  }
+
   @Test
   public void testRejectsOversizedColumnFieldsBeforeUpdate() {
     String oversizedName = "a".repeat(EntityFieldLimits.MAX_NAME_LENGTH + 1);

Reply via email to