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

lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 53800ab1 fix(types): assign omitted field IDs in type JSON (#380)
53800ab1 is described below

commit 53800ab1589c670ae8badc0748cdad0846aa3b00
Author: Nicholas Jiang <[email protected]>
AuthorDate: Tue Sep 22 08:56:08 2026 +0800

    fix(types): assign omitted field IDs in type JSON (#380)
---
 docs/source/user_guide/data_types.rst              |   5 +-
 include/paimon/defs.h                              |   4 +-
 .../data/variant/variant_shredding_write_plan.h    |   3 +-
 .../variant_shredding_write_plan_factory_test.cpp  |  71 +++++++++---
 src/paimon/common/types/data_field.cpp             |  16 +--
 src/paimon/common/types/data_field_test.cpp        |  31 ++++-
 src/paimon/common/types/data_type_json_parser.cpp  | 123 +++++++++++++++++---
 src/paimon/common/types/data_type_json_parser.h    |  45 ++++++--
 .../common/types/data_type_json_parser_test.cpp    | 127 +++++++++++++++++++++
 src/paimon/format/parquet/variant_parquet_test.cpp |  38 ++++--
 10 files changed, 401 insertions(+), 62 deletions(-)

diff --git a/docs/source/user_guide/data_types.rst 
b/docs/source/user_guide/data_types.rst
index c1bdda4a..17fdaca1 100644
--- a/docs/source/user_guide/data_types.rst
+++ b/docs/source/user_guide/data_types.rst
@@ -271,7 +271,10 @@ and `Arrow DataTypes 
<https://arrow.apache.org/docs/format/Columnar.html#data-ty
        `Variant Shredding 
<https://github.com/apache/parquet-format/blob/master/VariantShredding.md>`_
        specification by setting ``variant.shreddingSchema`` to a ROW type JSON
        whose fields map top-level variant column names to their shredding
-       types. Alternatively, setting ``variant.inferShreddingSchema`` to
+       types. Explicit field IDs are preserved. If every ROW field, including
+       nested fields, omits ``id``, IDs are assigned in preorder starting at 0,
+       as in Java Paimon. Partially specified IDs are rejected.
+       Alternatively, setting ``variant.inferShreddingSchema`` to
        ``true`` infers a shredding schema per file from the first written rows
        (tuned by ``variant.shredding.maxSchemaWidth``, which bounds the total
        number of shredded fields across all variant columns of the schema,
diff --git a/include/paimon/defs.h b/include/paimon/defs.h
index f51eb452..517ddb8b 100644
--- a/include/paimon/defs.h
+++ b/include/paimon/defs.h
@@ -511,7 +511,9 @@ struct PAIMON_EXPORT Options {
     static const char MAP_SHARED_SHREDDING_COLUMN_PLACEMENT_POLICY[];
 
     /// "variant.shreddingSchema" - The Variant shredding schema for writing: 
a ROW type JSON
-    /// whose fields map variant column names to their shredding types. No 
default value.
+    /// whose fields map variant column names to their shredding types. All 
ROW fields, including
+    /// nested fields, must all specify 'id' or all omit it. Omitted IDs are 
assigned in preorder
+    /// starting at 0. No default value.
     static const char VARIANT_SHREDDING_SCHEMA[];
     /// "parquet.variant.shreddingSchema" - Fallback key of 
"variant.shreddingSchema".
     static const char PARQUET_VARIANT_SHREDDING_SCHEMA[];
diff --git a/src/paimon/common/data/variant/variant_shredding_write_plan.h 
b/src/paimon/common/data/variant/variant_shredding_write_plan.h
index 2fa7a886..c9903fe0 100644
--- a/src/paimon/common/data/variant/variant_shredding_write_plan.h
+++ b/src/paimon/common/data/variant/variant_shredding_write_plan.h
@@ -75,7 +75,8 @@ class VariantShreddingWritePlan {
 
     /// Creates a plan from the `variant.shreddingSchema` option value: a ROW 
type JSON whose
     /// fields map top-level variant column names to their shredding types 
(nested variant
-    /// columns cannot be configured, as in Java).
+    /// columns cannot be configured, as in Java). All ROW fields, including 
nested fields, must
+    /// all specify 'id' or all omit it. Omitted IDs are assigned in preorder 
starting at 0.
     static Result<std::shared_ptr<VariantShreddingWritePlan>> 
FromConfiguredSchema(
         const std::shared_ptr<arrow::Schema>& logical_schema,
         const std::string& configured_schema_json);
diff --git 
a/src/paimon/common/data/variant/variant_shredding_write_plan_factory_test.cpp 
b/src/paimon/common/data/variant/variant_shredding_write_plan_factory_test.cpp
index 1546acad..49b28134 100644
--- 
a/src/paimon/common/data/variant/variant_shredding_write_plan_factory_test.cpp
+++ 
b/src/paimon/common/data/variant/variant_shredding_write_plan_factory_test.cpp
@@ -95,20 +95,63 @@ TEST_F(VariantShreddingWritePlanFactoryTest, 
ConfiguredSchema) {
             }
         } ]
     })";
-    ASSERT_OK_AND_ASSIGN(CoreOptions options,
-                         MakeOptions({{"variant.shreddingSchema", 
shredding_schema_json}}));
-    auto factory = VariantShreddingWritePlanFactory::Create(options, schema_, 
pool_);
-    ASSERT_TRUE(factory->ShouldCreateWritePlan());
-    ASSERT_FALSE(factory->ShouldInferWritePlan());
-    ASSERT_OK_AND_ASSIGN(std::shared_ptr<ShreddingBatchConverter> converter,
-                         factory->CreateConverter("parquet", {}));
-    ASSERT_NE(converter, nullptr);
-    auto variant_field = converter->GetPhysicalSchema()->GetFieldByName("v");
-    ASSERT_NE(variant_field, nullptr);
-    const auto& physical_type = static_cast<const 
arrow::StructType&>(*variant_field->type());
-    ASSERT_NE(physical_type.GetFieldByName("typed_value"), nullptr);
-    // Variant shredding only supports the parquet format.
-    ASSERT_TRUE(factory->CreateConverter("orc", 
{}).status().IsNotImplemented());
+    const char* shredding_schema_json_without_ids = R"({
+        "type": "ROW",
+        "fields": [ {
+            "name": "v",
+            "type": {
+                "type": "ROW",
+                "fields": [
+                    {"name": "age", "type": "INT"},
+                    {"name": "city", "type": "STRING"}
+                ]
+            }
+        } ]
+    })";
+    std::shared_ptr<arrow::Schema> physical_schema;
+    for (const char* option_key : {"variant.shreddingSchema", 
"parquet.variant.shreddingSchema"}) {
+        SCOPED_TRACE(option_key);
+        for (const char* configured_schema :
+             {shredding_schema_json, shredding_schema_json_without_ids}) {
+            SCOPED_TRACE(configured_schema);
+            ASSERT_OK_AND_ASSIGN(CoreOptions options,
+                                 MakeOptions({{option_key, 
configured_schema}}));
+            auto factory = VariantShreddingWritePlanFactory::Create(options, 
schema_, pool_);
+            ASSERT_TRUE(factory->ShouldCreateWritePlan());
+            ASSERT_FALSE(factory->ShouldInferWritePlan());
+            ASSERT_OK_AND_ASSIGN(std::shared_ptr<ShreddingBatchConverter> 
converter,
+                                 factory->CreateConverter("parquet", {}));
+            ASSERT_NE(converter, nullptr);
+            auto variant_field = 
converter->GetPhysicalSchema()->GetFieldByName("v");
+            ASSERT_NE(variant_field, nullptr);
+            const auto& physical_type =
+                static_cast<const arrow::StructType&>(*variant_field->type());
+            ASSERT_NE(physical_type.GetFieldByName("typed_value"), nullptr);
+            if (physical_schema == nullptr) {
+                physical_schema = converter->GetPhysicalSchema();
+            } else {
+                
ASSERT_TRUE(converter->GetPhysicalSchema()->Equals(*physical_schema,
+                                                                   
/*check_metadata=*/true));
+            }
+            ASSERT_TRUE(factory->CreateConverter("orc", 
{}).status().IsNotImplemented());
+        }
+    }
+}
+
+TEST_F(VariantShreddingWritePlanFactoryTest, 
ConfiguredSchemaRejectsPartialFieldIds) {
+    for (const char* configured_schema : {
+             R"({"type":"ROW","fields":[{"id":0,"name":"v","type":{
+                    "type":"ROW","fields":[{"name":"age","type":"INT"}]}}]})",
+             R"({"type":"ROW","fields":[{"name":"v","type":{
+                    
"type":"ROW","fields":[{"id":1,"name":"age","type":"INT"}]}}]})",
+         }) {
+        SCOPED_TRACE(configured_schema);
+        ASSERT_OK_AND_ASSIGN(CoreOptions options,
+                             MakeOptions({{"variant.shreddingSchema", 
configured_schema}}));
+        auto factory = VariantShreddingWritePlanFactory::Create(options, 
schema_, pool_);
+        ASSERT_NOK_WITH_MSG(factory->CreateConverter("parquet", {}),
+                            "Partial field id is not allowed.");
+    }
 }
 
 TEST_F(VariantShreddingWritePlanFactoryTest, InferredSchema) {
diff --git a/src/paimon/common/types/data_field.cpp 
b/src/paimon/common/types/data_field.cpp
index 97a167b0..8323fcf0 100644
--- a/src/paimon/common/types/data_field.cpp
+++ b/src/paimon/common/types/data_field.cpp
@@ -65,21 +65,13 @@ rapidjson::Value 
DataField::ToJson(rapidjson::Document::AllocatorType* allocator
 }
 
 void DataField::FromJson(const rapidjson::Value& obj) noexcept(false) {
-    id_ = RapidJsonUtil::DeserializeKeyValue<int32_t>(obj, "id");
-    auto name = RapidJsonUtil::DeserializeKeyValue<std::string>(obj, "name");
-    assert(obj.IsObject());
-    if (!obj.HasMember("type")) {
-        throw std::invalid_argument("key 'type' must exist");
-    }
-    auto field_result = DataTypeJsonParser::ParseType(name, obj["type"]);
+    // Serialized table schemas require explicit IDs for projection and schema 
evolution.
+    Result<DataField> field_result = DataTypeJsonParser::ParseDataField(obj);
     if (!field_result.ok()) {
-        throw std::invalid_argument(
-            fmt::format("parse data type failed, error msg: {}", 
field_result.status().ToString()));
+        throw std::invalid_argument(field_result.status().ToString());
     }
-    field_ = field_result.value();
+    *this = std::move(field_result).value();
     assert(field_);
-    description_ = 
RapidJsonUtil::DeserializeKeyValue<std::optional<std::string>>(
-        obj, "description", description_);
 }
 
 std::shared_ptr<arrow::Field> DataField::ConvertDataFieldToArrowField(const 
DataField& field) {
diff --git a/src/paimon/common/types/data_field_test.cpp 
b/src/paimon/common/types/data_field_test.cpp
index 6ac52ff5..27841aaa 100644
--- a/src/paimon/common/types/data_field_test.cpp
+++ b/src/paimon/common/types/data_field_test.cpp
@@ -20,6 +20,7 @@
 #include "paimon/common/types/data_field.h"
 
 #include <stdexcept>
+#include <utility>
 
 #include "arrow/api.h"
 #include "gtest/gtest.h"
@@ -137,7 +138,8 @@ TEST_F(DataFieldTest, ConvertArrowFieldToDataField) {
 TEST_F(DataFieldTest, FromJson) {
     const char* json = R"({
     "id" : 0,
-    "name" : "f0",
+    "name" : "f0\u0000tail",
+    "description" : "d\u0000tail",
     "type" : {
       "type" : "ROW",
       "fields" : [ {
@@ -161,7 +163,8 @@ TEST_F(DataFieldTest, FromJson) {
     DataField field;
     field.FromJson(doc);
     EXPECT_EQ(field.Id(), 0);
-    EXPECT_EQ(field.Name(), "f0");
+    ASSERT_EQ(field.Name(), std::string("f0\0tail", 7));
+    ASSERT_EQ(field.Description(), std::string("d\0tail", 6));
     EXPECT_EQ(field.Type()->id(), arrow::Type::STRUCT);
 
     auto sub_fields = field.Type()->fields();
@@ -226,6 +229,30 @@ TEST_F(DataFieldTest, FromJsonFailed) {
 })";
         check_result(json_str, "parse data type failed, error msg: ");
     }
+    const std::vector<std::pair<const char*, const char*>> test_cases = {
+        {R"([{"id":0,"name":"a","type":"INT"}])", "data field must be an 
object"},
+        {R"({"id":"0","name":"a","type":"INT"})", "value of key 'id' must be 
int"},
+        {R"({"name":"a","type":"INT"})", "key 'id' must exist"},
+        {R"({"id":null,"name":"a","type":"INT"})", "key 'id' must exist"},
+        {R"({"id":0,"type":"INT"})", "key 'name' must exist"},
+        {R"({"id":0,"name":0,"type":"INT"})", "value of key 'name' must be 
string"},
+        {R"({"id":0,"name":"a"})", "key 'type' must exist"},
+        {R"({"id":0,"name":"a","type":"INT","description":0})",
+         "value of key 'description' must be string"},
+        {R"({"id":0,"name":"a","type":{"type":"ROW",
+               "fields":[{"name":"b","type":"INT"}]}})",
+         "key 'id' must exist"},
+        {R"({"id":0,"name":"a","type":{"type":"ARRAY","element":{"type":"ROW",
+               "fields":[{"name":"b","type":"INT"}]}}})",
+         "key 'id' must exist"},
+        {R"({"id":0,"name":"a","type":{"type":"MAP","key":"STRING",
+               "value":{"type":"ROW","fields":[{"name":"b","type":"INT"}]}}})",
+         "key 'id' must exist"},
+    };
+    for (const auto& [json, error_msg] : test_cases) {
+        SCOPED_TRACE(json);
+        ASSERT_NO_FATAL_FAILURE(check_result(json, error_msg));
+    }
 }
 
 TEST_F(DataFieldTest, ToJson) {
diff --git a/src/paimon/common/types/data_type_json_parser.cpp 
b/src/paimon/common/types/data_type_json_parser.cpp
index d04e5ade..adcf5b73 100644
--- a/src/paimon/common/types/data_type_json_parser.cpp
+++ b/src/paimon/common/types/data_type_json_parser.cpp
@@ -35,7 +35,6 @@
 #include "paimon/common/types/data_field.h"
 #include "paimon/common/types/vector_type.h"
 #include "paimon/common/utils/date_time_utils.h"
-#include "paimon/common/utils/rapidjson_util.h"
 #include "paimon/common/utils/string_utils.h"
 #include "paimon/data/decimal.h"
 #include "paimon/data/timestamp.h"
@@ -647,17 +646,93 @@ Result<int32_t> 
TokenParser::ParseOptionalPrecision(int32_t default_precision) {
 }
 }  // namespace
 
