| /* | |
| * 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 |