blob: b77d6bda9e7e3ce95041fb05760cc87fbdabaab9 [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 <cstdint>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "arrow/api.h"
#include "arrow/c/abi.h"
#include "arrow/c/bridge.h"
#include "arrow/ipc/json_simple.h"
#include "gtest/gtest.h"
#include "paimon/commit_context.h"
#include "paimon/common/types/data_field.h"
#include "paimon/common/utils/path_util.h"
#include "paimon/common/utils/string_utils.h"
#include "paimon/core/schema/schema_manager.h"
#include "paimon/core/schema/table_schema.h"
#include "paimon/defs.h"
#include "paimon/file_store_commit.h"
#include "paimon/file_store_write.h"
#include "paimon/fs/file_system_factory.h"
#include "paimon/predicate/literal.h"
#include "paimon/predicate/predicate_builder.h"
#include "paimon/read_context.h"
#include "paimon/reader/batch_reader.h"
#include "paimon/record_batch.h"
#include "paimon/result.h"
#include "paimon/scan_context.h"
#include "paimon/status.h"
#include "paimon/table/source/startup_mode.h"
#include "paimon/table/source/table_read.h"
#include "paimon/table/source/table_scan.h"
#include "paimon/testing/utils/dict_array_converter.h"
#include "paimon/testing/utils/read_result_collector.h"
#include "paimon/testing/utils/test_helper.h"
#include "paimon/testing/utils/testharness.h"
#include "paimon/write_context.h"
namespace paimon {
class DataSplit;
class RecordBatch;
} // namespace paimon
namespace paimon::test {
class NestedColumnPruningInteTest : public ::testing::Test,
public ::testing::WithParamInterface<std::string> {
void SetUp() override {
file_format_ = GetParam();
dir_ = UniqueTestDirectory::Create("local");
test_dir_ = dir_->Str();
table_path_ = PathUtil::JoinPath(test_dir_, "foo.db/bar");
}
void TearDown() override {
dir_.reset();
}
void AssertChunkedArrayEquals(const std::shared_ptr<arrow::ChunkedArray>& expected,
const std::shared_ptr<arrow::ChunkedArray>& actual) const {
arrow::EqualOptions equal_options = arrow::EqualOptions::Defaults();
bool is_equal = expected->Equals(actual, equal_options.diff_sink(&std::cout));
if (!is_equal) {
std::cout << "[expected_type] " << expected->type()->ToString() << std::endl;
std::cout << "[actual_type] " << actual->type()->ToString() << std::endl;
std::cout << "[expected] " << expected->ToString() << std::endl;
std::cout << "[actual] " << actual->ToString() << std::endl;
}
ASSERT_TRUE(is_equal);
}
void ScanReadAndCheck(const std::string& table_path,
const std::shared_ptr<arrow::Schema>& expected_schema,
const std::string& expected_json,
const std::shared_ptr<Predicate>& predicate = nullptr) const {
ScanContextBuilder scan_context_builder(table_path);
scan_context_builder.AddOption(Options::SCAN_MODE, StartupMode::LatestFull().ToString());
if (predicate) {
scan_context_builder.SetPredicate(predicate);
}
ASSERT_OK_AND_ASSIGN(auto scan_context, scan_context_builder.Finish());
ASSERT_OK_AND_ASSIGN(auto table_scan, TableScan::Create(std::move(scan_context)));
ASSERT_OK_AND_ASSIGN(auto result_plan, table_scan->CreatePlan());
ASSERT_FALSE(result_plan->Splits().empty());
auto c_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*expected_schema, c_schema.get()).ok());
ReadContextBuilder read_context_builder(table_path);
read_context_builder.SetReadSchema(std::move(c_schema));
if (predicate) {
read_context_builder.SetPredicate(predicate);
}
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_OK_AND_ASSIGN(auto table_read, TableRead::Create(std::move(read_context)));
ASSERT_OK_AND_ASSIGN(auto batch_reader, table_read->CreateReader(result_plan->Splits()));
ASSERT_OK_AND_ASSIGN(auto actual, ReadResultCollector::CollectResult(batch_reader.get()));
arrow::FieldVector expected_fields = expected_schema->fields();
expected_fields.insert(expected_fields.begin(), arrow::field("_VALUE_KIND", arrow::int8()));
auto expected_type = arrow::struct_(expected_fields);
auto expected = std::make_shared<arrow::ChunkedArray>(
arrow::ipc::internal::json::ArrayFromJSON(expected_type, expected_json).ValueOrDie());
AssertChunkedArrayEquals(expected, actual);
}
protected:
std::string file_format_;
std::string test_dir_;
std::string table_path_;
std::unique_ptr<UniqueTestDirectory> dir_;
};
// Test: Table has struct field with 3 sub-fields, read only 1 sub-field via SetReadSchema.
TEST_P(NestedColumnPruningInteTest, PruneStructSubFields) {
// Table schema: f0 (int32), f1 (struct{a: int32, b: utf8, c: float64})
auto struct_type = arrow::struct_({
arrow::field("a", arrow::int32()),
arrow::field("b", arrow::utf8()),
arrow::field("c", arrow::float64()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
// Write data
std::string data = R"([
[1, [10, "hello", 1.1]],
[2, [20, "world", 2.2]],
[3, [30, "foo", 3.3]],
[4, [40, "bar", 4.4]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Build projected schema: only read f0 (full) and f1.a (sub-field of struct)
auto pruned_struct_type = arrow::struct_({
arrow::field("a", arrow::int32()),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", pruned_struct_type),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [10]],
[0, 2, [20]],
[0, 3, [30]],
[0, 4, [40]]
])");
}
// Test: Projecting a STRUCT column as empty struct should return this column
// as all null values.
TEST_P(NestedColumnPruningInteTest, ProjectStructColumnAsEmptyStructReturnsNullColumn) {
auto struct_type = arrow::struct_({
arrow::field("a", arrow::int32()),
arrow::field("b", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [11, "x"]],
[2, [22, "y"]],
[3, [33, "z"]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Project f1 as empty struct.
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", arrow::struct_({})),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, null],
[0, 2, null],
[0, 3, null]
])");
}
// Test: Two top-level struct columns have the same nested field name; projection should
// distinguish by parent column.
TEST_P(NestedColumnPruningInteTest, PruneSameNestedFieldNameFromDifferentStructColumns) {
// Table schema: f0 (int32), s0 (struct{f1: int32, a: utf8}), s1 (struct{f1: int32, b: utf8})
auto s0_type = arrow::struct_({
arrow::field("f1", arrow::int32()),
arrow::field("a", arrow::utf8()),
});
auto s1_type = arrow::struct_({
arrow::field("f1", arrow::int32()),
arrow::field("b", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("s0", s0_type),
arrow::field("s1", s1_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [11, "left-1"], [101, "right-1"]],
[2, [22, "left-2"], [202, "right-2"]],
[3, [33, "left-3"], [303, "right-3"]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Project only s0.f1 and s1.f1; both nested field names are identical.
auto projected_s0 = arrow::struct_({arrow::field("f1", arrow::int32())});
auto projected_s1 = arrow::struct_({arrow::field("f1", arrow::int32())});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("s0", projected_s0),
arrow::field("s1", projected_s1),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [11], [101]],
[0, 2, [22], [202]],
[0, 3, [33], [303]]
])");
}
// Test: Querying only non-existent struct sub-fields should fail fast.
TEST_P(NestedColumnPruningInteTest, QueryStructSubFieldsAllNonExistent) {
// Table schema: f0 (int32), f1 (struct{f1: int32, f2: utf8, f3: float64})
auto struct_type = arrow::struct_({
arrow::field("f1", arrow::int32()),
arrow::field("f2", arrow::utf8()),
arrow::field("f3", arrow::float64()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [11, "a", 1.1]],
[2, [22, "b", 2.2]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(auto data_splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
ASSERT_FALSE(data_splits.empty());
// Query struct sub-fields that do not exist in table schema.
auto projected_struct_type = arrow::struct_({
arrow::field("f4", arrow::int32()),
arrow::field("f5", arrow::int64()),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", projected_struct_type),
};
auto projected_schema = arrow::schema(projected_fields);
auto c_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*projected_schema, c_schema.get()).ok());
ReadContextBuilder read_context_builder(table_path_);
read_context_builder.SetOptions(options).SetReadSchema(std::move(c_schema));
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_NOK_WITH_MSG(TableRead::Create(std::move(read_context)),
"does not support schema evolution inside struct");
}
// Test: Querying a mix of existent and non-existent struct sub-fields should fail fast.
TEST_P(NestedColumnPruningInteTest, QueryStructSubFieldsWithNonExistentField) {
// Table schema: f0 (int32), f1 (struct{f1: int32, f2: utf8, f3: float64})
auto struct_type = arrow::struct_({
arrow::field("f1", arrow::int32()),
arrow::field("f2", arrow::utf8()),
arrow::field("f3", arrow::float64()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [11, "a", 1.1]],
[2, [22, "b", 2.2]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(auto data_splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
ASSERT_FALSE(data_splits.empty());
// Query struct sub-fields f2,f3,f4 where f4 does not exist in table schema.
auto projected_struct_type = arrow::struct_({
arrow::field("f2", arrow::utf8()),
arrow::field("f3", arrow::float64()),
arrow::field("f4", arrow::int32()),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", projected_struct_type),
};
auto projected_schema = arrow::schema(projected_fields);
auto c_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*projected_schema, c_schema.get()).ok());
ReadContextBuilder read_context_builder(table_path_);
read_context_builder.SetOptions(options).SetReadSchema(std::move(c_schema));
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_NOK_WITH_MSG(TableRead::Create(std::move(read_context)),
"does not support schema evolution inside struct");
}
// Test: Nested schema divergence (simulated evolution mismatch) must fail fast
// instead of silently skipping nested fields.
TEST_P(NestedColumnPruningInteTest, QueryStructSubFieldsWithTypeMismatchShouldFail) {
// File schema (old): f1.a is INT32.
auto struct_type = arrow::struct_({
arrow::field("a", arrow::int32()),
arrow::field("b", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [11, "x"]],
[2, [22, "y"]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Persist schema-2 on disk so table latest schema diverges from old data files.
std::string file_system_identifier = "local";
auto fs_iter = options.find(Options::FILE_SYSTEM);
if (fs_iter != options.end()) {
file_system_identifier = StringUtils::ToLowerCase(fs_iter->second);
}
ASSERT_OK_AND_ASSIGN(auto file_system,
FileSystemFactory::Get(file_system_identifier, table_path_, options));
std::shared_ptr<FileSystem> schema_fs(std::move(file_system));
SchemaManager schema_manager(schema_fs, table_path_);
ASSERT_OK_AND_ASSIGN(auto latest_schema_opt, schema_manager.Latest());
ASSERT_TRUE(latest_schema_opt.has_value());
auto latest_schema = latest_schema_opt.value();
auto schema_v2_arrow = arrow::schema({
arrow::field("f0", arrow::int32()),
arrow::field("f1", arrow::struct_({
arrow::field("a", arrow::utf8()),
arrow::field("b", arrow::utf8()),
})),
});
ASSERT_OK_AND_ASSIGN(
auto schema_v2,
TableSchema::Create(/*schema_id=*/latest_schema->Id() + 1, schema_v2_arrow,
latest_schema->PartitionKeys(), latest_schema->PrimaryKeys(),
latest_schema->Options()));
ASSERT_OK_AND_ASSIGN(auto schema_v2_json, schema_v2->ToJsonString());
auto schema_v2_path = PathUtil::JoinPath(schema_manager.SchemaDirectory(),
"schema-" + std::to_string(schema_v2->Id()));
ASSERT_OK(schema_fs->AtomicStore(schema_v2_path, schema_v2_json));
ASSERT_OK_AND_ASSIGN(auto data_splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
ASSERT_FALSE(data_splits.empty());
ReadContextBuilder read_context_builder(table_path_);
read_context_builder.SetOptions(options);
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_OK_AND_ASSIGN(auto table_read, TableRead::Create(std::move(read_context)));
ASSERT_NOK_WITH_MSG(table_read->CreateReader(data_splits),
"PruneDataType nested field type mismatch for 'a': read string vs "
"data int32");
}
// Test: With SetReadSchema using the new schema, context build should pass,
// and mismatch against old file type should be rejected in reader creation.
TEST_P(NestedColumnPruningInteTest,
QueryStructSubFieldsWithTypeMismatchAndSetReadSchemaFailAtContext) {
// File schema (old): f1.a is INT32.
auto struct_type = arrow::struct_({
arrow::field("a", arrow::int32()),
arrow::field("b", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [11, "x"]],
[2, [22, "y"]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Persist schema-2 on disk so table latest schema diverges from old data files.
std::string file_system_identifier = "local";
auto fs_iter = options.find(Options::FILE_SYSTEM);
if (fs_iter != options.end()) {
file_system_identifier = StringUtils::ToLowerCase(fs_iter->second);
}
ASSERT_OK_AND_ASSIGN(auto file_system,
FileSystemFactory::Get(file_system_identifier, table_path_, options));
std::shared_ptr<FileSystem> schema_fs(std::move(file_system));
SchemaManager schema_manager(schema_fs, table_path_);
ASSERT_OK_AND_ASSIGN(auto latest_schema_opt, schema_manager.Latest());
ASSERT_TRUE(latest_schema_opt.has_value());
auto latest_schema = latest_schema_opt.value();
auto schema_v2_arrow = arrow::schema({
arrow::field("f0", arrow::int32()),
arrow::field("f1", arrow::struct_({
arrow::field("a", arrow::utf8()),
arrow::field("b", arrow::utf8()),
})),
});
ASSERT_OK_AND_ASSIGN(
auto schema_v2,
TableSchema::Create(/*schema_id=*/latest_schema->Id() + 1, schema_v2_arrow,
latest_schema->PartitionKeys(), latest_schema->PrimaryKeys(),
latest_schema->Options()));
ASSERT_OK_AND_ASSIGN(auto schema_v2_json, schema_v2->ToJsonString());
auto schema_v2_path = PathUtil::JoinPath(schema_manager.SchemaDirectory(),
"schema-" + std::to_string(schema_v2->Id()));
ASSERT_OK(schema_fs->AtomicStore(schema_v2_path, schema_v2_json));
// User-provided read schema uses latest nested type (a:string).
auto projected_schema = arrow::schema({
arrow::field("f0", arrow::int32()),
arrow::field("f1", arrow::struct_({
arrow::field("a", arrow::utf8()),
arrow::field("b", arrow::utf8()),
})),
});
auto c_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*projected_schema, c_schema.get()).ok());
ReadContextBuilder read_context_builder(table_path_);
read_context_builder.SetOptions(options).SetReadSchema(std::move(c_schema));
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_OK_AND_ASSIGN(auto table_read, TableRead::Create(std::move(read_context)));
ASSERT_OK_AND_ASSIGN(auto data_splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
ASSERT_FALSE(data_splits.empty());
ASSERT_NOK_WITH_MSG(table_read->CreateReader(data_splits),
"PruneDataType nested field type mismatch for 'a': read string vs "
"data int32");
}
// Test: Read only top-level fields, skip struct entirely.
TEST_P(NestedColumnPruningInteTest, PruneEntireStructField) {
auto struct_type = arrow::struct_({
arrow::field("x", arrow::int64()),
arrow::field("y", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
arrow::field("f2", arrow::float64()),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[100, [1, "aa"], 0.1],
[200, [2, "bb"], 0.2],
[300, [3, "cc"], 0.3]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Only read f0 and f2, skip f1 entirely.
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f2", arrow::float64()),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 100, 0.1],
[0, 200, 0.2],
[0, 300, 0.3]
])");
}
// Test: Nested struct — prune sub-fields of a struct inside another struct.
TEST_P(NestedColumnPruningInteTest, PruneDeepNestedStruct) {
// Table schema: f0 (int32), f1 (struct{a: int32, inner: struct{x: int64, y: utf8}})
auto inner_struct = arrow::struct_({
arrow::field("x", arrow::int64()),
arrow::field("y", arrow::utf8()),
});
auto outer_struct = arrow::struct_({
arrow::field("a", arrow::int32()),
arrow::field("inner", inner_struct),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", outer_struct),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [10, [100, "aaa"]]],
[2, [20, [200, "bbb"]]],
[3, [30, [300, "ccc"]]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Projected: f0, f1{inner{x}} — skip f1.a and f1.inner.y
auto pruned_inner = arrow::struct_({
arrow::field("x", arrow::int64()),
});
auto pruned_outer = arrow::struct_({
arrow::field("inner", pruned_inner),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", pruned_outer),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [[100]]],
[0, 2, [[200]]],
[0, 3, [[300]]]
])");
}
// Test: Nested projected schema with special fields under row tracking.
TEST_P(NestedColumnPruningInteTest, PruneNestedStructWithSpecialFields) {
// Table schema: f0 (int32), f1 (struct{a: int32, inner: struct{x: int64, y: utf8}})
auto inner_struct = arrow::struct_({
arrow::field("x", arrow::int64()),
arrow::field("y", arrow::utf8()),
});
auto outer_struct = arrow::struct_({
arrow::field("a", arrow::int32()),
arrow::field("inner", inner_struct),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", outer_struct),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{Options::BUCKET, "-1"},
{Options::ROW_TRACKING_ENABLED, "true"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
std::string data = R"([
[1, [10, [100, "aaa"]]],
[2, [20, [200, "bbb"]]],
[3, [30, [300, "ccc"]]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(auto data_splits,
helper->NewScan(StartupMode::LatestFull(), /*snapshot_id=*/std::nullopt));
// Projected: f0, f1{inner{x}}, _SEQUENCE_NUMBER, _ROW_ID
auto pruned_inner = arrow::struct_({
arrow::field("x", arrow::int64()),
});
auto pruned_outer = arrow::struct_({
arrow::field("inner", pruned_inner),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", pruned_outer),
arrow::field("_SEQUENCE_NUMBER", arrow::int64()),
arrow::field("_ROW_ID", arrow::int64()),
};
auto projected_schema = arrow::schema(projected_fields);
auto c_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*projected_schema, c_schema.get()).ok());
ReadContextBuilder read_context_builder(table_path_);
read_context_builder.SetOptions(options).SetReadSchema(std::move(c_schema));
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_OK_AND_ASSIGN(auto table_read, TableRead::Create(std::move(read_context)));
ASSERT_OK_AND_ASSIGN(auto batch_reader, table_read->CreateReader(data_splits));
ASSERT_OK_AND_ASSIGN(auto read_result, ReadResultCollector::CollectResult(batch_reader.get()));
ASSERT_EQ(read_result->num_chunks(), 1);
auto result_array = std::dynamic_pointer_cast<arrow::StructArray>(read_result->chunk(0));
ASSERT_TRUE(result_array);
ASSERT_TRUE(result_array->GetFieldByName("_SEQUENCE_NUMBER"));
ASSERT_TRUE(result_array->GetFieldByName("_ROW_ID"));
auto nested_col = result_array->GetFieldByName("f1");
ASSERT_TRUE(nested_col);
auto expected_nested_type = arrow::struct_({
arrow::field("inner", arrow::struct_({arrow::field("x", arrow::int64())})),
});
ASSERT_TRUE(nested_col->type()->Equals(expected_nested_type));
auto expected_nested_array =
arrow::ipc::internal::json::ArrayFromJSON(expected_nested_type, R"([
[[100]],
[[200]],
[[300]]
])")
.ValueOrDie();
ASSERT_TRUE(nested_col->Equals(expected_nested_array));
}
// Test: Table has MAP<STRING, INT32> field, read with selected keys filter.
TEST_P(NestedColumnPruningInteTest, MapSelectedKeys) {
// Table schema: f0 (int32), f1 (map<string, int32>)
auto map_type = arrow::map(arrow::utf8(), arrow::int32());
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
// Write data: each row has a map with keys "a", "b", "c"
std::string data = R"([
[1, [["a", 10], ["b", 20], ["c", 30]]],
[2, [["a", 100], ["c", 300]]],
[3, [["b", 200], ["c", 400], ["d", 500]]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Build projected schema: read f0 and f1 with selected keys "a,c"
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {"a,c"});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type)->WithMetadata(selected_keys_metadata),
};
auto projected_schema = arrow::schema(projected_fields);
// Expected: only keys "a" and "c" remain in each map
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [["a", 10], ["c", 30]]],
[0, 2, [["a", 100], ["c", 300]]],
[0, 3, [["c", 400]]]
])");
}
// Test: Selected-keys metadata on MAP nested inside STRUCT should be applied.
TEST_P(NestedColumnPruningInteTest, NestedMapSelectedKeysInStruct) {
auto map_type = arrow::map(arrow::utf8(), arrow::int32());
auto struct_type = arrow::struct_({
arrow::field("m", map_type),
arrow::field("tag", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [[ ["a", 10], ["b", 20], ["c", 30] ], "r1"]],
[2, [[ ["a", 100], ["c", 300] ], "r2"]],
[3, [[ ["b", 200], ["c", 400], ["d", 500] ], "r3"]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {"a,c"});
auto projected_struct_type = arrow::struct_({
arrow::field("m", map_type)->WithMetadata(selected_keys_metadata),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", projected_struct_type),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [[ ["a", 10], ["c", 30] ]]],
[0, 2, [[ ["a", 100], ["c", 300] ]]],
[0, 3, [[ ["c", 400] ]]]
])");
}
// Test: Partial STRUCT sub-field recall where one recalled child is MAP with selected keys.
TEST_P(NestedColumnPruningInteTest, PruneStructSubFieldsWithNestedMapSelectedKeys) {
auto map_type = arrow::map(arrow::utf8(), arrow::int32());
auto struct_type = arrow::struct_({
arrow::field("m", map_type),
arrow::field("keep", arrow::int64()),
arrow::field("drop", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [[ ["a", 10], ["b", 20], ["c", 30] ], 1001, "x1"]],
[2, [[ ["a", 100], ["c", 300] ], 1002, "x2"]],
[3, [[ ["b", 200], ["c", 400], ["d", 500] ], 1003, "x3"]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {"a,c"});
auto projected_struct_type = arrow::struct_({
arrow::field("m", map_type)->WithMetadata(selected_keys_metadata),
arrow::field("keep", arrow::int64()),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", projected_struct_type),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [[ ["a", 10], ["c", 30] ], 1001]],
[0, 2, [[ ["a", 100], ["c", 300] ], 1002]],
[0, 3, [[ ["c", 400] ], 1003]]
])");
}
// Test: Null semantics should be preserved when pruning STRUCT sub-fields and
// applying selected-keys filtering on nested MAP.
TEST_P(NestedColumnPruningInteTest, PruneStructSubFieldsWithNestedMapSelectedKeysAndNulls) {
auto map_type = arrow::map(arrow::utf8(), arrow::int32());
auto struct_type = arrow::struct_({
arrow::field("m", map_type),
arrow::field("keep", arrow::int64()),
arrow::field("drop", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [[ ["a", 10], ["b", 20], ["c", 30] ], 1001, "x1"]],
[2, null],
[3, [null, 1003, "x3"]],
[4, [[ ["b", 200], ["c", 400], ["d", 500] ], null, "x4"]],
[5, [[ ["a", 500], ["c", null] ], 1005, "x5"]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {"a,c"});
auto projected_struct_type = arrow::struct_({
arrow::field("m", map_type)->WithMetadata(selected_keys_metadata),
arrow::field("keep", arrow::int64()),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", projected_struct_type),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [[ ["a", 10], ["c", 30] ], 1001]],
[0, 2, null],
[0, 3, [null, 1003]],
[0, 4, [[ ["c", 400] ], null]],
[0, 5, [[ ["a", 500], ["c", null] ], 1005]]
])");
}
// Test: MAP_SELECTED_KEYS metadata value is empty string, select empty-string map key.
TEST_P(NestedColumnPruningInteTest, MapSelectedKeysEmptyStringKey) {
// Table schema: f0 (int32), f1 (map<string, int32>)
auto map_type = arrow::map(arrow::utf8(), arrow::int32());
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
// Write data: each row has a map that may contain empty-string key.
std::string data = R"([
[1, [["", 9], ["a", 10], ["c", 30]]],
[2, [["a", 100], ["", 99], ["c", 300]]],
[3, [["b", 200], ["c", 400], ["d", 500]]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Build projected schema: read f0 and f1 with selected keys metadata set to empty string.
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {""});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type)->WithMetadata(selected_keys_metadata),
};
auto projected_schema = arrow::schema(projected_fields);
// Expected: only empty-string key remains.
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [["", 9]]],
[0, 2, [["", 99]]],
[0, 3, []]
])");
}
// Test: MAP_SELECTED_KEYS output map entry order should follow selected key order.
TEST_P(NestedColumnPruningInteTest, MapSelectedKeysPreserveOrder) {
auto map_type = arrow::map(arrow::utf8(), arrow::int32());
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{Options::BUCKET, "-1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
// Write data with map key order different from selected key order.
std::string data = R"([
[1, [["a", 10], ["b", 20], ["c", 30]]],
[2, [["a", 100], ["c", 300]]],
[3, [["c", 400], ["a", 500], ["d", 600]]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Query key order is c,a and output should follow this order.
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {"c,a"});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type)->WithMetadata(selected_keys_metadata),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [["c", 30], ["a", 10]]],
[0, 2, [["c", 300], ["a", 100]]],
[0, 3, [["c", 400], ["a", 500]]]
])");
}
TEST_P(NestedColumnPruningInteTest, NestedStructMapSelectedKeysWithPredicate) {
if (file_format_ == "avro") {
return;
}
auto map_type = arrow::map(arrow::utf8(), arrow::int32());
auto info_type = arrow::struct_({
arrow::field("score", arrow::int64()),
arrow::field("label", arrow::utf8()),
arrow::field("drop", arrow::utf8()),
});
auto payload_type = arrow::struct_({
arrow::field("attrs", map_type),
arrow::field("info", info_type),
arrow::field("note", arrow::utf8()),
});
arrow::FieldVector table_fields = {
arrow::field("id", arrow::int32()),
arrow::field("payload", payload_type),
arrow::field("category", arrow::utf8()),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1048576"},
{Options::BUCKET, "-1"},
{Options::WRITE_BATCH_SIZE, "1"},
{"parquet.page.size", "1"},
{"parquet.enable-dictionary", "false"},
{"parquet.write.enable-page-index", "true"},
{"parquet.write.max-row-group-length", "1"},
{"parquet.read.enable-page-index-filter", "true"},
{"orc.stripe.size", "1"},
{"orc.row.index.stride", "1"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
WriteContextBuilder write_context_builder(table_path_, "commit_user_1");
ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context,
write_context_builder.SetOptions(options).Finish());
ASSERT_OK_AND_ASSIGN(auto file_store_write, FileStoreWrite::Create(std::move(write_context)));
auto write_one_row = [&](const std::string& data) -> Status {
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<RecordBatch> batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
return file_store_write->Write(std::move(batch));
};
ASSERT_OK(write_one_row(
R"([[1, [[["a", 10], ["b", 20], ["c", 30]], [1001, "low", "x"], "n1"], "hot"]])"));
ASSERT_OK(write_one_row(
R"([[12, [[["a", 100], ["c", 300], ["d", 400]], [1002, "mid", "y"], "n2"], "warm"]])"));
ASSERT_OK(write_one_row(
R"([[21, [[["b", 200], ["c", 500], ["a", 600]], [1003, "high", "z"], "n3"], "cold"]])"));
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
file_store_write->PrepareCommit(/*wait_compaction=*/false,
/*commit_identifier=*/0));
ASSERT_OK(file_store_write->Close());
CommitContextBuilder commit_context_builder(table_path_, "commit_user_1");
ASSERT_OK_AND_ASSIGN(std::unique_ptr<CommitContext> commit_context,
commit_context_builder.SetOptions(options).Finish());
ASSERT_OK_AND_ASSIGN(auto commit, FileStoreCommit::Create(std::move(commit_context)));
ASSERT_OK(commit->Commit(commit_msgs, /*commit_identifier=*/0));
// Read selected MAP keys together with nested STRUCT sub-fields.
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {"c,a"});
auto selected_payload_type = arrow::struct_({
arrow::field("attrs", map_type)->WithMetadata(selected_keys_metadata),
arrow::field("info", arrow::struct_({arrow::field("score", arrow::int64())})),
});
auto selected_schema = arrow::schema({
arrow::field("id", arrow::int32()),
arrow::field("payload", selected_payload_type),
});
ScanReadAndCheck(table_path_, selected_schema, R"([
[0, 1, [[["c", 30], ["a", 10]], [1001]]],
[0, 12, [[["c", 300], ["a", 100]], [1002]]],
[0, 21, [[["c", 500], ["a", 600]], [1003]]]
])");
// Read only part of top-level columns and part of nested STRUCT fields.
auto partial_payload_type = arrow::struct_({
arrow::field("info", arrow::struct_({arrow::field("label", arrow::utf8())})),
});
auto partial_schema = arrow::schema({
arrow::field("payload", partial_payload_type),
arrow::field("category", arrow::utf8()),
});
ScanReadAndCheck(table_path_, partial_schema, R"([
[0, [["low"]], "hot"],
[0, [["mid"]], "warm"],
[0, [["high"]], "cold"]
])");
// Read selected nested fields with predicate pushdown.
auto predicate = PredicateBuilder::GreaterThan(/*field_index=*/0, /*field_name=*/"id",
FieldType::INT, Literal(10));
ScanReadAndCheck(table_path_, selected_schema, R"([
[0, 12, [[["c", 300], ["a", 100]], [1002]]],
[0, 21, [[["c", 500], ["a", 600]], [1003]]]
])",
predicate);
}
// Test: ORC dictionary-encoded map key/value should work with MAP_SELECTED_KEYS.
TEST_P(NestedColumnPruningInteTest, MapSelectedKeysWithOrcDictionaryEncodedMap) {
if (file_format_ != "orc") {
GTEST_SKIP() << "ORC-only dictionary encoding case";
}
auto map_type = arrow::map(arrow::utf8(), arrow::utf8());
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{Options::BUCKET, "-1"},
{"orc.read.enable-lazy-decoding", "true"},
{"orc.dictionary-key-size-threshold", "1.0"},
};
ASSERT_OK_AND_ASSIGN(
auto helper, TestHelper::Create(test_dir_, table_schema, /*partition_keys=*/{},
/*primary_keys=*/{}, options, /*is_streaming_mode=*/false));
// Low-cardinality map keys/values increase dictionary-encoding probability for ORC.
std::string data = R"([
[1, [["a", "v1"], ["b", "v2"], ["c", "v3"]]],
[2, [["a", "v1"], ["c", "v3"], ["d", "v4"]]],
[3, [["a", "v1"], ["b", "v2"], ["e", "v5"]]],
[4, [["a", "v1"], ["c", "v3"], ["e", "v5"]]],
[5, [["a", "v1"], ["b", "v2"], ["c", "v3"]]],
[6, [["a", "v1"], ["c", "v3"], ["d", "v4"]]],
[7, [["a", "v1"], ["b", "v2"], ["e", "v5"]]],
[8, [["a", "v1"], ["c", "v3"], ["e", "v5"]]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(auto data_splits, helper->NewScan(StartupMode::LatestFull(),
/*snapshot_id=*/std::nullopt));
ASSERT_FALSE(data_splits.empty());
auto selected_keys_metadata =
arrow::KeyValueMetadata::Make({DataField::MAP_SELECTED_KEYS}, {"a,c"});
auto projected_schema = arrow::schema({
arrow::field("f0", arrow::int32()),
arrow::field("f1", map_type)->WithMetadata(selected_keys_metadata),
});
auto c_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*projected_schema, c_schema.get()).ok());
ReadContextBuilder read_context_builder(table_path_);
read_context_builder.SetOptions(options).SetReadSchema(std::move(c_schema));
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_OK_AND_ASSIGN(auto table_read, TableRead::Create(std::move(read_context)));
ASSERT_OK_AND_ASSIGN(auto batch_reader, table_read->CreateReader(data_splits));
ASSERT_OK_AND_ASSIGN(auto read_result, ReadResultCollector::CollectResult(batch_reader.get()));
ASSERT_OK_AND_ASSIGN(
auto decoded_result,
DictArrayConverter::ConvertDictArray(read_result->chunk(0), arrow::default_memory_pool()));
auto actual_chunked = std::make_shared<arrow::ChunkedArray>(decoded_result);
auto expected_type = arrow::struct_({
arrow::field("_VALUE_KIND", arrow::int8()),
arrow::field("f0", arrow::int32()),
arrow::field("f1", arrow::map(arrow::utf8(), arrow::utf8())),
});
auto expected_array = arrow::ipc::internal::json::ArrayFromJSON(expected_type, R"([
[0, 1, [["a", "v1"], ["c", "v3"]]],
[0, 2, [["a", "v1"], ["c", "v3"]]],
[0, 3, [["a", "v1"]]],
[0, 4, [["a", "v1"], ["c", "v3"]]],
[0, 5, [["a", "v1"], ["c", "v3"]]],
[0, 6, [["a", "v1"], ["c", "v3"]]],
[0, 7, [["a", "v1"]]],
[0, 8, [["a", "v1"], ["c", "v3"]]]
])")
.ValueOrDie();
auto expected_chunked = std::make_shared<arrow::ChunkedArray>(expected_array);
AssertChunkedArrayEquals(expected_chunked, actual_chunked);
}
// Test: Deeper nested struct — prune sub-fields of a struct inside a struct inside another
// struct.
TEST_P(NestedColumnPruningInteTest, PruneDeeperNestedStruct) {
// Table schema: f0 (int32), f1 (struct{a: int32, inner1: struct{x: int64, inner2: struct{p:
// utf8, q: float64}}})
auto inner2_struct = arrow::struct_({
arrow::field("p", arrow::utf8()),
arrow::field("q", arrow::float64()),
});
auto inner1_struct = arrow::struct_({
arrow::field("x", arrow::int64()),
arrow::field("inner2", inner2_struct),
});
auto outer_struct = arrow::struct_({
arrow::field("a", arrow::int32()),
arrow::field("inner1", inner1_struct),
});
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", outer_struct),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [10, [100, ["ppp", 1.1]]]],
[2, [20, [200, ["qqq", 2.2]]]],
[3, [30, [300, ["rrr", 3.3]]]]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
// Projected: f0, f1{inner1{inner2{p}}}
auto pruned_inner2 = arrow::struct_({
arrow::field("p", arrow::utf8()),
});
auto pruned_inner1 = arrow::struct_({
arrow::field("inner2", pruned_inner2),
});
auto pruned_outer = arrow::struct_({
arrow::field("inner1", pruned_inner1),
});
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", pruned_outer),
};
auto projected_schema = arrow::schema(projected_fields);
ScanReadAndCheck(table_path_, projected_schema, R"([
[0, 1, [[[ "ppp" ]]]],
[0, 2, [[[ "qqq" ]]]],
[0, 3, [[[ "rrr" ]]]]
])");
}
// Test: Nested pruning for LIST<STRUCT<...>> in integration path.
TEST_P(NestedColumnPruningInteTest, PruneListStructSubFields) {
auto list_elem_struct = arrow::struct_({
arrow::field("x", arrow::int64()),
arrow::field("y", arrow::utf8()),
arrow::field("z", arrow::float64()),
});
auto list_struct_type = arrow::list(arrow::field("item", list_elem_struct));
arrow::FieldVector table_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", list_struct_type),
};
auto table_schema = arrow::schema(table_fields);
std::map<std::string, std::string> options = {
{Options::MANIFEST_FORMAT, "AVRO"},
{Options::FILE_FORMAT, StringUtils::ToUpperCase(file_format_)},
{Options::TARGET_FILE_SIZE, "1024"},
{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::string data = R"([
[1, [[100, "a", 1.1], [200, "b", 2.2]]],
[2, [[300, "c", 3.3]]],
[3, []]
])";
ASSERT_OK_AND_ASSIGN(auto batch,
TestHelper::MakeRecordBatch(arrow::struct_(table_fields), data,
/*partition_map=*/{}, /*bucket=*/0, {}));
int64_t commit_identifier = 0;
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
helper->WriteAndCommit(std::move(batch), commit_identifier++,
/*expected_commit_messages=*/std::nullopt));
ASSERT_OK_AND_ASSIGN(auto data_splits, helper->NewScan(StartupMode::LatestFull(),
/*snapshot_id=*/std::nullopt));
ASSERT_FALSE(data_splits.empty());
auto pruned_list_elem_struct = arrow::struct_({arrow::field("x", arrow::int64())});
auto pruned_list_type = arrow::list(arrow::field("item", pruned_list_elem_struct));
arrow::FieldVector projected_fields = {
arrow::field("f0", arrow::int32()),
arrow::field("f1", pruned_list_type),
};
auto projected_schema = arrow::schema(projected_fields);
auto c_schema = std::make_unique<ArrowSchema>();
ASSERT_TRUE(arrow::ExportSchema(*projected_schema, c_schema.get()).ok());
ReadContextBuilder read_context_builder(table_path_);
read_context_builder.SetOptions(options).SetReadSchema(std::move(c_schema));
ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
ASSERT_OK_AND_ASSIGN(auto table_read, TableRead::Create(std::move(read_context)));
auto create_reader_result = table_read->CreateReader(data_splits);
ASSERT_NOK_WITH_MSG(create_reader_result, "partial projection inside list");
}
std::vector<std::string> GetTestValuesForNestedColumnPruningInteTest() {
std::vector<std::string> values;
values.emplace_back("parquet");
#ifdef PAIMON_ENABLE_ORC
values.emplace_back("orc");
#endif
return values;
}
INSTANTIATE_TEST_SUITE_P(FileFormats, NestedColumnPruningInteTest,
::testing::ValuesIn(GetTestValuesForNestedColumnPruningInteTest()));
} // namespace paimon::test