+Result<int32_t> DataTypeJsonParser::FieldIdAssigner::Assign(
+    const std::optional<int32_t>& explicit_id) {
+    // Mixing both forms would let a generated id collide with an explicit one.
+    if (explicit_id.has_value()) {
+        if (next_generated_id_ != 0) {
+            return Status::Invalid("Partial field id is not allowed.");
+        }
+        has_explicit_id_ = true;
+        return explicit_id.value();
+    }
+    if (has_explicit_id_) {
+        return Status::Invalid("Partial field id is not allowed.");
+    }
+    return next_generated_id_++;
+}
+
 Result<std::shared_ptr<arrow::Field>> DataTypeJsonParser::ParseType(
     const std::string& name, const rapidjson::Value& type_json_value) {
+    FieldIdAssigner field_id_assigner;
+    return ParseType(name, type_json_value, &field_id_assigner);
+}
+
+Result<std::shared_ptr<arrow::Field>> DataTypeJsonParser::ParseType(
+    const std::string& name, const rapidjson::Value& type_json_value,
+    FieldIdAssigner* field_id_assigner) {
     if (type_json_value.IsString()) {
         return ParseAtomicTypeField(name, type_json_value);
     } else if (type_json_value.IsObject()) {
-        return ParseComplexTypeField(name, type_json_value);
+        return ParseComplexTypeField(name, type_json_value, field_id_assigner);
     }
 
     return Status::Invalid("cannot parse data type");
 }
 
