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

mchades 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 69ef06b81b [#11951] feat(iceberg): map V3 unknown type to Gravitino 
NullType (#11969)
69ef06b81b is described below

commit 69ef06b81ba947a275922086a720a92335d644ad
Author: Nevin Zheng <[email protected]>
AuthorDate: Tue Jul 14 20:24:37 2026 -0700

    [#11951] feat(iceberg): map V3 unknown type to Gravitino NullType (#11969)
    
    ### What changes were proposed in this pull request?
    
    Map Iceberg V3's `unknown` type to Gravitino's existing
    `Types.NullType`, so an `unknown` column loads through the native
    metadata API as a first-class `null` type instead of the
    `ExternalType("UNKNOWN")` stopgap it resolves to today.
    
    - `catalog-lakehouse-iceberg`: `FromIcebergType` maps `unknown →
    NullType`; `ToIcebergType` / `ToIcebergTypeVisitor` map `NullType →
    unknown` (via a `nullType()` dispatch hook, since `NullType` isn't a
    `PrimitiveType`), and reject a required (non-nullable) `unknown` column
    per the Iceberg spec.
    - docs: unified type reference (`Null type` section), Iceberg
    type-mapping table, OpenAPI examples.
    
    No new `api` type and no `common`/Python serde change — `NullType`
    already exists, round-trips as the JSON token `"null"`, and is already
    mapped for Spark, Flink, Lance, and CLI (the Python client decodes
    `"null"` to `NullType` with no change).
    
    Builds on the merged native `variant` support (#11932) and
    format-version-3 support (#11954).
    
    Fixes #11951
    
    ### Why are the changes needed?
    
    Loading an Iceberg V3 table with an `unknown` column through the native
    API resolves it to `ExternalType("UNKNOWN")` — opaque (nothing can
    branch on it), not writable back, and external types have caused
    downstream problems (unqueryable via Trino #10957; `catalogString()`
    written verbatim into DDL #11805). `unknown` is the universal null/void
    column type — Iceberg's own converters map it to each engine's null type
    (Spark `NullType`, Flink `NULL`, Arrow `null`) — and Gravitino already
    models that as `NullType`, so this is just the missing wiring.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes — Iceberg V3 tables with an `unknown` column now load through the
    native API as `null` (previously `external(UNKNOWN)`), and a
    `null`-typed column can be written to a format-version-3 Iceberg table.
    Other connectors are unchanged. Connector propagation (reject-with-test
    for engines without a null-type equivalent) is a planned follow-up.
    
    ### How was this patch tested?
    
    - Unit (`TestConvertUtil`): `testUnknownType` (converter both
    directions) and `testUnknownColumnToIcebergSchema` (write path →
    optional `unknown` field; required column rejected).
    - Docker IT (`CatalogIcebergRestIT`, passing) — both cross-surface
    directions between the Iceberg REST (IRC) API and the native metadata
    API:
    - `testV3TypeConversionViaIcebergClient`: IRC writes an `unknown` column
    → the native API reads it back as `NullType`.
    - `testCreateUnknownColumnWriteRoundTrip`: the native API writes a
    `NullType` column at format-version 3 → native reload returns
    `NullType`, and the IRC reads the same table back as Iceberg `unknown`.
---
 .../iceberg/converter/FromIcebergType.java         |  4 ++
 .../lakehouse/iceberg/converter/ToIcebergType.java | 14 +++++
 .../iceberg/converter/ToIcebergTypeVisitor.java    |  7 +++
 .../iceberg/converter/TestConvertUtil.java         | 43 ++++++++++++++
 .../integration/test/CatalogIcebergBaseIT.java     | 67 ++++++++++++++++++++--
 docs/lakehouse-iceberg-catalog.md                  |  8 +++
 docs/manage-relational-metadata-using-gravitino.md | 13 +++++
 docs/open-api/datatype.yaml                        |  5 +-
 8 files changed, 153 insertions(+), 8 deletions(-)

diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/FromIcebergType.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/FromIcebergType.java
index 1cf732e0e7..7328ff2083 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/FromIcebergType.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/FromIcebergType.java
@@ -130,6 +130,10 @@ public class FromIcebergType extends 
TypeUtil.SchemaVisitor<Type> {
         Types.DecimalType decimal = (Types.DecimalType) primitive;
         return org.apache.gravitino.rel.types.Types.DecimalType.of(
             decimal.precision(), decimal.scale());
+      case UNKNOWN:
+        // Iceberg V3 unknown is the null-only placeholder type; map it to 
Gravitino's NullType,
+        // matching how Iceberg's own engine converters map unknown <-> the 
engine null type.
+        return org.apache.gravitino.rel.types.Types.NullType.get();
       case GEOMETRY:
         return org.apache.gravitino.rel.types.Types.GeometryType.of(
             ((Types.GeometryType) primitive).crs());
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergType.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergType.java
index 7f671631eb..53dfdba639 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergType.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergType.java
@@ -68,6 +68,14 @@ public class ToIcebergType extends 
ToIcebergTypeVisitor<Type> {
 
       String doc = field.comment();
 
+      if (type instanceof Types.UnknownType && !field.nullable()) {
+        // Iceberg requires unknown (null) columns to be optional: they are 
not stored in data
+        // files and must default to null. Reject a required unknown column 
with a clear message.
+        throw new IllegalArgumentException(
+            String.format(
+                "Iceberg unknown/null type column '%s' must be optional 
(nullable)", field.name()));
+      }
+
       if (field.nullable()) {
         newFields.add(Types.NestedField.optional(id, field.name(), type, doc));
       } else {
@@ -188,4 +196,10 @@ public class ToIcebergType extends 
ToIcebergTypeVisitor<Type> {
     }
     throw new UnsupportedOperationException("Not a supported type: " + 
primitive.toString());
   }
+
+  @Override
+  public Type nullType(org.apache.gravitino.rel.types.Types.NullType nullType) 
{
+    // Gravitino NullType maps to Iceberg's V3 unknown type (the null-only 
placeholder).
+    return Types.UnknownType.get();
+  }
 }
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergTypeVisitor.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergTypeVisitor.java
index 8d63728dea..28a6849fc9 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergTypeVisitor.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/ToIcebergTypeVisitor.java
@@ -53,6 +53,9 @@ public class ToIcebergTypeVisitor<T> {
         fieldResults.add(visitor.field(field, visit(field.type(), visitor)));
       }
       return visitor.struct((Types.StructType) type, fieldResults);
+    } else if (type instanceof Types.NullType) {
+      // NullType implements Type directly rather than PrimitiveType, so it 
needs its own dispatch.
+      return visitor.nullType((Types.NullType) type);
     } else {
       return visitor.atomic((Type.PrimitiveType) type);
     }
@@ -81,4 +84,8 @@ public class ToIcebergTypeVisitor<T> {
   public T atomic(Type.PrimitiveType primitive) {
     throw new UnsupportedOperationException();
   }
+
+  public T nullType(Types.NullType nullType) {
+    throw new UnsupportedOperationException();
+  }
 }
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/TestConvertUtil.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/TestConvertUtil.java
index 06e6b56886..32fd08d21f 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/TestConvertUtil.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/converter/TestConvertUtil.java
@@ -59,6 +59,49 @@ public class TestConvertUtil extends TestBaseConvert {
             instanceof Types.VariantType);
   }
 
+  @Test
+  public void testUnknownType() {
+    // Iceberg V3 unknown <-> Gravitino NullType, in both directions.
+    Assertions.assertTrue(
+        CONVERTER.toGravitino(Types.UnknownType.get())
+            instanceof org.apache.gravitino.rel.types.Types.NullType);
+    Assertions.assertTrue(
+        
CONVERTER.fromGravitino(org.apache.gravitino.rel.types.Types.NullType.get())
+            instanceof Types.UnknownType);
+  }
+
+  @Test
+  public void testUnknownColumnToIcebergSchema() {
+    // Write path: a nullable null-typed column becomes an optional Iceberg 
unknown field
+    // (exercises ConvertUtil.toIcebergSchema -> ToIcebergType.struct and the 
nullType dispatch).
+    Column nullableColumn =
+        IcebergColumn.builder()
+            .withName("c_unknown")
+            .withType(org.apache.gravitino.rel.types.Types.NullType.get())
+            .withNullable(true)
+            .withComment(TEST_COMMENT)
+            .build();
+    Schema schema = ConvertUtil.toIcebergSchema(new Column[] {nullableColumn});
+    Types.NestedField field = schema.findField("c_unknown");
+    Assertions.assertNotNull(field);
+    Assertions.assertTrue(field.isOptional());
+    Assertions.assertTrue(field.type() instanceof Types.UnknownType);
+
+    // A required null-typed column is rejected: Iceberg unknown must be 
optional.
+    Column requiredColumn =
+        IcebergColumn.builder()
+            .withName("c_unknown_required")
+            .withType(org.apache.gravitino.rel.types.Types.NullType.get())
+            .withNullable(false)
+            .withComment(TEST_COMMENT)
+            .build();
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> ConvertUtil.toIcebergSchema(new Column[] {requiredColumn}));
+    Assertions.assertTrue(exception.getMessage().contains("must be optional"));
+  }
+
   @Test
   public void testGeometryType() {
     // Iceberg V3 geometry <-> Gravitino GeometryType, preserving the CRS in 
both directions.
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergBaseIT.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergBaseIT.java
index 7b465f9cca..b44bea50c7 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergBaseIT.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergBaseIT.java
@@ -727,9 +727,10 @@ public abstract class CatalogIcebergBaseIT extends BaseIT {
     // like Spark 4 would, directly through the Iceberg catalog, then load 
through the native
     // metadata interface.
     //
-    // Variant, geometry, and geography load as their native Gravitino types. 
Nanosecond timestamps
-    // are asserted below; unknown remains an ExternalType until it gains 
unified-model support.
+    // Variant, unknown, geometry, and geography load as their native 
Gravitino types. Nanosecond
+    // timestamps are asserted below.
     assertV3LoadsAsVariant("v3_variant");
+    assertV3LoadsAsNull("v3_unknown");
     assertV3LoadsAsGeometry("v3_geometry", 
org.apache.iceberg.types.Types.GeometryType.crs84());
     assertV3LoadsAsGeometry(
         "v3_geometry_srid", 
org.apache.iceberg.types.Types.GeometryType.of("srid:3857"));
@@ -750,10 +751,6 @@ public abstract class CatalogIcebergBaseIT extends BaseIT {
         "v3_timestamptz_ns",
         org.apache.iceberg.types.Types.TimestampNanoType.withZone(),
         Types.TimestampType.withTimeZone(NANO_PRECISION));
-
-    // TODO(apache/gravitino#11929): expect native types once these gain 
unified-model support.
-    assertV3LoadsAsExternal(
-        "v3_unknown", org.apache.iceberg.types.Types.UnknownType.get(), 
"UNKNOWN");
   }
 
   @Test
@@ -818,6 +815,57 @@ public abstract class CatalogIcebergBaseIT extends BaseIT {
         "3", 
loaded.properties().get(IcebergTablePropertiesMetadata.FORMAT_VERSION));
   }
 
+  @Test
+  void testCreateUnknownColumnWriteRoundTrip() {
+    // Write path: create a null-typed (Iceberg unknown) column *through the 
Gravitino relational
+    // API*, write it to the REST (IRC) backend, then load it back and confirm 
it round-trips as
+    // NullType. REST-backend only, for the same reason as 
testV3TypeConversionViaIcebergClient:
+    // the CI Hive metastore cannot store V3 column types.
+    Assumptions.assumeTrue(
+        "rest".equalsIgnoreCase(TYPE),
+        "Unknown columns require a backend that does not validate against the 
Hive metastore");
+
+    NameIdentifier ident = NameIdentifier.of(schemaName, "t_unknown_write");
+    // A null-typed column maps to Iceberg's V3 unknown type. Column.of(..) 
defaults to nullable,
+    // which unknown requires (a required unknown column is rejected before 
reaching the backend).
+    Column[] columns =
+        new Column[] {
+          Column.of("id", Types.IntegerType.get(), "id"),
+          Column.of("payload", Types.NullType.get(), "unknown col")
+        };
+    Map<String, String> properties = Maps.newHashMap();
+    // unknown is a V3 type, so the table must be created at format-version 3.
+    properties.put(IcebergTablePropertiesMetadata.FORMAT_VERSION, "3");
+
+    TableCatalog tableCatalog = catalog.asTableCatalog();
+    Table created =
+        tableCatalog.createTable(
+            ident,
+            columns,
+            "unknown write",
+            properties,
+            Transforms.EMPTY_TRANSFORM,
+            Distributions.NONE,
+            new SortOrder[0]);
+    Assertions.assertInstanceOf(Types.NullType.class, 
created.columns()[1].dataType());
+
+    // Load it back through the native metadata API to confirm the round-trip 
against the backend.
+    Table loaded = tableCatalog.loadTable(ident);
+    Assertions.assertInstanceOf(Types.NullType.class, 
loaded.columns()[1].dataType());
+    Assertions.assertEquals(
+        "3", 
loaded.properties().get(IcebergTablePropertiesMetadata.FORMAT_VERSION));
+
+    // Cross-surface check (native write -> IRC read): the native write 
persisted a real Iceberg
+    // `unknown` column, so reading the same table through the Iceberg REST 
(IRC) API returns
+    // unknown. This is the mirror of testV3TypeConversionViaIcebergClient 
(IRC write -> native
+    // read).
+    org.apache.iceberg.Table icebergTable =
+        
icebergCatalog.loadTable(IcebergCatalogWrapperHelper.buildIcebergTableIdentifier(ident));
+    Assertions.assertEquals(
+        org.apache.iceberg.types.Types.UnknownType.get(),
+        icebergTable.schema().findField("payload").type());
+  }
+
   @Test
   void testCreateGeometryColumnWriteRoundTrip() {
     // Write path: create a geometry column with a non-default CRS *through 
the Gravitino relational
@@ -996,6 +1044,13 @@ public abstract class CatalogIcebergBaseIT extends BaseIT 
{
     Assertions.assertInstanceOf(Types.VariantType.class, loaded.dataType());
   }
 
+  private void assertV3LoadsAsNull(String tableName) {
+    NameIdentifier ident =
+        createV3Table(tableName, 
org.apache.iceberg.types.Types.UnknownType.get());
+    Column loaded = catalog.asTableCatalog().loadTable(ident).columns()[0];
+    Assertions.assertInstanceOf(Types.NullType.class, loaded.dataType());
+  }
+
   private void assertV3LoadsAsGeometry(
       String tableName, org.apache.iceberg.types.Types.GeometryType 
icebergType) {
     NameIdentifier ident = createV3Table(tableName, icebergType);
diff --git a/docs/lakehouse-iceberg-catalog.md 
b/docs/lakehouse-iceberg-catalog.md
index 3be17a3104..e8d3ba94d5 100644
--- a/docs/lakehouse-iceberg-catalog.md
+++ b/docs/lakehouse-iceberg-catalog.md
@@ -437,9 +437,17 @@ If you doesn't specify distribution expressions, the table 
distribution will be
 | `Binary`          | `Binary`                    |
 | `UUID`            | `UUID`                      |
 | `Variant`         | `Variant`                   |
+| `Null`            | `Unknown`                   |
 | `Geometry`        | `Geometry`                  |
 | `Geography`       | `Geography`                 |
 
+:::note
+Gravitino `Null` maps to Apache Iceberg's V3 `unknown` type — a null-only 
placeholder for a column
+whose type is not yet known. It requires table `format-version` 3, must be an 
optional (nullable)
+column, and can be promoted to a concrete type via schema evolution. This is 
the recommended way to
+represent an Iceberg `unknown` column in Gravitino.
+:::
+
 :::info
 Apache Iceberg doesn't support Gravitino `Varchar` `Fixedchar` `Byte` `Short` 
`Union` type.
 Meanwhile, the data types other than listed above are mapped to Gravitino 
**[External 
Type](./manage-relational-metadata-using-gravitino.md#external-type)** that 
represents an unresolvable data type since 0.6.0-incubating.
diff --git a/docs/manage-relational-metadata-using-gravitino.md 
b/docs/manage-relational-metadata-using-gravitino.md
index ebdace5c19..f8e99974bd 100644
--- a/docs/manage-relational-metadata-using-gravitino.md
+++ b/docs/manage-relational-metadata-using-gravitino.md
@@ -931,11 +931,24 @@ The following types that Gravitino supports:
 | Union                     | `Types.UnionType.of([type1, type2, ...])`        
                        | `{"type": "union", "types": [type JSON, ...]}`        
                                                                               
| Union type, indicates a union of types                                        
                                                                                
             |
 | UUID                      | `Types.UUIDType.get()`                           
                        | `uuid`                                                
                                                                               
| UUID type, indicates a universally unique identifier                          
                                                                                
             |
 | Variant                   | `Types.VariantType.get()`                        
                        | `variant`                                             
                                                                               
| Variant type, indicates semi-structured data whose shape is not fixed by the 
schema                                                                          
              |
+| Null                      | `Types.NullType.get()`                           
                        | `null`                                                
                                                                               
| Null type, indicates a column that holds only null values and whose concrete 
type is not yet known; it may be assigned a specific type later via schema 
evolution           |
 | Geometry                  | `Types.GeometryType.of(crs)`                     
                        | `geometry` or `geometry(<crs>)`                       
                                                                               
| Geometry type, indicates a geospatial shape (WKB-encoded) on a planar 
coordinate reference system (CRS), default `OGC:CRS84`                          
                       |
 | Geography                 | `Types.GeographyType.of(crs, algorithm)`         
                        | `geography` or `geography(<crs>,<algorithm>)`         
                                                                               
| Geography type, indicates a geospatial shape (WKB-encoded) on a spheroidal 
CRS (default `OGC:CRS84`) with an edge-interpolation algorithm (default 
`spherical`)              |
 
 The related java doc is 
[here](pathname:///docs/2.0.0-SNAPSHOT/api/java/org/apache/gravitino/rel/types/Type.html).
 
+##### Null type
+
+The null type represents a column that holds only null values and whose 
concrete type is not yet
+known — it carries no usable data of its own, and the intent is that a more 
specific type is
+assigned later through schema evolution. This mirrors the "null"/"void" type 
in engines such as
+Spark, Flink, and Arrow, and maps to Apache Iceberg's V3 `unknown` type.
+
+Support is connector-specific and still expanding: it is mapped natively by 
the Iceberg, Spark,
+Flink, and Lance connectors, while connectors without an equivalent concept 
currently reject it.
+Because a null column holds no usable data, engines should not populate it; it 
is meant to be
+promoted to a concrete type before data is written.
+
 ##### External type
 
 External type is a special type of column type, when you need to use a data 
type that is not in the Gravitino type
diff --git a/docs/open-api/datatype.yaml b/docs/open-api/datatype.yaml
index 0cc75b5e19..d924754bbc 100644
--- a/docs/open-api/datatype.yaml
+++ b/docs/open-api/datatype.yaml
@@ -56,6 +56,7 @@ components:
         - "fixed(16)"
         - "binary"
         - "variant"
+        - "null"
         - "geometry"
         - "geometry(srid:3857)"
         - "geography"
@@ -221,5 +222,5 @@ components:
         description: The string representation of this type in the catalog
     example: {
       "type": "external",
-      "externalType": "user-defined"
-    }
\ No newline at end of file
+      "catalogString": "user-defined"
+    }

Reply via email to