blob: ba41c59cd37b1271e239defb1f27e05c8ce664a6 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#include <algorithm>
#include <cstdint>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "arrow/api.h"
#include "arrow/c/bridge.h"
#include "fmt/format.h"
#include "gtest/gtest.h"
#include "paimon/common/data/variant/generic_variant.h"
#include "paimon/common/data/variant/variant_shredding_utils.h"
#include "paimon/common/data/variant/variant_type_utils.h"
#include "paimon/common/factories/io_hook.h"
#include "paimon/common/utils/checked_cast.h"
#include "paimon/common/utils/path_util.h"
#include "paimon/common/utils/scope_guard.h"
#include "paimon/core/io/data_file_meta.h"
#include "paimon/core/table/source/data_split_impl.h"
#include "paimon/data/variant.h"
#include "paimon/defs.h"
#include "paimon/format/file_format_factory.h"
#include "paimon/format/reader_builder.h"
#include "paimon/fs/file_system.h"
#include "paimon/memory/memory_pool.h"
#include "paimon/reader/file_batch_reader.h"
#include "paimon/record_batch.h"
#include "paimon/table/source/startup_mode.h"
#include "paimon/testing/utils/io_exception_helper.h"
#include "paimon/testing/utils/read_result_collector.h"
#include "paimon/testing/utils/test_helper.h"
#include "paimon/testing/utils/testharness.h"
#include "paimon/testing/utils/variant_test_data.h"
namespace paimon::test {
// End-to-end tests for tables with a VARIANT column: create, write, commit, scan and read.
class VariantTableInteTest : public ::testing::Test {
public:
void SetUp() override {
dir_ = UniqueTestDirectory::Create();
ASSERT_TRUE(dir_);
test_dir_ = dir_->Str();
pool_ = GetDefaultPool();
fields_ = {arrow::field("id", arrow::int32()), VariantTypeUtils::ToArrowField("v")};
schema_ = arrow::schema(fields_);
}
void TearDown() override {
dir_.reset();
}
std::shared_ptr<arrow::StructArray> BuildArray(const std::vector<const char*>& jsons,
int32_t id_offset = 0) {
auto result =
VariantTestData::BuildVariantBatch(fields_[0], fields_[1], jsons, pool_, id_offset);
EXPECT_TRUE(result.ok()) << result.status().ToString();
return std::move(result).value();
}
Result<std::unique_ptr<RecordBatch>> MakeBatch(
const std::shared_ptr<arrow::StructArray>& array) {
::ArrowArray arrow_array;
PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*array, &arrow_array));
RecordBatchBuilder batch_builder(&arrow_array);
return batch_builder.SetPartition({}).SetBucket(0).SetRowKinds({}).Finish();
}
// Reads all rows back and checks the variant column renders to `expected_jsons` (nullptr
// means a null variant). The read result carries a leading `_VALUE_KIND` column.
void ReadAndCheck(TestHelper* helper, const std::vector<std::shared_ptr<Split>>& splits,
const std::vector<int32_t>& expected_ids,
const std::vector<const char*>& expected_jsons) {
ASSERT_OK_AND_ASSIGN(auto result, helper->ReadResult(splits));
ASSERT_EQ(result->num_chunks(), 1);
auto result_struct = checked_pointer_cast<arrow::StructArray>(result->chunk(0));
ASSERT_EQ(result_struct->length(), static_cast<int64_t>(expected_jsons.size()));
auto struct_type = checked_pointer_cast<arrow::StructType>(result_struct->type());
int32_t id_index = struct_type->GetFieldIndex("id");
int32_t variant_index = struct_type->GetFieldIndex("v");
ASSERT_GE(id_index, 0);
ASSERT_GE(variant_index, 0);
auto id_column = checked_pointer_cast<arrow::Int32Array>(result_struct->field(id_index));
auto variant_column =
checked_pointer_cast<arrow::StructArray>(result_struct->field(variant_index));
auto value_column = checked_pointer_cast<arrow::BinaryArray>(variant_column->field(0));
auto metadata_column = checked_pointer_cast<arrow::BinaryArray>(variant_column->field(1));
for (size_t i = 0; i < expected_jsons.size(); ++i) {
SCOPED_TRACE("row " + std::to_string(i));
ASSERT_EQ(id_column->Value(i), expected_ids[i]);
if (expected_jsons[i] == nullptr) {
ASSERT_TRUE(variant_column->IsNull(i));
continue;
}
ASSERT_FALSE(variant_column->IsNull(i));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> variant,
GenericVariant::Create(value_column->GetView(i),
metadata_column->GetView(i), pool_));
ASSERT_OK_AND_ASSIGN(std::string actual_json, variant->ToJson());
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> expected,
GenericVariant::FromJson(expected_jsons[i], pool_));
ASSERT_OK_AND_ASSIGN(std::string expected_json, expected->ToJson());
ASSERT_EQ(actual_json, expected_json);
}
}
// Builds a variant-access projection field via the public builder.
std::shared_ptr<arrow::Field> BuildAccessField(
const std::vector<std::pair<std::shared_ptr<arrow::DataType>, std::string>>& accesses,
const std::string& field_name) {
VariantAccessBuilder builder;
for (const auto& [type, path] : accesses) {
auto target = std::make_unique<ArrowSchema>();
EXPECT_TRUE(arrow::ExportField(arrow::Field("t", type), target.get()).ok());
EXPECT_OK(builder.AddField(target.get(), path, /*fail_on_error=*/false));
}
auto c_field = builder.Build(field_name);
EXPECT_TRUE(c_field.ok()) << c_field.status().ToString();
auto imported = arrow::ImportField(c_field.value().get());
EXPECT_TRUE(imported.ok()) << imported.status().ToString();
return imported.ValueOrDie();
}
// Reads `splits` back with `read_schema` projected and returns the single result chunk.
void ReadWithSchema(TestHelper* helper, const std::vector<std::shared_ptr<Split>>& splits,
const std::shared_ptr<arrow::Schema>& read_schema,
std::shared_ptr<arrow::StructArray>* result_struct) {
auto c_read_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*read_schema, c_read_schema.get()).ok());
ASSERT_OK_AND_ASSIGN(auto result, helper->ReadResult(splits, std::move(c_read_schema)));
ASSERT_EQ(result->num_chunks(), 1);
*result_struct = checked_pointer_cast<arrow::StructArray>(result->chunk(0));
}
Result<std::shared_ptr<arrow::Schema>> ReadDataFileSchema(
const std::string& bucket_path, const std::shared_ptr<DataFileMeta>& file,
const std::map<std::string, std::string>& options) const {
std::string file_path = PathUtil::JoinPath(bucket_path, file->file_name);
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<InputStream> unique_input_stream,
dir_->GetFileSystem()->Open(file_path));
std::shared_ptr<InputStream> input_stream(std::move(unique_input_stream));
PAIMON_ASSIGN_OR_RAISE(std::string format, file->FileFormat());
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileFormat> file_format,
FileFormatFactory::Get(format, options));
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReaderBuilder> reader_builder,
file_format->CreateReaderBuilder(/*batch_size=*/10));
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileBatchReader> reader,
reader_builder->Build(input_stream));
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ArrowSchema> c_file_schema, reader->GetFileSchema());
PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema> file_schema,
arrow::ImportSchema(c_file_schema.get()));
return file_schema;
}
std::map<std::string, std::string> AdaptiveInferenceOptions(int64_t target_file_row_num,
int32_t initial_sample_rows,
int32_t adaptive_sample_rows,
double admission_ratio,
double retention_ratio) const {
return {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "-1"},
{Options::WRITE_ONLY, "true"},
{Options::TARGET_FILE_ROW_NUM, std::to_string(target_file_row_num)},
{Options::VARIANT_INFER_SHREDDING_SCHEMA, "true"},
{Options::VARIANT_SHREDDING_INFERENCE_MODE, "adaptive"},
{Options::VARIANT_SHREDDING_MAX_INFER_BUFFER_ROW, std::to_string(initial_sample_rows)},
{Options::VARIANT_SHREDDING_ADAPTIVE_MAX_INFER_BUFFER_ROW,
std::to_string(adaptive_sample_rows)},
{Options::VARIANT_SHREDDING_MIN_FIELD_CARDINALITY_RATIO,
fmt::format("{}", admission_ratio)},
{Options::VARIANT_SHREDDING_ADAPTIVE_RETENTION_RATIO,
fmt::format("{}", retention_ratio)},
};
}
protected:
std::string test_dir_;
std::unique_ptr<UniqueTestDirectory> dir_;
std::shared_ptr<MemoryPool> pool_;
arrow::FieldVector fields_;
std::shared_ptr<arrow::Schema> schema_;
};
TEST_F(VariantTableInteTest, TestAppendTable) {
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, schema_, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
// The document set covers deep object/array alternation, escaped and unicode strings,
// wide integers, decimals, exponent doubles and empty containers.
std::vector<const char*> jsons = {
R"({"age": 35, "city": "Hangzhou"})",
nullptr,
"[1, \"two\", 3.5, null, true]",
"{\"nested\": {\"x\": [1, 2]}, \"s\": \"中文\"}",
R"({
"user": {
"id": 9007199254740993,
"name": "张三 \"quoted\" \\ / \b\f\n\r\t",
"tags": ["a", 1, 2.5, true, null, {"deep": [[1, [2, [3, [4]]]]]}],
"address": {
"city": "Hangzhou",
"geo": {"lat": 30.274085, "lng": 120.15507, "alt": -1.5e-3},
"history": [
{"year": 2020, "city": "Beijing"},
{"year": 2021, "city": "Shanghai", "note": null}
]
},
"balance": 12345678901234567890.123456789,
"scores": [0.1, -0.0, 1e100, -1e-100]
},
"empty_object": {},
"empty_array": [],
"flags": [true, false, null]
})",
R"([{"a": [{"b": {"c": [null, {"d": 1}]}}]}, [], {}, "end"])",
R"({"unicode": "😀"})",
};
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch, MakeBatch(BuildArray(jsons)));
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
ReadAndCheck(helper.get(), splits, {0, 1, 2, 3, 4, 5, 6}, jsons);
}
TEST_F(VariantTableInteTest, TestAdaptiveInferenceAcrossRollingFiles) {
std::map<std::string, std::string> options =
AdaptiveInferenceOptions(/*target_file_row_num=*/10, /*initial_sample_rows=*/10,
/*adaptive_sample_rows=*/10, /*admission_ratio=*/0.4,
/*retention_ratio=*/0.2);
ASSERT_OK_AND_ASSIGN(
std::unique_ptr<TestHelper> helper,
TestHelper::Create(test_dir_, schema_, /*partition_keys=*/{}, /*primary_keys=*/{}, options,
/*is_streaming_mode=*/false));
std::vector<std::string> json_storage;
json_storage.reserve(30);
for (int32_t id = 0; id < 10; ++id) {
json_storage.push_back(id < 5 ? fmt::format(R"({{"legacy":"value","stable":{}}})", id)
: fmt::format(R"({{"stable":{}}})", id));
}
for (int32_t id = 10; id < 20; ++id) {
json_storage.push_back(id < 19 ? fmt::format(R"({{"emerging":true,"stable":{}}})", id)
: fmt::format(R"({{"stable":{}}})", id));
}
for (int32_t id = 20; id < 30; ++id) {
json_storage.push_back(fmt::format(R"({{"stable":{}}})", id));
}
std::vector<const char*> jsons;
jsons.reserve(json_storage.size());
for (const std::string& json : json_storage) {
jsons.push_back(json.c_str());
}
std::vector<std::unique_ptr<RecordBatch>> batches;
for (int32_t file_index = 0; file_index < 3; ++file_index) {
auto begin = jsons.begin() + file_index * 10;
std::vector<const char*> file_jsons(begin, begin + 10);
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch,
MakeBatch(BuildArray(file_jsons, /*id_offset=*/file_index * 10)));
batches.push_back(std::move(batch));
}
ASSERT_OK(helper->WriteAndCommit(std::move(batches), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
ASSERT_EQ(1, splits.size());
auto data_split = std::dynamic_pointer_cast<DataSplitImpl>(splits[0]);
ASSERT_NE(data_split, nullptr);
std::vector<std::shared_ptr<DataFileMeta>> files = data_split->DataFiles();
std::sort(
files.begin(), files.end(),
[](const std::shared_ptr<DataFileMeta>& left, const std::shared_ptr<DataFileMeta>& right) {
return left->min_sequence_number < right->min_sequence_number;
});
ASSERT_EQ(3, files.size());
std::vector<std::shared_ptr<arrow::DataType>> expected_variant_types;
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> first_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("legacy", arrow::utf8()), arrow::field("stable", arrow::int64())})));
expected_variant_types.push_back(first_type);
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> second_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("emerging", arrow::boolean()), arrow::field("legacy", arrow::utf8()),
arrow::field("stable", arrow::int64())})));
expected_variant_types.push_back(second_type);
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> third_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("emerging", arrow::boolean()), arrow::field("stable", arrow::int64())})));
expected_variant_types.push_back(third_type);
for (size_t i = 0; i < files.size(); ++i) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Schema> file_schema,
ReadDataFileSchema(data_split->BucketPath(), files[i], options));
std::shared_ptr<arrow::Field> variant_field = file_schema->GetFieldByName("v");
ASSERT_NE(variant_field, nullptr);
ASSERT_TRUE(variant_field->type()->Equals(*expected_variant_types[i]))
<< "file=" << files[i]->file_name << ", actual=" << variant_field->type()->ToString()
<< ", expected=" << expected_variant_types[i]->ToString();
}
std::vector<int32_t> expected_ids;
expected_ids.reserve(30);
for (int32_t id = 0; id < 30; ++id) {
expected_ids.push_back(id);
}
ReadAndCheck(helper.get(), splits, expected_ids, jsons);
}
TEST_F(VariantTableInteTest, TestAdaptiveInferenceWithMultipleVariantFields) {
std::shared_ptr<arrow::Field> left_field = VariantTypeUtils::ToArrowField("left_payload");
std::shared_ptr<arrow::Field> right_field = VariantTypeUtils::ToArrowField("right_payload");
std::shared_ptr<arrow::Schema> table_schema =
arrow::schema({arrow::field("id", arrow::int32()), left_field, right_field});
std::map<std::string, std::string> options =
AdaptiveInferenceOptions(/*target_file_row_num=*/2, /*initial_sample_rows=*/2,
/*adaptive_sample_rows=*/2, /*admission_ratio=*/0.4,
/*retention_ratio=*/0.2);
ASSERT_OK_AND_ASSIGN(
std::unique_ptr<TestHelper> helper,
TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
std::vector<const char*> left_jsons = {
R"({"legacy":"a","stable":1})",
R"({"legacy":"b","stable":2})",
R"({"emerging":true,"stable":3})",
R"({"emerging":false,"stable":4})",
};
std::vector<const char*> right_jsons = {
R"({"sparse":"x","stable":"a"})",
R"({"stable":"b"})",
R"({"stable":"c"})",
R"({"emerging":true,"stable":"d"})",
};
std::vector<std::unique_ptr<RecordBatch>> batches;
for (int32_t file_index = 0; file_index < 2; ++file_index) {
auto left_begin = left_jsons.begin() + file_index * 2;
std::vector<const char*> file_left_jsons(left_begin, left_begin + 2);
auto right_begin = right_jsons.begin() + file_index * 2;
std::vector<const char*> file_right_jsons(right_begin, right_begin + 2);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> left,
VariantTestData::BuildVariantBatch(table_schema->field(0), left_field,
file_left_jsons, pool_,
/*id_offset=*/file_index * 2 + 1));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> right,
VariantTestData::BuildVariantBatch(table_schema->field(0), right_field,
file_right_jsons, pool_,
/*id_offset=*/file_index * 2 + 1));
std::shared_ptr<arrow::StructArray> rows =
arrow::StructArray::Make({left->field(0), left->field(1), right->field(1)},
table_schema->fields())
.ValueOrDie();
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch, MakeBatch(rows));
batches.push_back(std::move(batch));
}
ASSERT_OK(helper->WriteAndCommit(std::move(batches), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
ASSERT_EQ(1, splits.size());
std::shared_ptr<DataSplitImpl> data_split = std::dynamic_pointer_cast<DataSplitImpl>(splits[0]);
ASSERT_NE(data_split, nullptr);
std::vector<std::shared_ptr<DataFileMeta>> files = data_split->DataFiles();
std::sort(
files.begin(), files.end(),
[](const std::shared_ptr<DataFileMeta>& left, const std::shared_ptr<DataFileMeta>& right) {
return left->min_sequence_number < right->min_sequence_number;
});
ASSERT_EQ(2, files.size());
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> first_left_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("legacy", arrow::utf8()), arrow::field("stable", arrow::int64())})));
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> second_left_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("emerging", arrow::boolean()), arrow::field("legacy", arrow::utf8()),
arrow::field("stable", arrow::int64())})));
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> right_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("sparse", arrow::utf8()), arrow::field("stable", arrow::utf8())})));
std::vector<std::shared_ptr<arrow::DataType>> expected_left_types = {first_left_type,
second_left_type};
for (size_t i = 0; i < files.size(); ++i) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Schema> file_schema,
ReadDataFileSchema(data_split->BucketPath(), files[i], options));
std::shared_ptr<arrow::Field> file_left = file_schema->GetFieldByName("left_payload");
std::shared_ptr<arrow::Field> file_right = file_schema->GetFieldByName("right_payload");
ASSERT_NE(file_left, nullptr);
ASSERT_NE(file_right, nullptr);
ASSERT_TRUE(file_left->type()->Equals(*expected_left_types[i]))
<< "file=" << files[i]->file_name << ", actual=" << file_left->type()->ToString()
<< ", expected=" << expected_left_types[i]->ToString();
ASSERT_TRUE(file_right->type()->Equals(*right_type))
<< "file=" << files[i]->file_name << ", actual=" << file_right->type()->ToString()
<< ", expected=" << right_type->ToString();
}
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> result, helper->ReadResult(splits));
std::map<int32_t, std::string> actual;
for (const std::shared_ptr<arrow::Array>& chunk : result->chunks()) {
auto rows = checked_pointer_cast<arrow::StructArray>(chunk);
auto row_type = checked_pointer_cast<arrow::StructType>(rows->type());
auto ids =
checked_pointer_cast<arrow::Int32Array>(rows->field(row_type->GetFieldIndex("id")));
auto left = checked_pointer_cast<arrow::StructArray>(
rows->field(row_type->GetFieldIndex("left_payload")));
auto right = checked_pointer_cast<arrow::StructArray>(
rows->field(row_type->GetFieldIndex("right_payload")));
auto left_values = checked_pointer_cast<arrow::BinaryArray>(left->field(0));
auto left_metadata = checked_pointer_cast<arrow::BinaryArray>(left->field(1));
auto right_values = checked_pointer_cast<arrow::BinaryArray>(right->field(0));
auto right_metadata = checked_pointer_cast<arrow::BinaryArray>(right->field(1));
for (int64_t i = 0; i < rows->length(); ++i) {
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<GenericVariant> left_variant,
GenericVariant::Create(left_values->GetView(i), left_metadata->GetView(i), pool_));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> right_variant,
GenericVariant::Create(right_values->GetView(i),
right_metadata->GetView(i), pool_));
ASSERT_OK_AND_ASSIGN(std::string left_json, left_variant->ToJson());
ASSERT_OK_AND_ASSIGN(std::string right_json, right_variant->ToJson());
actual.emplace(ids->Value(i), left_json + "|" + right_json);
}
}
ASSERT_EQ(4, actual.size());
for (int32_t id = 1; id <= 4; ++id) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> expected_left,
GenericVariant::FromJson(left_jsons[id - 1], pool_));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> expected_right,
GenericVariant::FromJson(right_jsons[id - 1], pool_));
ASSERT_OK_AND_ASSIGN(std::string expected_left_json, expected_left->ToJson());
ASSERT_OK_AND_ASSIGN(std::string expected_right_json, expected_right->ToJson());
ASSERT_EQ(expected_left_json + "|" + expected_right_json, actual[id]);
}
}
TEST_F(VariantTableInteTest, TestAdaptiveInferenceWithNestedVariant) {
std::shared_ptr<arrow::Field> nested_variant = VariantTypeUtils::ToArrowField("payload");
std::shared_ptr<arrow::Field> nested_field = arrow::field(
"nested", arrow::struct_({arrow::field("label", arrow::utf8()), nested_variant}));
std::shared_ptr<arrow::Schema> table_schema =
arrow::schema({arrow::field("id", arrow::int32()), nested_field});
std::map<std::string, std::string> options =
AdaptiveInferenceOptions(/*target_file_row_num=*/2, /*initial_sample_rows=*/2,
/*adaptive_sample_rows=*/2, /*admission_ratio=*/0.4,
/*retention_ratio=*/0.2);
ASSERT_OK_AND_ASSIGN(
std::unique_ptr<TestHelper> helper,
TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
std::vector<const char*> labels = {"first", "second", "third", "fourth"};
std::vector<const char*> jsons = {
R"({"legacy":"a","stable":1})",
R"({"legacy":"b","stable":2})",
R"({"emerging":true,"stable":3})",
R"({"emerging":false,"stable":4})",
};
std::vector<std::unique_ptr<RecordBatch>> batches;
for (int32_t file_index = 0; file_index < 2; ++file_index) {
auto json_begin = jsons.begin() + file_index * 2;
std::vector<const char*> file_jsons(json_begin, json_begin + 2);
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::StructArray> variants,
VariantTestData::BuildVariantBatch(table_schema->field(0), nested_variant, file_jsons,
pool_, /*id_offset=*/file_index * 2 + 1));
arrow::StringBuilder label_builder;
for (int32_t i = file_index * 2; i < file_index * 2 + 2; ++i) {
arrow::Status status = label_builder.Append(labels[i]);
ASSERT_TRUE(status.ok()) << status.ToString();
}
std::shared_ptr<arrow::Array> label_array = label_builder.Finish().ValueOrDie();
std::shared_ptr<arrow::StructArray> nested =
arrow::StructArray::Make({label_array, variants->field(1)},
nested_field->type()->fields())
.ValueOrDie();
std::shared_ptr<arrow::StructArray> rows =
arrow::StructArray::Make({variants->field(0), nested}, table_schema->fields())
.ValueOrDie();
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch, MakeBatch(rows));
batches.push_back(std::move(batch));
}
ASSERT_OK(helper->WriteAndCommit(std::move(batches), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
ASSERT_EQ(1, splits.size());
std::shared_ptr<DataSplitImpl> data_split = std::dynamic_pointer_cast<DataSplitImpl>(splits[0]);
ASSERT_NE(data_split, nullptr);
std::vector<std::shared_ptr<DataFileMeta>> files = data_split->DataFiles();
std::sort(
files.begin(), files.end(),
[](const std::shared_ptr<DataFileMeta>& left, const std::shared_ptr<DataFileMeta>& right) {
return left->min_sequence_number < right->min_sequence_number;
});
ASSERT_EQ(2, files.size());
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> first_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("legacy", arrow::utf8()), arrow::field("stable", arrow::int64())})));
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<arrow::DataType> second_type,
VariantShreddingUtils::VariantShreddingSchema(arrow::struct_(
{arrow::field("emerging", arrow::boolean()), arrow::field("legacy", arrow::utf8()),
arrow::field("stable", arrow::int64())})));
std::vector<std::shared_ptr<arrow::DataType>> expected_types = {first_type, second_type};
for (size_t i = 0; i < files.size(); ++i) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Schema> file_schema,
ReadDataFileSchema(data_split->BucketPath(), files[i], options));
std::shared_ptr<arrow::Field> file_nested = file_schema->GetFieldByName("nested");
ASSERT_NE(file_nested, nullptr);
auto file_nested_type = checked_pointer_cast<arrow::StructType>(file_nested->type());
std::shared_ptr<arrow::Field> file_variant = file_nested_type->GetFieldByName("payload");
ASSERT_NE(file_variant, nullptr);
ASSERT_TRUE(file_variant->type()->Equals(*expected_types[i]))
<< "file=" << files[i]->file_name << ", actual=" << file_variant->type()->ToString()
<< ", expected=" << expected_types[i]->ToString();
}
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> result, helper->ReadResult(splits));
std::map<int32_t, std::string> actual;
for (const std::shared_ptr<arrow::Array>& chunk : result->chunks()) {
auto rows = checked_pointer_cast<arrow::StructArray>(chunk);
auto row_type = checked_pointer_cast<arrow::StructType>(rows->type());
auto ids =
checked_pointer_cast<arrow::Int32Array>(rows->field(row_type->GetFieldIndex("id")));
auto nested = checked_pointer_cast<arrow::StructArray>(
rows->field(row_type->GetFieldIndex("nested")));
auto nested_type = checked_pointer_cast<arrow::StructType>(nested->type());
auto label_column = checked_pointer_cast<arrow::StringArray>(
nested->field(nested_type->GetFieldIndex("label")));
auto variant = checked_pointer_cast<arrow::StructArray>(
nested->field(nested_type->GetFieldIndex("payload")));
auto value_column = checked_pointer_cast<arrow::BinaryArray>(variant->field(0));
auto metadata_column = checked_pointer_cast<arrow::BinaryArray>(variant->field(1));
for (int64_t i = 0; i < rows->length(); ++i) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> value,
GenericVariant::Create(value_column->GetView(i),
metadata_column->GetView(i), pool_));
ASSERT_OK_AND_ASSIGN(std::string json, value->ToJson());
actual.emplace(ids->Value(i), label_column->GetString(i) + "|" + json);
}
}
ASSERT_EQ(4, actual.size());
for (int32_t id = 1; id <= 4; ++id) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<GenericVariant> expected,
GenericVariant::FromJson(jsons[id - 1], pool_));
ASSERT_OK_AND_ASSIGN(std::string expected_json, expected->ToJson());
ASSERT_EQ(std::string(labels[id - 1]) + "|" + expected_json, actual[id]);
}
}
TEST_F(VariantTableInteTest, TestPrimaryKeyTable) {
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "1"},
};
ASSERT_OK_AND_ASSIGN(auto helper, TestHelper::Create(test_dir_, schema_, /*partition_keys=*/{},
/*primary_keys=*/{"id"}, options,
/*is_streaming_mode=*/true));
ASSERT_OK_AND_ASSIGN(
std::unique_ptr<RecordBatch> batch_1,
MakeBatch(BuildArray({"{\"a\": 1}", "{\"b\": 2}", nullptr}, /*id_offset=*/0)));
ASSERT_OK_AND_ASSIGN(auto commit_msgs_1,
helper->WriteAndCommit(std::move(batch_1), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch_2,
MakeBatch(BuildArray({"{\"b\": \"updated\"}", "[42]"}, /*id_offset=*/1)));
ASSERT_OK_AND_ASSIGN(auto commit_msgs_2,
helper->WriteAndCommit(std::move(batch_2), /*commit_identifier=*/1,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
// The second batch overwrites ids 1 and 2, so the merged view holds three rows.
ReadAndCheck(helper.get(), splits, {0, 1, 2}, {"{\"a\": 1}", R"({"b": "updated"})", "[42]"});
}
TEST_F(VariantTableInteTest, TestVariantAccessRead) {
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(auto helper, TestHelper::Create(test_dir_, schema_, /*partition_keys=*/{},
/*primary_keys=*/{}, options,
/*is_streaming_mode=*/false));
std::vector<const char*> jsons = {R"({"age": 35, "city": "Chicago"})",
R"({"age": 25, "other": "Hello"})", nullptr};
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch, MakeBatch(BuildArray(jsons)));
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
auto access_field =
BuildAccessField({{arrow::int64(), "$.age"}, {arrow::utf8(), "$.other"}}, "v");
auto read_schema = arrow::schema({fields_[0], access_field});
std::shared_ptr<arrow::StructArray> result_struct;
ReadWithSchema(helper.get(), splits, read_schema, &result_struct);
ASSERT_EQ(result_struct->length(), 3);
auto struct_type = checked_pointer_cast<arrow::StructType>(result_struct->type());
auto v_column = checked_pointer_cast<arrow::StructArray>(
result_struct->field(struct_type->GetFieldIndex("v")));
const auto& age = static_cast<const arrow::Int64Array&>(*v_column->field(0));
const auto& other = static_cast<const arrow::StringArray&>(*v_column->field(1));
ASSERT_EQ(age.Value(0), 35);
ASSERT_EQ(age.Value(1), 25);
ASSERT_TRUE(v_column->IsNull(2));
ASSERT_TRUE(other.IsNull(0));
ASSERT_EQ(other.GetString(1), "Hello");
}
// The two tests below read a variant nested inside a ROW and inside an ARRAY column as a
// variant-access projection. Unlike the format-level tests they go through the whole table read
// path, where the read schema is resolved against the table schema before the read plans see it.
TEST_F(VariantTableInteTest, TestNestedRowVariantAccessRead) {
// Table: [id, s: ROW<nv: VARIANT, t: STRING>]
auto struct_field = arrow::field("s", arrow::struct_({VariantTypeUtils::ToArrowField("nv"),
arrow::field("t", arrow::utf8())}));
auto table_schema = arrow::schema({fields_[0], struct_field});
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(auto helper,
TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options,
/*is_streaming_mode=*/false));
std::vector<const char*> jsons = {R"({"age": 35, "city": "Chicago"})",
R"({"age": 25, "other": "Hello"})", nullptr};
auto variant_batch = BuildArray(jsons);
arrow::StringBuilder sibling_builder;
for (size_t i = 0; i < jsons.size(); ++i) {
ASSERT_TRUE(sibling_builder.Append("t" + std::to_string(i)).ok());
}
std::shared_ptr<arrow::Array> sibling;
ASSERT_TRUE(sibling_builder.Finish(&sibling).ok());
auto struct_data = arrow::ArrayData::Make(
struct_field->type(), static_cast<int64_t>(jsons.size()), {nullptr},
{variant_batch->field(1)->data(), sibling->data()}, /*null_count=*/0);
auto batch_data = arrow::ArrayData::Make(
arrow::struct_({fields_[0], struct_field}), static_cast<int64_t>(jsons.size()), {nullptr},
{variant_batch->field(0)->data(), struct_data}, /*null_count=*/0);
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch,
MakeBatch(std::make_shared<arrow::StructArray>(batch_data)));
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
auto access_field =
BuildAccessField({{arrow::int64(), "$.age"}, {arrow::utf8(), "$.other"}}, "nv");
auto read_schema = arrow::schema(
{fields_[0],
struct_field->WithType(arrow::struct_({access_field, struct_field->type()->field(1)}))});
std::shared_ptr<arrow::StructArray> result_struct;
ReadWithSchema(helper.get(), splits, read_schema, &result_struct);
auto struct_type = checked_pointer_cast<arrow::StructType>(result_struct->type());
auto s_column = checked_pointer_cast<arrow::StructArray>(
result_struct->field(struct_type->GetFieldIndex("s")));
const auto& nv = static_cast<const arrow::StructArray&>(*s_column->field(0));
const auto& age = static_cast<const arrow::Int64Array&>(*nv.field(0));
const auto& other = static_cast<const arrow::StringArray&>(*nv.field(1));
const auto& kept_sibling = static_cast<const arrow::StringArray&>(*s_column->field(1));
ASSERT_EQ(age.Value(0), 35);
ASSERT_EQ(age.Value(1), 25);
ASSERT_TRUE(nv.IsNull(2));
ASSERT_TRUE(other.IsNull(0));
ASSERT_EQ(other.GetString(1), "Hello");
for (size_t i = 0; i < jsons.size(); ++i) {
EXPECT_EQ(kept_sibling.GetString(static_cast<int64_t>(i)), "t" + std::to_string(i));
}
}
TEST_F(VariantTableInteTest, TestArrayVariantAccessRead) {
// Table: [id, arr: ARRAY<VARIANT>]
auto list_field = arrow::field("arr", arrow::list(VariantTypeUtils::ToArrowField("element")));
auto table_schema = arrow::schema({fields_[0], list_field});
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(auto helper,
TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options,
/*is_streaming_mode=*/false));
// Row 0 holds two elements, row 1 is empty and row 2 holds one.
std::vector<const char*> flat = {R"({"x": 1, "y": 2})", R"({"x": 3})", R"({"x": 5})"};
std::vector<int32_t> offsets = {0, 2, 2, 3};
arrow::Int32Builder offset_builder;
ASSERT_TRUE(offset_builder.AppendValues(offsets).ok());
std::shared_ptr<arrow::Array> offset_array;
ASSERT_TRUE(offset_builder.Finish(&offset_array).ok());
auto elements = BuildArray(flat);
arrow::Int32Builder id_builder;
ASSERT_TRUE(id_builder.AppendValues({0, 1, 2}).ok());
std::shared_ptr<arrow::Array> ids;
ASSERT_TRUE(id_builder.Finish(&ids).ok());
auto list_data = arrow::ArrayData::Make(list_field->type(), /*length=*/3,
{nullptr, offset_array->data()->buffers[1]},
{elements->field(1)->data()}, /*null_count=*/0);
auto batch_data = arrow::ArrayData::Make(arrow::struct_({fields_[0], list_field}), /*length=*/3,
{nullptr}, {ids->data(), list_data}, /*null_count=*/0);
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch,
MakeBatch(std::make_shared<arrow::StructArray>(batch_data)));
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
auto access_field = BuildAccessField({{arrow::int64(), "$.x"}}, "element");
auto read_schema = arrow::schema({fields_[0], list_field->WithType(arrow::list(access_field))});
std::shared_ptr<arrow::StructArray> result_struct;
ReadWithSchema(helper.get(), splits, read_schema, &result_struct);
auto struct_type = checked_pointer_cast<arrow::StructType>(result_struct->type());
const auto& list = static_cast<const arrow::ListArray&>(
*result_struct->field(struct_type->GetFieldIndex("arr")));
ASSERT_EQ(list.length(), 3);
ASSERT_EQ(list.value_length(0), 2);
ASSERT_EQ(list.value_length(1), 0);
ASSERT_EQ(list.value_length(2), 1);
const auto& x = static_cast<const arrow::Int64Array&>(
*static_cast<const arrow::StructArray&>(*list.values()).field(0));
ASSERT_EQ(x.Value(list.value_offset(0)), 1);
ASSERT_EQ(x.Value(list.value_offset(0) + 1), 3);
ASSERT_EQ(x.Value(list.value_offset(2)), 5);
}
TEST_F(VariantTableInteTest, TestReadWithIOException) {
// Injects an IO error at every position of the scan+read path and verifies each failure
// surfaces as a clean error status.
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, schema_, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
std::vector<const char*> jsons = {R"({"age": 35, "city": "Hangzhou"})", nullptr, "[1, 2, 3]"};
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch, MakeBatch(BuildArray(jsons)));
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt));
bool run_complete = false;
auto io_hook = IOHook::GetInstance();
for (size_t i = 0; i < 500; i++) {
ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
io_hook->Reset(i, IOHook::Mode::RETURN_ERROR);
Result<std::vector<std::shared_ptr<Split>>> splits =
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt,
/*is_streaming=*/false);
CHECK_HOOK_STATUS(splits.status(), i);
Result<std::shared_ptr<arrow::ChunkedArray>> read_result =
helper->ReadResult(splits.value());
CHECK_HOOK_STATUS(read_result.status(), i);
run_complete = true;
// All IO succeeded before the injected position was reached: check the data.
io_hook->Clear();
ReadAndCheck(helper.get(), splits.value(), {0, 1, 2}, jsons);
break;
}
ASSERT_TRUE(run_complete);
}
TEST_F(VariantTableInteTest, TestWriteWithIOException) {
// Injects an IO error at every position of the create+write+commit path (on a fresh table
// directory per attempt) and verifies each failure surfaces as a clean error status.
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "parquet"},
{Options::BUCKET, "-1"},
};
std::vector<const char*> jsons = {R"({"age": 35, "city": "Hangzhou"})", nullptr};
bool run_complete = false;
auto io_hook = IOHook::GetInstance();
for (size_t i = 0; i < 500; i++) {
std::string table_dir = test_dir_ + fmt::format("/io_exception_{}", i);
ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
io_hook->Reset(i, IOHook::Mode::RETURN_ERROR);
Result<std::unique_ptr<TestHelper>> helper =
TestHelper::Create(table_dir, schema_, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false);
CHECK_HOOK_STATUS(helper.status(), i);
Result<std::unique_ptr<RecordBatch>> batch = MakeBatch(BuildArray(jsons));
CHECK_HOOK_STATUS(batch.status(), i);
Result<std::vector<std::shared_ptr<CommitMessage>>> commit_msgs =
helper.value()->WriteAndCommit(std::move(batch).value(), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt);
CHECK_HOOK_STATUS(commit_msgs.status(), i);
run_complete = true;
// All IO succeeded before the injected position was reached: check the data.
io_hook->Clear();
ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Split>> splits,
helper.value()->NewScan(StartupMode::LatestFull(),
/*snapshot_id=*/std::nullopt,
/*is_streaming=*/false));
ReadAndCheck(helper.value().get(), splits, {0, 1}, jsons);
break;
}
ASSERT_TRUE(run_complete);
}
TEST_F(VariantTableInteTest, TestOrcFormatRejected) {
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "avro"},
{Options::FILE_FORMAT, "orc"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, schema_, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch, MakeBatch(BuildArray({"{\"a\": 1}"})));
auto result = helper->WriteAndCommit(std::move(batch), /*commit_identifier=*/0,
/*expected_commit_messages=*/std::nullopt);
ASSERT_FALSE(result.ok());
}
} // namespace paimon::test