+Result<DataField> DataTypeJsonParser::ParseDataField(const rapidjson::Value& 
field_json_value) {
+    return ParseDataField(field_json_value, /*field_id_assigner=*/nullptr);
+}
+
+Result<DataField> DataTypeJsonParser::ParseDataField(const rapidjson::Value& 
field_json_value,
+                                                     FieldIdAssigner* 
field_id_assigner) {
+    if (!field_json_value.IsObject()) {
+        return Status::Invalid("data field must be an object");
+    }
+    std::optional<int32_t> explicit_id;
+    if (field_json_value.HasMember("id") && !field_json_value["id"].IsNull()) {
+        if (!field_json_value["id"].IsInt()) {
+            return Status::Invalid("value of key 'id' must be int");
+        }
+        explicit_id = field_json_value["id"].GetInt();
+    }
+    int32_t id = -1;
+    if (field_id_assigner != nullptr) {
+        PAIMON_ASSIGN_OR_RAISE(id, field_id_assigner->Assign(explicit_id));
+    } else if (explicit_id.has_value()) {
+        id = explicit_id.value();
+    } else {
+        return Status::Invalid("key 'id' must exist");
+    }
+    if (!field_json_value.HasMember("name")) {
+        return Status::Invalid("key 'name' must exist");
+    }
+    const auto& name_value = field_json_value["name"];
+    if (!name_value.IsString()) {
+        return Status::Invalid("value of key 'name' must be string");
+    }
+    std::string name(name_value.GetString(), name_value.GetStringLength());
+    if (!field_json_value.HasMember("type")) {
+        return Status::Invalid("key 'type' must exist");
+    }
+    Result<std::shared_ptr<arrow::Field>> field_result =
+        ParseType(name, field_json_value["type"], field_id_assigner);
+    if (!field_result.ok()) {
+        return Status::Invalid(
+            fmt::format("parse data type failed, error msg: {}", 
field_result.status().ToString()));
+    }
+    std::optional<std::string> description;
+    if (field_json_value.HasMember("description") && 
!field_json_value["description"].IsNull()) {
+        const auto& description_value = field_json_value["description"];
+        if (!description_value.IsString()) {
+            return Status::Invalid("value of key 'description' must be 
string");
+        }
+        description =
+            std::string(description_value.GetString(), 
description_value.GetStringLength());
+    }
+    return DataField(id, field_result.value(), description);
+}
+
 Result<std::shared_ptr<arrow::Field>> DataTypeJsonParser::ParseAtomicTypeField(
     const std::string& name, const rapidjson::Value& type_json_value) {
     bool nullable = true;
@@ -674,7 +749,8 @@ Result<std::shared_ptr<arrow::Field>> 
DataTypeJsonParser::ParseAtomicTypeField(
 }
 
 Result<std::shared_ptr<arrow::Field>> 
DataTypeJsonParser::ParseComplexTypeField(
-    const std::string& name, const rapidjson::Value& type_json_value) {
+    const std::string& name, const rapidjson::Value& type_json_value,
+    FieldIdAssigner* field_id_assigner) {
     if (!type_json_value.HasMember("type")) {
         return Status::Invalid("complex data type must have type");
     }
@@ -686,13 +762,13 @@ Result<std::shared_ptr<arrow::Field>> 
DataTypeJsonParser::ParseComplexTypeField(
     }
 
     if (StringUtils::StartsWith(type_str, "ARRAY")) {
-        return ParseArrayType(name, type_json_value, nullable);
+        return ParseArrayType(name, type_json_value, nullable, 
field_id_assigner);
     } else if (StringUtils::StartsWith(type_str, "VECTOR")) {
-        return ParseVectorType(name, type_json_value, nullable);
+        return ParseVectorType(name, type_json_value, nullable, 
field_id_assigner);
     } else if (StringUtils::StartsWith(type_str, "MAP")) {
-        return ParseMapType(name, type_json_value, nullable);
+        return ParseMapType(name, type_json_value, nullable, 
field_id_assigner);
     } else if (StringUtils::StartsWith(type_str, "ROW")) {
-        return ParseRowType(name, type_json_value, nullable);
+        return ParseRowType(name, type_json_value, nullable, 
field_id_assigner);
     } else if (StringUtils::StartsWith(type_str, "MULTISET")) {
         return Status::NotImplemented("MULTISET is not supported");
     }
@@ -701,18 +777,20 @@ Result<std::shared_ptr<arrow::Field>> 
DataTypeJsonParser::ParseComplexTypeField(
 }
 
 Result<std::shared_ptr<arrow::Field>> DataTypeJsonParser::ParseArrayType(
-    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable) {
+    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+    FieldIdAssigner* field_id_assigner) {
     if (!type_json_value.HasMember("element")) {
         return Status::Invalid("array data type must have element");
     }
 
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Field> element_field,
-                           ParseType("item", type_json_value["element"]));
+                           ParseType("item", type_json_value["element"], 
field_id_assigner));
     return arrow::field(name, arrow::list(element_field), nullable);
 }
 
 Result<std::shared_ptr<arrow::Field>> DataTypeJsonParser::ParseVectorType(
-    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable) {
+    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+    FieldIdAssigner* field_id_assigner) {
     if (!type_json_value.HasMember("element") || 
!type_json_value.HasMember("length")) {
         return Status::Invalid("vector data type must have element and 
length");
     }
@@ -724,7 +802,7 @@ Result<std::shared_ptr<arrow::Field>> 
DataTypeJsonParser::ParseVectorType(
         return Status::Invalid("Vector length must be between 1 and 2147483647 
(both inclusive)");
     }
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Field> element_field,
-                           ParseType("item", type_json_value["element"]));
+                           ParseType("item", type_json_value["element"], 
field_id_assigner));
     if (!VectorType::IsValidElementType(element_field->type())) {
         return Status::Invalid(
             fmt::format("Invalid element type for vector: {}", 
element_field->type()->ToString()));
@@ -733,13 +811,14 @@ Result<std::shared_ptr<arrow::Field>> 
DataTypeJsonParser::ParseVectorType(
 }
 
 Result<std::shared_ptr<arrow::Field>> DataTypeJsonParser::ParseMapType(
-    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable) {
+    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+    FieldIdAssigner* field_id_assigner) {
     if (!type_json_value.HasMember("key") || 
!type_json_value.HasMember("value")) {
         return Status::Invalid("map data type must have key and value");
     }
 
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Field> key,
-                           ParseType("key", type_json_value["key"]));
+                           ParseType("key", type_json_value["key"], 
field_id_assigner));
 
     // NOTE: Unlike Java Paimon, this C++ implementation does not support 
nullable keys in
     // MapType. This is a limitation of Apache Arrow, which does not allow 
null keys in its
@@ -748,14 +827,24 @@ Result<std::shared_ptr<arrow::Field>> 
DataTypeJsonParser::ParseMapType(
     // interoperating with Java Paimon.
     key = key->WithNullable(false);
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Field> value,
-                           ParseType("value", type_json_value["value"]));
+                           ParseType("value", type_json_value["value"], 
field_id_assigner));
     return arrow::field(name, std::make_shared<arrow::MapType>(key, value), 
nullable);
 }
 
 Result<std::shared_ptr<arrow::Field>> DataTypeJsonParser::ParseRowType(
-    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable) {
-    auto data_fields =
-        
RapidJsonUtil::DeserializeKeyValue<std::vector<DataField>>(type_json_value, 
"fields");
+    const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+    FieldIdAssigner* field_id_assigner) {
+    if (!type_json_value.HasMember("fields") || 
!type_json_value["fields"].IsArray()) {
+        return Status::Invalid("row data type must have fields");
+    }
+    const auto& field_json_array = type_json_value["fields"].GetArray();
+    std::vector<DataField> data_fields;
+    data_fields.reserve(field_json_array.Size());
+    for (const auto& field_json_value : field_json_array) {
+        PAIMON_ASSIGN_OR_RAISE(DataField data_field,
+                               ParseDataField(field_json_value, 
field_id_assigner));
+        data_fields.push_back(std::move(data_field));
+    }
 
     auto struct_type = 
DataField::ConvertDataFieldsToArrowStructType(data_fields);
     return arrow::field(name, struct_type, nullable);
diff --git a/src/paimon/common/types/data_type_json_parser.h 
b/src/paimon/common/types/data_type_json_parser.h
index 2134236a..ae302454 100644
--- a/src/paimon/common/types/data_type_json_parser.h
+++ b/src/paimon/common/types/data_type_json_parser.h
@@ -19,12 +19,13 @@
 
 #pragma once
 
+#include <cstdint>
 #include <memory>
+#include <optional>
 #include <string>
 
 #include "arrow/api.h"
 #include "paimon/common/types/data_field.h"
-#include "paimon/common/utils/rapidjson_util.h"
 #include "paimon/result.h"
 #include "rapidjson/document.h"
 
@@ -34,7 +35,8 @@ class DataTypeJsonParser {
     DataTypeJsonParser() = delete;
     ~DataTypeJsonParser() = delete;
 
-    /// Parses a data type from a JSON value and returns an Arrow field 
representation.
+    /// Parses JSON into an Arrow field. If all ROW fields omit 'id', assigns 
IDs in preorder
+    /// starting at 0. Preserves explicit IDs and rejects partially specified 
IDs.
     ///
     /// @param name The name of the field.
     /// @param type_json_value The JSON value representing the type.
@@ -42,20 +44,49 @@ class DataTypeJsonParser {
     static Result<std::shared_ptr<arrow::Field>> ParseType(const std::string& 
name,
                                                            const 
rapidjson::Value& type_json_value);
 
+    /// Parses a schema field, requiring explicit IDs on the field and all 
nested ROW fields.
+    ///
+    /// @param field_json_value The JSON value representing the field.
+    /// @return A Result containing the parsed DataField, or an error status 
if parsing fails.
+    static Result<DataField> ParseDataField(const rapidjson::Value& 
field_json_value);
+
  private:
+    /// Shares ID assignment across a type tree and rejects partially 
specified IDs.
+    class FieldIdAssigner {
+     public:
+        Result<int32_t> Assign(const std::optional<int32_t>& explicit_id);
+
+     private:
+        int32_t next_generated_id_ = 0;
+        bool has_explicit_id_ = false;
+    };
+
+    /// Reuses the type tree's ID assigner; nullptr requires explicit IDs on 
all ROW fields.
+    static Result<std::shared_ptr<arrow::Field>> ParseType(const std::string& 
name,
+                                                           const 
rapidjson::Value& type_json_value,
+                                                           FieldIdAssigner* 
field_id_assigner);
+
+    static Result<DataField> ParseDataField(const rapidjson::Value& 
field_json_value,
+                                            FieldIdAssigner* 
field_id_assigner);
+
     static Result<std::shared_ptr<arrow::Field>> ParseAtomicTypeField(
         const std::string& name, const rapidjson::Value& type_json_value);
     static Result<std::shared_ptr<arrow::Field>> ParseComplexTypeField(
-        const std::string& name, const rapidjson::Value& type_json_value);
+        const std::string& name, const rapidjson::Value& type_json_value,
+        FieldIdAssigner* field_id_assigner);
 
     static Result<std::shared_ptr<arrow::Field>> ParseArrayType(
-        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable);
+        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+        FieldIdAssigner* field_id_assigner);
     static Result<std::shared_ptr<arrow::Field>> ParseVectorType(
-        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable);
+        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+        FieldIdAssigner* field_id_assigner);
     static Result<std::shared_ptr<arrow::Field>> ParseMapType(
-        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable);
+        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+        FieldIdAssigner* field_id_assigner);
     static Result<std::shared_ptr<arrow::Field>> ParseRowType(
-        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable);
+        const std::string& name, const rapidjson::Value& type_json_value, bool 
nullable,
+        FieldIdAssigner* field_id_assigner);
 };
 
 }  // namespace paimon
