blob: ae5ba1a74e412a363475174c7ccd4ac8f5028f29 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#include <format>
#include <limits>
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include "iceberg/catalog/rest/json_serde_internal.h"
#include "iceberg/catalog/rest/types.h"
#include "iceberg/expression/expressions.h"
#include "iceberg/file_format.h"
#include "iceberg/json_serde_internal.h"
#include "iceberg/labels.h"
#include "iceberg/manifest/manifest_entry.h"
#include "iceberg/partition_spec.h"
#include "iceberg/result.h"
#include "iceberg/schema.h"
#include "iceberg/sort_order.h"
#include "iceberg/table_identifier.h"
#include "iceberg/table_metadata.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"
namespace iceberg::rest {
// Helper function to create a simple schema for testing
static std::shared_ptr<Schema> MakeSimpleSchema() {
return std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField(1, "id", int32(), false), // required
SchemaField(2, "data", string(), true)});
}
// Helper function to create a simple TableMetadata for testing
static std::shared_ptr<TableMetadata> MakeSimpleTableMetadata() {
auto schema = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField(1, "id", int32(), false)}, 1);
return std::make_shared<TableMetadata>(TableMetadata{
.format_version = 2,
.table_uuid = "test-uuid-1234",
.location = "s3://bucket/test",
.last_sequence_number = 0,
.last_updated_ms = TimePointMs{},
.last_column_id = 1,
.schemas = {schema},
.current_schema_id = 1,
.partition_specs = {PartitionSpec::Unpartitioned()},
.default_spec_id = 0,
.last_partition_id = 0,
.properties = {},
.current_snapshot_id = -1,
.snapshots = {},
.snapshot_log = {},
.metadata_log = {},
.sort_orders = {SortOrder::Unsorted()},
.default_sort_order_id = 0,
.refs = {},
.statistics = {},
.partition_statistics = {},
.next_row_id = 0,
});
}
std::string LoadTableJsonWithLabels(std::string_view labels) {
return std::string(R"({"labels":)") + std::string(labels) +
R"(,"metadata":{"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test","last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0,"properties":{}}})";
}
std::string LoadTableJsonWithCredentials(std::string_view storage_credentials) {
return std::string(R"({"storage-credentials":)") + std::string(storage_credentials) +
R"(,"metadata":{"format-version":2,"table-uuid":"test","location":"s3://test","last-sequence-number":0,"last-column-id":1,"last-updated-ms":0,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0}})";
}
// Test parameter structure for roundtrip tests
template <typename Model>
struct JsonRoundTripParam {
std::string test_name;
std::string expected_json_str;
Model model;
};
// Generic test class for roundtrip tests
template <typename Model>
class JsonRoundTripTest : public ::testing::TestWithParam<JsonRoundTripParam<Model>> {
using Base = ::testing::TestWithParam<JsonRoundTripParam<Model>>;
protected:
void TestRoundTrip() {
const auto& param = Base::GetParam();
// ToJson
auto json = ToJson(param.model);
auto expected_json = nlohmann::json::parse(param.expected_json_str);
ASSERT_EQ(json, expected_json) << "ToJson mismatch";
// FromJson
auto result = FromJson<Model>(expected_json);
ASSERT_THAT(result, IsOk()) << result.error().message;
auto parsed = std::move(result.value());
ASSERT_EQ(parsed, param.model);
}
};
#define DECLARE_ROUNDTRIP_TEST(Model) \
using Model##Test = JsonRoundTripTest<Model>; \
using Model##Param = JsonRoundTripParam<Model>; \
TEST_P(Model##Test, RoundTrip) { TestRoundTrip(); }
// Invalid JSON test parameter structure
template <typename Model>
struct JsonInvalidParam {
std::string test_name;
std::string invalid_json_str;
ErrorKind expected_error_kind = ErrorKind::kJsonParseError;
std::string expected_error_message;
};
// Generic test class for invalid JSON deserialization
template <typename Model>
class JsonInvalidTest : public ::testing::TestWithParam<JsonInvalidParam<Model>> {
using Base = ::testing::TestWithParam<JsonInvalidParam<Model>>;
protected:
void TestInvalidJson() {
const auto& param = Base::GetParam();
auto result = FromJson<Model>(nlohmann::json::parse(param.invalid_json_str));
ASSERT_THAT(result, IsError(param.expected_error_kind));
ASSERT_THAT(result, HasErrorMessage(param.expected_error_message))
<< result.error().message;
}
};
#define DECLARE_INVALID_TEST(Model) \
using Model##InvalidTest = JsonInvalidTest<Model>; \
using Model##InvalidParam = JsonInvalidParam<Model>; \
TEST_P(Model##InvalidTest, InvalidJson) { TestInvalidJson(); }
// Deserialization test parameter structure
template <typename Model>
struct JsonDeserParam {
std::string test_name;
std::string json_str;
Model expected_model;
};
// Generic test class for deserialization tests (FromJson only)
template <typename Model>
class JsonDeserTest : public ::testing::TestWithParam<JsonDeserParam<Model>> {
using Base = ::testing::TestWithParam<JsonDeserParam<Model>>;
protected:
void TestDeserialize() {
const auto& param = Base::GetParam();
auto result = FromJson<Model>(nlohmann::json::parse(param.json_str));
ASSERT_THAT(result, IsOk()) << result.error().message;
auto parsed = std::move(result.value());
ASSERT_EQ(parsed, param.expected_model);
}
};
#define DECLARE_DESERIALIZE_TEST(Model) \
using Model##DeserializeTest = JsonDeserTest<Model>; \
using Model##DeserializeParam = JsonDeserParam<Model>; \
TEST_P(Model##DeserializeTest, Deserialize) { TestDeserialize(); }
DECLARE_ROUNDTRIP_TEST(CreateNamespaceRequest)
INSTANTIATE_TEST_SUITE_P(
CreateNamespaceRequestCases, CreateNamespaceRequestTest,
::testing::Values(
// Full request with properties
CreateNamespaceRequestParam{
.test_name = "FullRequest",
.expected_json_str =
R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})",
.model = {.namespace_ = Namespace{{"accounting", "tax"}},
.properties = {{"owner", "Hank"}}}},
// Request with empty properties (omit properties field when empty)
CreateNamespaceRequestParam{
.test_name = "EmptyProperties",
.expected_json_str = R"({"namespace":["accounting","tax"]})",
.model = {.namespace_ = Namespace{{"accounting", "tax"}}},
},
// Request with empty namespace
CreateNamespaceRequestParam{
.test_name = "EmptyNamespace",
.expected_json_str = R"({"namespace":[]})",
.model = {.namespace_ = Namespace{}, .properties = {}},
}),
[](const ::testing::TestParamInfo<CreateNamespaceRequestParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(CreateNamespaceRequest)
INSTANTIATE_TEST_SUITE_P(
CreateNamespaceRequestInvalidCases, CreateNamespaceRequestInvalidTest,
::testing::Values(
// Incorrect type for namespace field
CreateNamespaceRequestInvalidParam{
.test_name = "WrongNamespaceType",
.invalid_json_str = R"({"namespace":"accounting%1Ftax","properties":null})",
.expected_error_message =
"Failed to parse 'namespace' (JSON value of type string)"},
// Incorrect type for properties field
CreateNamespaceRequestInvalidParam{
.test_name = "WrongPropertiesType",
.invalid_json_str = R"({"namespace":["accounting","tax"],"properties":[]})",
.expected_error_message =
"Failed to parse 'properties' (JSON value of type array)"},
// Misspelled required field
CreateNamespaceRequestInvalidParam{
.test_name = "MisspelledKeys",
.invalid_json_str =
R"({"namepsace":["accounting","tax"],"propertiezzzz":{"owner":"Hank"}})",
.expected_error_message = "Missing 'namespace'"},
// Empty JSON object
CreateNamespaceRequestInvalidParam{
.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'namespace'"}),
[](const ::testing::TestParamInfo<CreateNamespaceRequestInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(CreateNamespaceRequest)
INSTANTIATE_TEST_SUITE_P(
CreateNamespaceRequestDeserializeCases, CreateNamespaceRequestDeserializeTest,
::testing::Values(
// Properties field is null (should deserialize to empty map)
CreateNamespaceRequestDeserializeParam{
.test_name = "NullProperties",
.json_str = R"({"namespace":["accounting","tax"],"properties":null})",
.expected_model = {.namespace_ = Namespace{{"accounting", "tax"}}}},
// Properties field is missing (should deserialize to empty map)
CreateNamespaceRequestDeserializeParam{
.test_name = "MissingProperties",
.json_str = R"({"namespace":["accounting","tax"]})",
.expected_model = {.namespace_ = Namespace{{"accounting", "tax"}}}}),
[](const ::testing::TestParamInfo<CreateNamespaceRequestDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(CreateNamespaceResponse)
INSTANTIATE_TEST_SUITE_P(
CreateNamespaceResponseCases, CreateNamespaceResponseTest,
::testing::Values(
// Full response with namespace and properties
CreateNamespaceResponseParam{
.test_name = "FullResponse",
.expected_json_str =
R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})",
.model = {.namespace_ = Namespace{{"accounting", "tax"}},
.properties = {{"owner", "Hank"}}}},
// Response with empty properties (omit properties field when empty)
CreateNamespaceResponseParam{
.test_name = "EmptyProperties",
.expected_json_str = R"({"namespace":["accounting","tax"]})",
.model = {.namespace_ = Namespace{{"accounting", "tax"}}}},
// Response with empty namespace
CreateNamespaceResponseParam{.test_name = "EmptyNamespace",
.expected_json_str = R"({"namespace":[]})",
.model = {.namespace_ = Namespace{}}}),
[](const ::testing::TestParamInfo<CreateNamespaceResponseParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(CreateNamespaceResponse)
INSTANTIATE_TEST_SUITE_P(
CreateNamespaceResponseDeserializeCases, CreateNamespaceResponseDeserializeTest,
::testing::Values(
// Properties field is missing (should deserialize to empty map)
CreateNamespaceResponseDeserializeParam{
.test_name = "MissingProperties",
.json_str = R"({"namespace":["accounting","tax"]})",
.expected_model = {.namespace_ = Namespace{{"accounting", "tax"}}}},
// Properties field is null (should deserialize to empty map)
CreateNamespaceResponseDeserializeParam{
.test_name = "NullProperties",
.json_str = R"({"namespace":["accounting","tax"],"properties":null})",
.expected_model = {.namespace_ = Namespace{{"accounting", "tax"}}}}),
[](const ::testing::TestParamInfo<CreateNamespaceResponseDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(CreateNamespaceResponse)
INSTANTIATE_TEST_SUITE_P(
CreateNamespaceResponseInvalidCases, CreateNamespaceResponseInvalidTest,
::testing::Values(
// Incorrect type for namespace field
CreateNamespaceResponseInvalidParam{
.test_name = "WrongNamespaceType",
.invalid_json_str = R"({"namespace":"accounting%1Ftax","properties":null})",
.expected_error_message =
"Failed to parse 'namespace' (JSON value of type string)"},
// Incorrect type for properties field
CreateNamespaceResponseInvalidParam{
.test_name = "WrongPropertiesType",
.invalid_json_str = R"({"namespace":["accounting","tax"],"properties":[]})",
.expected_error_message =
"Failed to parse 'properties' (JSON value of type array)"},
// Empty JSON object
CreateNamespaceResponseInvalidParam{
.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'namespace'"}),
[](const ::testing::TestParamInfo<CreateNamespaceResponseInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(GetNamespaceResponse)
INSTANTIATE_TEST_SUITE_P(
GetNamespaceResponseCases, GetNamespaceResponseTest,
::testing::Values(
// Full response with namespace and properties
GetNamespaceResponseParam{
.test_name = "FullResponse",
.expected_json_str =
R"({"namespace":["accounting","tax"],"properties":{"owner":"Hank"}})",
.model = {.namespace_ = Namespace{{"accounting", "tax"}},
.properties = {{"owner", "Hank"}}}},
// Response with empty properties (omit properties field when empty)
GetNamespaceResponseParam{
.test_name = "EmptyProperties",
.expected_json_str = R"({"namespace":["accounting","tax"]})",
.model = {.namespace_ = Namespace{{"accounting", "tax"}}}}),
[](const ::testing::TestParamInfo<GetNamespaceResponseParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(GetNamespaceResponse)
INSTANTIATE_TEST_SUITE_P(
GetNamespaceResponseDeserializeCases, GetNamespaceResponseDeserializeTest,
::testing::Values(
// Properties field is null (should deserialize to empty map)
GetNamespaceResponseDeserializeParam{
.test_name = "NullProperties",
.json_str = R"({"namespace":["accounting","tax"],"properties":null})",
.expected_model = {.namespace_ = Namespace{{"accounting", "tax"}}}}),
[](const ::testing::TestParamInfo<GetNamespaceResponseDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(GetNamespaceResponse)
INSTANTIATE_TEST_SUITE_P(
GetNamespaceResponseInvalidCases, GetNamespaceResponseInvalidTest,
::testing::Values(
// Incorrect type for namespace field
GetNamespaceResponseInvalidParam{
.test_name = "WrongNamespaceType",
.invalid_json_str = R"({"namespace":"accounting%1Ftax","properties":null})",
.expected_error_message =
"Failed to parse 'namespace' (JSON value of type string)"},
// Incorrect type for properties field
GetNamespaceResponseInvalidParam{
.test_name = "WrongPropertiesType",
.invalid_json_str = R"({"namespace":["accounting","tax"],"properties":[]})",
.expected_error_message =
"Failed to parse 'properties' (JSON value of type array)"},
// Empty JSON object
GetNamespaceResponseInvalidParam{
.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'namespace'"}),
[](const ::testing::TestParamInfo<GetNamespaceResponseInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(ListNamespacesResponse)
INSTANTIATE_TEST_SUITE_P(
ListNamespacesResponseCases, ListNamespacesResponseTest,
::testing::Values(
// Full response with multiple namespaces
ListNamespacesResponseParam{
.test_name = "FullResponse",
.expected_json_str = R"({"namespaces":[["accounting"],["tax"]]})",
.model = {.next_page_token = "",
.namespaces = {Namespace{{"accounting"}}, Namespace{{"tax"}}}}},
// Response with empty namespaces
ListNamespacesResponseParam{.test_name = "EmptyNamespaces",
.expected_json_str = R"({"namespaces":[]})",
.model = {.next_page_token = ""}},
// Response with page token
ListNamespacesResponseParam{
.test_name = "WithPageToken",
.expected_json_str =
R"({"namespaces":[["accounting"],["tax"]],"next-page-token":"token"})",
.model = {.next_page_token = "token",
.namespaces = {Namespace{{"accounting"}}, Namespace{{"tax"}}}}}),
[](const ::testing::TestParamInfo<ListNamespacesResponseParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(ListNamespacesResponse)
INSTANTIATE_TEST_SUITE_P(
ListNamespacesResponseInvalidCases, ListNamespacesResponseInvalidTest,
::testing::Values(
// Incorrect type for namespaces field
ListNamespacesResponseInvalidParam{
.test_name = "WrongNamespacesType",
.invalid_json_str = R"({"namespaces":"accounting"})",
.expected_error_message = "Cannot parse namespace from non-array"},
// Empty JSON object
ListNamespacesResponseInvalidParam{
.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'namespaces'"}),
[](const ::testing::TestParamInfo<ListNamespacesResponseInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(UpdateNamespacePropertiesRequest)
INSTANTIATE_TEST_SUITE_P(
UpdateNamespacePropertiesRequestCases, UpdateNamespacePropertiesRequestTest,
::testing::Values(
// Full request with both removals and updates
UpdateNamespacePropertiesRequestParam{
.test_name = "FullRequest",
.expected_json_str =
R"({"removals":["foo","bar"],"updates":{"owner":"Hank"}})",
.model = {.removals = {"foo", "bar"}, .updates = {{"owner", "Hank"}}}},
// Request with only updates
UpdateNamespacePropertiesRequestParam{
.test_name = "OnlyUpdates",
.expected_json_str = R"({"updates":{"owner":"Hank"}})",
.model = {.updates = {{"owner", "Hank"}}}},
// Request with only removals
UpdateNamespacePropertiesRequestParam{
.test_name = "OnlyRemovals",
.expected_json_str = R"({"removals":["foo","bar"]})",
.model = {.removals = {"foo", "bar"}}},
// Request with all empty fields
UpdateNamespacePropertiesRequestParam{
.test_name = "AllEmpty", .expected_json_str = R"({})", .model = {}}),
[](const ::testing::TestParamInfo<UpdateNamespacePropertiesRequestParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(UpdateNamespacePropertiesRequest)
INSTANTIATE_TEST_SUITE_P(
UpdateNamespacePropertiesRequestDeserializeCases,
UpdateNamespacePropertiesRequestDeserializeTest,
::testing::Values(
// Removals is null (should deserialize to empty vector)
UpdateNamespacePropertiesRequestDeserializeParam{
.test_name = "NullRemovals",
.json_str = R"({"removals":null,"updates":{"owner":"Hank"}})",
.expected_model = {.updates = {{"owner", "Hank"}}}},
// Removals is missing (should deserialize to empty vector)
UpdateNamespacePropertiesRequestDeserializeParam{
.test_name = "MissingRemovals",
.json_str = R"({"updates":{"owner":"Hank"}})",
.expected_model = {.updates = {{"owner", "Hank"}}}},
// Updates is null (should deserialize to empty map)
UpdateNamespacePropertiesRequestDeserializeParam{
.test_name = "NullUpdates",
.json_str = R"({"removals":["foo","bar"],"updates":null})",
.expected_model = {.removals = {"foo", "bar"}}},
// All fields missing (should deserialize to empty)
UpdateNamespacePropertiesRequestDeserializeParam{
.test_name = "AllMissing", .json_str = R"({})", .expected_model = {}}),
[](const ::testing::TestParamInfo<UpdateNamespacePropertiesRequestDeserializeParam>&
info) { return info.param.test_name; });
DECLARE_INVALID_TEST(UpdateNamespacePropertiesRequest)
INSTANTIATE_TEST_SUITE_P(
UpdateNamespacePropertiesRequestInvalidCases,
UpdateNamespacePropertiesRequestInvalidTest,
::testing::Values(
// Incorrect type for removals field
UpdateNamespacePropertiesRequestInvalidParam{
.test_name = "WrongRemovalsType",
.invalid_json_str =
R"({"removals":{"foo":"bar"},"updates":{"owner":"Hank"}})",
.expected_error_message =
"Failed to parse 'removals' (JSON value of type object)"},
// Incorrect type for updates field
UpdateNamespacePropertiesRequestInvalidParam{
.test_name = "WrongUpdatesType",
.invalid_json_str = R"({"removals":["foo","bar"],"updates":["owner"]})",
.expected_error_message =
"Failed to parse 'updates' (JSON value of type array)"}),
[](const ::testing::TestParamInfo<UpdateNamespacePropertiesRequestInvalidParam>&
info) { return info.param.test_name; });
DECLARE_ROUNDTRIP_TEST(UpdateNamespacePropertiesResponse)
INSTANTIATE_TEST_SUITE_P(
UpdateNamespacePropertiesResponseCases, UpdateNamespacePropertiesResponseTest,
::testing::Values(
// Full response with updated, removed, and missing fields
UpdateNamespacePropertiesResponseParam{
.test_name = "FullResponse",
.expected_json_str =
R"({"removed":["foo"],"updated":["owner"],"missing":["bar"]})",
.model = {.updated = {"owner"}, .removed = {"foo"}, .missing = {"bar"}}},
// Response with only updated field
UpdateNamespacePropertiesResponseParam{
.test_name = "OnlyUpdated",
.expected_json_str = R"({"removed":[],"updated":["owner"]})",
.model = {.updated = {"owner"}}},
// Response with only removed field
UpdateNamespacePropertiesResponseParam{
.test_name = "OnlyRemoved",
.expected_json_str = R"({"removed":["foo"],"updated":[]})",
.model = {.removed = {"foo"}}},
// Response with only missing field
UpdateNamespacePropertiesResponseParam{
.test_name = "OnlyMissing",
.expected_json_str = R"({"removed":[],"updated":[],"missing":["bar"]})",
.model = {.missing = {"bar"}}},
// Response with all empty fields
UpdateNamespacePropertiesResponseParam{
.test_name = "AllEmpty",
.expected_json_str = R"({"removed":[],"updated":[]})",
.model = {}}),
[](const ::testing::TestParamInfo<UpdateNamespacePropertiesResponseParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(UpdateNamespacePropertiesResponse)
INSTANTIATE_TEST_SUITE_P(
UpdateNamespacePropertiesResponseDeserializeCases,
UpdateNamespacePropertiesResponseDeserializeTest,
::testing::Values(
// Only updated and removed present, missing is optional
UpdateNamespacePropertiesResponseDeserializeParam{
.test_name = "MissingOptional",
.json_str = R"({"updated":["owner"],"removed":[]})",
.expected_model = {.updated = {"owner"}}},
// All fields are missing
UpdateNamespacePropertiesResponseDeserializeParam{
.test_name = "AllMissing", .json_str = R"({})", .expected_model = {}}),
[](const ::testing::TestParamInfo<UpdateNamespacePropertiesResponseDeserializeParam>&
info) { return info.param.test_name; });
DECLARE_INVALID_TEST(UpdateNamespacePropertiesResponse)
INSTANTIATE_TEST_SUITE_P(
UpdateNamespacePropertiesResponseInvalidCases,
UpdateNamespacePropertiesResponseInvalidTest,
::testing::Values(
// Incorrect type for removed field
UpdateNamespacePropertiesResponseInvalidParam{
.test_name = "WrongRemovedType",
.invalid_json_str =
R"({"removed":{"foo":true},"updated":["owner"],"missing":["bar"]})",
.expected_error_message =
"Failed to parse 'removed' (JSON value of type object)"},
// Incorrect type for updated field
UpdateNamespacePropertiesResponseInvalidParam{
.test_name = "WrongUpdatedType",
.invalid_json_str = R"({"updated":"owner","missing":["bar"]})",
.expected_error_message =
"Failed to parse 'updated' (JSON value of type string)"},
// Valid top-level (array) types, but at least one entry in the list is not the
// expected type
UpdateNamespacePropertiesResponseInvalidParam{
.test_name = "InvalidArrayEntryType",
.invalid_json_str =
R"({"removed":["foo", "bar", 123456],"updated":["owner"],"missing":["bar"]})",
.expected_error_message =
"Failed to parse 'removed' (JSON value of type array)"}),
[](const ::testing::TestParamInfo<UpdateNamespacePropertiesResponseInvalidParam>&
info) { return info.param.test_name; });
DECLARE_ROUNDTRIP_TEST(ListTablesResponse)
INSTANTIATE_TEST_SUITE_P(
ListTablesResponseCases, ListTablesResponseTest,
::testing::Values(
// Full response with table identifiers
ListTablesResponseParam{
.test_name = "FullResponse",
.expected_json_str =
R"({"identifiers":[{"namespace":["accounting","tax"],"name":"paid"}]})",
.model = {.next_page_token = "",
.identifiers = {TableIdentifier{Namespace{{"accounting", "tax"}},
"paid"}}}},
// Response with empty identifiers
ListTablesResponseParam{.test_name = "EmptyIdentifiers",
.expected_json_str = R"({"identifiers":[]})",
.model = {.next_page_token = ""}},
// Response with page token
ListTablesResponseParam{
.test_name = "WithPageToken",
.expected_json_str =
R"({"identifiers":[{"namespace":["accounting","tax"],"name":"paid"}],"next-page-token":"token"})",
.model = {.next_page_token = "token",
.identifiers = {TableIdentifier{Namespace{{"accounting", "tax"}},
"paid"}}}}),
[](const ::testing::TestParamInfo<ListTablesResponseParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(ListTablesResponse)
INSTANTIATE_TEST_SUITE_P(
ListTablesResponseInvalidCases, ListTablesResponseInvalidTest,
::testing::Values(
// Incorrect type for identifiers field (string instead of array)
ListTablesResponseInvalidParam{
.test_name = "WrongIdentifiersType",
.invalid_json_str = R"({"identifiers":"accounting%1Ftax"})",
.expected_error_message = "Missing 'name'"},
// Empty JSON object
ListTablesResponseInvalidParam{.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'identifiers'"},
// Invalid identifier with wrong namespace type
ListTablesResponseInvalidParam{
.test_name = "InvalidIdentifierNamespaceType",
.invalid_json_str =
R"({"identifiers":[{"namespace":"accounting.tax","name":"paid"}]})",
.expected_error_message =
"Failed to parse 'namespace' (JSON value of type string)"}),
[](const ::testing::TestParamInfo<ListTablesResponseInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(RenameTableRequest)
INSTANTIATE_TEST_SUITE_P(
RenameTableRequestCases, RenameTableRequestTest,
::testing::Values(
// Full request with source and destination table identifiers
RenameTableRequestParam{
.test_name = "FullRequest",
.expected_json_str =
R"({"source":{"namespace":["accounting","tax"],"name":"paid"},"destination":{"namespace":["accounting","tax"],"name":"paid_2022"}})",
.model = {.source = TableIdentifier{Namespace{{"accounting", "tax"}}, "paid"},
.destination = TableIdentifier{Namespace{{"accounting", "tax"}},
"paid_2022"}}}),
[](const ::testing::TestParamInfo<RenameTableRequestParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(RenameTableRequest)
INSTANTIATE_TEST_SUITE_P(
RenameTableRequestInvalidCases, RenameTableRequestInvalidTest,
::testing::Values(
// Source table name is null
RenameTableRequestInvalidParam{
.test_name = "SourceNameNull",
.invalid_json_str =
R"({"source":{"namespace":["accounting","tax"],"name":null},"destination":{"namespace":["accounting","tax"],"name":"paid_2022"}})",
.expected_error_message = "Missing 'name'"},
// Destination table name is null
RenameTableRequestInvalidParam{
.test_name = "DestinationNameNull",
.invalid_json_str =
R"({"source":{"namespace":["accounting","tax"],"name":"paid"},"destination":{"namespace":["accounting","tax"],"name":null}})",
.expected_error_message = "Missing 'name'"},
// Empty JSON object
RenameTableRequestInvalidParam{.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'source'"}),
[](const ::testing::TestParamInfo<RenameTableRequestInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(RegisterTableRequest)
INSTANTIATE_TEST_SUITE_P(
RegisterTableRequestCases, RegisterTableRequestTest,
::testing::Values(
// Request with overwrite set to true
RegisterTableRequestParam{
.test_name = "WithOverwriteTrue",
.expected_json_str =
R"({"name":"table1","metadata-location":"s3://bucket/metadata.json","overwrite":true})",
.model = {.name = "table1",
.metadata_location = "s3://bucket/metadata.json",
.overwrite = true}},
// Request without overwrite field (defaults to false, omitted in serialization)
RegisterTableRequestParam{
.test_name = "WithoutOverwrite",
.expected_json_str =
R"({"name":"table1","metadata-location":"s3://bucket/metadata.json"})",
.model = {.name = "table1",
.metadata_location = "s3://bucket/metadata.json"}}),
[](const ::testing::TestParamInfo<RegisterTableRequestParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(RegisterTableRequest)
INSTANTIATE_TEST_SUITE_P(
RegisterTableRequestDeserializeCases, RegisterTableRequestDeserializeTest,
::testing::Values(
// Overwrite missing (should default to false)
RegisterTableRequestDeserializeParam{
.test_name = "MissingOverwrite",
.json_str =
R"({"name":"table1","metadata-location":"s3://bucket/metadata.json"})",
.expected_model = {.name = "table1",
.metadata_location = "s3://bucket/metadata.json",
.overwrite = false}}),
[](const ::testing::TestParamInfo<RegisterTableRequestDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(RegisterTableRequest)
INSTANTIATE_TEST_SUITE_P(
RegisterTableRequestInvalidCases, RegisterTableRequestInvalidTest,
::testing::Values(
// Missing required name field
RegisterTableRequestInvalidParam{
.test_name = "MissingName",
.invalid_json_str = R"({"metadata-location":"s3://bucket/metadata.json"})",
.expected_error_message = "Missing 'name' in"},
// Missing required metadata-location field
RegisterTableRequestInvalidParam{
.test_name = "MissingMetadataLocation",
.invalid_json_str = R"({"name":"table1"})",
.expected_error_message = "Missing 'metadata-location'"},
// Empty JSON object
RegisterTableRequestInvalidParam{.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'name'"}),
[](const ::testing::TestParamInfo<RegisterTableRequestInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(CatalogConfig)
INSTANTIATE_TEST_SUITE_P(
CatalogConfigCases, CatalogConfigTest,
::testing::Values(
// Full config with both defaults and overrides
CatalogConfigParam{
.test_name = "FullConfig",
.expected_json_str =
R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":{"clients":"5"}})",
.model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}},
.overrides = {{"clients", "5"}}}},
// Only defaults
CatalogConfigParam{
.test_name = "OnlyDefaults",
.expected_json_str =
R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":{}})",
.model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}}}},
// Only overrides
CatalogConfigParam{
.test_name = "OnlyOverrides",
.expected_json_str = R"({"defaults":{},"overrides":{"clients":"5"}})",
.model = {.overrides = {{"clients", "5"}}}},
// Both empty
CatalogConfigParam{.test_name = "BothEmpty",
.expected_json_str = R"({"defaults":{},"overrides":{}})",
.model = {}},
// With valid endpoints
CatalogConfigParam{
.test_name = "WithEndpoints",
.expected_json_str =
R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":{"clients":"5"},"endpoints":["GET /v1/config","POST /v1/tables"]})",
.model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}},
.overrides = {{"clients", "5"}},
.endpoints = {*Endpoint::Make(HttpMethod::kGet, "/v1/config"),
*Endpoint::Make(HttpMethod::kPost, "/v1/tables")}}},
// Only endpoints
CatalogConfigParam{
.test_name = "OnlyEndpoints",
.expected_json_str =
R"({"defaults":{},"overrides":{},"endpoints":["GET /v1/config"]})",
.model = {.endpoints = {*Endpoint::Make(HttpMethod::kGet, "/v1/config")}}}),
[](const ::testing::TestParamInfo<CatalogConfigParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(CatalogConfig)
INSTANTIATE_TEST_SUITE_P(
CatalogConfigDeserializeCases, CatalogConfigDeserializeTest,
::testing::Values(
// Missing overrides field
CatalogConfigDeserializeParam{
.test_name = "MissingOverrides",
.json_str = R"({"defaults":{"warehouse":"s3://bucket/warehouse"}})",
.expected_model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}}}},
// Null overrides field
CatalogConfigDeserializeParam{
.test_name = "NullOverrides",
.json_str =
R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":null})",
.expected_model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}}}},
// Missing defaults field
CatalogConfigDeserializeParam{
.test_name = "MissingDefaults",
.json_str = R"({"overrides":{"clients":"5"}})",
.expected_model = {.overrides = {{"clients", "5"}}}},
// Null defaults field
CatalogConfigDeserializeParam{
.test_name = "NullDefaults",
.json_str = R"({"defaults":null,"overrides":{"clients":"5"}})",
.expected_model = {.overrides = {{"clients", "5"}}}},
// Empty JSON object
CatalogConfigDeserializeParam{
.test_name = "EmptyJson", .json_str = R"({})", .expected_model = {}},
// Both fields null
CatalogConfigDeserializeParam{.test_name = "BothNull",
.json_str = R"({"defaults":null,"overrides":null})",
.expected_model = {}},
// Missing endpoints field, client will uses default endpoints
CatalogConfigDeserializeParam{
.test_name = "MissingEndpoints",
.json_str = R"({"defaults":{},"overrides":{}})",
.expected_model = {.defaults = {}, .overrides = {}, .endpoints = {}}}),
[](const ::testing::TestParamInfo<CatalogConfigDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(CatalogConfig)
INSTANTIATE_TEST_SUITE_P(
CatalogConfigInvalidCases, CatalogConfigInvalidTest,
::testing::Values(
// Defaults has wrong type (array instead of object)
CatalogConfigInvalidParam{
.test_name = "WrongDefaultsType",
.invalid_json_str =
R"({"defaults":["warehouse","s3://bucket/warehouse"],"overrides":{"clients":"5"}})",
.expected_error_message =
"Failed to parse 'defaults' (JSON value of type array)"},
// Overrides has wrong type (string instead of object)
CatalogConfigInvalidParam{
.test_name = "WrongOverridesType",
.invalid_json_str =
R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":"clients"})",
.expected_error_message =
"Failed to parse 'overrides' (JSON value of type string)"},
// Invalid endpoint format - missing space separator
CatalogConfigInvalidParam{
.test_name = "InvalidEndpointMissingSpace",
.invalid_json_str = R"({"endpoints":["GET_v1/namespaces/{namespace}"]})",
.expected_error_message =
"Invalid endpoint format (must consist of two elements separated by a "
"single space)"},
// Invalid endpoint format - extra element after path
CatalogConfigInvalidParam{
.test_name = "InvalidEndpointExtraElement",
.invalid_json_str =
R"({"endpoints":["GET v1/namespaces/{namespace} INVALID"]})",
.expected_error_message =
"Invalid endpoint format (must consist of two elements separated by a "
"single space)"}),
[](const ::testing::TestParamInfo<CatalogConfigInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(ErrorResponse)
INSTANTIATE_TEST_SUITE_P(
ErrorResponseCases, ErrorResponseTest,
::testing::Values(
// Error without stack trace
ErrorResponseParam{
.test_name = "WithoutStack",
.expected_json_str =
R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404}})",
.model = {.code = 404,
.type = "NoSuchNamespaceException",
.message = "The given namespace does not exist"}},
// Error with stack trace
ErrorResponseParam{
.test_name = "WithStack",
.expected_json_str =
R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404,"stack":["a","b"]}})",
.model = {.code = 404,
.type = "NoSuchNamespaceException",
.message = "The given namespace does not exist",
.stack = {"a", "b"}}},
// Different error type
ErrorResponseParam{
.test_name = "DifferentError",
.expected_json_str =
R"({"error":{"message":"Internal server error","type":"InternalServerError","code":500,"stack":["line1","line2","line3"]}})",
.model = {.code = 500,
.type = "InternalServerError",
.message = "Internal server error",
.stack = {"line1", "line2", "line3"}}}),
[](const ::testing::TestParamInfo<ErrorResponseParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(ErrorResponse)
INSTANTIATE_TEST_SUITE_P(
ErrorResponseDeserializeCases, ErrorResponseDeserializeTest,
::testing::Values(
// Stack field is null (should deserialize to empty vector)
ErrorResponseDeserializeParam{
.test_name = "NullStack",
.json_str =
R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404,"stack":null}})",
.expected_model = {.code = 404,
.type = "NoSuchNamespaceException",
.message = "The given namespace does not exist"}},
// Stack field is missing (should deserialize to empty vector)
ErrorResponseDeserializeParam{
.test_name = "MissingStack",
.json_str =
R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException","code":404}})",
.expected_model = {.code = 404,
.type = "NoSuchNamespaceException",
.message = "The given namespace does not exist"}}),
[](const ::testing::TestParamInfo<ErrorResponseDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(ErrorResponse)
INSTANTIATE_TEST_SUITE_P(
ErrorResponseInvalidCases, ErrorResponseInvalidTest,
::testing::Values(
// Missing error field
ErrorResponseInvalidParam{.test_name = "MissingError",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'error'"},
// Null error field
ErrorResponseInvalidParam{.test_name = "NullError",
.invalid_json_str = R"({"error":null})",
.expected_error_message = "Missing 'error'"},
// Missing required type field
ErrorResponseInvalidParam{
.test_name = "MissingType",
.invalid_json_str =
R"({"error":{"message":"The given namespace does not exist","code":404}})",
.expected_error_message = "Missing 'type'"},
// Missing required code field
ErrorResponseInvalidParam{
.test_name = "MissingCode",
.invalid_json_str =
R"({"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException"}})",
.expected_error_message = "Missing 'code'"},
// Wrong type for message field
ErrorResponseInvalidParam{
.test_name = "WrongMessageType",
.invalid_json_str =
R"({"error":{"message":123,"type":"NoSuchNamespaceException","code":404}})",
.expected_error_message =
"Failed to parse 'message' (JSON value of type number)"}),
[](const ::testing::TestParamInfo<ErrorResponseInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(CreateTableRequest)
INSTANTIATE_TEST_SUITE_P(
CreateTableRequestCases, CreateTableRequestTest,
::testing::Values(
// Minimal request with only required fields (name and schema)
CreateTableRequestParam{
.test_name = "MinimalRequest",
.expected_json_str =
R"({"name":"my_table","schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","type":"int","required":true},{"id":2,"name":"data","type":"string","required":false}]}})",
.model = {.name = "my_table", .schema = MakeSimpleSchema()}},
// Request with location
CreateTableRequestParam{
.test_name = "WithLocation",
.expected_json_str =
R"({"name":"my_table","schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","type":"int","required":true},{"id":2,"name":"data","type":"string","required":false}]},"location":"s3://bucket/warehouse/my_table"})",
.model = {.name = "my_table",
.location = "s3://bucket/warehouse/my_table",
.schema = MakeSimpleSchema()}},
// Request with properties
CreateTableRequestParam{
.test_name = "WithProperties",
.expected_json_str =
R"({"name":"my_table","schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","type":"int","required":true},{"id":2,"name":"data","type":"string","required":false}]},"properties":{"owner":"alice","version":"1.0"}})",
.model = {.name = "my_table",
.schema = MakeSimpleSchema(),
.properties = {{"owner", "alice"}, {"version", "1.0"}}}},
// Request with stage_create = true
CreateTableRequestParam{
.test_name = "WithStageCreate",
.expected_json_str =
R"({"name":"my_table","schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","type":"int","required":true},{"id":2,"name":"data","type":"string","required":false}]},"stage-create":true})",
.model = {.name = "my_table",
.schema = MakeSimpleSchema(),
.stage_create = true}},
// Request with partition_spec (unpartitioned)
CreateTableRequestParam{
.test_name = "WithUnpartitionedSpec",
.expected_json_str =
R"({"name":"my_table","schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","type":"int","required":true},{"id":2,"name":"data","type":"string","required":false}]},"partition-spec":{"spec-id":0,"fields":[]}})",
.model = {.name = "my_table",
.schema = MakeSimpleSchema(),
.partition_spec = PartitionSpec::Unpartitioned()}},
// Request with write_order (unsorted)
CreateTableRequestParam{
.test_name = "WithUnsortedOrder",
.expected_json_str =
R"({"name":"my_table","schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","type":"int","required":true},{"id":2,"name":"data","type":"string","required":false}]},"write-order":{"order-id":0,"fields":[]}})",
.model = {.name = "my_table",
.schema = MakeSimpleSchema(),
.write_order = SortOrder::Unsorted()}}),
[](const ::testing::TestParamInfo<CreateTableRequestParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(CreateTableRequest)
INSTANTIATE_TEST_SUITE_P(
CreateTableRequestDeserializeCases, CreateTableRequestDeserializeTest,
::testing::Values(
// Location field is missing (should deserialize to empty string)
CreateTableRequestDeserializeParam{
.test_name = "MissingLocation",
.json_str =
R"({"name":"my_table","schema":{"type":"struct","fields":[{"id":1,"name":"id","type":"int","required":true}]}})",
.expected_model = {.name = "my_table",
.schema =
std::make_shared<Schema>(std::vector<SchemaField>{
SchemaField(1, "id", int32(), false)})}},
// stage-create field is missing (should default to false)
CreateTableRequestDeserializeParam{
.test_name = "MissingStageCreate",
.json_str =
R"({"name":"my_table","schema":{"type":"struct","fields":[{"id":1,"name":"id","type":"int","required":true}]}})",
.expected_model = {.name = "my_table",
.schema =
std::make_shared<Schema>(std::vector<SchemaField>{
SchemaField(1, "id", int32(), false)}),
.stage_create = false}},
// Properties field is missing (should deserialize to empty map)
CreateTableRequestDeserializeParam{
.test_name = "MissingProperties",
.json_str =
R"({"name":"my_table","schema":{"type":"struct","fields":[{"id":1,"name":"id","type":"int","required":true}]}})",
.expected_model = {.name = "my_table",
.schema =
std::make_shared<Schema>(std::vector<SchemaField>{
SchemaField(1, "id", int32(), false)})}}),
[](const ::testing::TestParamInfo<CreateTableRequestDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(CreateTableRequest)
INSTANTIATE_TEST_SUITE_P(
CreateTableRequestInvalidCases, CreateTableRequestInvalidTest,
::testing::Values(
// Missing required name field
CreateTableRequestInvalidParam{
.test_name = "MissingName",
.invalid_json_str =
R"({"schema":{"type":"struct","fields":[{"id":1,"name":"id","type":"int","required":true}]}})",
.expected_error_message = "Missing 'name'"},
// Missing required schema field
CreateTableRequestInvalidParam{.test_name = "MissingSchema",
.invalid_json_str = R"({"name":"my_table"})",
.expected_error_message = "Missing 'schema'"},
// Empty JSON object
CreateTableRequestInvalidParam{.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'name'"},
// Wrong type for name field
CreateTableRequestInvalidParam{
.test_name = "WrongNameType",
.invalid_json_str =
R"({"name":123,"schema":{"type":"struct","fields":[{"id":1,"name":"id","type":"int","required":true}]}})",
.expected_error_message =
"Failed to parse 'name' (JSON value of type number)"},
// Wrong type for schema field
CreateTableRequestInvalidParam{
.test_name = "WrongSchemaType",
.invalid_json_str = R"({"name":"my_table","schema":"invalid"})",
.expected_error_message = "Cannot parse type string: invalid"}),
[](const ::testing::TestParamInfo<CreateTableRequestInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(LoadTableResult)
INSTANTIATE_TEST_SUITE_P(
LoadTableResultCases, LoadTableResultTest,
::testing::Values(
// Minimal case - only required metadata field
LoadTableResultParam{
.test_name = "MinimalMetadata",
.expected_json_str =
R"({"metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas":[{"fields":[{"id":1,"name":"id","required":true,"type":"int"}],"schema-id":1,"type":"struct"}],"snapshot-log":[],"snapshots":[],"sort-orders":[{"fields":[],"order-id":0}],"statistics":[],"table-uuid":"test-uuid-1234"}})",
.model = {.metadata = MakeSimpleTableMetadata()}},
// With metadata location
LoadTableResultParam{
.test_name = "WithMetadataLocation",
.expected_json_str =
R"({"metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas":[{"fields":[{"id":1,"name":"id","required":true,"type":"int"}],"schema-id":1,"type":"struct"}],"snapshot-log":[],"snapshots":[],"sort-orders":[{"fields":[],"order-id":0}],"statistics":[],"table-uuid":"test-uuid-1234"},"metadata-location":"s3://bucket/metadata/v1.json"})",
.model = {.metadata_location = "s3://bucket/metadata/v1.json",
.metadata = MakeSimpleTableMetadata()}},
// With config
LoadTableResultParam{
.test_name = "WithConfig",
.expected_json_str =
R"({"config":{"warehouse":"s3://bucket/warehouse"},"metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas":[{"fields":[{"id":1,"name":"id","required":true,"type":"int"}],"schema-id":1,"type":"struct"}],"snapshot-log":[],"snapshots":[],"sort-orders":[{"fields":[],"order-id":0}],"statistics":[],"table-uuid":"test-uuid-1234"}})",
.model = {.metadata = MakeSimpleTableMetadata(),
.config = {{"warehouse", "s3://bucket/warehouse"}}}},
// With both metadata location and config
LoadTableResultParam{
.test_name = "WithMetadataLocationAndConfig",
.expected_json_str =
R"({"config":{"foo":"bar","warehouse":"s3://bucket/warehouse"},"metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas":[{"fields":[{"id":1,"name":"id","required":true,"type":"int"}],"schema-id":1,"type":"struct"}],"snapshot-log":[],"snapshots":[],"sort-orders":[{"fields":[],"order-id":0}],"statistics":[],"table-uuid":"test-uuid-1234"},"metadata-location":"s3://bucket/metadata/v1.json"})",
.model = {.metadata_location = "s3://bucket/metadata/v1.json",
.metadata = MakeSimpleTableMetadata(),
.config = {{"warehouse", "s3://bucket/warehouse"},
{"foo", "bar"}}}},
LoadTableResultParam{
.test_name = "WithCredentials",
.expected_json_str =
R"({"metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas":[{"fields":[{"id":1,"name":"id","required":true,"type":"int"}],"schema-id":1,"type":"struct"}],"snapshot-log":[],"snapshots":[],"sort-orders":[{"fields":[],"order-id":0}],"statistics":[],"table-uuid":"test-uuid-1234"},"storage-credentials":[{"config":{"client.region":"us-east-1","s3.access-key-id":"AKIAtest","s3.secret-access-key":"secret","s3.session-token":"token"},"prefix":"s3"}]})",
.model =
{.metadata = MakeSimpleTableMetadata(),
.storage_credentials = {{.prefix = "s3",
.config = {{"s3.access-key-id", "AKIAtest"},
{"s3.secret-access-key", "secret"},
{"s3.session-token", "token"},
{"client.region", "us-east-1"}}}}}},
LoadTableResultParam{
.test_name = "WithLabels",
.expected_json_str =
R"({"labels":{"fields":[{"field-id":1,"labels":{"classification":"pii"}}],"object-labels":{"owner":"team-a"}},"metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas":[{"fields":[{"id":1,"name":"id","required":true,"type":"int"}],"schema-id":1,"type":"struct"}],"snapshot-log":[],"snapshots":[],"sort-orders":[{"fields":[],"order-id":0}],"statistics":[],"table-uuid":"test-uuid-1234"}})",
.model = {.metadata = MakeSimpleTableMetadata(),
.labels = {.object_labels = {{"owner", "team-a"}},
.fields = {{.field_id = 1,
.labels = {{"classification", "pii"}}}}}}}),
[](const ::testing::TestParamInfo<LoadTableResultParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(LoadTableResult)
INSTANTIATE_TEST_SUITE_P(
LoadTableResultDeserializeCases, LoadTableResultDeserializeTest,
::testing::Values(
// Minimal metadata - tests basic deserialization
LoadTableResultDeserializeParam{
.test_name = "MinimalMetadata",
.json_str =
R"({"metadata":{"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test","last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0,"properties":{}}})",
.expected_model = {.metadata = MakeSimpleTableMetadata()}},
// With metadata location
LoadTableResultDeserializeParam{
.test_name = "WithMetadataLocation",
.json_str =
R"({"metadata-location":"s3://bucket/metadata/v1.json","metadata":{"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test","last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0,"properties":{}}})",
.expected_model = {.metadata_location = "s3://bucket/metadata/v1.json",
.metadata = MakeSimpleTableMetadata()}},
// With config
LoadTableResultDeserializeParam{
.test_name = "WithConfig",
.json_str =
R"({"metadata":{"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test","last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0,"properties":{}},"config":{"warehouse":"s3://bucket/warehouse"}})",
.expected_model = {.metadata = MakeSimpleTableMetadata(),
.config = {{"warehouse", "s3://bucket/warehouse"}}}},
LoadTableResultDeserializeParam{
.test_name = "WithLabels",
.json_str = LoadTableJsonWithLabels(
R"({"object-labels":{"owner":"team-a"},"fields":[{"field-id":1,"labels":{"classification":"pii"}}]})"),
.expected_model = {.metadata = MakeSimpleTableMetadata(),
.labels = {.object_labels = {{"owner", "team-a"}},
.fields = {{.field_id = 1,
.labels = {{"classification",
"pii"}}}}}}},
LoadTableResultDeserializeParam{
.test_name = "NullLabels",
.json_str = LoadTableJsonWithLabels("null"),
.expected_model = {.metadata = MakeSimpleTableMetadata()}},
LoadTableResultDeserializeParam{
.test_name = "EmptyLabels",
.json_str = LoadTableJsonWithLabels("{}"),
.expected_model = {.metadata = MakeSimpleTableMetadata()}},
// Non-object labels are ignored rather than rejected
LoadTableResultDeserializeParam{
.test_name = "StringLabels",
.json_str = LoadTableJsonWithLabels(R"("oops")"),
.expected_model = {.metadata = MakeSimpleTableMetadata()}},
LoadTableResultDeserializeParam{
.test_name = "ArrayLabels",
.json_str = LoadTableJsonWithLabels(R"([{"object-labels":{"k":"v"}}])"),
.expected_model = {.metadata = MakeSimpleTableMetadata()}}),
[](const ::testing::TestParamInfo<LoadTableResultDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(LoadTableResult)
INSTANTIATE_TEST_SUITE_P(
LoadTableResultInvalidCases, LoadTableResultInvalidTest,
::testing::Values(
// Missing required metadata field
LoadTableResultInvalidParam{.test_name = "MissingMetadata",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'metadata'"},
// Null metadata field
LoadTableResultInvalidParam{.test_name = "NullMetadata",
.invalid_json_str = R"({"metadata":null})",
.expected_error_message = "Missing 'metadata'"},
// Wrong type for metadata field
LoadTableResultInvalidParam{
.test_name = "WrongMetadataType",
.invalid_json_str = R"({"metadata":"invalid"})",
.expected_error_message = "Cannot parse metadata from a non-object"},
// Wrong type for metadata-location field
LoadTableResultInvalidParam{
.test_name = "WrongMetadataLocationType",
.invalid_json_str =
R"({"metadata-location":123,"metadata":{"format-version":2,"table-uuid":"test","location":"s3://test","last-sequence-number":0,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"default-spec-id":0,"last-partition-id":0,"default-sort-order-id":0}})",
.expected_error_message =
"Failed to parse 'metadata-location' (JSON value of type number)"},
// Wrong type for config field
LoadTableResultInvalidParam{
.test_name = "WrongConfigType",
.invalid_json_str =
R"({"config":"invalid","metadata":{"format-version":2,"table-uuid":"test","location":"s3://test","last-sequence-number":0,"last-column-id":1,"last-updated-ms":0,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0}})",
.expected_error_message =
"Failed to parse 'config' (JSON value of type string)"},
// Invalid metadata content
LoadTableResultInvalidParam{
.test_name = "InvalidMetadataContent",
.invalid_json_str = R"({"metadata":{"format-version":"invalid"}})",
.expected_error_message =
"Failed to parse 'format-version' (JSON value of type string)"},
LoadTableResultInvalidParam{
.test_name = "LabelsFieldsNotArray",
.invalid_json_str = LoadTableJsonWithLabels(R"({"fields":"oops"})"),
.expected_error_message = "Cannot parse 'fields' from non-array"},
LoadTableResultInvalidParam{
.test_name = "CredentialsNotArray",
.invalid_json_str = LoadTableJsonWithCredentials(R"("oops")"),
.expected_error_message = "Cannot parse storage credentials from non-array"},
LoadTableResultInvalidParam{
.test_name = "CredentialMissingPrefix",
.invalid_json_str = LoadTableJsonWithCredentials(R"([{"config":{"k":"v"}}])"),
.expected_error_message = "Missing 'prefix'"},
LoadTableResultInvalidParam{
.test_name = "CredentialMissingConfig",
.invalid_json_str = LoadTableJsonWithCredentials(R"([{"prefix":"s3"}])"),
.expected_error_message = "Missing 'config'"},
LoadTableResultInvalidParam{.test_name = "CredentialEmptyPrefix",
.invalid_json_str = LoadTableJsonWithCredentials(
R"([{"prefix":"","config":{"k":"v"}}])"),
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_message = "prefix must be non-empty"},
LoadTableResultInvalidParam{
.test_name = "CredentialEmptyConfig",
.invalid_json_str =
LoadTableJsonWithCredentials(R"([{"prefix":"s3","config":{}}])"),
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_message = "config must be non-empty"}),
[](const ::testing::TestParamInfo<LoadTableResultInvalidParam>& info) {
return info.param.test_name;
});
TEST(LoadCredentialsResponseTest, ParsesVendedCredentials) {
auto json = nlohmann::json::parse(
R"({"storage-credentials":[{"prefix":"s3","config":{"s3.access-key-id":"AKIAtest"}}]})");
auto response = LoadCredentialsResponseFromJson(json);
ASSERT_THAT(response, IsOk());
EXPECT_EQ(response->storage_credentials,
(std::vector<StorageCredential>{
{.prefix = "s3", .config = {{"s3.access-key-id", "AKIAtest"}}}}));
}
TEST(LoadCredentialsResponseTest, RejectsResponseWithoutCredentials) {
for (std::string_view body : {"{}", R"({"storage-credentials": null})"}) {
auto response = LoadCredentialsResponseFromJson(nlohmann::json::parse(body));
EXPECT_THAT(response, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(response, HasErrorMessage("Missing 'storage-credentials'"));
}
}
TEST(LoadCredentialsResponseTest, RejectsNonArrayCredentials) {
auto response = LoadCredentialsResponseFromJson(
nlohmann::json::parse(R"({"storage-credentials":"oops"})"));
EXPECT_THAT(response, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(response,
HasErrorMessage("Cannot parse storage credentials from non-array"));
}
DECLARE_ROUNDTRIP_TEST(CommitTableRequest)
INSTANTIATE_TEST_SUITE_P(
CommitTableRequestCases, CommitTableRequestTest,
::testing::Values(
// Full request with identifier, requirements, and updates
CommitTableRequestParam{
.test_name = "FullRequest",
.expected_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[{"type":"assert-table-uuid","uuid":"2cc52516-5e73-41f2-b139-545d41a4e151"},{"type":"assert-create"}],"updates":[{"action":"assign-uuid","uuid":"2cc52516-5e73-41f2-b139-545d41a4e151"},{"action":"set-current-schema","schema-id":23}]})",
.model = {.identifier = TableIdentifier{Namespace{{"ns1"}}, "table1"},
.requirements = {std::make_shared<table::AssertUUID>(
"2cc52516-5e73-41f2-b139-545d41a4e151"),
std::make_shared<table::AssertDoesNotExist>()},
.updates = {std::make_shared<table::AssignUUID>(
"2cc52516-5e73-41f2-b139-545d41a4e151"),
std::make_shared<table::SetCurrentSchema>(23)}}},
// Request without identifier (identifier optional)
CommitTableRequestParam{
.test_name = "WithoutIdentifier",
.expected_json_str =
R"({"requirements":[{"type":"assert-table-uuid","uuid":"2cc52516-5e73-41f2-b139-545d41a4e151"},{"type":"assert-create"}],"updates":[{"action":"assign-uuid","uuid":"2cc52516-5e73-41f2-b139-545d41a4e151"},{"action":"set-current-schema","schema-id":23}]})",
.model = {.requirements = {std::make_shared<table::AssertUUID>(
"2cc52516-5e73-41f2-b139-545d41a4e151"),
std::make_shared<table::AssertDoesNotExist>()},
.updates = {std::make_shared<table::AssignUUID>(
"2cc52516-5e73-41f2-b139-545d41a4e151"),
std::make_shared<table::SetCurrentSchema>(23)}}},
// Request with empty requirements and updates
CommitTableRequestParam{
.test_name = "EmptyRequirementsAndUpdates",
.expected_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[],"updates":[]})",
.model = {.identifier = TableIdentifier{Namespace{{"ns1"}}, "table1"}}}),
[](const ::testing::TestParamInfo<CommitTableRequestParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(CommitTableRequest)
INSTANTIATE_TEST_SUITE_P(
CommitTableRequestDeserializeCases, CommitTableRequestDeserializeTest,
::testing::Values(
// Identifier field is missing (should deserialize to empty identifier)
CommitTableRequestDeserializeParam{
.test_name = "MissingIdentifier",
.json_str = R"({"requirements":[],"updates":[]})",
.expected_model = {}},
// Requirements field is missing (should deserialize to empty requirements)
CommitTableRequestDeserializeParam{
.test_name = "MissingRequirements",
.json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"updates":[]})",
.expected_model = {.identifier = TableIdentifier{Namespace{{"ns1"}},
"table1"}}},
// Updates field is missing (should deserialize to empty updates)
CommitTableRequestDeserializeParam{
.test_name = "MissingUpdates",
.json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[]})",
.expected_model = {.identifier = TableIdentifier{Namespace{{"ns1"}},
"table1"}}},
// Null requirements and updates are treated as absent
CommitTableRequestDeserializeParam{
.test_name = "NullRequirementsAndUpdates",
.json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":null,"updates":null})",
.expected_model = {.identifier = TableIdentifier{Namespace{{"ns1"}},
"table1"}}},
// Empty JSON object
CommitTableRequestDeserializeParam{
.test_name = "EmptyJson", .json_str = R"({})", .expected_model = {}}),
[](const ::testing::TestParamInfo<CommitTableRequestDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(CommitTableRequest)
INSTANTIATE_TEST_SUITE_P(
CommitTableRequestInvalidCases, CommitTableRequestInvalidTest,
::testing::Values(
// Invalid table identifier - missing name field
CommitTableRequestInvalidParam{
.test_name = "InvalidTableIdentifier",
.invalid_json_str = R"({"identifier":{},"requirements":[],"updates":[]})",
.expected_error_message = "Missing 'name'"},
// Invalid table identifier - wrong type for name
CommitTableRequestInvalidParam{
.test_name = "InvalidIdentifierNameType",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":23},"requirements":[],"updates":[]})",
.expected_error_message =
"Failed to parse 'name' (JSON value of type number)"},
// Invalid requirements - non-object value in requirements array
CommitTableRequestInvalidParam{
.test_name = "InvalidRequirementsNonObject",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[23],"updates":[]})",
.expected_error_message = "Missing 'type' in"},
// Invalid requirements - missing type field
CommitTableRequestInvalidParam{
.test_name = "InvalidRequirementsMissingType",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[{}],"updates":[]})",
.expected_error_message = "Missing 'type'"},
// Invalid requirements - assert-table-uuid missing uuid field
CommitTableRequestInvalidParam{
.test_name = "InvalidRequirementsMissingUUID",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[{"type":"assert-table-uuid"}],"updates":[]})",
.expected_error_message = "Missing 'uuid'"},
// Invalid updates - non-object value in updates array
CommitTableRequestInvalidParam{
.test_name = "InvalidUpdatesNonObject",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[],"updates":[23]})",
.expected_error_message = "Missing 'action' in"},
// Invalid updates - missing action field
CommitTableRequestInvalidParam{
.test_name = "InvalidUpdatesMissingAction",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[],"updates":[{}]})",
.expected_error_message = "Missing 'action'"},
// Invalid updates - assign-uuid missing uuid field
CommitTableRequestInvalidParam{
.test_name = "InvalidUpdatesMissingUUID",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[],"updates":[{"action":"assign-uuid"}]})",
.expected_error_message = "Missing 'uuid'"},
// Invalid requirements - must be an array
CommitTableRequestInvalidParam{
.test_name = "InvalidRequirementsNotArray",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":{"type":"assert-create"},"updates":[]})",
.expected_error_message = "Expected 'requirements' to be an array"},
// Invalid updates - must be an array
CommitTableRequestInvalidParam{
.test_name = "InvalidUpdatesNotArray",
.invalid_json_str =
R"({"identifier":{"namespace":["ns1"],"name":"table1"},"requirements":[],"updates":{"action":"assign-uuid","uuid":"2cc52516-5e73-41f2-b139-545d41a4e151"}})",
.expected_error_message = "Expected 'updates' to be an array"}),
[](const ::testing::TestParamInfo<CommitTableRequestInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(CommitTableResponse)
INSTANTIATE_TEST_SUITE_P(
CommitTableResponseCases, CommitTableResponseTest,
::testing::Values(
// Full response with metadata location and metadata
CommitTableResponseParam{
.test_name = "FullResponse",
.expected_json_str =
R"({"metadata-location":"s3://bucket/metadata/v2.json","metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas":[{"fields":[{"id":1,"name":"id","required":true,"type":"int"}],"schema-id":1,"type":"struct"}],"snapshot-log":[],"snapshots":[],"sort-orders":[{"fields":[],"order-id":0}],"statistics":[],"table-uuid":"test-uuid-1234"}})",
.model = {.metadata_location = "s3://bucket/metadata/v2.json",
.metadata = MakeSimpleTableMetadata()}}),
[](const ::testing::TestParamInfo<CommitTableResponseParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(CommitTableResponse)
INSTANTIATE_TEST_SUITE_P(
CommitTableResponseDeserializeCases, CommitTableResponseDeserializeTest,
::testing::Values(
// Standard response with all fields
CommitTableResponseDeserializeParam{
.test_name = "StandardResponse",
.json_str =
R"({"metadata-location":"s3://bucket/metadata/v2.json","metadata":{"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test","last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0,"properties":{}}})",
.expected_model = {.metadata_location = "s3://bucket/metadata/v2.json",
.metadata = MakeSimpleTableMetadata()}}),
[](const ::testing::TestParamInfo<CommitTableResponseDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(CommitTableResponse)
INSTANTIATE_TEST_SUITE_P(
CommitTableResponseInvalidCases, CommitTableResponseInvalidTest,
::testing::Values(
// Missing required metadata-location field
CommitTableResponseInvalidParam{
.test_name = "MissingMetadataLocation",
.invalid_json_str =
R"({"metadata":{"format-version":2,"table-uuid":"test","location":"s3://test","last-sequence-number":0,"last-column-id":1,"last-updated-ms":0,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0}})",
.expected_error_message = "Missing 'metadata-location'"},
// Missing required metadata field
CommitTableResponseInvalidParam{
.test_name = "MissingMetadata",
.invalid_json_str = R"({"metadata-location":"s3://bucket/metadata/v2.json"})",
.expected_error_message = "Missing 'metadata'"},
// Null metadata field
CommitTableResponseInvalidParam{
.test_name = "NullMetadata",
.invalid_json_str =
R"({"metadata-location":"s3://bucket/metadata/v2.json","metadata":null})",
.expected_error_message = "Missing 'metadata'"},
// Wrong type for metadata-location field
CommitTableResponseInvalidParam{
.test_name = "WrongMetadataLocationType",
.invalid_json_str =
R"({"metadata-location":123,"metadata":{"format-version":2,"table-uuid":"test","location":"s3://test","last-sequence-number":0,"last-column-id":1,"last-updated-ms":0,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0}})",
.expected_error_message =
"Failed to parse 'metadata-location' (JSON value of type number)"},
// Wrong type for metadata field
CommitTableResponseInvalidParam{
.test_name = "WrongMetadataType",
.invalid_json_str =
R"({"metadata-location":"s3://bucket/metadata/v2.json","metadata":"invalid"})",
.expected_error_message = "Cannot parse metadata from a non-object"},
// Empty JSON object
CommitTableResponseInvalidParam{
.test_name = "EmptyJson",
.invalid_json_str = R"({})",
.expected_error_message = "Missing 'metadata-location'"}),
[](const ::testing::TestParamInfo<CommitTableResponseInvalidParam>& info) {
return info.param.test_name;
});
// Helper: empty schema and specs for scan response tests that don't need content-file
// partition parsing.
static Schema EmptySchema() { return Schema({}, 0); }
static std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>> EmptySpecs() {
return {};
}
static std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>> UnpartitionedSpecs() {
return {{PartitionSpec::kInitialSpecId, PartitionSpec::Unpartitioned()}};
}
static Schema PartitionedSchema() {
return Schema({SchemaField::MakeRequired(1, "id", int32())}, 0);
}
static std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>> PartitionedSpecs(
const Schema& schema) {
auto spec_result = PartitionSpec::Make(
schema, /*spec_id=*/1, {PartitionField(1, 1000, "id", Transform::Identity())},
/*allow_missing_fields=*/false);
if (!spec_result.has_value()) {
ADD_FAILURE() << spec_result.error().message;
return {};
}
auto spec = std::move(spec_result.value());
return {{spec->spec_id(), std::shared_ptr<PartitionSpec>(std::move(spec))}};
}
// --- PlanTableScanResponse ---
TEST(PlanTableScanResponseFromJsonTest, SubmittedStatusMissingOptionalFields) {
auto json = nlohmann::json::parse(R"({"status":"submitted","plan-id":"abc-123"})");
auto result = PlanTableScanResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result->plan_status, PlanStatus::kSubmitted);
EXPECT_EQ(result->plan_id, "abc-123");
EXPECT_FALSE(result->plan_tasks.has_value());
EXPECT_FALSE(result->file_scan_tasks.has_value());
EXPECT_TRUE(result->delete_files.empty());
}
TEST(PlanTableScanResponseFromJsonTest, CompletedStatusWithPlanTasks) {
auto json = nlohmann::json::parse(
R"({"status":"completed","plan-id":"abc-123","plan-tasks":["task-1","task-2"],"delete-files":[],"file-scan-tasks":[]})");
auto result = PlanTableScanResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result->plan_status, PlanStatus::kCompleted);
EXPECT_EQ(result->plan_id, "abc-123");
ASSERT_TRUE(result->plan_tasks.has_value());
ASSERT_EQ(result->plan_tasks->size(), 2);
EXPECT_EQ(result->plan_tasks->at(0), "task-1");
EXPECT_EQ(result->plan_tasks->at(1), "task-2");
ASSERT_TRUE(result->file_scan_tasks.has_value());
EXPECT_TRUE(result->file_scan_tasks->empty());
}
TEST(PlanTableScanResponseFromJsonTest, FailedStatusWithError) {
auto json = nlohmann::json::parse(
R"({"status":"failed","error":{"message":"Planning failed","type":"PlanningException","code":500}})");
auto result = PlanTableScanResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result->plan_status, PlanStatus::kFailed);
EXPECT_TRUE(result->plan_id.empty());
ASSERT_TRUE(result->error.has_value());
EXPECT_EQ(result->error->message, "Planning failed");
EXPECT_EQ(result->error->type, "PlanningException");
EXPECT_EQ(result->error->code, 500);
}
struct PlanTableScanResponseInvalidParam {
std::string test_name;
std::string json_str;
ErrorKind expected_error_kind;
std::string expected_error_msg;
};
class PlanTableScanResponseInvalidTest
: public ::testing::TestWithParam<PlanTableScanResponseInvalidParam> {};
TEST_P(PlanTableScanResponseInvalidTest, InvalidInput) {
const auto& param = GetParam();
auto result = PlanTableScanResponseFromJson(nlohmann::json::parse(param.json_str),
UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsError(param.expected_error_kind));
if (!param.expected_error_msg.empty()) {
EXPECT_THAT(result, HasErrorMessage(param.expected_error_msg));
}
}
INSTANTIATE_TEST_SUITE_P(
PlanTableScanResponseInvalidCases, PlanTableScanResponseInvalidTest,
::testing::Values(
PlanTableScanResponseInvalidParam{
.test_name = "EmptyJson",
.json_str = R"({})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Missing 'status'"},
PlanTableScanResponseInvalidParam{
.test_name = "UnknownStatus",
.json_str = R"({"status":"someStatus"})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Unknown plan status"},
PlanTableScanResponseInvalidParam{
.test_name = "SubmittedWithoutPlanId",
.json_str = R"({"status":"submitted"})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "plan id should be defined when status is 'submitted'"},
PlanTableScanResponseInvalidParam{
.test_name = "CancelledStatus",
.json_str = R"({"status":"cancelled"})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "'cancelled' is not a valid status for planTableScan"},
PlanTableScanResponseInvalidParam{
.test_name = "SubmittedWithTasks",
.json_str =
R"({"status":"submitted","plan-id":"somePlanId","plan-tasks":["task1","task2"]})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "tasks can only be defined when status is 'completed'"},
PlanTableScanResponseInvalidParam{
.test_name = "SubmittedWithEmptyTasks",
.json_str =
R"({"status":"submitted","plan-id":"somePlanId","plan-tasks":[]})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "tasks can only be defined when status is 'completed'"},
PlanTableScanResponseInvalidParam{
.test_name = "FailedWithPlanId",
.json_str =
R"({"status":"failed","plan-id":"somePlanId","error":{"message":"x","type":"y","code":500}})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg =
"plan id can only be defined when status is 'submitted' or 'completed'"},
PlanTableScanResponseInvalidParam{
.test_name = "FailedWithoutError",
.json_str = R"({"status":"failed"})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Missing 'error'"},
PlanTableScanResponseInvalidParam{
.test_name = "CompletedWithError",
.json_str =
R"({"status":"completed","error":{"message":"x","type":"y","code":500}})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "error can only be present when status is 'failed'"},
PlanTableScanResponseInvalidParam{
.test_name = "DeleteFilesWithoutFileScanTasks",
.json_str =
R"({"status":"completed","delete-files":[{"content":"position-deletes","file-path":"s3://bucket/d.parquet","file-format":"PARQUET","spec-id":0,"partition":[],"file-size-in-bytes":512,"record-count":5}],"file-scan-tasks":[]})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg =
"deleteFiles should only be returned with fileScanTasks"}),
[](const ::testing::TestParamInfo<PlanTableScanResponseInvalidParam>& info) {
return info.param.test_name;
});
// --- FetchPlanningResultResponse ---
TEST(FetchPlanningResultResponseFromJsonTest, SubmittedStatusNoTasks) {
auto json = nlohmann::json::parse(R"({"status":"submitted"})");
auto result = FetchPlanningResultResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result->plan_status, PlanStatus::kSubmitted);
EXPECT_FALSE(result->plan_tasks.has_value());
EXPECT_FALSE(result->file_scan_tasks.has_value());
EXPECT_TRUE(result->delete_files.empty());
}
TEST(FetchPlanningResultResponseFromJsonTest, CompletedStatusWithPlanTasks) {
auto json = nlohmann::json::parse(
R"({"status":"completed","plan-tasks":["task-1"],"delete-files":[],"file-scan-tasks":[]})");
auto result = FetchPlanningResultResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result->plan_status, PlanStatus::kCompleted);
ASSERT_TRUE(result->plan_tasks.has_value());
ASSERT_EQ(result->plan_tasks->size(), 1);
EXPECT_EQ(result->plan_tasks->at(0), "task-1");
ASSERT_TRUE(result->file_scan_tasks.has_value());
EXPECT_TRUE(result->file_scan_tasks->empty());
}
TEST(FetchPlanningResultResponseFromJsonTest, FailedStatusWithError) {
auto json = nlohmann::json::parse(
R"({"status":"failed","error":{"message":"Planning failed","type":"PlanningException","code":500}})");
auto result = FetchPlanningResultResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result->plan_status, PlanStatus::kFailed);
ASSERT_TRUE(result->error.has_value());
EXPECT_EQ(result->error->message, "Planning failed");
EXPECT_EQ(result->error->type, "PlanningException");
EXPECT_EQ(result->error->code, 500);
}
struct FetchPlanningResultResponseInvalidParam {
std::string test_name;
std::string json_str;
ErrorKind expected_error_kind;
std::string expected_error_msg;
};
class FetchPlanningResultResponseInvalidTest
: public ::testing::TestWithParam<FetchPlanningResultResponseInvalidParam> {};
TEST_P(FetchPlanningResultResponseInvalidTest, InvalidInput) {
const auto& param = GetParam();
auto result = FetchPlanningResultResponseFromJson(nlohmann::json::parse(param.json_str),
UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsError(param.expected_error_kind));
if (!param.expected_error_msg.empty()) {
EXPECT_THAT(result, HasErrorMessage(param.expected_error_msg));
}
}
INSTANTIATE_TEST_SUITE_P(
FetchPlanningResultResponseInvalidCases, FetchPlanningResultResponseInvalidTest,
::testing::Values(
FetchPlanningResultResponseInvalidParam{
.test_name = "EmptyJson",
.json_str = R"({})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Missing 'status'"},
FetchPlanningResultResponseInvalidParam{
.test_name = "UnknownStatus",
.json_str = R"({"status":"someStatus"})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Unknown plan status"},
FetchPlanningResultResponseInvalidParam{
.test_name = "SubmittedWithTasks",
.json_str = R"({"status":"submitted","plan-tasks":["task1","task2"]})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "tasks can only be returned in a 'completed' status"},
FetchPlanningResultResponseInvalidParam{
.test_name = "SubmittedWithEmptyTasks",
.json_str = R"({"status":"submitted","plan-tasks":[]})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "tasks can only be returned in a 'completed' status"},
FetchPlanningResultResponseInvalidParam{
.test_name = "DeleteFilesWithoutFileScanTasks",
.json_str =
R"({"status":"submitted","delete-files":[{"content":"position-deletes","file-path":"s3://bucket/d.parquet","file-format":"PARQUET","spec-id":0,"partition":[],"file-size-in-bytes":512,"record-count":5}]})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg =
"deleteFiles should only be returned with fileScanTasks"},
FetchPlanningResultResponseInvalidParam{
.test_name = "FailedWithoutError",
.json_str = R"({"status":"failed"})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Missing 'error'"},
FetchPlanningResultResponseInvalidParam{
.test_name = "CompletedWithError",
.json_str =
R"({"status":"completed","error":{"message":"x","type":"y","code":500}})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "error can only be present when status is 'failed'"}),
[](const ::testing::TestParamInfo<FetchPlanningResultResponseInvalidParam>& info) {
return info.param.test_name;
});
// --- FetchScanTasksResponse ---
TEST(FetchScanTasksResponseFromJsonTest, WithFileScanTasks) {
auto json = nlohmann::json::parse(R"({
"plan-tasks": [],
"delete-files": [
{
"content": "position-deletes",
"file-path": "s3://bucket/deletes/delete.parquet",
"file-format": "PARQUET",
"spec-id": 0,
"partition": [],
"file-size-in-bytes": 512,
"record-count": 5
}
],
"file-scan-tasks": [
{
"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
},
"delete-file-references": [0]
}
]
})");
auto result = FetchScanTasksResponseFromJson(json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result->plan_tasks.has_value());
EXPECT_TRUE(result->plan_tasks->empty());
ASSERT_EQ(result->delete_files.size(), 1);
ASSERT_TRUE(result->file_scan_tasks.has_value());
ASSERT_EQ(result->file_scan_tasks->size(), 1);
EXPECT_EQ(result->file_scan_tasks->at(0)->data_file()->file_path,
"s3://bucket/data/file.parquet");
ASSERT_EQ(result->file_scan_tasks->at(0)->delete_files().size(), 1);
EXPECT_EQ(result->file_scan_tasks->at(0)->delete_files()[0]->file_path,
"s3://bucket/deletes/delete.parquet");
}
TEST(FetchScanTasksResponseFromJsonTest, WithPlanTasksOnly) {
auto json = nlohmann::json::parse(
R"({"plan-tasks":["task-1","task-2"],"delete-files":[],"file-scan-tasks":[]})");
auto result = FetchScanTasksResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result->plan_tasks.has_value());
ASSERT_EQ(result->plan_tasks->size(), 2);
EXPECT_EQ(result->plan_tasks->at(0), "task-1");
ASSERT_TRUE(result->file_scan_tasks.has_value());
EXPECT_TRUE(result->file_scan_tasks->empty());
}
TEST(FetchScanTasksResponseFromJsonTest, AllowsPresentEmptyTaskFields) {
auto json = nlohmann::json::parse(
R"({"plan-tasks":[],"delete-files":[],"file-scan-tasks":[]})");
auto result = FetchScanTasksResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result->plan_tasks.has_value());
EXPECT_TRUE(result->plan_tasks->empty());
ASSERT_TRUE(result->file_scan_tasks.has_value());
EXPECT_TRUE(result->file_scan_tasks->empty());
}
struct FetchScanTasksResponseInvalidParam {
std::string test_name;
std::string json_str;
ErrorKind expected_error_kind;
std::string expected_error_msg;
};
class FetchScanTasksResponseInvalidTest
: public ::testing::TestWithParam<FetchScanTasksResponseInvalidParam> {};
TEST_P(FetchScanTasksResponseInvalidTest, InvalidInput) {
const auto& param = GetParam();
auto result = FetchScanTasksResponseFromJson(nlohmann::json::parse(param.json_str),
UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsError(param.expected_error_kind));
if (!param.expected_error_msg.empty()) {
EXPECT_THAT(result, HasErrorMessage(param.expected_error_msg));
}
}
INSTANTIATE_TEST_SUITE_P(
FetchScanTasksResponseInvalidCases, FetchScanTasksResponseInvalidTest,
::testing::Values(
FetchScanTasksResponseInvalidParam{
.test_name = "EmptyJson",
.json_str = R"({})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "planTasks and fileScanTask cannot both be null"},
FetchScanTasksResponseInvalidParam{
.test_name = "NullPlanTasks",
.json_str = R"({"plan-tasks":null})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Missing 'plan-tasks'"},
FetchScanTasksResponseInvalidParam{
.test_name = "NullFileScanTasks",
.json_str = R"({"file-scan-tasks":null})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Missing 'file-scan-tasks'"},
FetchScanTasksResponseInvalidParam{
.test_name = "NullDeleteFiles",
.json_str = R"({"plan-tasks":[],"delete-files":null})",
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "Missing 'delete-files'"},
FetchScanTasksResponseInvalidParam{
.test_name = "DeleteFilesWithoutFileScanTasks",
.json_str =
R"({"plan-tasks":["task1","task2"],"delete-files":[{"content":"position-deletes","file-path":"s3://bucket/d.parquet","file-format":"PARQUET","spec-id":0,"partition":[],"file-size-in-bytes":512,"record-count":5}],"file-scan-tasks":[]})",
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg =
"deleteFiles should only be returned with fileScanTasks"}),
[](const ::testing::TestParamInfo<FetchScanTasksResponseInvalidParam>& info) {
return info.param.test_name;
});
// --- PlanTableScanRequest validation ---
struct PlanTableScanRequestValidationParam {
std::string test_name;
PlanTableScanRequest request;
std::string expected_error_msg;
};
class PlanTableScanRequestValidationTest
: public ::testing::TestWithParam<PlanTableScanRequestValidationParam> {};
TEST_P(PlanTableScanRequestValidationTest, ValidationFailed) {
const auto& param = GetParam();
auto status = param.request.Validate();
ASSERT_THAT(status, IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(status, HasErrorMessage(param.expected_error_msg));
}
INSTANTIATE_TEST_SUITE_P(
PlanTableScanRequestValidationCases, PlanTableScanRequestValidationTest,
::testing::Values(
PlanTableScanRequestValidationParam{
.test_name = "SnapshotIdWithStartSnapshotId",
.request = {.snapshot_id = 1, .start_snapshot_id = 2, .end_snapshot_id = 5},
.expected_error_msg =
"cannot provide both snapshotId and startSnapshotId/endSnapshotId"},
PlanTableScanRequestValidationParam{
.test_name = "SnapshotIdWithEndSnapshotId",
.request = {.snapshot_id = 1, .end_snapshot_id = 5},
.expected_error_msg =
"cannot provide both snapshotId and startSnapshotId/endSnapshotId"},
PlanTableScanRequestValidationParam{
.test_name = "StartSnapshotIdWithoutEnd",
.request = {.start_snapshot_id = 1},
.expected_error_msg = "startSnapshotId and endSnapshotId is required"},
PlanTableScanRequestValidationParam{
.test_name = "EndSnapshotIdWithoutStart",
.request = {.end_snapshot_id = 5},
.expected_error_msg = "startSnapshotId and endSnapshotId is required"},
PlanTableScanRequestValidationParam{
.test_name = "NegativeMinRowsRequested",
.request = {.min_rows_requested = -1},
.expected_error_msg = "minRowsRequested is negative"}),
[](const ::testing::TestParamInfo<PlanTableScanRequestValidationParam>& info) {
return info.param.test_name;
});
TEST(PlanTableScanRequestEqualityTest, ComparesFilterByExpressionContent) {
PlanTableScanRequest lhs;
lhs.filter = Expressions::GreaterThan("id", Literal::Int(10));
PlanTableScanRequest rhs;
rhs.filter = Expressions::GreaterThan("id", Literal::Int(10));
ASSERT_NE(lhs.filter, rhs.filter);
EXPECT_EQ(lhs, rhs);
}
TEST(PlanTableScanRequestJsonSerdeTest, RoundtripAndDefaults) {
PlanTableScanRequest request;
request.snapshot_id = 123;
request.select = {"id", "data"};
request.filter = Expressions::GreaterThan("id", Literal::Long(10));
request.case_sensitive = false;
request.use_snapshot_schema = true;
request.stats_fields = {"id"};
request.min_rows_requested = 100;
ICEBERG_UNWRAP_OR_FAIL(auto json, ToJson(request));
auto result = PlanTableScanRequestFromJson(json);
ASSERT_THAT(result, IsOk());
EXPECT_EQ(*result, request);
auto defaults_result = PlanTableScanRequestFromJson(R"({"select":["id"]})"_json);
ASSERT_THAT(defaults_result, IsOk());
EXPECT_EQ(defaults_result->select, std::vector<std::string>{"id"});
EXPECT_TRUE(defaults_result->case_sensitive);
EXPECT_FALSE(defaults_result->use_snapshot_schema);
}
TEST(FetchScanTasksRequestJsonSerdeTest, Roundtrip) {
FetchScanTasksRequest request{.planTask = "task-token"};
auto json = ToJson(request);
auto result = FetchScanTasksRequestFromJson(json);
ASSERT_THAT(result, IsOk());
EXPECT_EQ(*result, request);
}
struct ScanRequestFromJsonInvalidParam {
std::string test_name;
nlohmann::json json;
std::function<Result<void>(const nlohmann::json&)> parse;
ErrorKind expected_error_kind;
std::string expected_error_msg;
};
class ScanRequestFromJsonInvalidTest
: public ::testing::TestWithParam<ScanRequestFromJsonInvalidParam> {};
TEST_P(ScanRequestFromJsonInvalidTest, InvalidInput) {
const auto& param = GetParam();
auto result = param.parse(param.json);
ASSERT_THAT(result, IsError(param.expected_error_kind));
EXPECT_THAT(result, HasErrorMessage(param.expected_error_msg));
}
INSTANTIATE_TEST_SUITE_P(
ScanRequestFromJsonInvalidCases, ScanRequestFromJsonInvalidTest,
::testing::Values(
ScanRequestFromJsonInvalidParam{
.test_name = "PlanTableScanMissingEndSnapshotId",
.json = R"({"start-snapshot-id":1})"_json,
.parse = [](const nlohmann::json& json) -> Result<void> {
ICEBERG_ASSIGN_OR_RAISE(auto request, PlanTableScanRequestFromJson(json));
return {};
},
.expected_error_kind = ErrorKind::kValidationFailed,
.expected_error_msg = "startSnapshotId and endSnapshotId is required"},
ScanRequestFromJsonInvalidParam{
.test_name = "FetchScanTasksMissingPlanTask",
.json = nlohmann::json::object(),
.parse = [](const nlohmann::json& json) -> Result<void> {
ICEBERG_ASSIGN_OR_RAISE(auto request, FetchScanTasksRequestFromJson(json));
return {};
},
.expected_error_kind = ErrorKind::kJsonParseError,
.expected_error_msg = "plan-task"}),
[](const ::testing::TestParamInfo<ScanRequestFromJsonInvalidParam>& info) {
return info.param.test_name;
});
TEST(DataFileRoundtripTest, PartitionedFileRequiresMatchingSpecForSerialization) {
auto schema = PartitionedSchema();
auto specs = PartitionedSpecs(schema);
DataFile df;
df.content = DataFile::Content::kData;
df.file_path = "s3://bucket/data/file.parquet";
df.file_format = FileFormatType::kParquet;
df.partition_spec_id = 1;
df.partition = PartitionValues({Literal::Int(34)});
df.file_size_in_bytes = 12345;
df.record_count = 100;
ICEBERG_UNWRAP_OR_FAIL(auto json, iceberg::ToJson(df, specs, schema));
EXPECT_EQ(json["partition"], nlohmann::json::array({34}));
auto parsed = iceberg::DataFileFromJson(json, specs, schema);
ASSERT_THAT(parsed, IsOk());
EXPECT_EQ(parsed->partition_spec_id, 1);
ASSERT_EQ(parsed->partition.num_fields(), 1);
EXPECT_EQ(parsed->partition.values()[0], Literal::Int(34));
}
DataFile MakeDataFileForSerializationTest(std::optional<int32_t> spec_id,
PartitionValues partition) {
DataFile df;
df.content = DataFile::Content::kData;
df.file_path = "s3://bucket/data/file.parquet";
df.file_format = FileFormatType::kParquet;
df.partition_spec_id = spec_id;
df.partition = std::move(partition);
df.file_size_in_bytes = 12345;
df.record_count = 100;
return df;
}
enum class DataFileToJsonInvalidCase {
kPartitionedSpecWithoutPartitionData,
kUnknownSpec,
kMismatchedSpecId,
kMissingSpecId,
};
struct DataFileToJsonInvalidParam {
std::string test_name;
DataFileToJsonInvalidCase test_case;
std::string expected_error_msg;
};
class DataFileToJsonInvalidTest
: public ::testing::TestWithParam<DataFileToJsonInvalidParam> {};
TEST_P(DataFileToJsonInvalidTest, InvalidInput) {
const auto& param = GetParam();
Result<nlohmann::json> result = JsonParseError("uninitialized");
switch (param.test_case) {
case DataFileToJsonInvalidCase::kPartitionedSpecWithoutPartitionData: {
auto schema = PartitionedSchema();
result = iceberg::ToJson(MakeDataFileForSerializationTest(1, PartitionValues{}),
PartitionedSpecs(schema), schema);
break;
}
case DataFileToJsonInvalidCase::kUnknownSpec: {
auto schema = PartitionedSchema();
result = iceberg::ToJson(
MakeDataFileForSerializationTest(2, PartitionValues({Literal::Int(34)})),
PartitionedSpecs(schema), schema);
break;
}
case DataFileToJsonInvalidCase::kMismatchedSpecId: {
auto schema = PartitionedSchema();
auto partitioned_specs = PartitionedSpecs(schema);
auto specs = std::unordered_map<int32_t, std::shared_ptr<PartitionSpec>>{
{2, partitioned_specs.at(1)}};
result = iceberg::ToJson(
MakeDataFileForSerializationTest(2, PartitionValues({Literal::Int(34)})), specs,
schema);
break;
}
case DataFileToJsonInvalidCase::kMissingSpecId: {
auto schema = PartitionedSchema();
result = iceberg::ToJson(
MakeDataFileForSerializationTest(std::nullopt, PartitionValues{}),
PartitionedSpecs(schema), schema);
break;
}
}
EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(result, HasErrorMessage(param.expected_error_msg));
}
INSTANTIATE_TEST_SUITE_P(
DataFileToJsonInvalidCases, DataFileToJsonInvalidTest,
::testing::Values(
DataFileToJsonInvalidParam{
.test_name = "PartitionedSpecWithoutPartitionData",
.test_case = DataFileToJsonInvalidCase::kPartitionedSpecWithoutPartitionData,
.expected_error_msg = "Invalid partition data from content file"},
DataFileToJsonInvalidParam{.test_name = "UnknownSpec",
.test_case = DataFileToJsonInvalidCase::kUnknownSpec,
.expected_error_msg = "Invalid partition spec: null"},
DataFileToJsonInvalidParam{
.test_name = "MismatchedSpecId",
.test_case = DataFileToJsonInvalidCase::kMismatchedSpecId,
.expected_error_msg = "Invalid partition spec id from content file"},
DataFileToJsonInvalidParam{
.test_name = "MissingSpecId",
.test_case = DataFileToJsonInvalidCase::kMissingSpecId,
.expected_error_msg = "Invalid partition spec id from content file"}),
[](const ::testing::TestParamInfo<DataFileToJsonInvalidParam>& info) {
return info.param.test_name;
});
// --- FileScanTasksFromJson ---
TEST(FileScanTasksFromJsonTest, EmptyArray) {
auto result = FileScanTasksFromJson(nlohmann::json::array(), {}, {}, Schema({}, 0));
ASSERT_THAT(result, IsOk());
EXPECT_TRUE(result.value().empty());
}
TEST(FileScanTasksFromJsonTest, SingleTaskNoDeleteFiles) {
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
}
}])"_json;
auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value().size(), 1U);
const auto& task = result.value()[0];
ASSERT_NE(task->data_file(), nullptr);
EXPECT_EQ(task->data_file()->file_path, "s3://bucket/data/file.parquet");
EXPECT_EQ(task->start(), 0);
EXPECT_EQ(task->length(), 12345);
EXPECT_FALSE(task->is_split());
EXPECT_TRUE(task->delete_files().empty());
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;
auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value().size(), 1U);
EXPECT_EQ(result.value()[0]->start(), 0);
EXPECT_EQ(result.value()[0]->length(), 12345);
EXPECT_FALSE(result.value()[0]->is_split());
}
TEST(FileScanTasksFromJsonTest, RejectsNullOrMissingRangeEndpoint) {
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 (std::string_view missing_field : {"start", "length"}) {
SCOPED_TRACE(missing_field);
auto incomplete_json = json;
incomplete_json[0].erase(std::string(missing_field));
auto result =
FileScanTasksFromJson(incomplete_json, {}, UnpartitionedSpecs(), Schema({}, 0));
EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(result, HasErrorMessage("must be provided together"));
}
json[0]["start"] = nullptr;
json[0]["length"] = nullptr;
auto null_result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0));
EXPECT_THAT(null_result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(null_result, HasErrorMessage("Missing 'start'"));
}
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, AcceptsValidPartialRanges) {
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& [start, length] :
{std::pair<int64_t, int64_t>{0, 200}, {100, 200}, {12245, 100}}) {
SCOPED_TRACE(testing::Message() << "start=" << start << ", length=" << length);
auto split_json = json;
split_json[0]["start"] = start;
split_json[0]["length"] = length;
auto result =
FileScanTasksFromJson(split_json, {}, UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value().size(), 1U);
EXPECT_EQ(result.value()[0]->start(), start);
EXPECT_EQ(result.value()[0]->length(), length);
EXPECT_TRUE(result.value()[0]->is_split());
}
}
TEST(FileScanTasksFromJsonTest, RejectsInvalidRanges) {
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& [start, length] : {std::pair<int64_t, int64_t>{-1, 100},
{0, -1},
{0, 0},
{12345, 0},
{12345, 1},
{12000, 500}}) {
SCOPED_TRACE(testing::Message() << "start=" << start << ", length=" << length);
auto invalid_json = json;
invalid_json[0]["start"] = start;
invalid_json[0]["length"] = length;
auto result =
FileScanTasksFromJson(invalid_json, {}, UnpartitionedSpecs(), Schema({}, 0));
EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(result, HasErrorMessage("Invalid FileScanTask range"));
}
auto overflow_json = json;
overflow_json[0]["data-file"]["file-size-in-bytes"] =
std::numeric_limits<int64_t>::max();
overflow_json[0]["start"] = std::numeric_limits<int64_t>::max() - 1;
overflow_json[0]["length"] = 2;
auto overflow_result =
FileScanTasksFromJson(overflow_json, {}, UnpartitionedSpecs(), Schema({}, 0));
EXPECT_THAT(overflow_result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(overflow_result, HasErrorMessage("Invalid FileScanTask range"));
}
TEST(FileScanTasksFromJsonTest, AcceptsEmptyWholeFile) {
auto json = R"([{
"data-file": {
"content": "data",
"file-path": "s3://bucket/data/empty.parquet",
"file-format": "PARQUET",
"spec-id": 0,
"partition": [],
"file-size-in-bytes": 0,
"record-count": 0
},
"start": 0,
"length": 0
}])"_json;
auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value().size(), 1U);
EXPECT_EQ(result.value()[0]->start(), 0);
EXPECT_EQ(result.value()[0]->length(), 0);
EXPECT_FALSE(result.value()[0]->is_split());
}
TEST(FileScanTasksFromJsonTest, RowLineageSequence) {
GTEST_SKIP() << "REST scan-task JSON does not expose data-sequence-number yet: "
<< "https://github.com/apache/iceberg-cpp/issues/834";
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,
"first-row-id": 100,
"data-sequence-number": 7
}
}])"_json;
auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value().size(), 1U);
const auto& data_file = result.value()[0]->data_file();
ASSERT_NE(data_file, nullptr);
EXPECT_EQ(data_file->first_row_id, 100);
EXPECT_EQ(data_file->data_sequence_number, 7);
}
TEST(FileScanTasksFromJsonTest, TaskWithDeleteFileReferences) {
DataFile delete_file;
delete_file.content = DataFile::Content::kPositionDeletes;
delete_file.file_path = "s3://bucket/deletes/pos_delete.parquet";
delete_file.file_format = FileFormatType::kParquet;
delete_file.partition_spec_id = PartitionSpec::kInitialSpecId;
delete_file.partition = PartitionValues{};
delete_file.file_size_in_bytes = 1000;
delete_file.record_count = 5;
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
},
"delete-file-references": [0]
}])"_json;
auto result = FileScanTasksFromJson(json, {std::make_shared<DataFile>(delete_file)},
UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
ASSERT_EQ(result.value().size(), 1U);
const auto& task = result.value()[0];
ASSERT_EQ(task->delete_files().size(), 1U);
EXPECT_EQ(task->delete_files()[0]->file_path, "s3://bucket/deletes/pos_delete.parquet");
}
TEST(FileScanTasksFromJsonTest, DeleteFileReferenceOutOfRange) {
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": 100,
"record-count": 10
},
"delete-file-references": [5]
}])"_json;
auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({}, 0));
EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(result, HasErrorMessage("out of range"));
}
TEST(FileScanTasksFromJsonTest, NotAnArray) {
auto result = FileScanTasksFromJson(nlohmann::json::object(), {}, {}, Schema({}, 0));
EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(result, HasErrorMessage("non-array"));
}
// --- Roundtrip tests ---
TEST(DataFileRoundtripTest, RequiredFieldsOnly) {
DataFile df;
df.content = DataFile::Content::kData;
df.file_path = "s3://bucket/data/file.parquet";
df.file_format = FileFormatType::kParquet;
df.partition_spec_id = PartitionSpec::kInitialSpecId;
df.partition = PartitionValues{};
df.file_size_in_bytes = 12345;
df.record_count = 100;
ICEBERG_UNWRAP_OR_FAIL(auto json,
iceberg::ToJson(df, UnpartitionedSpecs(), Schema({}, 0)));
EXPECT_TRUE(json.contains("partition"));
EXPECT_EQ(json["partition"], nlohmann::json::array());
auto result = iceberg::DataFileFromJson(json, UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result.value(), df);
}
TEST(DataFileRoundtripTest, WithOptionalFields) {
DataFile df;
df.content = DataFile::Content::kPositionDeletes;
df.file_path = "s3://bucket/deletes/pos.parquet";
df.file_format = FileFormatType::kParquet;
df.file_size_in_bytes = 5000;
df.record_count = 50;
df.partition_spec_id = PartitionSpec::kInitialSpecId;
df.partition = PartitionValues{};
df.column_sizes = {{1, 1000}, {2, 2000}};
df.value_counts = {{1, 100}, {2, 100}};
df.null_value_counts = {{1, 0}};
df.nan_value_counts = {{2, 5}};
df.split_offsets = {0, 4096};
df.sort_order_id = 0;
df.referenced_data_file = "s3://bucket/data/file.parquet";
ICEBERG_UNWRAP_OR_FAIL(auto json,
iceberg::ToJson(df, UnpartitionedSpecs(), Schema({}, 0)));
auto result = iceberg::DataFileFromJson(json, UnpartitionedSpecs(), Schema({}, 0));
ASSERT_THAT(result, IsOk());
EXPECT_EQ(result.value(), df);
}
TEST(FetchScanTasksResponseRoundtripTest, WithFileScanTasksAndDeleteFiles) {
auto json = nlohmann::json::parse(R"({
"plan-tasks": [],
"delete-files": [
{
"content": "position-deletes",
"file-path": "s3://bucket/deletes/delete.parquet",
"file-format": "PARQUET",
"spec-id": 0,
"partition": [],
"file-size-in-bytes": 512,
"record-count": 5
}
],
"file-scan-tasks": [
{
"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
},
"delete-file-references": [0]
}
]
})");
auto result = FetchScanTasksResponseFromJson(json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
ToJson(*result, UnpartitionedSpecs(), EmptySchema()));
auto result2 =
FetchScanTasksResponseFromJson(roundtrip_json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result2, IsOk());
EXPECT_EQ(*result, *result2);
}
TEST(FetchScanTasksResponseRoundtripTest, PreservesDistinctSplitRanges) {
auto json = nlohmann::json::parse(R"({
"file-scan-tasks": [
{
"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": 5000
},
{
"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": 5000,
"length": 7345
}
]
})");
auto result = FetchScanTasksResponseFromJson(json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result.value().file_scan_tasks.has_value());
ASSERT_EQ(result.value().file_scan_tasks->size(), 2U);
EXPECT_EQ(result.value().file_scan_tasks->at(0)->start(), 0);
EXPECT_EQ(result.value().file_scan_tasks->at(0)->length(), 5000);
EXPECT_EQ(result.value().file_scan_tasks->at(1)->start(), 5000);
EXPECT_EQ(result.value().file_scan_tasks->at(1)->length(), 7345);
ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
ToJson(result.value(), UnpartitionedSpecs(), EmptySchema()));
EXPECT_EQ(roundtrip_json["file-scan-tasks"][0]["start"], 0);
EXPECT_EQ(roundtrip_json["file-scan-tasks"][0]["length"], 5000);
EXPECT_EQ(roundtrip_json["file-scan-tasks"][1]["start"], 5000);
EXPECT_EQ(roundtrip_json["file-scan-tasks"][1]["length"], 7345);
auto result2 =
FetchScanTasksResponseFromJson(roundtrip_json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result2, IsOk());
EXPECT_EQ(result.value(), result2.value());
}
TEST(FetchScanTasksResponseRoundtripTest, RejectsSplitOutsideChangedDataFile) {
auto data_file = std::make_shared<DataFile>();
data_file->content = DataFile::Content::kData;
data_file->file_path = "s3://bucket/data/file.parquet";
data_file->file_format = FileFormatType::kParquet;
data_file->partition_spec_id = PartitionSpec::kInitialSpecId;
data_file->partition = PartitionValues{};
data_file->file_size_in_bytes = 12345;
data_file->record_count = 100;
ICEBERG_UNWRAP_OR_FAIL(auto split, FileScanTask::MakeSplit(data_file, 5000, 7345));
FetchScanTasksResponse response;
response.file_scan_tasks = std::vector<std::shared_ptr<FileScanTask>>{split};
data_file->file_size_in_bytes = 12000;
auto result = ToJson(response, UnpartitionedSpecs(), EmptySchema());
EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
EXPECT_THAT(result, HasErrorMessage("Invalid FileScanTask range"));
}
TEST(FetchScanTasksResponseRoundtripTest, PreservesResidualFilter) {
auto json = nlohmann::json::parse(R"({
"file-scan-tasks": [
{
"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
},
"residual-filter": {"type": "gt", "term": "id", "value": 21}
}
]
})");
auto result = FetchScanTasksResponseFromJson(json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result->file_scan_tasks.has_value());
ASSERT_EQ(result->file_scan_tasks->size(), 1);
ASSERT_NE(result->file_scan_tasks->at(0)->residual_filter(), nullptr);
ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
ToJson(*result, UnpartitionedSpecs(), EmptySchema()));
ASSERT_EQ(roundtrip_json["file-scan-tasks"].size(), 1);
ASSERT_TRUE(roundtrip_json["file-scan-tasks"][0].contains("residual-filter"));
EXPECT_EQ(roundtrip_json["file-scan-tasks"][0]["residual-filter"],
json["file-scan-tasks"][0]["residual-filter"]);
auto result2 =
FetchScanTasksResponseFromJson(roundtrip_json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result2, IsOk());
EXPECT_EQ(*result, *result2);
}
TEST(FetchScanTasksResponseRoundtripTest, ToJsonDerivesDeleteFilesFromTasks) {
auto data_file = std::make_shared<DataFile>();
data_file->content = DataFile::Content::kData;
data_file->file_path = "s3://bucket/data/file.parquet";
data_file->file_format = FileFormatType::kParquet;
data_file->partition_spec_id = PartitionSpec::kInitialSpecId;
data_file->partition = PartitionValues{};
data_file->file_size_in_bytes = 12345;
data_file->record_count = 100;
auto delete_file = std::make_shared<DataFile>();
delete_file->content = DataFile::Content::kPositionDeletes;
delete_file->file_path = "s3://bucket/deletes/delete.parquet";
delete_file->file_format = FileFormatType::kParquet;
delete_file->partition_spec_id = PartitionSpec::kInitialSpecId;
delete_file->partition = PartitionValues{};
delete_file->file_size_in_bytes = 512;
delete_file->record_count = 5;
FetchScanTasksResponse response;
response.file_scan_tasks = std::vector<std::shared_ptr<FileScanTask>>{};
response.file_scan_tasks->push_back(
std::make_shared<FileScanTask>(data_file, std::vector{delete_file}));
ICEBERG_UNWRAP_OR_FAIL(auto json,
ToJson(response, UnpartitionedSpecs(), EmptySchema()));
ASSERT_TRUE(json.contains("delete-files"));
ASSERT_TRUE(json.contains("file-scan-tasks"));
ASSERT_EQ(json["delete-files"].size(), 1);
EXPECT_EQ(json["delete-files"][0]["file-path"], delete_file->file_path);
ASSERT_EQ(json["file-scan-tasks"].size(), 1);
ASSERT_TRUE(json["file-scan-tasks"][0].contains("delete-file-references"));
EXPECT_EQ(json["file-scan-tasks"][0]["delete-file-references"],
nlohmann::json::array({0}));
auto result = FetchScanTasksResponseFromJson(json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result->file_scan_tasks.has_value());
ASSERT_EQ(result->file_scan_tasks->size(), 1);
ASSERT_EQ(result->file_scan_tasks->at(0)->delete_files().size(), 1);
EXPECT_EQ(result->file_scan_tasks->at(0)->delete_files()[0]->file_path,
delete_file->file_path);
}
TEST(FetchScanTasksResponseRoundtripTest, ToJsonKeepsDeleteFilesWithSamePath) {
auto data_file = std::make_shared<DataFile>();
data_file->content = DataFile::Content::kData;
data_file->file_path = "s3://bucket/data/file.parquet";
data_file->file_format = FileFormatType::kParquet;
data_file->partition_spec_id = PartitionSpec::kInitialSpecId;
data_file->partition = PartitionValues{};
data_file->file_size_in_bytes = 12345;
data_file->record_count = 100;
auto delete_file_a = std::make_shared<DataFile>();
delete_file_a->content = DataFile::Content::kPositionDeletes;
delete_file_a->file_path = "s3://bucket/deletes/dv.puffin";
delete_file_a->file_format = FileFormatType::kPuffin;
delete_file_a->partition_spec_id = PartitionSpec::kInitialSpecId;
delete_file_a->partition = PartitionValues{};
delete_file_a->file_size_in_bytes = 1024;
delete_file_a->record_count = 5;
delete_file_a->content_offset = 10;
delete_file_a->content_size_in_bytes = 100;
auto delete_file_b = std::make_shared<DataFile>(*delete_file_a);
delete_file_b->content_offset = 110;
delete_file_b->content_size_in_bytes = 120;
FetchScanTasksResponse response;
response.file_scan_tasks = std::vector<std::shared_ptr<FileScanTask>>{};
response.file_scan_tasks->push_back(std::make_shared<FileScanTask>(
data_file, std::vector{delete_file_a, delete_file_b}));
ICEBERG_UNWRAP_OR_FAIL(auto json,
ToJson(response, UnpartitionedSpecs(), EmptySchema()));
ASSERT_EQ(json["delete-files"].size(), 2);
EXPECT_EQ(json["file-scan-tasks"][0]["delete-file-references"],
nlohmann::json::array({0, 1}));
auto result = FetchScanTasksResponseFromJson(json, UnpartitionedSpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ASSERT_TRUE(result->file_scan_tasks.has_value());
ASSERT_EQ(result->file_scan_tasks->at(0)->delete_files().size(), 2);
EXPECT_EQ(result->file_scan_tasks->at(0)->delete_files()[0]->content_offset, 10);
EXPECT_EQ(result->file_scan_tasks->at(0)->delete_files()[1]->content_offset, 110);
}
TEST(PlanTableScanResponseRoundtripTest, SubmittedStatus) {
auto json = nlohmann::json::parse(R"({"status": "submitted", "plan-id": "abc-123"})");
auto result = PlanTableScanResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
ToJson(*result, EmptySpecs(), EmptySchema()));
auto result2 =
PlanTableScanResponseFromJson(roundtrip_json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result2, IsOk());
EXPECT_EQ(*result, *result2);
}
TEST(PlanTableScanResponseRoundtripTest, FailedWithError) {
auto json = nlohmann::json::parse(
R"({"status":"failed","error":{"message":"Planning failed","type":"PlanningException","code":500}})");
auto result = PlanTableScanResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
ToJson(*result, EmptySpecs(), EmptySchema()));
auto result2 =
PlanTableScanResponseFromJson(roundtrip_json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result2, IsOk());
EXPECT_EQ(*result, *result2);
}
TEST(FetchPlanningResultResponseRoundtripTest, CompletedWithPlanTasks) {
auto json = nlohmann::json::parse(
R"({"status": "completed", "plan-tasks": ["task-1", "task-2"]})");
auto result = FetchPlanningResultResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
ToJson(*result, EmptySpecs(), EmptySchema()));
auto result2 =
FetchPlanningResultResponseFromJson(roundtrip_json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result2, IsOk());
EXPECT_EQ(*result, *result2);
}
TEST(FetchPlanningResultResponseRoundtripTest, FailedWithError) {
auto json = nlohmann::json::parse(
R"({"status":"failed","error":{"message":"Planning failed","type":"PlanningException","code":500}})");
auto result = FetchPlanningResultResponseFromJson(json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result, IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
ToJson(*result, EmptySpecs(), EmptySchema()));
auto result2 =
FetchPlanningResultResponseFromJson(roundtrip_json, EmptySpecs(), EmptySchema());
ASSERT_THAT(result2, IsOk());
EXPECT_EQ(*result, *result2);
}
DECLARE_ROUNDTRIP_TEST(FieldLabel)
INSTANTIATE_TEST_SUITE_P(
FieldLabelCases, FieldLabelTest,
::testing::Values(
FieldLabelParam{
.test_name = "WithLabels",
.expected_json_str =
R"({"field-id":1,"labels":{"classification":"pii","owner":"team-a"}})",
.model = {.field_id = 1,
.labels = {{"classification", "pii"}, {"owner", "team-a"}}}},
// A field label always writes "labels", even when empty
FieldLabelParam{.test_name = "EmptyLabels",
.expected_json_str = R"({"field-id":1,"labels":{}})",
.model = {.field_id = 1}},
FieldLabelParam{.test_name = "MaxInt32FieldId",
.expected_json_str = R"({"field-id":2147483647,"labels":{}})",
.model = {.field_id = 2147483647}},
FieldLabelParam{.test_name = "NegativeFieldId",
.expected_json_str = R"({"field-id":-1,"labels":{"k":"v"}})",
.model = {.field_id = -1, .labels = {{"k", "v"}}}}),
[](const ::testing::TestParamInfo<FieldLabelParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(FieldLabel)
INSTANTIATE_TEST_SUITE_P(
FieldLabelInvalidCases, FieldLabelInvalidTest,
::testing::Values(
FieldLabelInvalidParam{
.test_name = "NotObject",
.invalid_json_str = R"([])",
.expected_error_message = "Cannot parse field label from non-object"},
FieldLabelInvalidParam{.test_name = "MissingFieldId",
.invalid_json_str = R"({"labels":{}})",
.expected_error_message = "Missing 'field-id'"},
FieldLabelInvalidParam{.test_name = "NullFieldId",
.invalid_json_str = R"({"field-id":null,"labels":{}})",
.expected_error_message = "Missing 'field-id'"},
FieldLabelInvalidParam{.test_name = "FloatFieldId",
.invalid_json_str = R"({"field-id":1.5,"labels":{}})",
.expected_error_message = "'field-id' must be an integer"},
FieldLabelInvalidParam{.test_name = "BoolFieldId",
.invalid_json_str = R"({"field-id":true,"labels":{}})",
.expected_error_message = "'field-id' must be an integer"},
FieldLabelInvalidParam{.test_name = "StringFieldId",
.invalid_json_str = R"({"field-id":"1","labels":{}})",
.expected_error_message = "'field-id' must be an integer"},
FieldLabelInvalidParam{
.test_name = "FieldIdAboveInt32",
.invalid_json_str = R"({"field-id":2147483648,"labels":{}})",
.expected_error_message = "'field-id' integer is out of range"},
FieldLabelInvalidParam{
.test_name = "FieldIdBelowInt32",
.invalid_json_str = R"({"field-id":-2147483649,"labels":{}})",
.expected_error_message = "'field-id' integer is out of range"},
FieldLabelInvalidParam{.test_name = "MissingLabels",
.invalid_json_str = R"({"field-id":1})",
.expected_error_message = "Missing 'labels'"},
FieldLabelInvalidParam{.test_name = "NullLabels",
.invalid_json_str = R"({"field-id":1,"labels":null})",
.expected_error_message = "Missing 'labels'"},
FieldLabelInvalidParam{
.test_name = "LabelsNotObject",
.invalid_json_str = R"({"field-id":1,"labels":"x"})",
.expected_error_message =
"Failed to parse 'labels' (JSON value of type string)"},
FieldLabelInvalidParam{
.test_name = "NonStringLabelValue",
.invalid_json_str = R"({"field-id":1,"labels":{"k":1}})",
.expected_error_message =
"Failed to parse 'labels' (JSON value of type object)"}),
[](const ::testing::TestParamInfo<FieldLabelInvalidParam>& info) {
return info.param.test_name;
});
DECLARE_ROUNDTRIP_TEST(Labels)
INSTANTIATE_TEST_SUITE_P(
LabelsCases, LabelsTest,
::testing::Values(
LabelsParam{.test_name = "Empty", .expected_json_str = R"({})", .model = {}},
LabelsParam{.test_name = "ObjectLabelsOnly",
.expected_json_str = R"({"object-labels":{"owner":"team-a"}})",
.model = {.object_labels = {{"owner", "team-a"}}}},
LabelsParam{
.test_name = "FieldsOnly",
.expected_json_str =
R"({"fields":[{"field-id":1,"labels":{"classification":"pii"}},{"field-id":2,"labels":{"unit":"usd"}}]})",
.model = {.fields = {{.field_id = 1, .labels = {{"classification", "pii"}}},
{.field_id = 2, .labels = {{"unit", "usd"}}}}}},
LabelsParam{
.test_name = "ObjectAndFields",
.expected_json_str =
R"({"object-labels":{"owner":"team-a"},"fields":[{"field-id":1,"labels":{"classification":"pii"}}]})",
.model = {.object_labels = {{"owner", "team-a"}},
.fields = {{.field_id = 1,
.labels = {{"classification", "pii"}}}}}},
// Duplicate field IDs are kept verbatim and in order; no resolution
LabelsParam{
.test_name = "DuplicateFieldIds",
.expected_json_str =
R"({"fields":[{"field-id":1,"labels":{"a":"1"}},{"field-id":1,"labels":{"b":"2"}}]})",
.model = {.fields = {{.field_id = 1, .labels = {{"a", "1"}}},
{.field_id = 1, .labels = {{"b", "2"}}}}}}),
[](const ::testing::TestParamInfo<LabelsParam>& info) {
return info.param.test_name;
});
DECLARE_DESERIALIZE_TEST(Labels)
INSTANTIATE_TEST_SUITE_P(
LabelsDeserializeCases, LabelsDeserializeTest,
::testing::Values(
LabelsDeserializeParam{.test_name = "NullObjectLabels",
.json_str = R"({"object-labels":null})",
.expected_model = {}},
LabelsDeserializeParam{.test_name = "NullFields",
.json_str = R"({"fields":null})",
.expected_model = {}},
LabelsDeserializeParam{
.test_name = "UnknownKeysIgnored",
.json_str =
R"({"object-labels":{"a":"b"},"future":1,"fields":[{"field-id":2,"labels":{},"extra":true}]})",
.expected_model = {.object_labels = {{"a", "b"}},
.fields = {{.field_id = 2}}}}),
[](const ::testing::TestParamInfo<LabelsDeserializeParam>& info) {
return info.param.test_name;
});
DECLARE_INVALID_TEST(Labels)
INSTANTIATE_TEST_SUITE_P(
LabelsInvalidCases, LabelsInvalidTest,
::testing::Values(
LabelsInvalidParam{
.test_name = "ObjectLabelsNotObject",
.invalid_json_str = R"({"object-labels":"x"})",
.expected_error_message =
"Failed to parse 'object-labels' (JSON value of type string)"},
LabelsInvalidParam{
.test_name = "ObjectLabelsNonStringValue",
.invalid_json_str = R"({"object-labels":{"k":1}})",
.expected_error_message =
"Failed to parse 'object-labels' (JSON value of type object)"},
LabelsInvalidParam{
.test_name = "ObjectLabelsNullValue",
.invalid_json_str = R"({"object-labels":{"k":null}})",
.expected_error_message =
"Failed to parse 'object-labels' (JSON value of type object)"},
LabelsInvalidParam{
.test_name = "FieldsNotArray",
.invalid_json_str = R"({"fields":{}})",
.expected_error_message = "Cannot parse 'fields' from non-array"},
LabelsInvalidParam{
.test_name = "FieldEntryNotObject",
.invalid_json_str = R"({"fields":[1]})",
.expected_error_message = "Cannot parse field label from non-object"},
LabelsInvalidParam{
.test_name = "FieldEntryNull",
.invalid_json_str = R"({"fields":[null]})",
.expected_error_message = "Cannot parse field label from non-object"},
LabelsInvalidParam{
.test_name = "FieldEntryInvalidFieldId",
.invalid_json_str = R"({"fields":[{"field-id":1.5,"labels":{}}]})",
.expected_error_message = "'field-id' must be an integer"}),
[](const ::testing::TestParamInfo<LabelsInvalidParam>& info) {
return info.param.test_name;
});
// Parse errors describe the offending key and JSON type; they must not
// reproduce JSON values from the response.
TEST(ParseErrorMessageTest, LoadTableResultErrorOmitsValues) {
constexpr std::string_view kConfigValue = "config-value-1234";
constexpr std::string_view kCredentialValue = "credential-value-5678";
nlohmann::json without_metadata = {
{"metadata-location", "s3://bucket/metadata/v1.json"},
{"config", {{"s3.secret-access-key", kConfigValue}}},
{"storage-credentials",
nlohmann::json::array(
{{{"prefix", "s3"}, {"config", {{"s3.session-token", kCredentialValue}}}}})}};
nlohmann::json mistyped_metadata = without_metadata;
mistyped_metadata["metadata"] = 1;
for (const auto& json : {without_metadata, mistyped_metadata}) {
auto result = LoadTableResultFromJson(json);
ASSERT_THAT(result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(result, HasErrorMessage("metadata"));
EXPECT_THAT(result, ::testing::Not(HasErrorMessage(kConfigValue)));
EXPECT_THAT(result, ::testing::Not(HasErrorMessage(kCredentialValue)));
}
}
TEST(ParseErrorMessageTest, OAuthTokenResponseErrorOmitsValues) {
constexpr std::string_view kAccessToken = "access-token-1234";
constexpr std::string_view kRefreshToken = "refresh-token-5678";
nlohmann::json json = {{"access_token", kAccessToken},
{"token_type", "bearer"},
{"expires_in", "3600"},
{"refresh_token", kRefreshToken}};
auto result = OAuthTokenResponseFromJson(json);
ASSERT_THAT(result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(result, HasErrorMessage("'expires_in'"));
EXPECT_THAT(result, HasErrorMessage("string"));
EXPECT_THAT(result, ::testing::Not(HasErrorMessage(kAccessToken)));
EXPECT_THAT(result, ::testing::Not(HasErrorMessage(kRefreshToken)));
}
TEST(ParseErrorMessageTest, InvalidJsonStringIsNotEchoed) {
constexpr std::string_view kValue = "access-token-1234";
auto result = FromJsonString(std::format(R"({{"access_token":"{}",)", kValue));
ASSERT_THAT(result, IsError(ErrorKind::kJsonParseError));
EXPECT_THAT(result, ::testing::Not(HasErrorMessage(kValue)));
}
} // namespace iceberg::rest