This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new b4b2f795576 [fix](variant) Preserve nested Variant append atomicity
(#66421)
b4b2f795576 is described below
commit b4b2f7955768fd53db62709745cbbdf059c0653e
Author: Gabriel <[email protected]>
AuthorDate: Tue Aug 4 19:44:54 2026 +0800
[fix](variant) Preserve nested Variant append atomicity (#66421)
### What problem does this PR solve?
Iceberg Variant reads need broader coverage across physical shredding
states, nested containers, delete files, projection pruning, predicate
pruning, and delayed materialization. Review also exposed a reader bug:
a corrupt lazily decoded Variant nested after an already appended
sibling could leave the destination column partially mutated.
### What is changed?
- Make compatible nested-column appends transactional for nullable,
struct, array, and map outputs so a failed lazy Variant fallback
restores every child and offset.
- Add a focused Parquet reader test for lazy corruption after partial
nested appends.
- Use explicit unshredded Spark fixtures and the stable shredded fixture
for physical-mode coverage.
- Strengthen Iceberg checks for multiple active scanners, PUFFIN
deletion-vector metadata and observable deleted rows, completion-aware
profile polling, merged-profile counter accounting, and Variant-specific
delayed materialization.
### Tests
- BE ASAN Variant tests: 559 passed, 5 existing conditional skips, 0
failed.
- Regression framework unit tests: 3 passed, 0 failed.
- Iceberg Variant regression suite: 1 suite passed, 0 failed.
- BE C++ ASAN build: passed.
- clang-format and diff validation: passed.
---
.../parquet/reader/variant_column_reader.cpp | 101 ++-
.../parquet/variant_column_reader_test.cpp | 894 +++++++++++++++++++++
.../iceberg/test_iceberg_variant_read.out | 59 +-
.../iceberg/test_iceberg_variant_read.groovy | 552 ++++++++++++-
4 files changed, 1555 insertions(+), 51 deletions(-)
diff --git a/be/src/format_v2/parquet/reader/variant_column_reader.cpp
b/be/src/format_v2/parquet/reader/variant_column_reader.cpp
index 26e98e5128b..43ff808cf5b 100644
--- a/be/src/format_v2/parquet/reader/variant_column_reader.cpp
+++ b/be/src/format_v2/parquet/reader/variant_column_reader.cpp
@@ -26,6 +26,7 @@
#include <mutex>
#include <optional>
#include <string_view>
+#include <vector>
#include "common/exception.h"
#include "core/assert_cast.h"
@@ -852,16 +853,30 @@ ColumnPtr transform_node(const
VariantMaterializationNode& plan, ColumnPtr physi
void append_compatible_column(IColumn& output, const IColumn& converted) {
if (auto* output_nullable = check_and_get_column<ColumnNullable>(output)) {
- if (const auto* converted_nullable =
check_and_get_column<ColumnNullable>(converted)) {
- append_compatible_column(output_nullable->get_nested_column(),
- converted_nullable->get_nested_column());
- output_nullable->get_null_map_column().insert_range_from(
- converted_nullable->get_null_map_column(), 0,
converted.size());
- } else {
- append_compatible_column(output_nullable->get_nested_column(),
converted);
- // External slots and nested Iceberg fields may remain nullable
even when one file's
- // physical node is required. Preserve that destination invariant
with non-null bits.
- output_nullable->push_false_to_nullmap(converted.size());
+ auto& nested = output_nullable->get_nested_column();
+ auto& null_map = output_nullable->get_null_map_column();
+ const size_t nested_size = nested.size();
+ const size_t null_map_size = null_map.size();
+ try {
+ if (const auto* converted_nullable =
check_and_get_column<ColumnNullable>(converted)) {
+ append_compatible_column(nested,
converted_nullable->get_nested_column());
+
null_map.insert_range_from(converted_nullable->get_null_map_column(), 0,
+ converted.size());
+ } else {
+ append_compatible_column(nested, converted);
+ // External slots and nested Iceberg fields may remain
nullable even when one
+ // file's physical node is required. Preserve that destination
invariant with
+ // non-null bits.
+ output_nullable->push_false_to_nullmap(converted.size());
+ }
+ } catch (...) {
+ if (nested.size() > nested_size) {
+ nested.pop_back(nested.size() - nested_size);
+ }
+ if (null_map.size() > null_map_size) {
+ null_map.pop_back(null_map.size() - null_map_size);
+ }
+ throw;
}
return;
}
@@ -885,8 +900,25 @@ void append_compatible_column(IColumn& output, const
IColumn& converted) {
throw Exception(ErrorCode::CORRUPTION,
"Parquet Variant materialization produced an
incompatible STRUCT");
}
+ std::vector<size_t> original_sizes(output_struct->tuple_size());
for (size_t i = 0; i < output_struct->tuple_size(); ++i) {
- append_compatible_column(output_struct->get_column(i),
converted_struct->get_column(i));
+ original_sizes[i] = output_struct->get_column(i).size();
+ }
+ try {
+ for (size_t i = 0; i < output_struct->tuple_size(); ++i) {
+ append_compatible_column(output_struct->get_column(i),
+ converted_struct->get_column(i));
+ }
+ } catch (...) {
+ // Variant corruption can surface only during lazy fallback after
earlier siblings
+ // were appended. Roll every child back to preserve the
failed-append invariant.
+ for (size_t i = 0; i < output_struct->tuple_size(); ++i) {
+ auto& child = output_struct->get_column(i);
+ if (child.size() > original_sizes[i]) {
+ child.pop_back(child.size() - original_sizes[i]);
+ }
+ }
+ throw;
}
return;
}
@@ -897,12 +929,22 @@ void append_compatible_column(IColumn& output, const
IColumn& converted) {
throw Exception(ErrorCode::CORRUPTION,
"Parquet Variant materialization produced an
incompatible ARRAY");
}
- const size_t element_base = output_array->get_data().size();
- append_compatible_column(output_array->get_data(),
converted_array->get_data());
+ auto& output_data = output_array->get_data();
auto& output_offsets = output_array->get_offsets();
- output_offsets.reserve(output_offsets.size() +
converted_array->size());
- for (const auto offset : converted_array->get_offsets()) {
- output_offsets.push_back(element_base + offset);
+ const size_t element_base = output_data.size();
+ const size_t offsets_size = output_offsets.size();
+ try {
+ append_compatible_column(output_data, converted_array->get_data());
+ output_offsets.reserve(output_offsets.size() +
converted_array->size());
+ for (const auto offset : converted_array->get_offsets()) {
+ output_offsets.push_back(element_base + offset);
+ }
+ } catch (...) {
+ if (output_data.size() > element_base) {
+ output_data.pop_back(output_data.size() - element_base);
+ }
+ output_offsets.resize(offsets_size);
+ throw;
}
return;
}
@@ -913,13 +955,28 @@ void append_compatible_column(IColumn& output, const
IColumn& converted) {
throw Exception(ErrorCode::CORRUPTION,
"Parquet Variant materialization produced an
incompatible MAP");
}
- const size_t element_base = output_map->get_keys().size();
- append_compatible_column(output_map->get_keys(),
converted_map->get_keys());
- append_compatible_column(output_map->get_values(),
converted_map->get_values());
+ auto& output_keys = output_map->get_keys();
+ auto& output_values = output_map->get_values();
auto& output_offsets = output_map->get_offsets();
- output_offsets.reserve(output_offsets.size() + converted_map->size());
- for (const auto offset : converted_map->get_offsets()) {
- output_offsets.push_back(element_base + offset);
+ const size_t element_base = output_keys.size();
+ const size_t values_size = output_values.size();
+ const size_t offsets_size = output_offsets.size();
+ try {
+ append_compatible_column(output_keys, converted_map->get_keys());
+ append_compatible_column(output_values,
converted_map->get_values());
+ output_offsets.reserve(output_offsets.size() +
converted_map->size());
+ for (const auto offset : converted_map->get_offsets()) {
+ output_offsets.push_back(element_base + offset);
+ }
+ } catch (...) {
+ if (output_keys.size() > element_base) {
+ output_keys.pop_back(output_keys.size() - element_base);
+ }
+ if (output_values.size() > values_size) {
+ output_values.pop_back(output_values.size() - values_size);
+ }
+ output_offsets.resize(offsets_size);
+ throw;
}
return;
}
diff --git a/be/test/format_v2/parquet/variant_column_reader_test.cpp
b/be/test/format_v2/parquet/variant_column_reader_test.cpp
index 40c18635aad..24ace262fab 100644
--- a/be/test/format_v2/parquet/variant_column_reader_test.cpp
+++ b/be/test/format_v2/parquet/variant_column_reader_test.cpp
@@ -20,20 +20,33 @@
#include <gtest/gtest.h>
#include <array>
+#include <cmath>
+#include <functional>
+#include <initializer_list>
+#include <limits>
#include <string_view>
#include <vector>
+#include "common/exception.h"
#include "core/assert_cast.h"
#include "core/column/column_array.h"
+#include "core/column/column_decimal.h"
+#include "core/column/column_map.h"
#include "core/column/column_nullable.h"
#include "core/column/column_string.h"
#include "core/column/column_struct.h"
#include "core/column/variant_v2/column_variant_v2.h"
+#include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_date_or_datetime_v2.h"
+#include "core/data_type/data_type_decimal.h"
+#include "core/data_type/data_type_map.h"
#include "core/data_type/data_type_nullable.h"
#include "core/data_type/data_type_number.h"
#include "core/data_type/data_type_string.h"
#include "core/data_type/data_type_struct.h"
+#include "core/data_type/data_type_timestamptz.h"
#include "core/data_type/data_type_variant_v2.h"
+#include "core/value/timestamptz_value.h"
#include "core/value/variant/variant_batch_builder.h"
#include "core/value/variant/variant_parquet_encoding.h"
#include "exprs/function/function_variant_element_v2.h"
@@ -90,6 +103,16 @@ ParquetColumnSchema shredded_int64_schema() {
return schema;
}
+ParquetColumnSchema shredded_primitive_schema(DataTypePtr type) {
+ auto schema = unshredded_schema();
+ auto typed = std::make_unique<ParquetColumnSchema>();
+ typed->name = "typed_value";
+ typed->kind = ParquetColumnSchemaKind::PRIMITIVE;
+ typed->type = make_nullable(std::move(type));
+ schema.children.push_back(std::move(typed));
+ return schema;
+}
+
ParquetColumnSchema shredded_object_schema() {
auto schema = unshredded_schema();
auto typed = std::make_unique<ParquetColumnSchema>();
@@ -110,6 +133,12 @@ ParquetColumnSchema shredded_object_schema() {
return schema;
}
+ParquetColumnSchema shredded_named_object_schema(std::string field_name) {
+ auto schema = shredded_object_schema();
+ schema.children.back()->children[0]->name = std::move(field_name);
+ return schema;
+}
+
ParquetColumnSchema shredded_binary_object_schema() {
auto schema = shredded_object_schema();
auto* leaf = schema.children.back()->children[0]->children[0].get();
@@ -136,6 +165,17 @@ ParquetColumnSchema shredded_array_schema() {
return schema;
}
+ParquetColumnSchema shredded_mixed_array_schema() {
+ auto schema = shredded_array_schema();
+ auto* element = schema.children.back()->children[0].get();
+ auto value = std::make_unique<ParquetColumnSchema>();
+ value->name = "value";
+ value->kind = ParquetColumnSchemaKind::PRIMITIVE;
+ value->type = make_nullable(std::make_shared<DataTypeString>());
+ element->children.insert(element->children.begin(), std::move(value));
+ return schema;
+}
+
MutableColumnPtr shredded_int64_physical(const std::vector<int64_t>& values) {
const std::array<char, 1> ignored {0};
const StringRef metadata(VARIANT_EMPTY_METADATA.data(),
VARIANT_EMPTY_METADATA.size());
@@ -157,6 +197,20 @@ MutableColumnPtr shredded_int64_physical(const
std::vector<int64_t>& values) {
return ColumnNullable::create(std::move(structure), std::move(root_nulls));
}
+MutableColumnPtr shredded_primitive_physical(MutableColumnPtr typed) {
+ const size_t rows = typed->size();
+ const std::array<char, 1> ignored {0};
+ const StringRef metadata(VARIANT_EMPTY_METADATA.data(),
VARIANT_EMPTY_METADATA.size());
+ MutableColumns fields;
+ fields.push_back(nullable_strings(std::vector<StringRef>(rows, metadata),
+ std::vector<uint8_t>(rows, 0)));
+ fields.push_back(nullable_strings(std::vector<StringRef>(rows,
{ignored.data(), 0}),
+ std::vector<uint8_t>(rows, 1)));
+ fields.push_back(std::move(typed));
+ return ColumnNullable::create(ColumnStruct::create(std::move(fields)),
+ ColumnUInt8::create(rows, 0));
+}
+
MutableColumnPtr projected_shredded_object_physical(const
std::vector<int64_t>& values,
const IColumn**
decoded_leaf = nullptr) {
auto integers = ColumnInt64::create();
@@ -182,6 +236,37 @@ MutableColumnPtr projected_shredded_object_physical(const
std::vector<int64_t>&
return ColumnNullable::create(std::move(root),
ColumnUInt8::create(values.size(), 0));
}
+MutableColumnPtr root_wrapper(MutableColumns fields, NullMap root_nulls = {0});
+MutableColumnPtr nullable_int64(const std::vector<int64_t>& values,
+ const std::vector<uint8_t>& nulls);
+
+MutableColumnPtr complete_shredded_object_physical(std::string_view
residual_key,
+ int64_t residual_value,
int64_t typed_value) {
+ VariantBatchBuilder builder;
+ auto row = builder.begin_row();
+ auto object = row.start_object();
+ object.add_key(StringRef(residual_key.data(), residual_key.size()));
+ row.add_int(residual_value);
+ object.finish();
+ row.finish();
+ VariantBatchBuilder batch = builder.finish_batch();
+ const VariantRef residual = batch.value_at(0);
+
+ MutableColumns wrapper_fields;
+ wrapper_fields.push_back(nullable_int64({typed_value}, {0}));
+ MutableColumns object_fields;
+
object_fields.push_back(ColumnNullable::create(ColumnStruct::create(std::move(wrapper_fields)),
+ ColumnUInt8::create(1, 0)));
+ MutableColumns root_fields;
+ root_fields.push_back(
+ nullable_strings({StringRef(residual.metadata.data,
residual.metadata.size)}, {0}));
+ root_fields.push_back(
+ nullable_strings({StringRef(residual.value.data,
residual.value.size)}, {0}));
+
root_fields.push_back(ColumnNullable::create(ColumnStruct::create(std::move(object_fields)),
+ ColumnUInt8::create(1, 0)));
+ return root_wrapper(std::move(root_fields));
+}
+
MutableColumnPtr projected_two_field_object_physical(const
std::vector<int64_t>& first,
const
std::vector<int64_t>& second) {
DORIS_CHECK(first.size() == second.size());
@@ -226,6 +311,58 @@ MutableColumnPtr projected_wide_object_physical(size_t
field_count, int64_t valu
ColumnUInt8::create(1, 0));
}
+std::string materialization_error(const ParquetColumnSchema& schema, ColumnPtr
physical) {
+ auto output =
make_nullable(std::make_shared<DataTypeVariantV2>())->create_column();
+ const Status status = materialize_variant_rows(schema,
std::move(physical), output);
+ if (!status.ok()) {
+ return status.to_string();
+ }
+ try {
+ const auto& variants = assert_cast<const ColumnVariantV2&>(
+ assert_cast<const
ColumnNullable&>(*output).get_nested_column());
+ (void)variants.get_value_ref(0);
+ } catch (const Exception& exception) {
+ return exception.what();
+ }
+ return {};
+}
+
+MutableColumnPtr root_wrapper(MutableColumns fields, NullMap root_nulls) {
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().assign(root_nulls.begin(), root_nulls.end());
+ return ColumnNullable::create(ColumnStruct::create(std::move(fields)),
std::move(null_map));
+}
+
+MutableColumnPtr nullable_int64(const std::vector<int64_t>& values,
+ const std::vector<uint8_t>& nulls) {
+ auto data = ColumnInt64::create();
+ data->get_data().assign(values.begin(), values.end());
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().assign(nulls.begin(), nulls.end());
+ return ColumnNullable::create(std::move(data), std::move(null_map));
+}
+
+template <typename ColumnType, typename Value>
+MutableColumnPtr nullable_fixed(std::initializer_list<Value> values,
+ std::initializer_list<uint8_t> nulls) {
+ auto data = ColumnType::create();
+ for (const Value& value : values) {
+ data->insert_value(value);
+ }
+ auto null_map = ColumnUInt8::create();
+ null_map->get_data().assign(nulls.begin(), nulls.end());
+ return ColumnNullable::create(std::move(data), std::move(null_map));
+}
+
+template <typename ColumnType, typename Value>
+MutableColumnPtr nullable_decimal(uint32_t scale, std::initializer_list<Value>
values) {
+ auto data = ColumnType::create(0, scale);
+ for (const Value& value : values) {
+ data->insert_value(value);
+ }
+ return ColumnNullable::create(std::move(data),
ColumnUInt8::create(values.size(), 0));
+}
+
} // namespace
TEST(VariantColumnReaderTest, UnshreddedRowsPreserveSqlNullAndVariantNull) {
@@ -304,6 +441,177 @@ TEST(VariantColumnReaderTest,
ShreddedIntegerKeepsDeclaredPhysicalWidth) {
EXPECT_EQ(variants.get_value_ref(0).primitive_id(),
VariantPrimitiveId::INT64);
}
+TEST(VariantColumnReaderTest, ReconstructsShreddedPrimitiveTypeMatrix) {
+ auto decode = [](ParquetColumnSchema schema, MutableColumnPtr typed,
+ const std::function<void(const ColumnVariantV2&)>&
verify) {
+ auto output =
make_nullable(std::make_shared<DataTypeVariantV2>())->create_column();
+ const Status status = materialize_variant_rows(
+ schema, shredded_primitive_physical(std::move(typed)), output);
+ ASSERT_TRUE(status.ok()) << status;
+ verify(assert_cast<const ColumnVariantV2&>(
+ assert_cast<const
ColumnNullable&>(*output).get_nested_column()));
+ };
+
+ decode(shredded_primitive_schema(std::make_shared<DataTypeBool>()),
+ nullable_fixed<ColumnUInt8, UInt8>({0, 1}, {0, 0}), [](const auto&
values) {
+ EXPECT_EQ(values.get_value_ref(0).primitive_id(),
VariantPrimitiveId::FALSE_VALUE);
+ EXPECT_EQ(values.get_value_ref(1).primitive_id(),
VariantPrimitiveId::TRUE_VALUE);
+ });
+
+ auto verify_integer = [&](DataTypePtr type, MutableColumnPtr typed, int
width, int64_t first,
+ int64_t second) {
+ auto schema = shredded_primitive_schema(std::move(type));
+ schema.children.back()->type_descriptor.integer_bit_width = width;
+ decode(std::move(schema), std::move(typed), [&](const auto& values) {
+ EXPECT_EQ(values.get_value_ref(0).get_int(), first);
+ EXPECT_EQ(values.get_value_ref(1).get_int(), second);
+ });
+ };
+ verify_integer(
+ std::make_shared<DataTypeInt8>(),
+ nullable_fixed<ColumnInt8, Int8>(
+ {std::numeric_limits<Int8>::min(),
std::numeric_limits<Int8>::max()}, {0, 0}),
+ 8, std::numeric_limits<Int8>::min(),
std::numeric_limits<Int8>::max());
+ verify_integer(
+ std::make_shared<DataTypeInt16>(),
+ nullable_fixed<ColumnInt16, Int16>(
+ {std::numeric_limits<Int16>::min(),
std::numeric_limits<Int16>::max()}, {0, 0}),
+ 16, std::numeric_limits<Int16>::min(),
std::numeric_limits<Int16>::max());
+ verify_integer(
+ std::make_shared<DataTypeInt32>(),
+ nullable_fixed<ColumnInt32, Int32>(
+ {std::numeric_limits<Int32>::min(),
std::numeric_limits<Int32>::max()}, {0, 0}),
+ 32, std::numeric_limits<Int32>::min(),
std::numeric_limits<Int32>::max());
+ verify_integer(
+ std::make_shared<DataTypeInt64>(),
+ nullable_fixed<ColumnInt64, Int64>(
+ {std::numeric_limits<Int64>::min(),
std::numeric_limits<Int64>::max()}, {0, 0}),
+ 64, std::numeric_limits<Int64>::min(),
std::numeric_limits<Int64>::max());
+
+ decode(shredded_primitive_schema(std::make_shared<DataTypeFloat32>()),
+ nullable_fixed<ColumnFloat32,
Float32>({std::numeric_limits<Float32>::quiet_NaN(),
+
std::numeric_limits<Float32>::infinity()},
+ {0, 0}),
+ [](const auto& values) {
+ EXPECT_TRUE(std::isnan(values.get_value_ref(0).get_float()));
+ EXPECT_TRUE(std::isinf(values.get_value_ref(1).get_float()));
+ });
+ decode(shredded_primitive_schema(std::make_shared<DataTypeFloat64>()),
+ nullable_fixed<ColumnFloat64,
Float64>({-std::numeric_limits<Float64>::infinity(), 1.25},
+ {0, 0}),
+ [](const auto& values) {
+ EXPECT_EQ(values.get_value_ref(0).get_double(),
+ -std::numeric_limits<Float64>::infinity());
+ EXPECT_EQ(values.get_value_ref(1).get_double(), 1.25);
+ });
+
+ {
+ auto schema =
shredded_primitive_schema(std::make_shared<DataTypeDecimal32>(9, 2));
+ schema.children.back()->type_descriptor.decimal_precision = 9;
+ schema.children.back()->type_descriptor.decimal_scale = 2;
+ decode(std::move(schema),
+ nullable_decimal<ColumnDecimal32, Decimal32>(2, {Decimal32
{12345}, Decimal32 {-1}}),
+ [](const auto& values) {
+ EXPECT_EQ(values.get_value_ref(0).get_decimal(),
(VariantDecimal {12345, 2, 4}));
+ EXPECT_EQ(values.get_value_ref(1).get_decimal(),
(VariantDecimal {-1, 2, 4}));
+ });
+ }
+ {
+ auto schema =
shredded_primitive_schema(std::make_shared<DataTypeDecimal64>(18, 3));
+ schema.children.back()->type_descriptor.decimal_precision = 18;
+ schema.children.back()->type_descriptor.decimal_scale = 3;
+ decode(std::move(schema),
+ nullable_decimal<ColumnDecimal64, Decimal64>(
+ 3, {Decimal64 {123456789}, Decimal64 {-123456789}}),
+ [](const auto& values) {
+ EXPECT_EQ(values.get_value_ref(0).get_decimal(),
+ (VariantDecimal {123456789, 3, 8}));
+ EXPECT_EQ(values.get_value_ref(1).get_decimal(),
+ (VariantDecimal {-123456789, 3, 8}));
+ });
+ }
+ {
+ auto schema =
shredded_primitive_schema(std::make_shared<DataTypeDecimal128>(38, 4));
+ schema.children.back()->type_descriptor.decimal_precision = 38;
+ schema.children.back()->type_descriptor.decimal_scale = 4;
+ decode(std::move(schema),
+ nullable_decimal<ColumnDecimal128V3, Decimal128V3>(
+ 4, {Decimal128V3
{static_cast<Int128>(1234567890123456789LL)}}),
+ [](const auto& values) {
+ EXPECT_EQ(values.get_value_ref(0).get_decimal(),
+ (VariantDecimal {1234567890123456789LL, 4, 16}));
+ });
+ }
+
+ const auto date = DateV2Value<DateV2ValueType>::create_from_olap_date(
+ (static_cast<uint32_t>(1970) << 9) | (static_cast<uint32_t>(1) <<
5) | 2);
+ decode(shredded_primitive_schema(std::make_shared<DataTypeDateV2>()),
+ nullable_fixed<ColumnDateV2, DateV2Value<DateV2ValueType>>({date},
{0}),
+ [](const auto& values) {
EXPECT_EQ(values.get_value_ref(0).get_date(), 1); });
+
+ auto datetime =
DateV2Value<DateTimeV2ValueType>::create_from_olap_datetime(19700101000001ULL);
+ datetime.set_microsecond(234567);
+ {
+ auto schema =
shredded_primitive_schema(std::make_shared<DataTypeDateTimeV2>(6));
+ schema.children.back()->type_descriptor.time_unit =
ParquetTimeUnit::MICROS;
+ schema.children.back()->type_descriptor.timestamp_is_adjusted_to_utc =
false;
+ decode(std::move(schema),
+ nullable_fixed<ColumnDateTimeV2,
DateV2Value<DateTimeV2ValueType>>({datetime}, {0}),
+ [](const auto& values) {
+
EXPECT_EQ(values.get_value_ref(0).get_timestamp_ntz_micros(), 1234567);
+ });
+ }
+ TimestampTzValue timestamp;
+ timestamp.unchecked_set_time(1970, 1, 1, 0, 0, 2, 345678);
+ {
+ auto schema =
shredded_primitive_schema(std::make_shared<DataTypeTimeStampTz>(6));
+ schema.children.back()->type_descriptor.time_unit =
ParquetTimeUnit::MICROS;
+ schema.children.back()->type_descriptor.timestamp_is_adjusted_to_utc =
true;
+ decode(std::move(schema),
+ nullable_fixed<ColumnTimeStampTz,
TimestampTzValue>({timestamp}, {0}),
+ [](const auto& values) {
+ EXPECT_EQ(values.get_value_ref(0).get_timestamp_micros(),
2345678);
+ });
+ }
+
+ auto verify_bytes = [&](bool string_annotation, bool uuid) {
+ const std::array<uint8_t, 16> bytes {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15};
+ auto schema =
shredded_primitive_schema(std::make_shared<DataTypeString>());
+ schema.children.back()->type_descriptor.is_string_annotation =
string_annotation;
+ schema.children.back()->type_descriptor.is_uuid = uuid;
+ auto strings = ColumnString::create();
+ if (uuid) {
+ strings->insert_data(reinterpret_cast<const char*>(bytes.data()),
bytes.size());
+ } else {
+ strings->insert_data("bytes", 5);
+ }
+ auto typed = ColumnNullable::create(std::move(strings),
ColumnUInt8::create(1, 0));
+ decode(std::move(schema), std::move(typed), [&](const auto& values) {
+ if (uuid) {
+ EXPECT_EQ(values.get_value_ref(0).get_uuid(), bytes);
+ } else if (string_annotation) {
+ EXPECT_EQ(values.get_value_ref(0).get_string(),
StringRef("bytes"));
+ } else {
+ EXPECT_EQ(values.get_value_ref(0).get_binary(),
StringRef("bytes"));
+ }
+ });
+ };
+ verify_bytes(false, false);
+ verify_bytes(true, false);
+ verify_bytes(false, true);
+}
+
+TEST(VariantColumnReaderTest, RejectsInvalidShreddedUuidWidth) {
+ auto schema =
shredded_primitive_schema(std::make_shared<DataTypeString>());
+ schema.children.back()->type_descriptor.is_uuid = true;
+ auto strings = ColumnString::create();
+ strings->insert_data("short", 5);
+ auto typed = ColumnNullable::create(std::move(strings),
ColumnUInt8::create(1, 0));
+ const std::string error =
+ materialization_error(schema,
shredded_primitive_physical(std::move(typed)));
+ EXPECT_NE(error.find("UUID has 5 bytes instead of 16"), std::string::npos)
<< error;
+}
+
TEST(VariantColumnReaderTest, DifferentMetadataDictionariesRemainIndependent) {
VariantBatchBuilder first_builder;
auto first_row = first_builder.begin_row();
@@ -349,6 +657,31 @@ TEST(VariantColumnReaderTest,
DifferentMetadataDictionariesRemainIndependent) {
EXPECT_EQ(field.get_int(), 2);
}
+TEST(VariantColumnReaderTest,
AppendsCompleteShreddedStatesWithDifferentSchemasAndMetadata) {
+ auto output =
make_nullable(std::make_shared<DataTypeVariantV2>())->create_column();
+ auto first_schema = shredded_named_object_schema("a");
+ ASSERT_TRUE(materialize_variant_rows(first_schema,
+
complete_shredded_object_physical("left", 1, 11), output)
+ .ok());
+ auto second_schema = shredded_named_object_schema("b");
+ ASSERT_TRUE(materialize_variant_rows(second_schema,
+
complete_shredded_object_physical("right", 2, 22), output)
+ .ok());
+
+ const auto& variants = assert_cast<const ColumnVariantV2&>(
+ assert_cast<const ColumnNullable&>(*output).get_nested_column());
+ ASSERT_EQ(variants.size(), 2);
+ VariantRef field;
+ ASSERT_TRUE(variants.get_value_ref(0).object_find(StringRef("left"),
&field));
+ EXPECT_EQ(field.get_int(), 1);
+ ASSERT_TRUE(variants.get_value_ref(0).object_find(StringRef("a"), &field));
+ EXPECT_EQ(field.get_int(), 11);
+ ASSERT_TRUE(variants.get_value_ref(1).object_find(StringRef("right"),
&field));
+ EXPECT_EQ(field.get_int(), 2);
+ ASSERT_TRUE(variants.get_value_ref(1).object_find(StringRef("b"), &field));
+ EXPECT_EQ(field.get_int(), 22);
+}
+
TEST(VariantColumnReaderTest, ShreddedObjectFieldMayOmitResidualValueColumn) {
const std::array<char, 1> ignored {0};
const StringRef metadata(VARIANT_EMPTY_METADATA.data(),
VARIANT_EMPTY_METADATA.size());
@@ -776,6 +1109,449 @@ TEST(VariantColumnReaderTest,
MaterializesShreddedArrayElements) {
EXPECT_EQ(value.array_at(1).get_int(), 4);
}
+TEST(VariantColumnReaderTest, RejectsCorruptShreddedWrappersWithoutCrashing) {
+ const std::array<char, 2> int_seven {
+ static_cast<char>(static_cast<uint8_t>(VariantPrimitiveId::INT8)
+ << VARIANT_VALUE_HEADER_SHIFT),
+ 7};
+ const std::array<char, 1> invalid_value {static_cast<char>(0xff)};
+ const std::array<char, 1> ignored {0};
+ const StringRef metadata(VARIANT_EMPTY_METADATA.data(),
VARIANT_EMPTY_METADATA.size());
+ const StringRef residual_int(int_seven.data(), int_seven.size());
+ auto expect_error = [](const std::string& error, std::string_view
expected) {
+ EXPECT_NE(error.find(expected), std::string::npos) << error;
+ };
+ std::string_view current_case;
+
+ try {
+ {
+ current_case = "null metadata";
+ SCOPED_TRACE("null metadata");
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {1}));
+ fields.push_back(nullable_strings({residual_int}, {0}));
+ expect_error(
+ materialization_error(unshredded_schema(),
root_wrapper(std::move(fields))),
+ "null metadata");
+ }
+ {
+ current_case = "wrapper without carriers";
+ SCOPED_TRACE("wrapper without carriers");
+ auto schema = unshredded_schema();
+ schema.children.pop_back();
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ expect_error(materialization_error(schema,
root_wrapper(std::move(fields))),
+ "neither value nor typed_value");
+ }
+ {
+ current_case = "scalar with residual";
+ SCOPED_TRACE("scalar with residual");
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({residual_int}, {0}));
+ fields.push_back(nullable_int64({8}, {0}));
+ expect_error(
+ materialization_error(shredded_int64_schema(),
root_wrapper(std::move(fields))),
+ "scalar typed_value cannot have residual");
+ }
+ {
+ current_case = "object with scalar residual";
+ SCOPED_TRACE("object with scalar residual");
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({residual_int}, {0}));
+ MutableColumns wrapper_fields;
+ wrapper_fields.push_back(nullable_int64({9}, {0}));
+ MutableColumns object_fields;
+ object_fields.push_back(ColumnNullable::create(
+ ColumnStruct::create(std::move(wrapper_fields)),
ColumnUInt8::create(1, 0)));
+
fields.push_back(ColumnNullable::create(ColumnStruct::create(std::move(object_fields)),
+ ColumnUInt8::create(1,
0)));
+ expect_error(materialization_error(shredded_object_schema(),
+
root_wrapper(std::move(fields))),
+ "non-object residual");
+ }
+ {
+ current_case = "object field count mismatch";
+ SCOPED_TRACE("object field count mismatch");
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({{ignored.data(), 0}}, {1}));
+ MutableColumns unexpected_object_fields;
+ unexpected_object_fields.push_back(nullable_int64({1}, {0}));
+ unexpected_object_fields.push_back(nullable_int64({2}, {0}));
+ fields.push_back(ColumnNullable::create(
+ ColumnStruct::create(std::move(unexpected_object_fields)),
+ ColumnUInt8::create(1, 0)));
+ expect_error(materialization_error(shredded_object_schema(),
+
root_wrapper(std::move(fields))),
+ "physical field count mismatch");
+ }
+ {
+ current_case = "array with residual";
+ SCOPED_TRACE("array with residual");
+ MutableColumns empty_wrapper_fields;
+ empty_wrapper_fields.push_back(nullable_int64({}, {}));
+ auto empty_elements = ColumnNullable::create(
+ ColumnStruct::create(std::move(empty_wrapper_fields)),
ColumnUInt8::create());
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->insert_value(0);
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({residual_int}, {0}));
+ fields.push_back(ColumnNullable::create(
+ ColumnArray::create(std::move(empty_elements),
std::move(offsets)),
+ ColumnUInt8::create(1, 0)));
+ expect_error(
+ materialization_error(shredded_array_schema(),
root_wrapper(std::move(fields))),
+ "array typed_value cannot have residual");
+ }
+ {
+ current_case = "null array element wrapper";
+ SCOPED_TRACE("null array element wrapper");
+ MutableColumns wrapper_fields;
+ wrapper_fields.push_back(nullable_int64({0}, {1}));
+ auto wrappers = ColumnStruct::create(std::move(wrapper_fields));
+ auto elements = ColumnNullable::create(std::move(wrappers),
ColumnUInt8::create(1, 1));
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->insert_value(1);
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({{ignored.data(), 0}}, {1}));
+ fields.push_back(ColumnNullable::create(
+ ColumnArray::create(std::move(elements),
std::move(offsets)),
+ ColumnUInt8::create(1, 0)));
+ expect_error(
+ materialization_error(shredded_array_schema(),
root_wrapper(std::move(fields))),
+ "array element wrapper is null");
+ }
+ {
+ current_case = "missing array element";
+ SCOPED_TRACE("missing array element");
+ MutableColumns element_fields;
+ element_fields.push_back(nullable_strings({{ignored.data(), 0}},
{1}));
+ element_fields.push_back(nullable_int64({0}, {1}));
+ auto elements =
ColumnNullable::create(ColumnStruct::create(std::move(element_fields)),
+ ColumnUInt8::create(1, 0));
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->insert_value(1);
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({{ignored.data(), 0}}, {1}));
+ fields.push_back(ColumnNullable::create(
+ ColumnArray::create(std::move(elements),
std::move(offsets)),
+ ColumnUInt8::create(1, 0)));
+ expect_error(materialization_error(shredded_mixed_array_schema(),
+
root_wrapper(std::move(fields))),
+ "array element is missing");
+ }
+ {
+ current_case = "root field count mismatch";
+ SCOPED_TRACE("root field count mismatch");
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({residual_int}, {0}));
+ fields.push_back(nullable_int64({8}, {0}));
+ fields.push_back(nullable_int64({9}, {0}));
+ expect_error(
+ materialization_error(shredded_int64_schema(),
root_wrapper(std::move(fields))),
+ "physical field count mismatch");
+ }
+ {
+ current_case = "invalid metadata";
+ SCOPED_TRACE("invalid metadata");
+ MutableColumns fields;
+ fields.push_back(nullable_strings({StringRef("bad")}, {0}));
+ fields.push_back(nullable_strings({residual_int}, {0}));
+ expect_error(
+ materialization_error(unshredded_schema(),
root_wrapper(std::move(fields))),
+ "metadata");
+ }
+ {
+ current_case = "invalid residual value";
+ SCOPED_TRACE("invalid residual value");
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({{invalid_value.data(),
invalid_value.size()}}, {0}));
+ expect_error(
+ materialization_error(unshredded_schema(),
root_wrapper(std::move(fields))),
+ "Variant");
+ }
+ } catch (const std::exception& error) {
+ FAIL() << "Unexpected exception in " << current_case << ": " <<
error.what();
+ }
+}
+
+TEST(VariantColumnReaderTest, ImmediateCorruptionLeavesDestinationUnchanged) {
+ auto output =
make_nullable(std::make_shared<DataTypeVariantV2>())->create_column();
+ ASSERT_TRUE(
+ materialize_variant_rows(shredded_int64_schema(),
shredded_int64_physical({7}), output)
+ .ok());
+ MutableColumns invalid_fields;
+ invalid_fields.push_back(nullable_strings(
+ {{VARIANT_EMPTY_METADATA.data(), VARIANT_EMPTY_METADATA.size()}},
{0}));
+ const Status status = materialize_variant_rows(shredded_int64_schema(),
+
root_wrapper(std::move(invalid_fields)), output);
+ EXPECT_FALSE(status.ok());
+ ASSERT_EQ(output->size(), 1);
+ const auto& variants = assert_cast<const ColumnVariantV2&>(
+ assert_cast<const ColumnNullable&>(*output).get_nested_column());
+ EXPECT_EQ(variants.get_value_ref(0).get_int(), 7);
+}
+
+TEST(VariantColumnReaderTest, LazyNestedCorruptionLeavesDestinationUnchanged) {
+ const std::array<char, 1> invalid_value {static_cast<char>(0xff)};
+ const StringRef metadata(VARIANT_EMPTY_METADATA.data(),
VARIANT_EMPTY_METADATA.size());
+ auto corrupt_variant = [&]() {
+ MutableColumns fields;
+ fields.push_back(nullable_strings({metadata}, {0}));
+ fields.push_back(nullable_strings({{invalid_value.data(),
invalid_value.size()}}, {0}));
+ return root_wrapper(std::move(fields));
+ };
+ auto label_schema = []() {
+ auto schema = std::make_unique<ParquetColumnSchema>();
+ schema->name = "label";
+ schema->kind = ParquetColumnSchemaKind::PRIMITIVE;
+ schema->type = make_nullable(std::make_shared<DataTypeString>());
+ return schema;
+ };
+ auto make_plan = [](const ParquetColumnSchema& root) {
+ auto build = [&](auto&& self, const ParquetColumnSchema* schema)
+ -> std::unique_ptr<VariantMaterializationNode> {
+ auto node = std::make_unique<VariantMaterializationNode>();
+ node->schema = schema;
+ node->contains_variant = schema->kind ==
ParquetColumnSchemaKind::VARIANT;
+ for (const auto& child_schema : schema->children) {
+ auto child = self(self, child_schema.get());
+ node->contains_variant = node->contains_variant ||
child->contains_variant;
+ node->children.push_back(std::move(child));
+ }
+ return node;
+ };
+ return build(build, &root);
+ };
+ auto make_struct_schema = [&](ParquetColumnSchema variant_schema) {
+ ParquetColumnSchema root;
+ root.name = "row";
+ root.kind = ParquetColumnSchemaKind::STRUCT;
+ root.children.push_back(label_schema());
+
root.children.push_back(std::make_unique<ParquetColumnSchema>(std::move(variant_schema)));
+ return root;
+ };
+ auto make_struct_physical = [&](std::string_view label, MutableColumnPtr
variant) {
+ MutableColumns fields;
+ fields.push_back(nullable_strings({StringRef(label.data(),
label.size())}, {0}));
+ fields.push_back(std::move(variant));
+ return ColumnStruct::create(std::move(fields));
+ };
+ const auto element_type = std::make_shared<DataTypeStruct>(
+ DataTypes {make_nullable(std::make_shared<DataTypeString>()),
+ make_nullable(std::make_shared<DataTypeVariantV2>())},
+ Strings {"label", "payload"});
+
+ {
+ auto output = element_type->create_column();
+ auto valid_schema = make_struct_schema(shredded_int64_schema());
+ auto valid_plan = make_plan(valid_schema);
+ ASSERT_TRUE(materialize_variant_columns(
+ *valid_plan,
+ *make_struct_physical("before",
shredded_int64_physical({7})), output)
+ .ok());
+
+ auto corrupt_schema = make_struct_schema(unshredded_schema());
+ auto corrupt_plan = make_plan(corrupt_schema);
+ const Status status = materialize_variant_columns(
+ *corrupt_plan, *make_struct_physical("after",
corrupt_variant()), output);
+ EXPECT_FALSE(status.ok());
+
+ const auto& structure = assert_cast<const ColumnStruct&>(*output);
+ const auto& label = assert_cast<const
ColumnNullable&>(structure.get_column(0));
+ EXPECT_EQ(label.size(), 1);
+ EXPECT_EQ(label.get_null_map_data(), (NullMap {0}));
+ EXPECT_EQ(label.get_nested_column().get_data_at(0).to_string(),
"before");
+ const auto& payload = assert_cast<const
ColumnNullable&>(structure.get_column(1));
+ EXPECT_EQ(payload.size(), 1);
+ EXPECT_EQ(payload.get_null_map_data(), (NullMap {0}));
+ EXPECT_EQ(assert_cast<const
ColumnVariantV2&>(payload.get_nested_column())
+ .get_value_ref(0)
+ .get_int(),
+ 7);
+ }
+
+ {
+ auto output =
std::make_shared<DataTypeArray>(element_type)->create_column();
+ auto valid_element_schema =
make_struct_schema(shredded_int64_schema());
+ ParquetColumnSchema valid_schema;
+ valid_schema.name = "rows";
+ valid_schema.kind = ParquetColumnSchemaKind::LIST;
+ valid_schema.children.push_back(
+
std::make_unique<ParquetColumnSchema>(std::move(valid_element_schema)));
+ auto valid_plan = make_plan(valid_schema);
+ auto valid_offsets = ColumnArray::ColumnOffsets::create();
+ valid_offsets->insert_value(1);
+ auto valid_physical =
+ ColumnArray::create(make_struct_physical("before",
shredded_int64_physical({7})),
+ std::move(valid_offsets));
+ ASSERT_TRUE(materialize_variant_columns(*valid_plan, *valid_physical,
output).ok());
+
+ auto corrupt_element_schema = make_struct_schema(unshredded_schema());
+ ParquetColumnSchema corrupt_schema;
+ corrupt_schema.name = "rows";
+ corrupt_schema.kind = ParquetColumnSchemaKind::LIST;
+ corrupt_schema.children.push_back(
+
std::make_unique<ParquetColumnSchema>(std::move(corrupt_element_schema)));
+ auto corrupt_plan = make_plan(corrupt_schema);
+ auto corrupt_offsets = ColumnArray::ColumnOffsets::create();
+ corrupt_offsets->insert_value(1);
+ auto corrupt_physical = ColumnArray::create(
+ make_struct_physical("after", corrupt_variant()),
std::move(corrupt_offsets));
+ const Status status = materialize_variant_columns(*corrupt_plan,
*corrupt_physical, output);
+ EXPECT_FALSE(status.ok());
+
+ const auto& array = assert_cast<const ColumnArray&>(*output);
+ EXPECT_EQ(array.get_offsets(), (ColumnArray::Offsets64 {1}));
+ const auto& element = assert_cast<const
ColumnNullable&>(array.get_data());
+ EXPECT_EQ(element.get_null_map_data(), (NullMap {0}));
+ const auto& structure = assert_cast<const
ColumnStruct&>(element.get_nested_column());
+ const auto& label = assert_cast<const
ColumnNullable&>(structure.get_column(0));
+ EXPECT_EQ(label.size(), 1);
+ EXPECT_EQ(label.get_null_map_data(), (NullMap {0}));
+ EXPECT_EQ(label.get_nested_column().get_data_at(0).to_string(),
"before");
+ const auto& payload = assert_cast<const
ColumnNullable&>(structure.get_column(1));
+ EXPECT_EQ(payload.size(), 1);
+ EXPECT_EQ(payload.get_null_map_data(), (NullMap {0}));
+ EXPECT_EQ(assert_cast<const
ColumnVariantV2&>(payload.get_nested_column())
+ .get_value_ref(0)
+ .get_int(),
+ 7);
+ }
+
+ {
+ auto output =
+
std::make_shared<DataTypeMap>(make_nullable(std::make_shared<DataTypeString>()),
+
make_nullable(std::make_shared<DataTypeVariantV2>()))
+ ->create_column();
+ auto make_map_schema = [&](ParquetColumnSchema variant_schema) {
+ ParquetColumnSchema root;
+ root.name = "entries";
+ root.kind = ParquetColumnSchemaKind::MAP;
+ root.children.push_back(label_schema());
+ root.children.push_back(
+
std::make_unique<ParquetColumnSchema>(std::move(variant_schema)));
+ return root;
+ };
+ auto make_map_physical = [&](std::string_view key, MutableColumnPtr
variant) {
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->insert_value(1);
+ return ColumnMap::create(nullable_strings({StringRef(key.data(),
key.size())}, {0}),
+ std::move(variant), std::move(offsets));
+ };
+
+ auto valid_schema = make_map_schema(shredded_int64_schema());
+ auto valid_plan = make_plan(valid_schema);
+ ASSERT_TRUE(materialize_variant_columns(
+ *valid_plan, *make_map_physical("before",
shredded_int64_physical({7})),
+ output)
+ .ok());
+ auto corrupt_schema = make_map_schema(unshredded_schema());
+ auto corrupt_plan = make_plan(corrupt_schema);
+ const Status status = materialize_variant_columns(
+ *corrupt_plan, *make_map_physical("after", corrupt_variant()),
output);
+ EXPECT_FALSE(status.ok());
+
+ const auto& map = assert_cast<const ColumnMap&>(*output);
+ EXPECT_EQ(map.get_offsets(), (ColumnArray::Offsets64 {1}));
+ const auto& keys = assert_cast<const ColumnNullable&>(map.get_keys());
+ EXPECT_EQ(keys.size(), 1);
+ EXPECT_EQ(keys.get_null_map_data(), (NullMap {0}));
+ EXPECT_EQ(keys.get_nested_column().get_data_at(0).to_string(),
"before");
+ const auto& values = assert_cast<const
ColumnNullable&>(map.get_values());
+ EXPECT_EQ(values.size(), 1);
+ EXPECT_EQ(values.get_null_map_data(), (NullMap {0}));
+ EXPECT_EQ(assert_cast<const
ColumnVariantV2&>(values.get_nested_column())
+ .get_value_ref(0)
+ .get_int(),
+ 7);
+ }
+}
+
+TEST(VariantColumnReaderTest, MaterializesMixedRootArraysAndNullKinds) {
+ VariantBatchBuilder residual_builder;
+ {
+ auto row = residual_builder.begin_row();
+ row.add_null();
+ row.finish();
+ }
+ {
+ auto row = residual_builder.begin_row();
+ auto object = row.start_object();
+ object.add_key(StringRef("x"));
+ row.add_int(2);
+ object.finish();
+ row.finish();
+ }
+ {
+ auto row = residual_builder.begin_row();
+ auto array = row.start_array();
+ row.add_int(3);
+ row.add_int(4);
+ array.finish();
+ row.finish();
+ }
+ {
+ auto row = residual_builder.begin_row();
+ row.add_string(StringRef("tail"));
+ row.finish();
+ }
+ VariantBatchBuilder residuals = residual_builder.finish_batch();
+ const VariantRef first = residuals.value_at(0);
+ std::vector<StringRef> residual_values;
+ for (size_t row = 0; row < residuals.num_rows(); ++row) {
+ residual_values.push_back(residuals.value_at(row).value);
+ }
+ residual_values.insert(residual_values.begin() + 1, StringRef {});
+
+ MutableColumns element_fields;
+ element_fields.push_back(nullable_strings(residual_values, {0, 1, 0, 0,
0}));
+ element_fields.push_back(nullable_int64({0, 1, 0, 0, 0}, {1, 0, 1, 1, 1}));
+ auto elements =
ColumnNullable::create(ColumnStruct::create(std::move(element_fields)),
+ ColumnUInt8::create(5, 0));
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->get_data().assign({0, 5, 5, 5});
+ auto arrays = ColumnArray::create(std::move(elements), std::move(offsets));
+
+ const StringRef metadata(first.metadata.data, first.metadata.size);
+ const std::array<char, 1> ignored {0};
+ MutableColumns root_fields;
+ root_fields.push_back(nullable_strings({metadata, metadata, metadata,
metadata}, {0, 0, 0, 0}));
+ root_fields.push_back(nullable_strings(
+ {{ignored.data(), 0}, {ignored.data(), 0}, {ignored.data(), 0},
{ignored.data(), 0}},
+ {1, 1, 1, 1}));
+ auto typed_nulls = ColumnUInt8::create(4, 0);
+ typed_nulls->get_data()[2] = 1;
+ typed_nulls->get_data()[3] = 1;
+ root_fields.push_back(ColumnNullable::create(std::move(arrays),
std::move(typed_nulls)));
+ auto physical = root_wrapper(std::move(root_fields), {0, 0, 0, 1});
+
+ auto output =
make_nullable(std::make_shared<DataTypeVariantV2>())->create_column();
+ ASSERT_TRUE(materialize_variant_rows(shredded_mixed_array_schema(),
*physical, output).ok());
+ const auto& nullable = assert_cast<const ColumnNullable&>(*output);
+ EXPECT_EQ(nullable.get_null_map_data(), (NullMap {0, 0, 0, 1}));
+ const auto& variants = assert_cast<const
ColumnVariantV2&>(nullable.get_nested_column());
+ EXPECT_EQ(variants.get_value_ref(0).num_elements(), 0);
+ const VariantRef mixed = variants.get_value_ref(1);
+ ASSERT_EQ(mixed.num_elements(), 5);
+ EXPECT_TRUE(mixed.array_at(0).is_null());
+ EXPECT_EQ(mixed.array_at(1).get_int(), 1);
+ VariantRef object_field;
+ ASSERT_TRUE(mixed.array_at(2).object_find(StringRef("x"), &object_field));
+ EXPECT_EQ(object_field.get_int(), 2);
+ EXPECT_EQ(mixed.array_at(3).array_at(1).get_int(), 4);
+ EXPECT_EQ(mixed.array_at(4).get_string(), StringRef("tail"));
+ EXPECT_TRUE(variants.get_value_ref(2).is_null());
+}
+
TEST(VariantColumnReaderTest, MaterializesVariantNestedInStruct) {
const std::array<char, 2> int_seven {
static_cast<char>(static_cast<uint8_t>(VariantPrimitiveId::INT8)
@@ -817,6 +1593,124 @@ TEST(VariantColumnReaderTest,
MaterializesVariantNestedInStruct) {
EXPECT_EQ(variants.get_value_ref(0).get_int(), 7);
}
+TEST(VariantColumnReaderTest,
MaterializesPhysicallyShreddedVariantInStructArrayAndMap) {
+ auto make_plan_child = [](const ParquetColumnSchema* schema) {
+ auto child = std::make_unique<VariantMaterializationNode>();
+ child->schema = schema;
+ child->contains_variant = schema->kind ==
ParquetColumnSchemaKind::VARIANT;
+ return child;
+ };
+
+ {
+ ParquetColumnSchema root_schema;
+ root_schema.name = "root";
+ root_schema.kind = ParquetColumnSchemaKind::STRUCT;
+ root_schema.children.push_back(
+
std::make_unique<ParquetColumnSchema>(shredded_int64_schema()));
+ VariantMaterializationNode plan;
+ plan.schema = &root_schema;
+ plan.contains_variant = true;
+
plan.children.push_back(make_plan_child(root_schema.children[0].get()));
+ MutableColumns physical_fields;
+ physical_fields.push_back(shredded_int64_physical({11}));
+ auto physical = ColumnStruct::create(std::move(physical_fields));
+ auto output = std::make_shared<DataTypeStruct>(
+ DataTypes
{make_nullable(std::make_shared<DataTypeVariantV2>())},
+ Strings {"v"})
+ ->create_column();
+ ASSERT_TRUE(materialize_variant_columns(plan, *physical, output).ok());
+ const auto& variants = assert_cast<const ColumnVariantV2&>(
+ assert_cast<const ColumnNullable&>(
+ assert_cast<const
ColumnStruct&>(*output).get_column(0))
+ .get_nested_column());
+ EXPECT_EQ(variants.get_value_ref(0).get_int(), 11);
+ }
+
+ {
+ ParquetColumnSchema root_schema;
+ root_schema.name = "items";
+ root_schema.kind = ParquetColumnSchemaKind::LIST;
+ root_schema.children.push_back(
+
std::make_unique<ParquetColumnSchema>(shredded_int64_schema()));
+ VariantMaterializationNode plan;
+ plan.schema = &root_schema;
+ plan.contains_variant = true;
+
plan.children.push_back(make_plan_child(root_schema.children[0].get()));
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->insert_value(2);
+ auto physical = ColumnArray::create(shredded_int64_physical({12, 13}),
std::move(offsets));
+ auto output = std::make_shared<DataTypeArray>(
+
make_nullable(std::make_shared<DataTypeVariantV2>()))
+ ->create_column();
+ ASSERT_TRUE(materialize_variant_columns(plan, *physical, output).ok());
+ const auto& variants = assert_cast<const ColumnVariantV2&>(
+ assert_cast<const ColumnNullable&>(
+ assert_cast<const ColumnArray&>(*output).get_data())
+ .get_nested_column());
+ EXPECT_EQ(variants.get_value_ref(0).get_int(), 12);
+ EXPECT_EQ(variants.get_value_ref(1).get_int(), 13);
+ }
+
+ {
+ ParquetColumnSchema root_schema;
+ root_schema.name = "entries";
+ root_schema.kind = ParquetColumnSchemaKind::MAP;
+ auto key_schema = std::make_unique<ParquetColumnSchema>();
+ key_schema->name = "key";
+ key_schema->kind = ParquetColumnSchemaKind::PRIMITIVE;
+ key_schema->type = std::make_shared<DataTypeString>();
+ root_schema.children.push_back(std::move(key_schema));
+ root_schema.children.push_back(
+
std::make_unique<ParquetColumnSchema>(shredded_int64_schema()));
+ VariantMaterializationNode plan;
+ plan.schema = &root_schema;
+ plan.contains_variant = true;
+
plan.children.push_back(make_plan_child(root_schema.children[0].get()));
+
plan.children.push_back(make_plan_child(root_schema.children[1].get()));
+ auto keys = ColumnString::create();
+ keys->insert_data("a", 1);
+ keys->insert_data("b", 1);
+ auto offsets = ColumnArray::ColumnOffsets::create();
+ offsets->insert_value(2);
+ auto physical = ColumnMap::create(std::move(keys),
shredded_int64_physical({14, 15}),
+ std::move(offsets));
+ auto output =
+
std::make_shared<DataTypeMap>(std::make_shared<DataTypeString>(),
+
make_nullable(std::make_shared<DataTypeVariantV2>()))
+ ->create_column();
+ ASSERT_TRUE(materialize_variant_columns(plan, *physical, output).ok());
+ const auto& variants = assert_cast<const ColumnVariantV2&>(
+ assert_cast<const ColumnNullable&>(
+ assert_cast<const ColumnMap&>(*output).get_values())
+ .get_nested_column());
+ EXPECT_EQ(variants.get_value_ref(0).get_int(), 14);
+ EXPECT_EQ(variants.get_value_ref(1).get_int(), 15);
+ }
+}
+
+TEST(VariantColumnReaderTest,
ProjectedShreddedStateRejectsRootMaterialization) {
+ auto schema = shredded_object_schema();
+ schema.local_id = 0;
+ schema.children[2]->local_id = 2;
+ schema.children[2]->children[0]->local_id = 0;
+ schema.children[2]->children[0]->children[0]->local_id = 0;
+ auto projection = format::LocalColumnIndex::partial_local(0);
+ projection.children.push_back(format::LocalColumnIndex::partial_local(2));
+
projection.children.back().children.push_back(format::LocalColumnIndex::partial_local(0));
+ projection.children.back().children.back().children.push_back(
+ format::LocalColumnIndex::local(0));
+ VariantMaterializationNode plan;
+ plan.schema = &schema;
+ plan.contains_variant = true;
+ plan.variant_projection = std::move(projection);
+ auto output =
make_nullable(std::make_shared<DataTypeVariantV2>())->create_column();
+ ASSERT_TRUE(materialize_variant_columns(plan,
projected_shredded_object_physical({17}), output)
+ .ok());
+ const auto& variants = assert_cast<const ColumnVariantV2&>(
+ assert_cast<const ColumnNullable&>(*output).get_nested_column());
+ EXPECT_THROW((void)variants.get_value_ref(0), Exception);
+}
+
TEST(VariantColumnReaderTest, AlignsNestedPrimitiveNullabilityAroundVariant) {
const std::array<char, 2> int_seven {
static_cast<char>(static_cast<uint8_t>(VariantPrimitiveId::INT8)
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_variant_read.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_variant_read.out
index a7c41169d8d..88408fc6dea 100644
---
a/regression-test/data/external_table_p0/iceberg/test_iceberg_variant_read.out
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_variant_read.out
@@ -21,6 +21,13 @@
8 false {"n":30,"name":"same","ok":true}
9 false
{"arr":[5,6],"n":40,"name":"carol","nested":{"city":"bj"},"new_key":"new","ok":true,"ratio":4.5}
+-- !variant_root_array_projection --
+1 false [] \N \N \N \N
+2 false [null,1,{"x":2},[3,4],"tail"] null 1 2 4
+3 false [{"nested":[null,{"y":5}]}] {"nested":[null,{"y":5}]}
\N \N \N
+4 false null \N \N \N \N
+5 true \N \N \N \N \N
+
-- !variant_path_expressions --
1 ALICE 11 1.5 true 3 hz
10 DAVE 51 5.5 false 15 sz
@@ -47,13 +54,52 @@
8 30
9 40
--- !variant_implicit_shredded_filter --
+-- !variant_multi_file_serial --
+2 20 \N 2 \N {"b":2,"shared":20,"z":200}
+3 30 3 \N \N {"a":3,"shared":30,"z":300}
+4 40 \N \N \N {"c":4,"shared":40}
+5 50 \N 5 500
{"b":5,"new_field":{"k":500},"shared":50}
+
+-- !variant_multi_file_parallel --
+2 20 \N 2 \N {"b":2,"shared":20,"z":200}
+3 30 3 \N \N {"a":3,"shared":30,"z":300}
+4 40 \N \N \N {"c":4,"shared":40}
+5 50 \N 5 500
{"b":5,"new_field":{"k":500},"shared":50}
+
+-- !variant_type_matrix --
+true -128 -32768 2147483647 -9223372036854775808 true true
-1234567890.1234 1970-01-02 1970-01-01T00:00:01.234567
"YmluYXJ5" false
+
+-- !variant_multi_row_group_result --
+192 8000 8191 1554336
+
+-- !variant_deletion_vector_current --
+2048 0 4094 4192256
+
+-- !variant_deletion_vector_before_delete --
+4096 0 4095 8386560
+
+-- !variant_equality_delete_current --
+1 10 keep-one {"label":"keep-one","n":10}
+3 30 keep-three {"label":"keep-three","n":30}
+
+-- !variant_equality_delete_before_delete --
+1 10 keep-one {"label":"keep-one","n":10}
+2 20 delete {"label":"delete","n":20}
+3 30 keep-three {"label":"keep-three","n":30}
+
+-- !variant_implicit_filter --
10
{"arr":[7,8],"n":50,"name":"dave","nested":{"city":"sz"},"ok":false,"ratio":5.5}
11
{"arr":[9,10],"n":60,"name":null,"nested":{"city":null},"ok":true,"ratio":6.5}
9
{"arr":[5,6],"n":40,"name":"carol","nested":{"city":"bj"},"new_key":"new","ok":true,"ratio":4.5}
+-- !variant_shredded_only_time_travel --
+1095 3001 4095 3885060
+
+-- !variant_mixed_before_delete --
+1096 3001 5000 3890060
+
-- !variant_page_pruning_result --
-1095 3001 4095
+1094 3001 4094
-- !variant_aggregate --
false 2 70 4
@@ -112,6 +158,15 @@ true 5 170 4.17
-- !variant_delete_only_merge --
0
+-- !variant_position_delete_alignment --
+10 dave 50
{"arr":[7,8],"n":50,"name":"dave","nested":{"city":"sz"},"ok":false,"ratio":5.5}
+9 carol 40
{"arr":[5,6],"n":40,"name":"carol","nested":{"city":"bj"},"new_key":"new","ok":true,"ratio":4.5}
+
+-- !variant_before_position_delete --
+10 dave 50
{"arr":[7,8],"n":50,"name":"dave","nested":{"city":"sz"},"ok":false,"ratio":5.5}
+11 null 60
{"arr":[9,10],"n":60,"name":null,"nested":{"city":null},"ok":true,"ratio":6.5}
+9 carol 40
{"arr":[5,6],"n":40,"name":"carol","nested":{"city":"bj"},"new_key":"new","ok":true,"ratio":4.5}
+
-- !variant_orc_missing_column --
1 \N
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
index 7b3bc3e833d..7161574b962 100644
---
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
@@ -56,6 +56,39 @@ suite("test_iceberg_variant_read",
.withPathStyleAccessEnabled(true)
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.build()
+ def executeCommand = { String command, int timeoutSeconds = 300 ->
+ StringBuilder stdout = new StringBuilder()
+ StringBuilder stderr = new StringBuilder()
+ def process = new ProcessBuilder("/bin/bash", "-c", command).start()
+ process.consumeProcessOutput(stdout, stderr)
+ process.waitForOrKill(timeoutSeconds * 1000)
+ assertEquals(0, process.exitValue(),
+ "Command failed\nstdout:\n${stdout}\nstderr:\n${stderr}")
+ return stdout.toString()
+ }
+ String dockerCommand =
context.config.otherConfigs.get("externalDockerCommand") ?: "docker"
+ String sparkContainer =
context.config.otherConfigs.get("icebergSparkContainer")
+ if (sparkContainer == null || sparkContainer.isEmpty()) {
+ String containers = executeCommand(
+ "${dockerCommand} ps --format '{{.ID}}\t{{.Names}}'", 30)
+ def matches = []
+ containers.readLines().each { String line ->
+ String containerId = line.split(/\t/, 2)[0]
+ String probe = "${dockerCommand} exec ${containerId} bash -lc " +
+ "'test -f /mnt/SUCCESS && command -v spark-sql >/dev/null'"
+ try {
+ executeCommand(probe, 30)
+ matches.add(containerId)
+ } catch (Throwable ignored) {
+ // Only the Spark service contains the Iceberg writer
dependencies.
+ }
+ }
+ assertEquals(1, matches.size(), "Expected exactly one usable Spark
Iceberg container")
+ sparkContainer = matches[0]
+ }
+ def runInSparkContainer = { String command ->
+ executeCommand("${dockerCommand} exec ${sparkContainer} bash -lc
'${command}'", 300)
+ }
def latestSnapshotId = { String tableName ->
List<List<Object>> rows = spark_iceberg """
@@ -90,16 +123,125 @@ suite("test_iceberg_variant_read",
(6, parse_json('42')),
(7, parse_json('"root-string"'));
ALTER TABLE demo.${dbName}.variant_values SET TBLPROPERTIES (
- 'write.parquet.shred-variants'='true',
+ 'write.parquet.shred-variants'='false',
'write.parquet.variant-inference-buffer-size'='100'
);
INSERT INTO demo.${dbName}.variant_values
- WITH (`shred-variants`=true, `variant-inference-buffer-size`=100)
VALUES
+ VALUES
(8, parse_json('{"ok":true,"n":30,"name":"same"}')),
(9,
parse_json('{"name":"carol","n":40,"ratio":4.5,"ok":true,"arr":[5,6],"nested":{"city":"bj"},"new_key":"new"}')),
(10,
parse_json('{"name":"dave","n":50,"ratio":5.5,"ok":false,"arr":[7,8],"nested":{"city":"sz"}}')),
(11,
parse_json('{"name":null,"n":60,"ratio":6.5,"ok":true,"arr":[9,10],"nested":{"city":null}}'));
+ DROP TABLE IF EXISTS demo.${dbName}.variant_root_arrays;
+ CREATE TABLE demo.${dbName}.variant_root_arrays (id INT, v VARIANT)
USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='3',
+ 'write.format.default'='parquet',
+ 'write.parquet.shred-variants'='false',
+ 'write.parquet.variant-inference-buffer-size'='100'
+ );
+ INSERT INTO demo.${dbName}.variant_root_arrays VALUES
+ (1, parse_json('[]')),
+ (2, parse_json('[null,1,{"x":2},[3,4],"tail"]')),
+ (3, parse_json('[{"nested":[null,{"y":5}]}]')),
+ (4, parse_json('null')),
+ (5, NULL);
+
+ DROP TABLE IF EXISTS demo.${dbName}.variant_multi_file;
+ CREATE TABLE demo.${dbName}.variant_multi_file (id INT, v VARIANT)
USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='3',
+ 'write.format.default'='parquet',
+ 'write.parquet.shred-variants'='false'
+ );
+ INSERT INTO demo.${dbName}.variant_multi_file
+ VALUES (1, parse_json('{"a":1,"shared":10}'));
+ ALTER TABLE demo.${dbName}.variant_multi_file SET TBLPROPERTIES (
+ 'write.parquet.shred-variants'='false',
+ 'write.parquet.variant-inference-buffer-size'='1'
+ );
+ INSERT INTO demo.${dbName}.variant_multi_file
+ VALUES (2, parse_json('{"b":2,"shared":20,"z":200}'));
+ INSERT INTO demo.${dbName}.variant_multi_file
+ VALUES (3, parse_json('{"z":300,"shared":30,"a":3}'));
+ ALTER TABLE demo.${dbName}.variant_multi_file SET TBLPROPERTIES
+ ('write.parquet.shred-variants'='false');
+ INSERT INTO demo.${dbName}.variant_multi_file
+ VALUES (4, parse_json('{"c":4,"shared":40}'));
+ ALTER TABLE demo.${dbName}.variant_multi_file SET TBLPROPERTIES
+ ('write.parquet.shred-variants'='false');
+ INSERT INTO demo.${dbName}.variant_multi_file
+ VALUES (5,
parse_json('{"shared":50,"b":5,"new_field":{"k":500}}'));
+
+ DROP TABLE IF EXISTS demo.${dbName}.variant_type_matrix;
+ CREATE TABLE demo.${dbName}.variant_type_matrix (id INT, v VARIANT)
USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='3',
+ 'write.format.default'='parquet',
+ 'write.parquet.shred-variants'='false',
+ 'write.parquet.variant-inference-buffer-size'='100'
+ );
+ INSERT INTO demo.${dbName}.variant_type_matrix SELECT 1,
to_variant_object(named_struct(
+ 'bool_value', true,
+ 'tiny_value', CAST(-128 AS TINYINT),
+ 'small_value', CAST(-32768 AS SMALLINT),
+ 'int_value', CAST(2147483647 AS INT),
+ 'big_value', CAST('-9223372036854775808' AS BIGINT),
+ 'float_value', CAST('NaN' AS FLOAT),
+ 'double_value', CAST('Infinity' AS DOUBLE),
+ 'decimal_value', CAST('-1234567890.1234' AS DECIMAL(20, 4)),
+ 'date_value', CAST('1970-01-02' AS DATE),
+ 'timestamp_value', TIMESTAMP'1970-01-01 00:00:01.234567',
+ 'binary_value', CAST('binary' AS BINARY),
+ 'null_value', CAST(NULL AS INT)
+ ));
+
+ DROP TABLE IF EXISTS demo.${dbName}.variant_multi_row_group;
+ CREATE TABLE demo.${dbName}.variant_multi_row_group (id INT, v
VARIANT) USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='3',
+ 'write.format.default'='parquet',
+ 'write.parquet.shred-variants'='false',
+ 'write.parquet.variant-inference-buffer-size'='100',
+ 'write.parquet.row-group-size-bytes'='4096'
+ );
+ SET spark.sql.shuffle.partitions=1;
+ INSERT INTO demo.${dbName}.variant_multi_row_group
+ SELECT /*+ COALESCE(1) */ CAST(id AS INT), parse_json(concat(
+ '{"n":', id, ',"padding":"', repeat('x', 256), '"}'))
+ FROM range(0, 8192);
+
+ DROP TABLE IF EXISTS demo.${dbName}.variant_deletion_vector;
+ CREATE TABLE demo.${dbName}.variant_deletion_vector (id INT, v
VARIANT) USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='3',
+ 'write.format.default'='parquet',
+ 'write.parquet.shred-variants'='false',
+ 'write.parquet.variant-inference-buffer-size'='100',
+ 'write.delete.mode'='merge-on-read',
+ 'read.parquet.vectorization.enabled'='false',
+ 'write.parquet.row-group-size-bytes'='4096'
+ );
+ INSERT INTO demo.${dbName}.variant_deletion_vector
+ SELECT /*+ COALESCE(1) */ CAST(id AS INT), parse_json(concat('{"n":',
id, ',"keep":',
+ IF(id % 2 = 0, 'true', 'false'), '}'))
+ FROM range(0, 4096);
+ RESET spark.sql.shuffle.partitions;
+
+ DROP TABLE IF EXISTS demo.${dbName}.variant_equality_delete;
+ CREATE TABLE demo.${dbName}.variant_equality_delete (id INT, v
VARIANT) USING iceberg
+ TBLPROPERTIES (
+ 'format-version'='3',
+ 'write.format.default'='parquet',
+ 'write.parquet.shred-variants'='false',
+ 'write.parquet.variant-inference-buffer-size'='100'
+ );
+ INSERT INTO demo.${dbName}.variant_equality_delete VALUES
+ (1, parse_json('{"n":10,"label":"keep-one"}')),
+ (2, parse_json('{"n":20,"label":"delete"}')),
+ (3, parse_json('{"n":30,"label":"keep-three"}'));
+
DROP TABLE IF EXISTS demo.${dbName}.variant_page_pruning;
DROP TABLE IF EXISTS demo.${dbName}.variant_nested;
@@ -112,7 +254,7 @@ suite("test_iceberg_variant_read",
TBLPROPERTIES (
'format-version'='3',
'write.format.default'='parquet',
- 'write.parquet.shred-variants'='true',
+ 'write.parquet.shred-variants'='false',
'write.parquet.variant-inference-buffer-size'='100'
);
INSERT INTO demo.${dbName}.variant_nested SELECT
@@ -134,7 +276,7 @@ suite("test_iceberg_variant_read",
TBLPROPERTIES (
'format-version'='3',
'write.format.default'='parquet',
- 'write.parquet.shred-variants'='true',
+ 'write.parquet.shred-variants'='false',
'write.parquet.variant-inference-buffer-size'='100'
);
INSERT INTO demo.${dbName}.variant_signed_selector
@@ -156,10 +298,105 @@ suite("test_iceberg_variant_read",
INSERT INTO demo.${dbName}.variant_write_guard VALUES (1);
"""
+ List<List<Object>> multiFileDataFiles = spark_iceberg """
+ SELECT COUNT(*) FROM demo.${dbName}.variant_multi_file.files WHERE
content = 0
+ """
+ assertEquals(1, multiFileDataFiles.size())
+ assertTrue(Long.parseLong(multiFileDataFiles[0][0].toString()) > 1,
+ "The parallel Variant fixture must contain multiple data files")
+
+ List<List<Object>> multiRowGroupFiles = spark_iceberg """
+ SELECT COUNT(*) FROM demo.${dbName}.variant_multi_row_group.files
WHERE content = 0
+ """
+ assertEquals(1, multiRowGroupFiles.size())
+ assertEquals("1", multiRowGroupFiles[0][0].toString(),
+ "The multi-row-group fixture must contain exactly one data file")
+
+ String equalityDeleteBaseSnapshot =
latestSnapshotId("variant_equality_delete")
+ String equalityDeleteJava = '''
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.DeleteFile;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.data.parquet.GenericParquetWriter;
+import org.apache.iceberg.deletes.EqualityDeleteWriter;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.parquet.Parquet;
+
+public class AppendVariantEqualityDelete {
+ public static void main(String[] args) throws Exception {
+ Map<String, String> props = new HashMap<>();
+ props.put("type", "rest");
+ props.put("uri", "http://rest:8181");
+ props.put("warehouse", "s3://warehouse/wh/");
+ props.put("io-impl", "org.apache.iceberg.aws.s3.S3FileIO");
+ props.put("s3.endpoint", "http://minio:9000");
+ props.put("s3.path-style-access", "true");
+ props.put("s3.region", "us-east-1");
+ Catalog catalog = CatalogUtil.buildIcebergCatalog("demo", props, null);
+ Table table = catalog.loadTable(TableIdentifier.of(args[0], args[1]));
+ Schema equalitySchema = table.schema().select("id");
+ int fieldId = table.schema().findField("id").fieldId();
+ OutputFile output = table.io().newOutputFile(
+ table.location() + "/data/variant-equality-delete-" +
+ System.currentTimeMillis() + ".parquet");
+ EqualityDeleteWriter<Record> writer = Parquet.writeDeletes(output)
+ .forTable(table)
+ .rowSchema(equalitySchema)
+ .withSpec(PartitionSpec.unpartitioned())
+ .createWriterFunc(GenericParquetWriter::create)
+ .equalityFieldIds(fieldId)
+ .overwrite()
+ .buildEqualityWriter();
+ GenericRecord record = GenericRecord.create(equalitySchema);
+ record.setField("id", Integer.valueOf(args[2]));
+ writer.write(record);
+ writer.close();
+ DeleteFile deleteFile = writer.toDeleteFile();
+ table.newRowDelta().addDeletes(deleteFile).commit();
+ }
+}
+'''
+ String encodedEqualityDeleteJava =
+ equalityDeleteJava.getBytes("UTF-8").encodeBase64().toString()
+ runInSparkContainer(
+ "echo ${encodedEqualityDeleteJava} | base64 -d " +
+ ">/tmp/AppendVariantEqualityDelete.java && " +
+ "javac -cp \"/opt/spark/jars/*\" " +
+ "/tmp/AppendVariantEqualityDelete.java && " +
+ "java -cp \"/tmp:/opt/spark/jars/*\"
AppendVariantEqualityDelete " +
+ "${dbName} variant_equality_delete 2")
+
String writeGuardSourceSnapshot = latestSnapshotId("variant_write_guard")
+ String deletionVectorBaseSnapshot =
latestSnapshotId("variant_deletion_vector")
spark_iceberg """
ALTER TABLE demo.${dbName}.variant_write_guard ADD COLUMN payload
VARIANT
"""
+ spark_iceberg """
+ DELETE FROM demo.${dbName}.variant_deletion_vector WHERE id % 2 = 1
+ """
+ List<List<Object>> deletionVectorFiles = spark_iceberg """
+ SELECT file_format, content_offset, content_size_in_bytes
+ FROM demo.${dbName}.variant_deletion_vector.files
+ WHERE content = 1
+ """
+ assertFalse(deletionVectorFiles.isEmpty(),
+ "The Variant deletion fixture must expose a live delete file")
+ deletionVectorFiles.each { List<Object> deleteFile ->
+ assertEquals("PUFFIN", deleteFile[0].toString().toUpperCase(),
+ "The format-v3 Variant fixture must use PUFFIN deletion
vectors")
+ assertTrue(Long.parseLong(deleteFile[1].toString()) >= 0,
+ "A PUFFIN deletion vector must expose its content offset")
+ assertTrue(Long.parseLong(deleteFile[2].toString()) > 0,
+ "A PUFFIN deletion vector must expose its content size")
+ }
// Register a stable Iceberg metadata fixture so the page-pruning case
always uses a
// standards-compliant shredded Variant file, independent of the Spark
writer version.
@@ -175,6 +412,21 @@ suite("test_iceberg_variant_read",
metadata_file =>
's3a://warehouse/wh/${dbName}/variant_page_pruning/metadata/${shreddedMetadataName}')
"""
+ String shreddedOnlySnapshot = latestSnapshotId("variant_page_pruning")
+ spark_iceberg_multi """
+ ALTER TABLE demo.${dbName}.variant_page_pruning SET TBLPROPERTIES (
+ 'read.parquet.vectorization.enabled'='false',
+ 'write.delete.mode'='merge-on-read'
+ );
+ INSERT INTO demo.${dbName}.variant_page_pruning VALUES
+ (5000, parse_json('{"n":5000,"padding":"mixed-unshredded"}'));
+ """
+ String mixedBeforeDeleteSnapshot = latestSnapshotId("variant_page_pruning")
+ // One deletion vector targets the shredded fixture and another targets
the appended
+ // unshredded file, forcing both physical states through the same scan and
delete alignment.
+ spark_iceberg """
+ DELETE FROM demo.${dbName}.variant_page_pruning WHERE id IN (4095,
5000)
+ """
sql """drop catalog if exists ${catalogName}"""
sql """
@@ -198,27 +450,61 @@ suite("test_iceberg_variant_read",
sql """set profile_level=2"""
def profileAction = new ProfileAction(context)
- def getProfileByToken = { String token ->
- for (int retry = 0; retry < 20; ++retry) {
- List profileData = profileAction.getProfileList()
- for (final def profileItem in profileData) {
- if (profileItem["Sql Statement"].toString().contains(token)) {
- return profileAction.getProfile(profileItem["Profile
ID"].toString())
- }
+ def mergedProfile = { String profile ->
+ if (!profile.contains("MergedProfile:")) {
+ return profile
+ }
+ String merged = profile.substring(profile.indexOf("MergedProfile:"))
+ int end = merged.length()
+ ["DetailProfile(", "Execution Profile:", "Appendix:"].each { String
sectionName ->
+ int sectionIndex = merged.indexOf(sectionName)
+ if (sectionIndex > 0) {
+ end = Math.min(end, sectionIndex)
}
- Thread.sleep(500)
}
- throw new IllegalStateException("Missing profile for token: " + token)
+ return merged.substring(0, end)
}
def counterSum = { String profile, String counterName ->
- Pattern pattern = Pattern.compile(Pattern.quote(counterName) +
":\\s*([0-9,]+)")
- Matcher matcher = pattern.matcher(profile)
+ Pattern pattern = Pattern.compile("(?m)^\\s*(?:-\\s*)?" +
+ Pattern.quote(counterName) + ":\\s+([^\\n]+)")
+ Matcher matcher = pattern.matcher(mergedProfile(profile))
long sum = 0
while (matcher.find()) {
- sum += Long.parseLong(matcher.group(1).replace(",", ""))
+ String valueText = matcher.group(1)
+ // Merged counters may be human-readable; the parenthesized value
is the exact sum.
+ Matcher exact =
Pattern.compile("\\(([0-9,]+)\\)").matcher(valueText)
+ Matcher number = Pattern.compile("([0-9,]+)").matcher(valueText)
+ if (exact.find()) {
+ sum += Long.parseLong(exact.group(1).replace(",", ""))
+ } else if (number.find()) {
+ sum += Long.parseLong(number.group(1).replace(",", ""))
+ }
}
return sum
}
+ def profileInfoValues = { String profile, String infoName ->
+ Pattern pattern = Pattern.compile(
+ Pattern.quote(infoName) + ":\\s*\\[([^\\]]*)\\]")
+ Matcher matcher = pattern.matcher(profile)
+ if (!matcher.find()) {
+ return []
+ }
+ return matcher.group(1).split(",").collect { String value ->
value.trim() }
+ .findAll { String value -> !value.isEmpty() }
+ .collect { String value -> Long.parseLong(value.replace(",",
"")) }
+ }
+ def getProfileByToken = { String token, List<String> positiveCounters = []
->
+ String lastProfile = profileAction.getProfileBySql(token,
positiveCounters)
+ if (positiveCounters.every { String counter -> counterSum(lastProfile,
counter) > 0 }) {
+ return lastProfile
+ }
+ return profileAction.waitProfile({
+ lastProfile = profileAction.getProfileBySql(token,
positiveCounters)
+ return positiveCounters.every {
+ String counter -> counterSum(lastProfile, counter) > 0
+ } ? lastProfile : ""
+ }, [], "Completed profile with positive counters ${positiveCounters}
for ${token}")
+ }
String evolutionInitial = latestSnapshotId("variant_evolution")
sql """ALTER TABLE variant_evolution CREATE TAG variant_initial"""
@@ -306,6 +592,18 @@ suite("test_iceberg_variant_read",
ORDER BY id
"""
+ order_qt_variant_root_array_projection """
+ SELECT id,
+ v IS NULL,
+ CAST(v AS STRING),
+ CAST(v[1] AS STRING),
+ CAST(v[2] AS INT),
+ CAST(v[3]['x'] AS INT),
+ CAST(v[4][2] AS INT)
+ FROM variant_root_arrays
+ ORDER BY id
+ """
+
order_qt_variant_path_expressions """
SELECT id,
UPPER(CAST(v['name'] AS STRING)),
@@ -327,8 +625,8 @@ suite("test_iceberg_variant_read",
ORDER BY id
"""
- // The first INSERT is unshredded while the second is shredded. Keep both
small files on one
- // scanner so their complete and leaf-only physical states must be
projected before batching.
+ // Keep the independent Spark writes on one scanner to exercise metadata
dictionaries and
+ // complete Variant state transitions across file boundaries before
batching.
sql "set parallel_pipeline_task_num=1"
sql "set max_file_scanners_concurrency=1"
order_qt_variant_cross_file_leaf_projection """
@@ -337,44 +635,218 @@ suite("test_iceberg_variant_read",
ORDER BY id
"""
- // Keep the root Variant as output while the implicit scalar comparison
drives the shredded
- // typed_value statistics/page-index path.
- order_qt_variant_implicit_shredded_filter """
+ order_qt_variant_multi_file_serial """
+ SELECT id,
+ CAST(v['shared'] AS INT),
+ CAST(v['a'] AS INT),
+ CAST(v['b'] AS INT),
+ CAST(v['new_field']['k'] AS INT),
+ CAST(v AS STRING)
+ FROM variant_multi_file
+ WHERE v['shared'] >= 20
+ ORDER BY id
+ """
+ sql "set parallel_pipeline_task_num=4"
+ sql "set max_file_scanners_concurrency=8"
+ sql "set min_file_scanners_concurrency=4"
+ order_qt_variant_multi_file_parallel """
+ SELECT id,
+ CAST(v['shared'] AS INT),
+ CAST(v['a'] AS INT),
+ CAST(v['b'] AS INT),
+ CAST(v['new_field']['k'] AS INT),
+ CAST(v AS STRING)
+ FROM variant_multi_file
+ WHERE v['shared'] >= 20
+ ORDER BY id
+ """
+ String parallelScanToken =
+ "iceberg_variant_parallel_scan_" + UUID.randomUUID().toString()
+ List<List<Object>> parallelScanRows = sql """
+ SELECT '${parallelScanToken}', id,
+ CAST(v['shared'] AS INT),
+ CAST(v['a'] AS INT),
+ CAST(v['b'] AS INT),
+ CAST(v['new_field']['k'] AS INT),
+ CAST(v AS STRING)
+ FROM variant_multi_file
+ WHERE v['shared'] >= 20
+ ORDER BY id
+ """
+ assertEquals(4, parallelScanRows.size(),
+ "The parallel Variant query must read rows from multiple data
files")
+ String parallelScanProfile = profileAction.getProfileBySql(
+ parallelScanToken, ["PerScannerRowsRead"])
+ if (profileInfoValues(parallelScanProfile, "PerScannerRowsRead")
+ .count { long rows -> rows > 0 } <= 1) {
+ parallelScanProfile = profileAction.waitProfile({
+ String profile = profileAction.getProfileBySql(
+ parallelScanToken, ["PerScannerRowsRead"])
+ return profileInfoValues(profile, "PerScannerRowsRead")
+ .count { long rows -> rows > 0 } > 1 ? profile : ""
+ }, [], "Completed parallel Variant profile with multiple non-empty
scanners")
+ }
+ assertTrue(profileInfoValues(parallelScanProfile, "PerScannerRowsRead")
+ .count { long rows -> rows > 0 } > 1,
+ "The parallel Variant query did not use multiple non-empty
scanners")
+ sql "set min_file_scanners_concurrency=1"
+
+ order_qt_variant_type_matrix """
+ SELECT CAST(v['bool_value'] AS BOOLEAN),
+ CAST(v['tiny_value'] AS TINYINT),
+ CAST(v['small_value'] AS SMALLINT),
+ CAST(v['int_value'] AS INT),
+ CAST(v['big_value'] AS BIGINT),
+ ISNAN(CAST(v['float_value'] AS FLOAT)),
+ ISINF(CAST(v['double_value'] AS DOUBLE)),
+ CAST(v['decimal_value'] AS DECIMAL(20, 4)),
+ CAST(v['date_value'] AS DATE),
+ CAST(v['timestamp_value'] AS DATETIMEV2(6)),
+ CAST(v['binary_value'] AS STRING),
+ v['null_value'] IS NULL
+ FROM variant_type_matrix
+ """
+
+ String multiRowGroupColdToken =
+ "iceberg_variant_multi_row_group_cold_" +
UUID.randomUUID().toString()
+ sql """
+ SELECT '${multiRowGroupColdToken}', COUNT(*), MIN(id), MAX(id)
+ FROM variant_multi_row_group
+ WHERE CAST(v['n'] AS INT) >= 8000
+ """
+ String multiRowGroupColdProfile = getProfileByToken(multiRowGroupColdToken,
+ ["RowGroupsTotalNum", "VariantDirectLeafPathMisses",
"VariantReconstructedRows",
+ "FilteredRowsByLazyRead"]).toString()
+ assertTrue(counterSum(multiRowGroupColdProfile, "RowGroupsTotalNum") > 1,
+ "The generated Variant file did not contain multiple Parquet
row groups")
+ assertTrue(counterSum(multiRowGroupColdProfile,
"VariantDirectLeafPathMisses") > 0,
+ "The unshredded scan did not record its direct-leaf fallback")
+ assertTrue(counterSum(multiRowGroupColdProfile,
"VariantReconstructedRows") > 0,
+ "The unshredded scan did not reconstruct Variant rows")
+ assertTrue(counterSum(multiRowGroupColdProfile, "FilteredRowsByLazyRead")
> 0,
+ "The unshredded Variant predicate did not defer non-predicate
columns")
+ String multiRowGroupWarmToken =
+ "iceberg_variant_multi_row_group_warm_" +
UUID.randomUUID().toString()
+ sql """
+ SELECT '${multiRowGroupWarmToken}', COUNT(*), MIN(id), MAX(id)
+ FROM variant_multi_row_group
+ WHERE CAST(v['n'] AS INT) >= 8000
+ """
+ String multiRowGroupWarmProfile = getProfileByToken(multiRowGroupWarmToken,
+ ["VariantDirectLeafPathMisses"]).toString()
+ assertTrue(counterSum(multiRowGroupWarmProfile,
"VariantDirectLeafPathMisses") > 0,
+ "The warm unshredded scan did not preserve its direct-leaf
fallback")
+ qt_variant_multi_row_group_result """
+ SELECT COUNT(*), MIN(id), MAX(id), SUM(CAST(v['n'] AS BIGINT))
+ FROM variant_multi_row_group
+ WHERE CAST(v['n'] AS INT) >= 8000
+ """
+
+ qt_variant_deletion_vector_current """
+ SELECT COUNT(*), MIN(id), MAX(id), SUM(CAST(v['n'] AS BIGINT))
+ FROM variant_deletion_vector
+ WHERE v['n'] >= 0
+ """
+ qt_variant_deletion_vector_before_delete """
+ SELECT COUNT(*), MIN(id), MAX(id), SUM(CAST(v['n'] AS BIGINT))
+ FROM variant_deletion_vector FOR VERSION AS OF
${deletionVectorBaseSnapshot}
+ WHERE v['n'] >= 0
+ """
+ order_qt_variant_equality_delete_current """
+ SELECT id, CAST(v['n'] AS INT), CAST(v['label'] AS STRING), CAST(v AS
STRING)
+ FROM variant_equality_delete
+ WHERE v['n'] >= 0
+ ORDER BY id
+ """
+ order_qt_variant_equality_delete_before_delete """
+ SELECT id, CAST(v['n'] AS INT), CAST(v['label'] AS STRING), CAST(v AS
STRING)
+ FROM variant_equality_delete FOR VERSION AS OF
${equalityDeleteBaseSnapshot}
+ WHERE v['n'] >= 0
+ ORDER BY id
+ """
+
+ // Keep the root Variant as output while the scalar comparison exercises
the fallback path for
+ // the unshredded Spark files.
+ order_qt_variant_implicit_filter """
SELECT id, CAST(v AS STRING)
FROM variant_values
WHERE v['n'] > 35
ORDER BY id
"""
- // The query projects the root Variant, while the predicate uses
typed_value page metadata.
+ qt_variant_shredded_only_time_travel """
+ SELECT COUNT(*), MIN(id), MAX(id), SUM(CAST(v['n'] AS BIGINT))
+ FROM variant_page_pruning FOR VERSION AS OF ${shreddedOnlySnapshot}
+ WHERE CAST(v['n'] AS INT) > 3000
+ """
+ qt_variant_mixed_before_delete """
+ SELECT COUNT(*), MIN(id), MAX(id), SUM(CAST(v['n'] AS BIGINT))
+ FROM variant_page_pruning FOR VERSION AS OF
${mixedBeforeDeleteSnapshot}
+ WHERE CAST(v['n'] AS INT) > 3000
+ """
+
+ // The complete Variant is the only scanned output column outside the
predicate. A positive
+ // lazy-read count therefore proves Variant output deferral rather than
deferral of an id
+ // sibling, while the row relationship proves reconstruction happens after
filtering.
+ String lazyVariantToken =
+ "iceberg_variant_lazy_materialization_" +
UUID.randomUUID().toString()
+ List<List<Object>> lazyVariantRows = sql """
+ SELECT '${lazyVariantToken}', CAST(v AS STRING)
+ FROM variant_page_pruning FOR VERSION AS OF ${shreddedOnlySnapshot}
+ WHERE CAST(v['n'] AS INT) > 3000
+ """
+ String lazyVariantProfile = getProfileByToken(lazyVariantToken,
+ ["VariantDirectLeafRows", "VariantReconstructedRows",
+ "FilteredRowsByLazyRead"]).toString()
+ long reconstructedVariantRows =
+ counterSum(lazyVariantProfile, "VariantReconstructedRows")
+ assertEquals((long) lazyVariantRows.size(), reconstructedVariantRows,
+ "Complete Variant reconstruction must be limited to selected
output rows")
+ assertTrue(counterSum(lazyVariantProfile, "VariantDirectLeafRows") >
+ reconstructedVariantRows,
+ "Variant output was not deferred until after its shredded-leaf
predicate")
+ assertTrue(counterSum(lazyVariantProfile, "FilteredRowsByLazyRead") > 0,
+ "The shredded predicate did not defer complete Variant output")
+
+ // The query projects the complete Variant while its predicate reads the
shredded typed leaf.
+ // The appended unshredded file must fall back independently in the same
scan.
String pagePruningToken = "iceberg_variant_page_pruning_" +
UUID.randomUUID().toString()
sql """
SELECT '${pagePruningToken}', id, CAST(v AS STRING)
FROM variant_page_pruning
- WHERE v['n'] > 3000
+ WHERE CAST(v['n'] AS INT) > 3000
ORDER BY id
"""
- String pagePruningProfile = getProfileByToken(pagePruningToken).toString()
+ String pagePruningProfile = getProfileByToken(pagePruningToken,
+ ["FilteredRowsByPage", "VariantLeafProjections",
"VariantDirectLeafPathMisses",
+ "VariantDirectLeafRows", "VariantReconstructedRows"]).toString()
assertTrue(counterSum(pagePruningProfile, "FilteredRowsByPage") > 0,
"Shredded Variant typed_value did not filter any Parquet page")
// The predicate_access_paths contract keeps the typed leaf eager while
the complete Variant
// root is read through the independent deferred-output projection.
assertTrue(counterSum(pagePruningProfile, "VariantLeafProjections") > 0,
"A root Variant output query did not retain its typed predicate
leaf projection")
+ assertTrue(counterSum(pagePruningProfile, "VariantDirectLeafPathMisses") >
0,
+ "The mixed scan did not fall back for its unshredded Variant
file")
+ assertTrue(counterSum(pagePruningProfile, "VariantDirectLeafRows") > 0,
+ "The mixed scan did not evaluate rows from the shredded typed
leaf")
+ assertTrue(counterSum(pagePruningProfile, "VariantReconstructedRows") > 0,
+ "The mixed scan did not reconstruct complete Variant output")
String leafProjectionToken =
"iceberg_variant_leaf_projection_" + UUID.randomUUID().toString()
sql """
SELECT '${leafProjectionToken}', COUNT(*)
FROM variant_page_pruning
- WHERE v['n'] > 3000
+ WHERE CAST(v['n'] AS INT) > 3000
"""
- String leafProjectionProfile =
getProfileByToken(leafProjectionToken).toString()
+ String leafProjectionProfile = getProfileByToken(leafProjectionToken,
+ ["VariantLeafProjections"]).toString()
assertTrue(counterSum(leafProjectionProfile, "VariantLeafProjections") > 0,
"Variant typed predicate did not retain a physical leaf
projection")
qt_variant_page_pruning_result """
SELECT COUNT(*), MIN(id), MAX(id)
FROM variant_page_pruning
- WHERE v['n'] > 3000
+ WHERE CAST(v['n'] AS INT) > 3000
"""
order_qt_variant_aggregate """
@@ -521,6 +993,7 @@ suite("test_iceberg_variant_read",
// A delete-only MERGE emits only position deletes. It must remain
available even though
// update/insert actions would route the unchanged Variant through the
unsupported data writer.
+ String beforePositionDeleteSnapshot = latestSnapshotId("variant_values")
sql """
MERGE INTO variant_values t
USING (SELECT 11 AS id) s
@@ -528,6 +1001,31 @@ suite("test_iceberg_variant_read",
WHEN MATCHED THEN DELETE
"""
qt_variant_delete_only_merge "SELECT COUNT(*) FROM variant_values WHERE id
= 11"
+ order_qt_variant_position_delete_alignment """
+ SELECT id, CAST(v['name'] AS STRING), CAST(v['n'] AS INT), CAST(v AS
STRING)
+ FROM variant_values
+ WHERE v['n'] >= 40
+ ORDER BY id
+ """
+ order_qt_variant_before_position_delete """
+ SELECT id, CAST(v['name'] AS STRING), CAST(v['n'] AS INT), CAST(v AS
STRING)
+ FROM variant_values FOR VERSION AS OF ${beforePositionDeleteSnapshot}
+ WHERE v['n'] >= 40
+ ORDER BY id
+ """
+ String positionDeleteToken =
+ "iceberg_variant_position_delete_" + UUID.randomUUID().toString()
+ sql """
+ SELECT '${positionDeleteToken}', COUNT(*)
+ FROM variant_values
+ WHERE v['n'] >= 40
+ """
+ String positionDeleteProfile = getProfileByToken(positionDeleteToken,
+ ["VariantDirectLeafPathMisses",
"VariantReconstructedRows"]).toString()
+ assertTrue(counterSum(positionDeleteProfile,
"VariantDirectLeafPathMisses") > 0,
+ "Position-delete filtering did not preserve the unshredded
Variant fallback")
+ assertTrue(counterSum(positionDeleteProfile, "VariantReconstructedRows") >
0,
+ "Position-delete filtering did not reconstruct its Variant
rows")
// Files written before the Variant field existed have no physical Variant
payload. Schema
// evolution must synthesize NULL instead of rejecting their non-Parquet
file format.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]