diff --git a/src/paimon/common/types/data_type_json_parser_test.cpp 
b/src/paimon/common/types/data_type_json_parser_test.cpp
index e5dfbc21..5049afc3 100644
--- a/src/paimon/common/types/data_type_json_parser_test.cpp
+++ b/src/paimon/common/types/data_type_json_parser_test.cpp
@@ -24,6 +24,7 @@
 
 #include "gtest/gtest.h"
 #include "paimon/common/data/variant/variant_type_utils.h"
+#include "paimon/common/types/data_field.h"
 #include "paimon/common/utils/checked_cast.h"
 #include "paimon/common/utils/date_time_utils.h"
 #include "paimon/status.h"
@@ -34,6 +35,15 @@
 
 namespace paimon::test {
 
+namespace {
+
+void CheckFieldId(const std::shared_ptr<arrow::Field>& field, int32_t 
expected_id) {
+    ASSERT_OK_AND_ASSIGN(DataField data_field, 
DataField::ConvertArrowFieldToDataField(field));
+    ASSERT_EQ(data_field.Id(), expected_id);
+}
+
+}  // namespace
+
 TEST(DataTypeJsonParserTest, ParseTypeArrayTypeSuccess) {
     const std::string name = "array_field";
     const char* json = R"({
@@ -127,6 +137,13 @@ TEST(DataTypeJsonParserTest, ParseTypeRowTypeSuccess) {
         "id" : 4,
         "name" : "sub4",
         "type" : "BYTES"
+      }, {
+        "id" : 7,
+        "name" : "sub7",
+        "type" : {
+          "type" : "ROW",
+          "fields" : [ { "id" : 9, "name" : "nested", "type" : "INT" } ]
+        }
       }]})";
     rapidjson::Document doc;
     doc.Parse(json);
