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.