You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ga...@apache.org on 2026/10/11 14:54:51 UTC
(iceberg-cpp) branch main updated: feat(serde): support standalone FileScanTask JSON in core (#939)
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 0a22087d feat(serde): support standalone FileScanTask JSON in core (#939)
0a22087d is described below
commit 0a22087d6fae7b26a6dacd9a64e3ef93239700ea
Author: kamcheungting-db <91...@users.noreply.github.com>
AuthorDate: Sun Oct 11 07:54:45 2026 -0700
feat(serde): support standalone FileScanTask JSON in core (#939)
---
src/iceberg/catalog/rest/json_serde.cc | 8 +
src/iceberg/json_serde.cc | 243 +++++++++++-----
src/iceberg/json_serde_internal.h | 33 +++
src/iceberg/test/json_serde_test.cc | 464 +++++++++++++++++++++++++++++++
src/iceberg/test/rest_json_serde_test.cc | 99 +++++++
src/iceberg/util/json_util_internal.h | 72 +++++
6 files changed, 851 insertions(+), 68 deletions(-)
diff --git a/src/iceberg/catalog/rest/json_serde.cc b/src/iceberg/catalog/rest/json_serde.cc
index d98544ab..ba348dec 100644
--- a/src/iceberg/catalog/rest/json_serde.cc
+++ b/src/iceberg/catalog/rest/json_serde.cc
@@ -108,6 +108,8 @@ constexpr std::string_view kPlanTask = "plan-task";
constexpr std::string_view kDataFile = "data-file";
constexpr std::string_view kDeleteFileReferences = "delete-file-references";
constexpr std::string_view kResidualFilter = "residual-filter";
+constexpr std::string_view kStart = "start";
+constexpr std::string_view kLength = "length";
Result<nlohmann::json> StorageCredentialToJson(const StorageCredential& credential) {
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
@@ -168,6 +170,12 @@ Result<std::vector<std::shared_ptr<FileScanTask>>> FileScanTasksFromJson(
ICEBERG_ASSIGN_OR_RAISE(
auto data_file,
iceberg::DataFileFromJson(data_file_json, partition_spec_by_id, schema));
+ ICEBERG_ASSIGN_OR_RAISE(auto start, GetJsonIntegerOrDefault(task_json, kStart, 0));
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto length,
+ GetJsonIntegerOrDefault(task_json, kLength, data_file.file_size_in_bytes));
+ ICEBERG_RETURN_UNEXPECTED(
+ CheckFileScanTaskNotSplit(start, length, data_file.file_size_in_bytes));
// FIXME: REST scan-task DataFile JSON currently carries first-row-id,
// but not the manifest-entry data sequence number. Until the REST API exposes
// it, REST-planned tasks cannot inherit _last_updated_sequence_number.
diff --git a/src/iceberg/json_serde.cc b/src/iceberg/json_serde.cc
index eb7bf09d..c7d0cb43 100644
--- a/src/iceberg/json_serde.cc
+++ b/src/iceberg/json_serde.cc
@@ -21,7 +21,6 @@
#include <concepts>
#include <cstdint>
#include <format>
-#include <limits>
#include <map>
#include <regex>
#include <string>
@@ -31,6 +30,7 @@
#include <nlohmann/json.hpp>
#include "iceberg/constants.h"
+#include "iceberg/expression/expressions.h"
#include "iceberg/expression/json_serde_internal.h"
#include "iceberg/expression/literal.h"
#include "iceberg/file_format.h"
@@ -50,6 +50,7 @@
#include "iceberg/table_metadata.h"
#include "iceberg/table_properties.h"
#include "iceberg/table_requirement.h"
+#include "iceberg/table_scan.h"
#include "iceberg/table_update.h"
#include "iceberg/transform.h"
#include "iceberg/type.h"
@@ -248,7 +249,7 @@ constexpr std::string_view kRequirementAssertDefaultSortOrderID =
constexpr std::string_view kLastAssignedFieldId = "last-assigned-field-id";
constexpr std::string_view kLastAssignedPartitionId = "last-assigned-partition-id";
-// DataFile JSON (Iceberg REST ContentFile field names)
+// DataFile / FileScanTask JSON (Iceberg REST ContentFile field names)
constexpr std::string_view kContent = "content";
constexpr std::string_view kContentData = "data";
constexpr std::string_view kContentPositionDeletes = "position-deletes";
@@ -269,60 +270,17 @@ constexpr std::string_view kEqualityIds = "equality-ids";
constexpr std::string_view kReferencedDataFile = "referenced-data-file";
constexpr std::string_view kContentOffset = "content-offset";
constexpr std::string_view kContentSizeInBytes = "content-size-in-bytes";
+constexpr std::string_view kDataFile = "data-file";
+constexpr std::string_view kDeleteFiles = "delete-files";
+constexpr std::string_view kDeleteFileReferences = "delete-file-references";
+constexpr std::string_view kResidualFilter = "residual-filter";
+constexpr std::string_view kTaskType = "task-type";
+constexpr std::string_view kFileScanTaskType = "file-scan-task";
+constexpr std::string_view kStart = "start";
+constexpr std::string_view kLength = "length";
constexpr std::string_view kMapKeys = "keys";
constexpr std::string_view kMapValues = "values";
-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) {
@@ -1087,10 +1045,11 @@ Result<std::unique_ptr<PartitionField>> PartitionFieldFromJson(
std::move(transform));
}
-Result<std::unique_ptr<PartitionSpec>> PartitionSpecFromJson(
- const std::shared_ptr<Schema>& schema, const nlohmann::json& json,
- int32_t default_spec_id) {
- ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonValue<int32_t>(json, kSpecId));
+namespace {
+
+Result<std::unique_ptr<PartitionSpec>> ParseBoundPartitionSpec(
+ const Schema& schema, const nlohmann::json& json, int32_t spec_id,
+ bool allow_missing_fields) {
ICEBERG_ASSIGN_OR_RAISE(auto fields, GetJsonValue<nlohmann::json>(json, kFields));
std::vector<PartitionField> partition_fields;
@@ -1099,17 +1058,18 @@ Result<std::unique_ptr<PartitionSpec>> PartitionSpecFromJson(
partition_fields.push_back(std::move(*partition_field));
}
- std::unique_ptr<PartitionSpec> spec;
- if (default_spec_id == spec_id) {
- ICEBERG_ASSIGN_OR_RAISE(
- spec, PartitionSpec::Make(*schema, spec_id, std::move(partition_fields),
- /*allow_missing_fields=*/false));
- } else {
- ICEBERG_ASSIGN_OR_RAISE(
- spec, PartitionSpec::Make(*schema, spec_id, std::move(partition_fields),
- /*allow_missing_fields=*/true));
- }
- return spec;
+ return PartitionSpec::Make(schema, spec_id, std::move(partition_fields),
+ allow_missing_fields);
+}
+
+} // namespace
+
+Result<std::unique_ptr<PartitionSpec>> PartitionSpecFromJson(
+ const std::shared_ptr<Schema>& schema, const nlohmann::json& json,
+ int32_t default_spec_id) {
+ ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonValue<int32_t>(json, kSpecId));
+ return ParseBoundPartitionSpec(*schema, json, spec_id,
+ /*allow_missing_fields=*/spec_id != default_spec_id);
}
Result<std::unique_ptr<PartitionSpec>> PartitionSpecFromJson(const nlohmann::json& json) {
@@ -2434,4 +2394,151 @@ Result<nlohmann::json> ToJson(
return DataFileToJsonUnchecked(data_file);
}
+Result<nlohmann::json> ToJson(
+ const FileScanTask& task,
+ const std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>>&
+ partition_specs_by_id,
+ const Schema& schema) {
+ if (!task.data_file()) {
+ return ValidationFailed("Cannot serialize FileScanTask without data-file");
+ }
+ if (task.data_file()->content != DataFile::Content::kData) {
+ return ValidationFailed("FileScanTask data-file must have data content");
+ }
+
+ nlohmann::json json;
+ json[kTaskType] = kFileScanTaskType;
+ ICEBERG_ASSIGN_OR_RAISE(auto schema_json, ToJson(schema));
+ json[kSchema] = std::move(schema_json);
+
+ ICEBERG_ASSIGN_OR_RAISE(auto data_file_json,
+ ToJson(*task.data_file(), partition_specs_by_id, schema));
+ const auto spec_id = task.data_file()->partition_spec_id.value();
+ const auto spec_it = partition_specs_by_id.find(spec_id);
+ if (spec_it == partition_specs_by_id.end() || !spec_it->second) {
+ return ValidationFailed("Invalid partition spec: null");
+ }
+ json[kSpec] = ToJson(*spec_it->second);
+ json[kDataFile] = std::move(data_file_json);
+ json[kStart] = 0;
+ json[kLength] = task.data_file()->file_size_in_bytes;
+
+ nlohmann::json delete_files_json = nlohmann::json::array();
+ for (const auto& delete_file : task.delete_files()) {
+ if (!delete_file) {
+ return ValidationFailed("FileScanTask delete-files must not contain null");
+ }
+ if (delete_file->content == DataFile::Content::kData) {
+ return ValidationFailed("FileScanTask delete-file must have delete content");
+ }
+ if (delete_file->partition_spec_id != task.data_file()->partition_spec_id) {
+ return ValidationFailed(
+ "Invalid partition spec id from content file: expected = {}, actual = {}",
+ spec_id,
+ delete_file->partition_spec_id.has_value()
+ ? std::to_string(delete_file->partition_spec_id.value())
+ : "null");
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto delete_file_json,
+ ToJson(*delete_file, partition_specs_by_id, schema));
+ delete_files_json.push_back(std::move(delete_file_json));
+ }
+ if (!delete_files_json.empty()) {
+ json[kDeleteFiles] = std::move(delete_files_json);
+ }
+
+ if (task.residual_filter()) {
+ ICEBERG_ASSIGN_OR_RAISE(auto residual_json, ToJson(*task.residual_filter()));
+ json[kResidualFilter] = std::move(residual_json);
+ }
+ return json;
+}
+
+Status CheckFileScanTaskNotSplit(int64_t start, int64_t length,
+ int64_t file_size_in_bytes) {
+ if (start != 0 || length != file_size_in_bytes) {
+ return NotSupported(
+ "Split FileScanTask is not supported: start={}, length={}, "
+ "file-size-in-bytes={}",
+ start, length, file_size_in_bytes);
+ }
+ return {};
+}
+
+Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(const nlohmann::json& json) {
+ if (!json.is_object()) {
+ return JsonParseError("Cannot parse file scan task from a non-object: {}",
+ SafeDumpJson(json));
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto task_type,
+ GetJsonValueOptional<std::string>(json, kTaskType));
+ if (task_type.has_value() &&
+ !StringUtils::EqualsIgnoreCase(*task_type, kFileScanTaskType)) {
+ return JsonParseError("Unsupported scan task type: {}", *task_type);
+ }
+ if (json.contains(kDeleteFileReferences) && !json.at(kDeleteFileReferences).is_null()) {
+ return JsonParseError(
+ "Cannot parse FileScanTask with 'delete-file-references'; use "
+ "rest::FileScanTasksFromJson for REST scan responses");
+ }
+
+ ICEBERG_ASSIGN_OR_RAISE(auto schema_json, GetJsonValue<nlohmann::json>(json, kSchema));
+ ICEBERG_ASSIGN_OR_RAISE(auto schema, SchemaFromJson(schema_json));
+ auto shared_schema = std::shared_ptr<Schema>(std::move(schema));
+
+ ICEBERG_ASSIGN_OR_RAISE(auto spec_json, GetJsonValue<nlohmann::json>(json, kSpec));
+ ICEBERG_ASSIGN_OR_RAISE(auto spec_id, GetJsonInteger<int32_t>(spec_json, kSpecId));
+ // An embedded task spec may reference columns dropped from its embedded schema.
+ ICEBERG_ASSIGN_OR_RAISE(auto spec,
+ ParseBoundPartitionSpec(*shared_schema, spec_json, spec_id,
+ /*allow_missing_fields=*/true));
+ auto shared_spec = std::shared_ptr<PartitionSpec>(std::move(spec));
+ const std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>> partition_spec_by_id{
+ {shared_spec->spec_id(), shared_spec}};
+
+ ICEBERG_ASSIGN_OR_RAISE(auto data_file_json,
+ GetJsonValue<nlohmann::json>(json, kDataFile));
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto data_file,
+ DataFileFromJson(data_file_json, partition_spec_by_id, *shared_schema));
+ if (data_file.content != DataFile::Content::kData) {
+ return JsonParseError("FileScanTask data-file must have data content");
+ }
+
+ ICEBERG_ASSIGN_OR_RAISE(auto start, GetJsonInteger<int64_t>(json, kStart));
+ ICEBERG_ASSIGN_OR_RAISE(auto length, GetJsonInteger<int64_t>(json, kLength));
+ ICEBERG_RETURN_UNEXPECTED(
+ CheckFileScanTaskNotSplit(start, length, data_file.file_size_in_bytes));
+
+ std::vector<std::shared_ptr<DataFile>> delete_files;
+ if (json.contains(kDeleteFiles)) {
+ ICEBERG_ASSIGN_OR_RAISE(auto delete_files_json,
+ GetJsonValue<nlohmann::json>(json, kDeleteFiles));
+ if (!delete_files_json.is_array()) {
+ return JsonParseError("Cannot parse delete files from non-array: {}",
+ SafeDumpJson(delete_files_json));
+ }
+ for (const auto& delete_file_json : delete_files_json) {
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto delete_file,
+ DataFileFromJson(delete_file_json, partition_spec_by_id, *shared_schema));
+ if (delete_file.content == DataFile::Content::kData) {
+ return JsonParseError("FileScanTask delete-file must have delete content");
+ }
+ delete_files.push_back(std::make_shared<DataFile>(std::move(delete_file)));
+ }
+ }
+
+ std::shared_ptr<Expression> residual_filter = Expressions::AlwaysTrue();
+ if (json.contains(kResidualFilter)) {
+ ICEBERG_ASSIGN_OR_RAISE(auto filter_json,
+ GetJsonValue<nlohmann::json>(json, kResidualFilter));
+ ICEBERG_ASSIGN_OR_RAISE(residual_filter, ExpressionFromJson(filter_json));
+ }
+
+ return std::make_shared<FileScanTask>(std::make_shared<DataFile>(std::move(data_file)),
+ std::move(delete_files),
+ std::move(residual_filter));
+}
+
} // namespace iceberg
diff --git a/src/iceberg/json_serde_internal.h b/src/iceberg/json_serde_internal.h
index 3aaced5c..2465df14 100644
--- a/src/iceberg/json_serde_internal.h
+++ b/src/iceberg/json_serde_internal.h
@@ -445,4 +445,37 @@ ICEBERG_EXPORT Result<DataFile> DataFileFromJson(
partition_spec_by_id,
const Schema& schema);
+/// \brief Serializes a `FileScanTask` to a self-contained JSON object.
+///
+/// Unlike REST scan responses, delete files are inlined under `delete-files` rather
+/// than encoded as `delete-file-references` into a sibling array.
+///
+/// JSON fields:
+/// - `task-type`: `file-scan-task`
+/// - `schema` (required): schema used by this task
+/// - `spec` (required): partition spec used by this task
+/// - `data-file` (required): ContentFile JSON
+/// - `start` (required): always 0 because split tasks are unsupported
+/// - `length` (required): always the data file size
+/// - `delete-files` (optional on input; omitted when the task has no delete files):
+/// array of ContentFile JSON
+/// - `residual-filter` (optional): Expression JSON
+ICEBERG_EXPORT Result<nlohmann::json> ToJson(
+ const FileScanTask& task,
+ const std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>>&
+ partition_specs_by_id,
+ const Schema& schema);
+
+/// \brief Deserializes a self-contained FileScanTask JSON object.
+///
+/// Reads the embedded schema and partition spec. Rejects REST
+/// `delete-file-references` and byte-range splits (`start` != 0 or `length` != file
+/// size). Both range fields are required, matching Java core.
+ICEBERG_EXPORT Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(
+ const nlohmann::json& json);
+
+/// Rejects `start != 0 || length != file_size_in_bytes`.
+ICEBERG_EXPORT Status CheckFileScanTaskNotSplit(int64_t start, int64_t length,
+ int64_t file_size_in_bytes);
+
} // namespace iceberg
diff --git a/src/iceberg/test/json_serde_test.cc b/src/iceberg/test/json_serde_test.cc
index c3187e6e..be363ea3 100644
--- a/src/iceberg/test/json_serde_test.cc
+++ b/src/iceberg/test/json_serde_test.cc
@@ -18,6 +18,7 @@
*/
#include <memory>
+#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -26,6 +27,8 @@
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
+#include "iceberg/expression/expressions.h"
+#include "iceberg/expression/json_serde_internal.h"
#include "iceberg/expression/literal.h"
#include "iceberg/file_format.h"
#include "iceberg/json_serde_internal.h"
@@ -40,6 +43,7 @@
#include "iceberg/sort_order.h"
#include "iceberg/statistics_file.h"
#include "iceberg/table_requirement.h"
+#include "iceberg/table_scan.h"
#include "iceberg/table_update.h"
#include "iceberg/test/matchers.h"
#include "iceberg/transform.h"
@@ -1511,4 +1515,464 @@ TEST_F(DataFileJavaJsonTest, RejectsInvalidPartitionTypeWhenSerializing) {
EXPECT_THAT(result, HasErrorMessage("partition value type"));
}
+class FileScanTaskJsonTest : 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 DataFileForTask() 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;
+ }
+
+ DataFile DeleteFileForTask() const {
+ DataFile delete_file;
+ delete_file.content = DataFile::Content::kPositionDeletes;
+ delete_file.file_path = "/path/to/delete.parquet";
+ delete_file.file_format = FileFormatType::kParquet;
+ delete_file.partition_spec_id = 0;
+ delete_file.partition = PartitionValues({Literal::Int(7)});
+ delete_file.record_count = 1;
+ delete_file.file_size_in_bytes = 4;
+ return delete_file;
+ }
+
+ nlohmann::json JavaGolden() const {
+ return R"({
+ "task-type": "file-scan-task",
+ "schema": {
+ "type": "struct",
+ "schema-id": 0,
+ "fields": [
+ {"id": 1, "name": "id", "required": true, "type": "int"}
+ ]
+ },
+ "spec": {
+ "spec-id": 0,
+ "fields": [
+ {"source-id": 1, "field-id": 1000, "name": "id", "transform": "identity"}
+ ]
+ },
+ "data-file": {
+ "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
+ },
+ "start": 0,
+ "length": 10,
+ "delete-files": [
+ {
+ "spec-id": 0,
+ "content": "position-deletes",
+ "file-path": "/path/to/delete.parquet",
+ "file-format": "parquet",
+ "partition": [7],
+ "file-size-in-bytes": 4,
+ "record-count": 1
+ }
+ ],
+ "residual-filter": true
+ })"_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(FileScanTaskJsonTest, SerializesJavaGolden) {
+ FileScanTask task(std::make_shared<DataFile>(DataFileForTask()),
+ {std::make_shared<DataFile>(DeleteFileForTask())},
+ Expressions::AlwaysTrue());
+
+ ICEBERG_UNWRAP_OR_FAIL(auto json, ToJson(task, specs_, schema_));
+ EXPECT_EQ(json, JavaGolden());
+}
+
+TEST_F(FileScanTaskJsonTest, ParsesJavaGoldenWithoutExternalSchemaOrSpec) {
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(JavaGolden()));
+
+ ASSERT_NE(task->data_file(), nullptr);
+ EXPECT_EQ(*task->data_file(), DataFileForTask());
+ ASSERT_EQ(task->delete_files().size(), 1U);
+ ASSERT_NE(task->delete_files()[0], nullptr);
+ EXPECT_EQ(*task->delete_files()[0], DeleteFileForTask());
+ ASSERT_NE(task->residual_filter(), nullptr);
+ ICEBERG_UNWRAP_OR_FAIL(auto residual, ToJson(*task->residual_filter()));
+ EXPECT_EQ(residual, true);
+}
+
+TEST_F(FileScanTaskJsonTest, OmitsEmptyDeleteFilesLikeJava) {
+ FileScanTask task(std::make_shared<DataFile>(DataFileForTask()));
+
+ ICEBERG_UNWRAP_OR_FAIL(auto json, ToJson(task, specs_, schema_));
+ EXPECT_FALSE(json.contains("delete-files"));
+ EXPECT_EQ(json["start"], 0);
+ EXPECT_EQ(json["length"], 10);
+}
+
+TEST_F(FileScanTaskJsonTest, AcceptsMissingDeleteFiles) {
+ auto json = JavaGolden();
+ json.erase("delete-files");
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ EXPECT_TRUE(task->delete_files().empty());
+}
+
+TEST_F(FileScanTaskJsonTest, RequiresJavaCoreFields) {
+ for (std::string_view field : {"schema", "spec", "start", "length"}) {
+ for (bool use_null : {false, true}) {
+ SCOPED_TRACE(std::string(field) + (use_null ? " null" : " missing"));
+ auto json = JavaGolden();
+ if (use_null) {
+ json[field] = nullptr;
+ } else {
+ json.erase(field);
+ }
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage(field));
+ }
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsSplitTasksUsingExactOrCondition) {
+ struct Case {
+ int64_t start;
+ int64_t length;
+ bool supported;
+ };
+ for (const auto& test_case : {Case{.start = 0, .length = 10, .supported = true},
+ Case{.start = 1, .length = 10, .supported = false},
+ Case{.start = 0, .length = 9, .supported = false}}) {
+ SCOPED_TRACE(testing::Message()
+ << "start=" << test_case.start << ", length=" << test_case.length);
+ auto json = JavaGolden();
+ json["start"] = test_case.start;
+ json["length"] = test_case.length;
+
+ auto result = FileScanTaskFromJson(json);
+ if (test_case.supported) {
+ EXPECT_THAT(result, IsOk());
+ } else {
+ EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
+ EXPECT_THAT(result, HasErrorMessage("Split FileScanTask is not supported"));
+ }
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsFractionalIntegerFields) {
+ for (std::string_view field :
+ {"start", "length", "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();
+ if (field == "start" || field == "length") {
+ json[field] = 0.5;
+ } else {
+ json["data-file"][field] = 0.5;
+ }
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage(field));
+ }
+
+ for (std::string_view field : {"split-offsets", "equality-ids"}) {
+ SCOPED_TRACE(field);
+ auto json = JavaGolden();
+ json["data-file"][field] = {0.5};
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage(field));
+ }
+
+ auto scalar_map = JavaGolden();
+ scalar_map["data-file"]["column-sizes"] = {{"keys", {1}}, {"values", {0.5}}};
+ EXPECT_THAT(FileScanTaskFromJson(scalar_map), IsError(ErrorKind::kJsonParseError));
+
+ auto bytes_map = JavaGolden();
+ bytes_map["data-file"]["lower-bounds"] = {{"keys", {0.5}}, {"values", {"00"}}};
+ EXPECT_THAT(FileScanTaskFromJson(bytes_map), IsError(ErrorKind::kJsonParseError));
+}
+
+TEST_F(FileScanTaskJsonTest, DoesNotTreatOffsetAsStartAlias) {
+ auto json = JavaGolden();
+ json.erase("start");
+ json["offset"] = 0;
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("start"));
+}
+
+TEST_F(FileScanTaskJsonTest, ValidatesTaskTypeWhenPresent) {
+ auto json = JavaGolden();
+ json["task-type"] = "FILE-SCAN-TASK";
+ EXPECT_THAT(FileScanTaskFromJson(json), IsOk());
+
+ json["task-type"] = nullptr;
+ EXPECT_THAT(FileScanTaskFromJson(json), IsOk());
+
+ json.erase("task-type");
+ EXPECT_THAT(FileScanTaskFromJson(json), IsOk());
+
+ json["task-type"] = "data-task";
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("task type"));
+}
+
+TEST_F(FileScanTaskJsonTest, ParsesPartitionObjectsByFieldId) {
+ auto json = JavaGolden();
+ json["data-file"]["partition"] = {{"1000", 7}};
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ ASSERT_EQ(task->data_file()->partition.num_fields(), 1U);
+ EXPECT_EQ(task->data_file()->partition.values()[0], Literal::Int(7));
+}
+
+TEST_F(FileScanTaskJsonTest, ParsesNullPartitionValues) {
+ const std::vector<nlohmann::json> null_partitions = {
+ nlohmann::json::object(), {{"1000", nullptr}}, nlohmann::json::array({nullptr})};
+ for (const auto& partition : null_partitions) {
+ SCOPED_TRACE(partition.dump());
+ auto json = JavaGolden();
+ json["data-file"]["partition"] = partition;
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ ASSERT_EQ(task->data_file()->partition.num_fields(), 1U);
+ const auto& value = task->data_file()->partition.values()[0];
+ EXPECT_TRUE(value.IsNull());
+ ASSERT_NE(value.type(), nullptr);
+ EXPECT_EQ(value.type()->type_id(), TypeId::kInt);
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, RoundTripsHistoricalSpecWithDroppedSourceField) {
+ auto json = JavaGolden();
+ json["schema"]["schema-id"] = 1;
+ json["schema"]["fields"] = nlohmann::json::array();
+ json["data-file"]["partition"] = nlohmann::json::array({nullptr});
+ json["delete-files"][0]["partition"] = nlohmann::json::array({nullptr});
+
+ auto historical_schema =
+ std::make_shared<Schema>(std::vector<SchemaField>{}, /*schema_id=*/1);
+ // Parsing a table's default spec remains strict.
+ auto default_spec_result = PartitionSpecFromJson(historical_schema, json["spec"], 0);
+ EXPECT_THAT(default_spec_result, IsError(ErrorKind::kInvalidArgument));
+ EXPECT_THAT(default_spec_result,
+ HasErrorMessage("Cannot find source column for partition field"));
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ ASSERT_NE(task->data_file(), nullptr);
+ ASSERT_EQ(task->delete_files().size(), 1U);
+ for (const auto& file : {task->data_file(), task->delete_files()[0]}) {
+ ASSERT_EQ(file->partition.num_fields(), 1U);
+ const auto& value = file->partition.values()[0];
+ EXPECT_TRUE(value.IsNull());
+ ASSERT_NE(value.type(), nullptr);
+ EXPECT_EQ(value.type()->type_id(), TypeId::kUnknown);
+ }
+
+ ICEBERG_UNWRAP_OR_FAIL(auto serialized, ToJson(*task, specs_, *historical_schema));
+ EXPECT_EQ(serialized, json);
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsNonNullPartitionForDroppedSourceField) {
+ auto json = JavaGolden();
+ json["schema"]["fields"] = nlohmann::json::array();
+ json["data-file"]["partition"] = {7};
+
+ // The embedded schema no longer contains the source type, so neither Java nor
+ // C++ can decode a non-null value for this historical partition field.
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
+ EXPECT_THAT(result, HasErrorMessage("Unsupported type for literal JSON parsing"));
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsFilesWithMismatchedEmbeddedSpecId) {
+ for (bool mismatch_delete_file : {false, true}) {
+ SCOPED_TRACE(mismatch_delete_file ? "delete-file" : "data-file");
+ auto json = JavaGolden();
+ if (mismatch_delete_file) {
+ json["delete-files"][0]["spec-id"] = 1;
+ } else {
+ json["data-file"]["spec-id"] = 1;
+ }
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("Invalid partition spec id"));
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, AcceptsMissingPartitionLikeJava) {
+ auto json = JavaGolden();
+ json["data-file"].erase("partition");
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ EXPECT_EQ(task->data_file()->partition.num_fields(), 0U);
+}
+
+TEST_F(FileScanTaskJsonTest, AcceptsNullMetricMapsLikeJava) {
+ auto json = JavaGolden();
+ for (std::string_view field : {"column-sizes", "value-counts", "null-value-counts",
+ "nan-value-counts", "lower-bounds", "upper-bounds"}) {
+ json["data-file"][field] = nullptr;
+ }
+
+ EXPECT_THAT(FileScanTaskFromJson(json), IsOk());
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsDuplicateMetricMapKeys) {
+ for (std::string_view field : {"column-sizes", "lower-bounds"}) {
+ SCOPED_TRACE(field);
+ auto json = JavaGolden();
+ json["data-file"][field] = {
+ {"keys", {1, 1}},
+ {"values", field == "lower-bounds" ? nlohmann::json({"00", "01"})
+ : nlohmann::json({1, 2})}};
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("duplicate key"));
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, AcceptsLegacyContentEnumNames) {
+ auto json = JavaGolden();
+ json["data-file"]["content"] = "DATA";
+ json["delete-files"][0]["content"] = "POSITION_DELETES";
+
+ EXPECT_THAT(FileScanTaskFromJson(json), IsOk());
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsInvalidContentRolesWhenParsing) {
+ auto invalid_data = JavaGolden();
+ invalid_data["data-file"]["content"] = "position-deletes";
+ auto data_result = FileScanTaskFromJson(invalid_data);
+ EXPECT_THAT(data_result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(data_result, HasErrorMessage("data-file"));
+
+ auto invalid_delete = JavaGolden();
+ invalid_delete["delete-files"][0]["content"] = "data";
+ auto delete_result = FileScanTaskFromJson(invalid_delete);
+ EXPECT_THAT(delete_result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(delete_result, HasErrorMessage("delete-file"));
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsInvalidContentRolesWhenSerializing) {
+ auto invalid_data = DataFileForTask();
+ invalid_data.content = DataFile::Content::kPositionDeletes;
+ FileScanTask data_task(std::make_shared<DataFile>(std::move(invalid_data)));
+ auto data_result = ToJson(data_task, specs_, schema_);
+ EXPECT_THAT(data_result, IsError(ErrorKind::kValidationFailed));
+ EXPECT_THAT(data_result, HasErrorMessage("data-file"));
+
+ auto invalid_delete = DeleteFileForTask();
+ invalid_delete.content = DataFile::Content::kData;
+ FileScanTask delete_task(std::make_shared<DataFile>(DataFileForTask()),
+ {std::make_shared<DataFile>(std::move(invalid_delete))});
+ auto delete_result = ToJson(delete_task, specs_, schema_);
+ EXPECT_THAT(delete_result, IsError(ErrorKind::kValidationFailed));
+ EXPECT_THAT(delete_result, HasErrorMessage("delete-file"));
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsDeleteFilesWithDifferentSpecIdWhenSerializing) {
+ for (const auto& spec_id : {std::optional<int32_t>(1), std::optional<int32_t>()}) {
+ SCOPED_TRACE(spec_id.value_or(-1));
+ auto delete_file = DeleteFileForTask();
+ delete_file.partition_spec_id = spec_id;
+ FileScanTask task(std::make_shared<DataFile>(DataFileForTask()),
+ {std::make_shared<DataFile>(std::move(delete_file))});
+
+ auto result = ToJson(task, specs_, schema_);
+ EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
+ EXPECT_THAT(result, HasErrorMessage("Invalid partition spec id"));
+ EXPECT_THAT(result, HasErrorMessage(spec_id ? "actual = 1" : "actual = null"));
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsInvalidPartitionValueTypeWhenSerializing) {
+ auto data_file = DataFileForTask();
+ data_file.partition = PartitionValues({Literal::String("not-an-int")});
+ FileScanTask task(std::make_shared<DataFile>(std::move(data_file)));
+
+ auto result = ToJson(task, specs_, schema_);
+ EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
+ EXPECT_THAT(result, HasErrorMessage("partition value type"));
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsNullDeleteEntries) {
+ auto json = JavaGolden();
+ json["delete-files"][0] = nullptr;
+ auto parse_result = FileScanTaskFromJson(json);
+ EXPECT_THAT(parse_result, IsError(ErrorKind::kJsonParseError));
+
+ FileScanTask task(std::make_shared<DataFile>(DataFileForTask()), {nullptr});
+ auto serialize_result = ToJson(task, specs_, schema_);
+ EXPECT_THAT(serialize_result, IsError(ErrorKind::kValidationFailed));
+ EXPECT_THAT(serialize_result, HasErrorMessage("null"));
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsExplicitNullDeleteFilesAndResidual) {
+ for (std::string_view field : {"delete-files", "residual-filter"}) {
+ SCOPED_TRACE(field);
+ auto json = JavaGolden();
+ json[field] = nullptr;
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage(field));
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, DefaultsMissingResidualToAlwaysTrue) {
+ auto json = JavaGolden();
+ json.erase("residual-filter");
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ ASSERT_NE(task->residual_filter(), nullptr);
+ ICEBERG_UNWRAP_OR_FAIL(auto residual, ToJson(*task->residual_filter()));
+ EXPECT_EQ(residual, true);
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsRestDeleteFileReferences) {
+ auto json = JavaGolden();
+ json["delete-file-references"] = nlohmann::json::array({0});
+
+ auto result = FileScanTaskFromJson(json);
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("delete-file-references"));
+}
+
} // namespace iceberg
diff --git a/src/iceberg/test/rest_json_serde_test.cc b/src/iceberg/test/rest_json_serde_test.cc
index 8d246202..34ca4a45 100644
--- a/src/iceberg/test/rest_json_serde_test.cc
+++ b/src/iceberg/test/rest_json_serde_test.cc
@@ -2131,6 +2131,105 @@ TEST(FileScanTasksFromJsonTest, SingleTaskNoDeleteFiles) {
EXPECT_EQ(task->residual_filter(), nullptr);
}
+TEST(FileScanTasksFromJsonTest, AcceptsWholeFileStartAndLength) {
+ auto json = R"([{
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/file.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 12345,
+ "record-count": 100
+ },
+ "start": 0,
+ "length": 12345
+ }])"_json;
+
+ EXPECT_THAT(FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0)),
+ IsOk());
+}
+
+TEST(FileScanTasksFromJsonTest, NullStartAndLengthUseWholeFileDefaults) {
+ auto json = R"([{
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/file.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 12345,
+ "record-count": 100
+ },
+ "start": null,
+ "length": null
+ }])"_json;
+
+ EXPECT_THAT(FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0)),
+ IsOk());
+}
+
+TEST(FileScanTasksFromJsonTest, RejectsInvalidRangeIntegers) {
+ auto valid_json = R"([{
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/file.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 12345,
+ "record-count": 100
+ },
+ "start": 0,
+ "length": 12345
+ }])"_json;
+
+ for (std::string_view field : {"start", "length"}) {
+ for (const auto& [invalid_value, expected_error] :
+ {std::pair<nlohmann::json, std::string_view>{0.5, "must be an integer"},
+ {true, "must be an integer"},
+ {"0", "must be an integer"},
+ {18446744073709551615ULL, "out of range"}}) {
+ SCOPED_TRACE(testing::Message() << field << "=" << invalid_value.dump());
+ auto json = valid_json;
+ json[0][field] = invalid_value;
+
+ auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0));
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage(field));
+ EXPECT_THAT(result, HasErrorMessage(expected_error));
+ }
+ }
+}
+
+TEST(FileScanTasksFromJsonTest, RejectsSplitTaskWhenEitherRangeFieldDiffers) {
+ auto json = R"([{
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/file.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 12345,
+ "record-count": 100
+ },
+ "start": 0,
+ "length": 12345
+ }])"_json;
+
+ for (const auto& [field, value] :
+ {std::pair<std::string_view, int64_t>{"start", 100},
+ std::pair<std::string_view, int64_t>{"length", 200}}) {
+ SCOPED_TRACE(field);
+ auto split_json = json;
+ split_json[0][field] = value;
+ auto result =
+ FileScanTasksFromJson(split_json, {}, UnpartitionedSpecs(), Schema({}, 0));
+ EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
+ EXPECT_THAT(result, HasErrorMessage("Split FileScanTask is not supported"));
+ }
+}
+
TEST(FileScanTasksFromJsonTest, RowLineageSequence) {
GTEST_SKIP() << "REST scan-task JSON does not expose data-sequence-number yet: "
<< "https://github.com/apache/iceberg-cpp/issues/834";
diff --git a/src/iceberg/util/json_util_internal.h b/src/iceberg/util/json_util_internal.h
index 65764c4c..0c30b449 100644
--- a/src/iceberg/util/json_util_internal.h
+++ b/src/iceberg/util/json_util_internal.h
@@ -19,6 +19,9 @@
#pragma once
+#include <concepts>
+#include <format>
+#include <limits>
#include <optional>
#include <nlohmann/json.hpp>
@@ -105,6 +108,75 @@ Result<T> GetJsonValueOrDefault(const nlohmann::json& json, std::string_view key
return GetJsonValueImpl<T>(json, key);
}
+/// \brief Parses a JSON integer that must be an exact, in-range signed integer.
+///
+/// Unlike `GetJsonValue<Integer>`, which relies on nlohmann's implicit conversions,
+/// this rejects fractional numbers (e.g. `1.5`), non-numeric values, and values that do
+/// not fit `Integer`, matching Java Iceberg's `JsonUtil.getInt`/`getLong`.
+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);
+}
+
+/// \brief Parses a required JSON integer field with `IntegerFromJson` strictness.
+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));
+}
+
+/// \brief Parses an optional `int64_t` field, returning `default_value` when the key is
+/// missing or null.
+inline Result<int64_t> GetJsonIntegerOrDefault(const nlohmann::json& json,
+ std::string_view key,
+ int64_t default_value) {
+ if (!json.contains(key) || json.at(key).is_null()) {
+ return default_value;
+ }
+ return IntegerFromJson<int64_t>(json.at(key), std::format("'{}'", key));
+}
+
+/// \brief Parses a JSON array of signed integers with `IntegerFromJson` strictness.
+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;
+}
+
/// \brief Convert a list of items to a json array.
///
/// Note that ToJson(const T&) is required for this function to work.