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

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


The following commit(s) were added to refs/heads/main by this push:
     new 87ed9632 fix(serde): make DataFile JSON compatible with Java Iceberg 
(#975)
87ed9632 is described below

commit 87ed9632fda681fdfaa9d884a0ce47942aff3e56
Author: kamcheungting-db <[email protected]>
AuthorDate: Sun Oct 11 06:55:34 2026 -0700

    fix(serde): make DataFile JSON compatible with Java Iceberg (#975)
---
 src/iceberg/json_serde.cc                | 273 +++++++++++++++++++++++++------
 src/iceberg/test/json_serde_test.cc      | 247 +++++++++++++++++++++++++++-
 src/iceberg/test/rest_json_serde_test.cc |   2 +
 3 files changed, 470 insertions(+), 52 deletions(-)

diff --git a/src/iceberg/json_serde.cc b/src/iceberg/json_serde.cc
index 6b7800ae..eb7bf09d 100644
--- a/src/iceberg/json_serde.cc
+++ b/src/iceberg/json_serde.cc
@@ -18,10 +18,13 @@
  */
 
 #include <algorithm>
+#include <concepts>
 #include <cstdint>
 #include <format>
+#include <limits>
 #include <map>
 #include <regex>
+#include <string>
 #include <unordered_set>
 #include <utility>
 
@@ -269,7 +272,58 @@ constexpr std::string_view kContentSizeInBytes = 
"content-size-in-bytes";
 constexpr std::string_view kMapKeys = "keys";
 constexpr std::string_view kMapValues = "values";
 
-template <typename Value>
+template <std::signed_integral Integer>
+Result<Integer> IntegerFromJson(const nlohmann::json& json,
+                                std::string_view description) {
+  if (!json.is_number_integer()) {
+    return JsonParseError("{} must be an integer, but is {}", description,
+                          json.type_name());
+  }
+  if (json.is_number_unsigned()) {
+    const auto value = json.get<uint64_t>();
+    if (value > static_cast<uint64_t>(std::numeric_limits<Integer>::max())) {
+      return JsonParseError("{} integer is out of range: {}", description,
+                            SafeDumpJson(json));
+    }
+    return static_cast<Integer>(value);
+  }
+
+  const auto value = json.get<int64_t>();
+  if (value < static_cast<int64_t>(std::numeric_limits<Integer>::min()) ||
+      value > static_cast<int64_t>(std::numeric_limits<Integer>::max())) {
+    return JsonParseError("{} integer is out of range: {}", description,
+                          SafeDumpJson(json));
+  }
+  return static_cast<Integer>(value);
+}
+
+template <std::signed_integral Integer>
+Result<Integer> GetJsonInteger(const nlohmann::json& json, std::string_view 
key) {
+  ICEBERG_ASSIGN_OR_RAISE(auto value, GetJsonValue<nlohmann::json>(json, key));
+  return IntegerFromJson<Integer>(value, std::format("'{}'", key));
+}
+
+template <std::signed_integral Integer>
+Result<std::vector<Integer>> IntegerVectorFromJson(const nlohmann::json& json,
+                                                   std::string_view key) {
+  ICEBERG_ASSIGN_OR_RAISE(auto values_json, GetJsonValue<nlohmann::json>(json, 
key));
+  if (!values_json.is_array()) {
+    return JsonParseError("'{}' must be an array, but is {}", key,
+                          values_json.type_name());
+  }
+
+  std::vector<Integer> values;
+  values.reserve(values_json.size());
+  for (const auto& value_json : values_json) {
+    ICEBERG_ASSIGN_OR_RAISE(
+        auto value,
+        IntegerFromJson<Integer>(value_json, std::format("'{}' element", 
key)));
+    values.push_back(value);
+  }
+  return values;
+}
+
+template <std::signed_integral Value>
 Result<std::map<int32_t, Value>> KeyValueMapFromJson(const nlohmann::json& 
json,
                                                      std::string_view key) {
   std::map<int32_t, Value> result;
@@ -278,16 +332,27 @@ Result<std::map<int32_t, Value>> 
KeyValueMapFromJson(const nlohmann::json& json,
   }
 
   ICEBERG_ASSIGN_OR_RAISE(auto map_json, GetJsonValue<nlohmann::json>(json, 
key));
-  ICEBERG_ASSIGN_OR_RAISE(auto keys,
-                          GetJsonValue<std::vector<int32_t>>(map_json, 
kMapKeys));
-  ICEBERG_ASSIGN_OR_RAISE(auto values,
-                          GetJsonValue<std::vector<Value>>(map_json, 
kMapValues));
-  if (keys.size() != values.size()) {
+  ICEBERG_ASSIGN_OR_RAISE(auto keys_json,
+                          GetJsonValue<nlohmann::json>(map_json, kMapKeys));
+  ICEBERG_ASSIGN_OR_RAISE(auto values_json,
+                          GetJsonValue<nlohmann::json>(map_json, kMapValues));
+  if (!keys_json.is_array() || !values_json.is_array()) {
+    return JsonParseError("'{}' map keys and values must be arrays", key);
+  }
+  if (keys_json.size() != values_json.size()) {
     return JsonParseError("'{}' map keys and values have different lengths", 
key);
   }
 
-  for (size_t i = 0; i < keys.size(); ++i) {
-    result[keys[i]] = std::move(values[i]);
+  for (size_t i = 0; i < keys_json.size(); ++i) {
+    ICEBERG_ASSIGN_OR_RAISE(
+        auto field_id,
+        IntegerFromJson<int32_t>(keys_json[i], std::format("'{}' map key", 
key)));
+    ICEBERG_ASSIGN_OR_RAISE(
+        auto value,
+        IntegerFromJson<Value>(values_json[i], std::format("'{}' map value", 
key)));
+    if (!result.emplace(field_id, value).second) {
+      return JsonParseError("'{}' map contains duplicate key {}", key, 
field_id);
+    }
   }
   return result;
 }
@@ -310,6 +375,118 @@ void SetKeyValueMap(nlohmann::json& json, 
std::string_view key,
   json[key] = {{kMapKeys, std::move(keys)}, {kMapValues, std::move(values)}};
 }
 
+std::string BytesToHex(const std::vector<uint8_t>& bytes) {
+  std::string hex;
+  hex.reserve(bytes.size() * 2);
+  for (uint8_t byte : bytes) {
+    hex += std::format("{:02X}", byte);
+  }
+  return hex;
+}
+
+Result<std::map<int32_t, std::vector<uint8_t>>> BytesMapFromJson(
+    const nlohmann::json& json, std::string_view key) {
+  std::map<int32_t, std::vector<uint8_t>> result;
+  if (!json.contains(key) || json.at(key).is_null()) {
+    return result;
+  }
+
+  ICEBERG_ASSIGN_OR_RAISE(auto map_json, GetJsonValue<nlohmann::json>(json, 
key));
+  ICEBERG_ASSIGN_OR_RAISE(auto keys_json,
+                          GetJsonValue<nlohmann::json>(map_json, kMapKeys));
+  ICEBERG_ASSIGN_OR_RAISE(auto values_json,
+                          GetJsonValue<nlohmann::json>(map_json, kMapValues));
+  if (!keys_json.is_array() || !values_json.is_array()) {
+    return JsonParseError("'{}' map keys and values must be arrays", key);
+  }
+  if (keys_json.size() != values_json.size()) {
+    return JsonParseError("'{}' map keys and values have different lengths", 
key);
+  }
+
+  for (size_t i = 0; i < keys_json.size(); ++i) {
+    ICEBERG_ASSIGN_OR_RAISE(
+        auto field_id,
+        IntegerFromJson<int32_t>(keys_json[i], std::format("'{}' map key", 
key)));
+    if (!values_json[i].is_string()) {
+      return JsonParseError("'{}' map value must be a string, but is {}", key,
+                            values_json[i].type_name());
+    }
+    ICEBERG_ASSIGN_OR_RAISE(
+        auto bytes, 
StringUtils::HexStringToBytes(values_json[i].get<std::string>()));
+    if (!result.emplace(field_id, std::move(bytes)).second) {
+      return JsonParseError("'{}' map contains duplicate key {}", key, 
field_id);
+    }
+  }
+  return result;
+}
+
+void SetBytesMap(nlohmann::json& json, std::string_view key,
+                 const std::map<int32_t, std::vector<uint8_t>>& map) {
+  if (map.empty()) {
+    return;
+  }
+
+  std::vector<int32_t> keys;
+  std::vector<std::string> values;
+  keys.reserve(map.size());
+  values.reserve(map.size());
+  for (const auto& [field_id, value] : map) {
+    keys.push_back(field_id);
+    values.push_back(BytesToHex(value));
+  }
+  json[key] = {{kMapKeys, std::move(keys)}, {kMapValues, std::move(values)}};
+}
+
+Result<Literal> PartitionLiteralFromJson(const nlohmann::json* value,
+                                         const SchemaField& field) {
+  if (!field.type() || !field.type()->is_primitive()) {
+    return InvalidSchema("Partition field {} must have a primitive type",
+                         field.field_id());
+  }
+
+  auto primitive_type = 
internal::checked_pointer_cast<PrimitiveType>(field.type());
+  if (value == nullptr || value->is_null()) {
+    return Literal::Null(std::move(primitive_type));
+  }
+  return LiteralFromJson(*value, primitive_type.get());
+}
+
+Result<PartitionValues> PartitionValuesFromJson(const nlohmann::json& json,
+                                                const StructType& 
partition_type) {
+  const auto& fields = partition_type.fields();
+  std::vector<Literal> values;
+  values.reserve(fields.size());
+
+  if (json.is_array()) {
+    if (json.size() != fields.size()) {
+      return JsonParseError("Invalid partition data size: expected = {}, 
actual = {}",
+                            fields.size(), json.size());
+    }
+    for (size_t pos = 0; pos < fields.size(); ++pos) {
+      ICEBERG_ASSIGN_OR_RAISE(auto value,
+                              PartitionLiteralFromJson(&json[pos], 
fields[pos]));
+      values.push_back(std::move(value));
+    }
+  } else if (json.is_object()) {
+    if (json.size() > fields.size()) {
+      return JsonParseError("Invalid partition data size: expected <= {}, 
actual = {}",
+                            fields.size(), json.size());
+    }
+    for (const auto& field : fields) {
+      const auto it = json.find(std::to_string(field.field_id()));
+      const nlohmann::json* value = it == json.end() ? nullptr : &it.value();
+      ICEBERG_ASSIGN_OR_RAISE(auto literal, PartitionLiteralFromJson(value, 
field));
+      values.push_back(std::move(literal));
+    }
+  } else {
+    return JsonParseError(
+        "Invalid partition data for content file: expected array or object 
({})",
+        SafeDumpJson(json));
+  }
+
+  return PartitionValues(std::move(values));
+}
+
 Result<nlohmann::json> DataFileToJsonUnchecked(const DataFile& data_file) {
   nlohmann::json json;
   switch (data_file.content) {
@@ -327,7 +504,7 @@ Result<nlohmann::json> DataFileToJsonUnchecked(const 
DataFile& data_file) {
   json[kFileFormat] = ToString(data_file.file_format);
 
   if (!data_file.partition_spec_id.has_value()) {
-    return ValidationFailed("Cannot serialize REST content file without 
'spec-id'");
+    return ValidationFailed("Cannot serialize content file without 'spec-id'");
   }
   json[kSpecId] = data_file.partition_spec_id.value();
 
@@ -345,11 +522,11 @@ Result<nlohmann::json> DataFileToJsonUnchecked(const 
DataFile& data_file) {
   SetKeyValueMap(json, kValueCounts, data_file.value_counts);
   SetKeyValueMap(json, kNullValueCounts, data_file.null_value_counts);
   SetKeyValueMap(json, kNanValueCounts, data_file.nan_value_counts);
-  SetKeyValueMap(json, kLowerBounds, data_file.lower_bounds);
-  SetKeyValueMap(json, kUpperBounds, data_file.upper_bounds);
+  SetBytesMap(json, kLowerBounds, data_file.lower_bounds);
+  SetBytesMap(json, kUpperBounds, data_file.upper_bounds);
 
   if (!data_file.key_metadata.empty()) {
-    json[kKeyMetadata] = data_file.key_metadata;
+    json[kKeyMetadata] = BytesToHex(data_file.key_metadata);
   }
   if (!data_file.split_offsets.empty()) {
     json[kSplitOffsets] = data_file.split_offsets;
@@ -2134,11 +2311,13 @@ Result<DataFile> DataFileFromJson(
   DataFile data_file;
 
   ICEBERG_ASSIGN_OR_RAISE(auto content_str, GetJsonValue<std::string>(json, 
kContent));
-  if (content_str == kContentData) {
+  if (content_str == kContentData || content_str == "DATA") {
     data_file.content = DataFile::Content::kData;
-  } else if (content_str == kContentPositionDeletes) {
+  } else if (content_str == kContentPositionDeletes ||
+             content_str == "POSITION_DELETES") {
     data_file.content = DataFile::Content::kPositionDeletes;
-  } else if (content_str == kContentEqualityDeletes) {
+  } else if (content_str == kContentEqualityDeletes ||
+             content_str == "EQUALITY_DELETES") {
     data_file.content = DataFile::Content::kEqualityDeletes;
   } else {
     return JsonParseError("Unknown data file content: {}", content_str);
@@ -2149,37 +2328,25 @@ Result<DataFile> DataFileFromJson(
   ICEBERG_ASSIGN_OR_RAISE(auto format_str, GetJsonValue<std::string>(json, 
kFileFormat));
   ICEBERG_ASSIGN_OR_RAISE(data_file.file_format, 
FileFormatTypeFromString(format_str));
 
-  ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonValue<int32_t>(json, kSpecId));
+  ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonInteger<int32_t>(json, 
kSpecId));
   data_file.partition_spec_id = spec_id;
 
-  ICEBERG_ASSIGN_OR_RAISE(auto partition_vals,
-                          GetJsonValue<nlohmann::json>(json, kPartition));
-  if (!partition_vals.is_array()) {
-    return JsonParseError("PartitionValues must be a JSON array: {}",
-                          SafeDumpJson(partition_vals));
-  }
-  std::vector<Literal> literals;
   auto it = partition_spec_by_id.find(spec_id);
-  if (it == partition_spec_by_id.end()) {
+  if (it == partition_spec_by_id.end() || !it->second) {
     return JsonParseError("Invalid partition spec id: {}", spec_id);
   }
-  ICEBERG_ASSIGN_OR_RAISE(auto struct_type, it->second->PartitionType(schema));
-  auto fields = struct_type->fields();
-  if (partition_vals.size() != fields.size()) {
-    return JsonParseError("Invalid partition data size: expected = {}, actual 
= {}",
-                          fields.size(), partition_vals.size());
-  }
-  for (size_t pos = 0; pos < fields.size(); ++pos) {
-    ICEBERG_ASSIGN_OR_RAISE(
-        auto literal, LiteralFromJson(partition_vals[pos], 
fields[pos].type().get()));
-    literals.push_back(std::move(literal));
+  if (json.contains(kPartition)) {
+    ICEBERG_ASSIGN_OR_RAISE(auto partition_vals,
+                            GetJsonValue<nlohmann::json>(json, kPartition));
+    ICEBERG_ASSIGN_OR_RAISE(auto struct_type, 
it->second->PartitionType(schema));
+    ICEBERG_ASSIGN_OR_RAISE(data_file.partition,
+                            PartitionValuesFromJson(partition_vals, 
*struct_type));
   }
-  data_file.partition = PartitionValues(std::move(literals));
 
   ICEBERG_ASSIGN_OR_RAISE(data_file.record_count,
-                          GetJsonValue<int64_t>(json, kRecordCount));
+                          GetJsonInteger<int64_t>(json, kRecordCount));
   ICEBERG_ASSIGN_OR_RAISE(data_file.file_size_in_bytes,
-                          GetJsonValue<int64_t>(json, kFileSizeInBytes));
+                          GetJsonInteger<int64_t>(json, kFileSizeInBytes));
 
   ICEBERG_ASSIGN_OR_RAISE(data_file.column_sizes,
                           KeyValueMapFromJson<int64_t>(json, kColumnSizes));
@@ -2189,30 +2356,30 @@ Result<DataFile> DataFileFromJson(
                           KeyValueMapFromJson<int64_t>(json, 
kNullValueCounts));
   ICEBERG_ASSIGN_OR_RAISE(data_file.nan_value_counts,
                           KeyValueMapFromJson<int64_t>(json, kNanValueCounts));
-  ICEBERG_ASSIGN_OR_RAISE(data_file.lower_bounds,
-                          KeyValueMapFromJson<std::vector<uint8_t>>(json, 
kLowerBounds));
-  ICEBERG_ASSIGN_OR_RAISE(data_file.upper_bounds,
-                          KeyValueMapFromJson<std::vector<uint8_t>>(json, 
kUpperBounds));
+  ICEBERG_ASSIGN_OR_RAISE(data_file.lower_bounds, BytesMapFromJson(json, 
kLowerBounds));
+  ICEBERG_ASSIGN_OR_RAISE(data_file.upper_bounds, BytesMapFromJson(json, 
kUpperBounds));
 
   if (json.contains(kKeyMetadata) && !json.at(kKeyMetadata).is_null()) {
+    ICEBERG_ASSIGN_OR_RAISE(auto key_metadata,
+                            GetJsonValue<std::string>(json, kKeyMetadata));
     ICEBERG_ASSIGN_OR_RAISE(data_file.key_metadata,
-                            GetJsonValue<std::vector<uint8_t>>(json, 
kKeyMetadata));
+                            StringUtils::HexStringToBytes(key_metadata));
   }
   if (json.contains(kSplitOffsets) && !json.at(kSplitOffsets).is_null()) {
     ICEBERG_ASSIGN_OR_RAISE(data_file.split_offsets,
-                            GetJsonValue<std::vector<int64_t>>(json, 
kSplitOffsets));
+                            IntegerVectorFromJson<int64_t>(json, 
kSplitOffsets));
   }
   if (json.contains(kEqualityIds) && !json.at(kEqualityIds).is_null()) {
     ICEBERG_ASSIGN_OR_RAISE(data_file.equality_ids,
-                            GetJsonValue<std::vector<int32_t>>(json, 
kEqualityIds));
+                            IntegerVectorFromJson<int32_t>(json, 
kEqualityIds));
   }
   if (json.contains(kSortOrderId) && !json.at(kSortOrderId).is_null()) {
     ICEBERG_ASSIGN_OR_RAISE(data_file.sort_order_id,
-                            GetJsonValue<int32_t>(json, kSortOrderId));
+                            GetJsonInteger<int32_t>(json, kSortOrderId));
   }
   if (json.contains(kFirstRowId) && !json.at(kFirstRowId).is_null()) {
     ICEBERG_ASSIGN_OR_RAISE(data_file.first_row_id,
-                            GetJsonValue<int64_t>(json, kFirstRowId));
+                            GetJsonInteger<int64_t>(json, kFirstRowId));
   }
   if (json.contains(kReferencedDataFile) && 
!json.at(kReferencedDataFile).is_null()) {
     ICEBERG_ASSIGN_OR_RAISE(data_file.referenced_data_file,
@@ -2220,11 +2387,11 @@ Result<DataFile> DataFileFromJson(
   }
   if (json.contains(kContentOffset) && !json.at(kContentOffset).is_null()) {
     ICEBERG_ASSIGN_OR_RAISE(data_file.content_offset,
-                            GetJsonValue<int64_t>(json, kContentOffset));
+                            GetJsonInteger<int64_t>(json, kContentOffset));
   }
   if (json.contains(kContentSizeInBytes) && 
!json.at(kContentSizeInBytes).is_null()) {
     ICEBERG_ASSIGN_OR_RAISE(data_file.content_size_in_bytes,
-                            GetJsonValue<int64_t>(json, kContentSizeInBytes));
+                            GetJsonInteger<int64_t>(json, 
kContentSizeInBytes));
   }
 
   return data_file;
@@ -2254,6 +2421,16 @@ Result<nlohmann::json> ToJson(
         partition_type->fields().empty() ? "unpartitioned" : "partitioned",
         data_file.partition.num_fields() == 0 ? "unpartitioned" : 
"partitioned");
   }
+  for (size_t pos = 0; pos < partition_type->fields().size(); ++pos) {
+    const auto& literal = data_file.partition.values()[pos];
+    const auto& expected_type = partition_type->fields()[pos].type();
+    if (!literal.IsNull() && (!literal.type() || *literal.type() != 
*expected_type)) {
+      return ValidationFailed(
+          "Invalid partition value type at position {}: expected = {}, actual 
= {}", pos,
+          expected_type->ToString(),
+          literal.type() ? literal.type()->ToString() : "unknown");
+    }
+  }
   return DataFileToJsonUnchecked(data_file);
 }
 
diff --git a/src/iceberg/test/json_serde_test.cc 
b/src/iceberg/test/json_serde_test.cc
index f375164f..c3187e6e 100644
--- a/src/iceberg/test/json_serde_test.cc
+++ b/src/iceberg/test/json_serde_test.cc
@@ -18,6 +18,8 @@
  */
 
 #include <memory>
+#include <string>
+#include <string_view>
 #include <unordered_map>
 
 #include <gmock/gmock.h>
@@ -1251,7 +1253,7 @@ TEST(DataFileFromJsonTest, MissingSpecId) {
   EXPECT_THAT(result, HasErrorMessage("Missing 'spec-id'"));
 }
 
-TEST(DataFileFromJsonTest, MissingPartition) {
+TEST(DataFileFromJsonTest, MissingPartitionIsAccepted) {
   auto json = R"({
     "content": "data",
     "file-path": "s3://bucket/data/file.parquet",
@@ -1261,9 +1263,9 @@ TEST(DataFileFromJsonTest, MissingPartition) {
     "record-count": 10
   })"_json;
 
-  auto result = DataFileFromJson(json, UnpartitionedSpecs(), Schema({}, 0));
-  EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
-  EXPECT_THAT(result, HasErrorMessage("Missing 'partition'"));
+  ICEBERG_UNWRAP_OR_FAIL(auto data_file,
+                         DataFileFromJson(json, UnpartitionedSpecs(), 
Schema({}, 0)));
+  EXPECT_EQ(data_file.partition.num_fields(), 0U);
 }
 
 TEST(DataFileFromJsonTest, NotAnObject) {
@@ -1272,4 +1274,241 @@ TEST(DataFileFromJsonTest, NotAnObject) {
   EXPECT_THAT(result, HasErrorMessage("DataFile must be a JSON object"));
 }
 
+class DataFileJavaJsonTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    ICEBERG_UNWRAP_OR_FAIL(
+        auto spec,
+        PartitionSpec::Make(schema_, /*spec_id=*/0,
+                            {PartitionField(1, 1000, "id", 
Transform::Identity())},
+                            /*allow_missing_fields=*/false));
+    spec_ = std::shared_ptr<PartitionSpec>(std::move(spec));
+    specs_.emplace(spec_->spec_id(), spec_);
+  }
+
+  DataFile DataFileForTest() const {
+    DataFile data_file;
+    data_file.content = DataFile::Content::kData;
+    data_file.file_path = "/path/to/data.parquet";
+    data_file.file_format = FileFormatType::kParquet;
+    data_file.partition_spec_id = 0;
+    data_file.partition = PartitionValues({Literal::Int(7)});
+    data_file.record_count = 1;
+    data_file.file_size_in_bytes = 10;
+    data_file.lower_bounds = {{1, {0x01, 0x00, 0x00, 0x00}}};
+    data_file.upper_bounds = {{1, {0x05, 0x00, 0x00, 0x00}}};
+    data_file.key_metadata = {0x0A, 0x0B};
+    data_file.sort_order_id = 0;
+    return data_file;
+  }
+
+  nlohmann::json JavaGolden() const {
+    return R"({
+      "spec-id": 0,
+      "content": "data",
+      "file-path": "/path/to/data.parquet",
+      "file-format": "parquet",
+      "partition": [7],
+      "file-size-in-bytes": 10,
+      "record-count": 1,
+      "lower-bounds": {"keys": [1], "values": ["01000000"]},
+      "upper-bounds": {"keys": [1], "values": ["05000000"]},
+      "key-metadata": "0A0B",
+      "sort-order-id": 0
+    })"_json;
+  }
+
+  Schema schema_{{SchemaField::MakeRequired(1, "id", int32())}, 0};
+  std::shared_ptr<PartitionSpec> spec_;
+  std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>> specs_;
+};
+
+TEST_F(DataFileJavaJsonTest, SerializesAndParsesJavaEncoding) {
+  ICEBERG_UNWRAP_OR_FAIL(auto json, ToJson(DataFileForTest(), specs_, 
schema_));
+  EXPECT_EQ(json, JavaGolden());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed, DataFileFromJson(json, specs_, schema_));
+  EXPECT_EQ(parsed, DataFileForTest());
+}
+
+TEST_F(DataFileJavaJsonTest, ParsesPartitionObject) {
+  auto json = JavaGolden();
+  json["partition"] = {{"1000", 7}};
+
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed, DataFileFromJson(json, specs_, schema_));
+  ASSERT_EQ(parsed.partition.num_fields(), 1U);
+  EXPECT_EQ(parsed.partition.values()[0], Literal::Int(7));
+}
+
+TEST_F(DataFileJavaJsonTest, ParsesAllLegacyContentNames) {
+  struct Case {
+    std::string_view legacy;
+    DataFile::Content content;
+    std::string_view canonical;
+  };
+  for (const auto& test_case :
+       {Case{.legacy = "DATA", .content = DataFile::Content::kData, .canonical 
= "data"},
+        Case{.legacy = "POSITION_DELETES",
+             .content = DataFile::Content::kPositionDeletes,
+             .canonical = "position-deletes"},
+        Case{.legacy = "EQUALITY_DELETES",
+             .content = DataFile::Content::kEqualityDeletes,
+             .canonical = "equality-deletes"}}) {
+    SCOPED_TRACE(test_case.legacy);
+    auto json = JavaGolden();
+    json["content"] = std::string(test_case.legacy);
+
+    ICEBERG_UNWRAP_OR_FAIL(auto parsed, DataFileFromJson(json, specs_, 
schema_));
+    EXPECT_EQ(parsed.content, test_case.content);
+    ICEBERG_UNWRAP_OR_FAIL(auto serialized, ToJson(parsed, specs_, schema_));
+    EXPECT_EQ(serialized["content"], std::string(test_case.canonical));
+  }
+}
+
+TEST_F(DataFileJavaJsonTest, ParsesFieldIdPartitionsInSpecOrderAndTypedNulls) {
+  Schema schema{{SchemaField::MakeRequired(1, "id", int32()),
+                 SchemaField::MakeOptional(2, "region", string())},
+                0};
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto spec,
+      PartitionSpec::Make(schema, /*spec_id=*/0,
+                          {PartitionField(2, 1001, "region", 
Transform::Identity()),
+                           PartitionField(1, 1000, "id", 
Transform::Identity())},
+                          /*allow_missing_fields=*/false));
+  std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>> specs{
+      {0, std::shared_ptr<PartitionSpec>(std::move(spec))}};
+
+  auto json = JavaGolden();
+  json["partition"] = {{"1000", 7}, {"1001", "north"}};
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed, DataFileFromJson(json, specs, schema));
+  ASSERT_EQ(parsed.partition.num_fields(), 2U);
+  EXPECT_EQ(parsed.partition.values()[0], Literal::String("north"));
+  EXPECT_EQ(parsed.partition.values()[1], Literal::Int(7));
+
+  json["partition"] = {{"1000", 7}};
+  ICEBERG_UNWRAP_OR_FAIL(auto sparse, DataFileFromJson(json, specs, schema));
+  ASSERT_EQ(sparse.partition.num_fields(), 2U);
+  EXPECT_TRUE(sparse.partition.values()[0].IsNull());
+  ASSERT_NE(sparse.partition.values()[0].type(), nullptr);
+  EXPECT_EQ(sparse.partition.values()[0].type()->type_id(), TypeId::kString);
+  EXPECT_EQ(sparse.partition.values()[1], Literal::Int(7));
+
+  json["partition"] = nlohmann::json::array({nullptr, 7});
+  ICEBERG_UNWRAP_OR_FAIL(auto positional, DataFileFromJson(json, specs, 
schema));
+  ASSERT_EQ(positional.partition.num_fields(), 2U);
+  EXPECT_TRUE(positional.partition.values()[0].IsNull());
+  ASSERT_NE(positional.partition.values()[0].type(), nullptr);
+  EXPECT_EQ(positional.partition.values()[0].type()->type_id(), 
TypeId::kString);
+  EXPECT_EQ(positional.partition.values()[1], Literal::Int(7));
+}
+
+TEST_F(DataFileJavaJsonTest, RejectsMalformedPartitionShapes) {
+  for (const auto& partition :
+       {nlohmann::json::array(), nlohmann::json::object({{"1000", 7}, {"9999", 
8}}),
+        nlohmann::json("invalid")}) {
+    SCOPED_TRACE(partition.dump());
+    auto json = JavaGolden();
+    json["partition"] = partition;
+    EXPECT_THAT(DataFileFromJson(json, specs_, schema_),
+                IsError(ErrorKind::kJsonParseError));
+  }
+}
+
+TEST_F(DataFileJavaJsonTest, RejectsInvalidIntegersAndDuplicateMetricKeys) {
+  for (std::string_view field :
+       {"spec-id", "file-size-in-bytes", "record-count", "sort-order-id", 
"first-row-id",
+        "content-offset", "content-size-in-bytes"}) {
+    SCOPED_TRACE(field);
+    auto json = JavaGolden();
+    json[field] = 0.5;
+    auto result = DataFileFromJson(json, specs_, schema_);
+    EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+    EXPECT_THAT(result, HasErrorMessage(field));
+  }
+
+  auto expect_invalid = [&](nlohmann::json json, std::string_view field) {
+    auto result = DataFileFromJson(json, specs_, schema_);
+    EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+    EXPECT_THAT(result, HasErrorMessage(field));
+  };
+
+  auto fractional_key = JavaGolden();
+  fractional_key["column-sizes"] = {{"keys", {0.5}}, {"values", {10}}};
+  expect_invalid(fractional_key, "column-sizes");
+
+  auto fractional_value = JavaGolden();
+  fractional_value["column-sizes"] = {{"keys", {1}}, {"values", {0.5}}};
+  expect_invalid(fractional_value, "column-sizes");
+
+  auto fractional_bound_key = JavaGolden();
+  fractional_bound_key["lower-bounds"]["keys"] = {0.5};
+  expect_invalid(fractional_bound_key, "lower-bounds");
+
+  for (std::string_view field : {"split-offsets", "equality-ids"}) {
+    auto json = JavaGolden();
+    json[field] = {0.5};
+    expect_invalid(std::move(json), field);
+  }
+
+  auto oversized_spec_id = JavaGolden();
+  oversized_spec_id["spec-id"] = 2147483648ULL;
+  expect_invalid(oversized_spec_id, "spec-id");
+
+  auto oversized_record_count = JavaGolden();
+  oversized_record_count["record-count"] = 18446744073709551615ULL;
+  expect_invalid(oversized_record_count, "record-count");
+
+  auto duplicate_keys = JavaGolden();
+  duplicate_keys["column-sizes"] = {{"keys", {1, 1}}, {"values", {10, 20}}};
+  auto result = DataFileFromJson(duplicate_keys, specs_, schema_);
+  EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+  EXPECT_THAT(result, HasErrorMessage("duplicate key"));
+
+  auto duplicate_bounds = JavaGolden();
+  duplicate_bounds["lower-bounds"] = {{"keys", {1, 1}}, {"values", {"01", 
"02"}}};
+  auto bounds_result = DataFileFromJson(duplicate_bounds, specs_, schema_);
+  EXPECT_THAT(bounds_result, IsError(ErrorKind::kJsonParseError));
+  EXPECT_THAT(bounds_result, HasErrorMessage("duplicate key"));
+}
+
+TEST_F(DataFileJavaJsonTest, RejectsMalformedHexMetadata) {
+  auto bad_bound = JavaGolden();
+  bad_bound["lower-bounds"]["values"] = {"0G"};
+  EXPECT_THAT(DataFileFromJson(bad_bound, specs_, schema_),
+              IsError(ErrorKind::kInvalidArgument));
+
+  auto non_string_bound = JavaGolden();
+  non_string_bound["lower-bounds"]["values"] = {7};
+  EXPECT_THAT(DataFileFromJson(non_string_bound, specs_, schema_),
+              IsError(ErrorKind::kJsonParseError));
+
+  auto bad_key_metadata = JavaGolden();
+  bad_key_metadata["key-metadata"] = "0G";
+  EXPECT_THAT(DataFileFromJson(bad_key_metadata, specs_, schema_),
+              IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST_F(DataFileJavaJsonTest, AcceptsMissingPartitionAndNullMetricMaps) {
+  auto json = JavaGolden();
+  json.erase("partition");
+  for (std::string_view field : {"column-sizes", "value-counts", 
"null-value-counts",
+                                 "nan-value-counts", "lower-bounds", 
"upper-bounds"}) {
+    json[field] = nullptr;
+  }
+
+  ICEBERG_UNWRAP_OR_FAIL(auto parsed, DataFileFromJson(json, specs_, schema_));
+  EXPECT_EQ(parsed.partition.num_fields(), 0U);
+  EXPECT_TRUE(parsed.column_sizes.empty());
+  EXPECT_TRUE(parsed.lower_bounds.empty());
+}
+
+TEST_F(DataFileJavaJsonTest, RejectsInvalidPartitionTypeWhenSerializing) {
+  auto data_file = DataFileForTest();
+  data_file.partition = PartitionValues({Literal::String("not-an-int")});
+
+  auto result = ToJson(data_file, specs_, schema_);
+  EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
+  EXPECT_THAT(result, HasErrorMessage("partition value type"));
+}
+
 }  // namespace iceberg
diff --git a/src/iceberg/test/rest_json_serde_test.cc 
b/src/iceberg/test/rest_json_serde_test.cc
index d0a69c05..8d246202 100644
--- a/src/iceberg/test/rest_json_serde_test.cc
+++ b/src/iceberg/test/rest_json_serde_test.cc
@@ -19,7 +19,9 @@
 
 #include <memory>
 #include <string>
+#include <string_view>
 #include <unordered_map>
+#include <utility>
 #include <vector>
 
 #include <gmock/gmock.h>

Reply via email to