@@ -134,6 +151,116 @@ TEST(DataTypeJsonParserTest, ParseTypeRowTypeSuccess) {
     ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Field> field,
                          DataTypeJsonParser::ParseType(name, doc));
     ASSERT_NE(field, nullptr);
+    ASSERT_EQ(field->type()->num_fields(), 3);
+    ASSERT_NO_FATAL_FAILURE(CheckFieldId(field->type()->field(0), 1));
+    ASSERT_NO_FATAL_FAILURE(CheckFieldId(field->type()->field(1), 4));
+    ASSERT_NO_FATAL_FAILURE(CheckFieldId(field->type()->field(2), 7));
+    
ASSERT_NO_FATAL_FAILURE(CheckFieldId(field->type()->field(2)->type()->field(0), 
9));
+}
+
+TEST(DataTypeJsonParserTest, ParseTypeRowTypeGeneratesMissingFieldIds) {
+    const char* json = R"({
+      "type" : "ROW",
+      "fields" : [ {
+        "name" : "payload",
+        "type" : {
+          "type" : "ROW",
+          "fields" : [ {
+            "name" : "age",
+            "type" : "INT"
+          }, {
+            "name" : "profile",
+            "type" : {
+              "type" : "ROW",
+              "fields" : [ { "name" : "city", "type" : "STRING" } ]
+            }
+          } ]
+        }
+      }, {
+        "name" : "tags",
+        "type" : {
+          "type" : "ARRAY",
+          "element" : {
+            "type" : "ROW",
+            "fields" : [ { "name" : "tag", "type" : "STRING" } ]
+          }
+        }
+      }, {
+        "name" : "props",
+        "type" : {
+          "type" : "MAP",
+          "key" : {
+            "type" : "ROW",
+            "fields" : [ { "name" : "code", "type" : "STRING" } ]
+          },
+          "value" : {
+            "type" : "ROW",
+            "fields" : [ { "name" : "weight", "type" : "DOUBLE" } ]
+          }
+        }
+      } ]})";
+    rapidjson::Document doc;
+    doc.Parse(json);
+
+    for (int32_t attempt = 0; attempt < 2; ++attempt) {
+        SCOPED_TRACE(attempt);
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Field> field,
+                             DataTypeJsonParser::ParseType("row_field", doc));
+        const std::shared_ptr<arrow::DataType>& row = field->type();
+        ASSERT_EQ(row->num_fields(), 3);
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(row->field(0), 0));
+        const std::shared_ptr<arrow::DataType>& payload = 
row->field(0)->type();
+        ASSERT_EQ(payload->num_fields(), 2);
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(payload->field(0), 1));
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(payload->field(1), 2));
+        
ASSERT_NO_FATAL_FAILURE(CheckFieldId(payload->field(1)->type()->field(0), 3));
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(row->field(1), 4));
+        const std::shared_ptr<arrow::DataType>& element = 
row->field(1)->type()->field(0)->type();
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(element->field(0), 5));
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(row->field(2), 6));
+        const std::shared_ptr<arrow::DataType>& entries = 
row->field(2)->type()->field(0)->type();
+        const std::shared_ptr<arrow::DataType>& map_key = 
entries->field(0)->type();
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(map_key->field(0), 7));
+        const std::shared_ptr<arrow::DataType>& map_value = 
entries->field(1)->type();
+        ASSERT_NO_FATAL_FAILURE(CheckFieldId(map_value->field(0), 8));
+    }
+}
+
+TEST(DataTypeJsonParserTest, ParseTypeComplexTypeFailure) {
+    const std::vector<std::pair<const char*, const char*>> test_cases = {
+        {R"({"type":"ROW"})", "row data type must have fields"},
+        {R"({"type":"ROW","fields":{}})", "row data type must have fields"},
+        {R"({"type":"ROW","fields":[null]})", "data field must be an object"},
+        {R"({"type":"ROW","fields":[{"name":"a","type":"INT"},
+                                    {"id":1,"name":"b","type":"INT"}]})",
+         "Partial field id is not allowed."},
+        {R"({"type":"ROW","fields":[{"id":0,"name":"a","type":"INT"},
+                                    {"name":"b","type":"INT"}]})",
+         "Partial field id is not allowed."},
+        {R"({"type":"ROW","fields":[{"id":0,"name":"a","type":{
+               "type":"ROW","fields":[{"name":"b","type":"INT"}]}}]})",
+         "Partial field id is not allowed."},
+        {R"({"type":"ROW","fields":[{"name":"a","type":{
+               "type":"ROW","fields":[{"id":1,"name":"b","type":"INT"}]}}]})",
+         "Partial field id is not allowed."},
+        {R"({"type":"ROW","fields":[{"name":"a","type":{"type":"ARRAY",
+               
"element":{"type":"ROW","fields":[{"id":1,"name":"b","type":"INT"}]}}}]})",
+         "Partial field id is not allowed."},
+        {R"({"type":"MAP",
+               
"key":{"type":"ROW","fields":[{"id":0,"name":"a","type":"INT"}]},
+               "value":{"type":"ROW","fields":[{"name":"b","type":"INT"}]}})",
+         "Partial field id is not allowed."},
+        {R"({"type":"MAP",
+               "key":{"type":"ROW","fields":[{"name":"a","type":"INT"}]},
+               
"value":{"type":"ROW","fields":[{"id":1,"name":"b","type":"INT"}]}})",
+         "Partial field id is not allowed."},
+    };
+    for (const auto& [json, error_msg] : test_cases) {
+        SCOPED_TRACE(json);
+        rapidjson::Document doc;
+        doc.Parse(json);
+        ASSERT_NOK_WITH_MSG(DataTypeJsonParser::ParseType("row_field", doc), 
error_msg);
+    }
 }
 
 TEST(DataTypeJsonParserTest, ParseTypeAtomicTypeSuccess) {
diff --git a/src/paimon/format/parquet/variant_parquet_test.cpp 
b/src/paimon/format/parquet/variant_parquet_test.cpp
index 2c1bab1d..94c501f5 100644
--- a/src/paimon/format/parquet/variant_parquet_test.cpp
+++ b/src/paimon/format/parquet/variant_parquet_test.cpp
@@ -503,6 +503,24 @@ constexpr const char* kNestedShreddingSchema = R"({
     } ]
 })";
 
+constexpr const char* kNestedShreddingSchemaWithoutIds = R"({
+    "type": "ROW",
+    "fields": [ {
+        "name": "v",
+        "type": {
+            "type": "ROW",
+            "fields": [
+                {"name": "age", "type": "INT"},
+                {"name": "addr", "type": {
+                    "type": "ROW",
+                    "fields": [ {"name": "city", "type": "STRING"} ]
+                }},
+                {"name": "tags", "type": {"type": "ARRAY", "element": 
"STRING"}}
+            ]
+        }
+    } ]
+})";
+
 void CollectFieldIds(const ::parquet::schema::Node& node, const std::string& 
prefix,
                      std::map<std::string, int32_t>* field_ids) {
     std::string path = prefix.empty() ? node.name() : prefix + "." + 
node.name();
@@ -648,12 +666,18 @@ TEST_F(VariantParquetTest, ShreddedWriteAndReadRoundTrip) 
{
         R"({"age": "not a number", "extra": [1, 2]})",
         "[\"top level array\"]",
     };
-    for (const std::string mode : {"configured", "per-file", "adaptive"}) {
+    const std::map<std::string, std::map<std::string, int32_t>> 
expected_object_ids = {
+        {"configured", {{"age", 3}, {"addr", 4}, {"city", 5}, {"tags", 6}}},
+        {"configured-without-ids", {{"age", 1}, {"addr", 2}, {"city", 3}, 
{"tags", 4}}},
+        {"per-file", {{"age", 1}, {"addr", 0}, {"city", 0}, {"tags", 2}}},
+        {"adaptive", {{"age", 1}, {"addr", 0}, {"city", 0}, {"tags", 2}}}};
+    for (const auto& [mode, object_ids] : expected_object_ids) {
         SCOPED_TRACE(mode);
-        const bool configured = mode == "configured";
+        const bool configured = mode == "configured" || mode == 
"configured-without-ids";
         std::map<std::string, std::string> option_map;
         if (configured) {
-            option_map[Options::VARIANT_SHREDDING_SCHEMA] = 
kNestedShreddingSchema;
+            option_map[Options::VARIANT_SHREDDING_SCHEMA] =
+                mode == "configured" ? kNestedShreddingSchema : 
kNestedShreddingSchemaWithoutIds;
         } else {
             option_map[Options::VARIANT_INFER_SHREDDING_SCHEMA] = "true";
             option_map[Options::VARIANT_SHREDDING_INFERENCE_MODE] = mode;
@@ -703,16 +727,16 @@ TEST_F(VariantParquetTest, ShreddedWriteAndReadRoundTrip) 
{
                     {"v.metadata", 0},
                     {"v.value", 1},
                     {"v.typed_value", 2},
-                    {"v.typed_value.age", configured ? 3 : 1},
+                    {"v.typed_value.age", object_ids.at("age")},
                     {"v.typed_value.age.value", 0},
                     {"v.typed_value.age.typed_value", 1},
-                    {"v.typed_value.addr", configured ? 4 : 0},
+                    {"v.typed_value.addr", object_ids.at("addr")},
                     {"v.typed_value.addr.value", 0},
                     {"v.typed_value.addr.typed_value", 1},
-                    {"v.typed_value.addr.typed_value.city", configured ? 5 : 
0},
+                    {"v.typed_value.addr.typed_value.city", 
object_ids.at("city")},
                     {"v.typed_value.addr.typed_value.city.value", 0},
                     {"v.typed_value.addr.typed_value.city.typed_value", 1},
-                    {"v.typed_value.tags", configured ? 6 : 2},
+                    {"v.typed_value.tags", object_ids.at("tags")},
                     {"v.typed_value.tags.value", 0},
                     {"v.typed_value.tags.typed_value", 1},
                     {"v.typed_value.tags.typed_value.list", -1},

Reply via email to