This is an automated email from the ASF dual-hosted git repository.
lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new ccfe3466 perf(read): skip the payload Concatenate when late
materialization matches a single run (#336)
ccfe3466 is described below
commit ccfe346670f32b82d7c9139a8f3d356c33dc5b7d
Author: Yonghao Fang <[email protected]>
AuthorDate: Tue Sep 15 13:23:59 2026 +0800
perf(read): skip the payload Concatenate when late materialization matches
a single run (#336)
---
.../predicate/multi_literals_leaf_function.cpp | 13 +--
.../predicate/null_false_leaf_binary_function.cpp | 11 +-
.../late_materializing_file_batch_reader.cpp | 15 ++-
.../late_materializing_file_batch_reader_test.cpp | 81 ++++++++++++++
src/paimon/common/utils/arrow/arrow_utils.cpp | 76 +++++++++++++
src/paimon/common/utils/arrow/arrow_utils.h | 16 +++
src/paimon/common/utils/arrow/arrow_utils_test.cpp | 123 +++++++++++++++++++++
7 files changed, 317 insertions(+), 18 deletions(-)
diff --git a/src/paimon/common/predicate/multi_literals_leaf_function.cpp
b/src/paimon/common/predicate/multi_literals_leaf_function.cpp
index c8a41812..d423e57c 100644
--- a/src/paimon/common/predicate/multi_literals_leaf_function.cpp
+++ b/src/paimon/common/predicate/multi_literals_leaf_function.cpp
@@ -31,6 +31,7 @@
#include "arrow/memory_pool.h"
#include "arrow/type.h"
#include "paimon/common/predicate/literal_converter.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
#include "paimon/common/utils/arrow/status_utils.h"
#include "paimon/common/utils/checked_cast.h"
#include "paimon/data/decimal.h"
@@ -201,15 +202,9 @@ Result<std::vector<char>> ProbeInValueSet(const
arrow::Array& array, const arrow
// last owner at the end of the statement and leave that reference
dangling.
std::shared_ptr<arrow::Array> matches_array = matches.make_array();
const auto& matched = checked_cast<const
arrow::BooleanArray&>(*matches_array);
- std::vector<char> is_valid(matched.length(), 0);
- for (int64_t i = 0; i < matched.length(); i++) {
- if (matched.IsNull(i)) {
- // `IN` and `NOT IN` are both false on a null value, leave the row
at 0.
- continue;
- }
- is_valid[i] = static_cast<char>(matched.Value(i) != negate);
- }
- return is_valid;
+ // `IN` and `NOT IN` are both false on a null value, so the null rows
unpack to 0 rather than to
+ // the negation of a value they do not hold.
+ return ArrowUtils::UnpackBooleansToBytes(matched, negate);
}
} // namespace
diff --git a/src/paimon/common/predicate/null_false_leaf_binary_function.cpp
b/src/paimon/common/predicate/null_false_leaf_binary_function.cpp
index a53c0610..5968ca28 100644
--- a/src/paimon/common/predicate/null_false_leaf_binary_function.cpp
+++ b/src/paimon/common/predicate/null_false_leaf_binary_function.cpp
@@ -32,6 +32,7 @@
#include "arrow/scalar.h"
#include "arrow/type.h"
#include "paimon/common/predicate/literal_converter.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
#include "paimon/common/utils/arrow/status_utils.h"
#include "paimon/common/utils/checked_cast.h"
#include "paimon/common/utils/field_type_utils.h"
@@ -196,13 +197,9 @@ Result<std::vector<char>> ProbeComparison(const
arrow::Array& array, const char*
// last owner at the end of the statement and leave that reference
dangling.
std::shared_ptr<arrow::Array> matches_array = matches.make_array();
const auto& matched = checked_cast<const
arrow::BooleanArray&>(*matches_array);
- std::vector<char> is_valid(matched.length(), 0);
- for (int64_t i = 0; i < matched.length(); i++) {
- // A kernel emits a null for a null row, and every
`NullFalseLeafBinaryFunction` is false on
- // one, so a null row is left at 0 rather than read as a value.
- is_valid[i] = static_cast<char>(!matched.IsNull(i) &&
matched.Value(i));
- }
- return is_valid;
+ // A kernel emits a null for a null row, and every
`NullFalseLeafBinaryFunction` is false on
+ // one, so the null rows unpack to 0 rather than to a value they do not
hold.
+ return ArrowUtils::UnpackBooleansToBytes(matched, /*negate=*/false);
}
} // namespace
diff --git a/src/paimon/common/reader/late_materializing_file_batch_reader.cpp
b/src/paimon/common/reader/late_materializing_file_batch_reader.cpp
index a5984c70..5554b92b 100644
--- a/src/paimon/common/reader/late_materializing_file_batch_reader.cpp
+++ b/src/paimon/common/reader/late_materializing_file_batch_reader.cpp
@@ -193,8 +193,19 @@ Result<FileBatchReader::ReadBatch>
LateMaterializingFileBatchReader::ReadPayload
// Compact the payload superset down to the matched rows (ascending
file row order).
PAIMON_ASSIGN_OR_RAISE(arrow::ArrayVector payload_slices,
ReaderUtils::GenerateFilteredArrayVector(payload_array, valid));
- PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array>
payload_compacted,
- arrow::Concatenate(payload_slices,
arrow_pool_.get()));
+ std::shared_ptr<arrow::Array> payload_compacted;
+ if (payload_slices.size() == 1) {
+ // A single run has nothing to merge, and `AssembleFullBatch`
normalizes each column
+ // anyway, so hand the slice over rather than let `Concatenate`
copy the payload a
+ // second time. A run at the batch head then stays zero-copy; a
mid-batch run is still
+ // copied once by that normalization, so this is never worse.
Where it stays zero-copy
+ // the slice keeps the batch's buffers alive until the consumer
releases the assembled
+ // batch, bounded by the batch size times the prefetch queue depth.
+ payload_compacted = std::move(payload_slices.front());
+ } else {
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+ payload_compacted, arrow::Concatenate(payload_slices,
arrow_pool_.get()));
+ }
auto card = static_cast<int64_t>(valid.Cardinality());
if (probe_cursor_ + card > probe_data_->length()) {
diff --git
a/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp
b/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp
index 4346eeed..0849836c 100644
--- a/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp
+++ b/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp
@@ -29,6 +29,7 @@
#include <vector>
#include "arrow/api.h"
+#include "arrow/array/builder_dict.h"
#include "arrow/array/builder_nested.h"
#include "arrow/c/bridge.h"
#include "gtest/gtest.h"
@@ -214,6 +215,31 @@ class LateMaterializingFileBatchReaderTest : public
::testing::Test {
return array;
}
+ // Build a struct with a dictionary-encoded payload column [k:int64,
v:dictionary<int32,utf8>].
+ std::shared_ptr<arrow::Array> BuildDictionaryPayloadData(int32_t n) {
+ auto type =
+ arrow::struct_({arrow::field("k", arrow::int64()),
+ arrow::field("v",
arrow::dictionary(arrow::int32(), arrow::utf8()))});
+ // `StringDictionaryBuilder` picks the narrowest index type its
dictionary fits in - int8
+ // for the three values below - and `StructBuilder::type()` takes each
field type from its
+ // builder, so the column would come out as `dictionary<int8,utf8>`
whatever `type` says.
+ // The 32 builder is what keeps the index width this fixture declares.
+ arrow::StructBuilder builder(type, arrow::default_memory_pool(),
+ {std::make_shared<arrow::Int64Builder>(),
+
std::make_shared<arrow::StringDictionary32Builder>()});
+ auto* k = checked_cast<arrow::Int64Builder*>(builder.field_builder(0));
+ auto* v =
checked_cast<arrow::StringDictionary32Builder*>(builder.field_builder(1));
+ for (int32_t i = 0; i < n; ++i) {
+ EXPECT_TRUE(builder.Append().ok());
+ EXPECT_TRUE(k->Append(i).ok());
+ // Repeat a few values so the dictionary is shorter than the
column it encodes.
+ EXPECT_TRUE(v->Append("d_" + std::to_string(i % 3)).ok());
+ }
+ std::shared_ptr<arrow::Array> array;
+ EXPECT_TRUE(builder.Finish(&array).ok());
+ return array;
+ }
+
protected:
std::shared_ptr<MemoryPool> pool_;
std::shared_ptr<arrow::Field> k_field_;
@@ -533,6 +559,61 @@ TEST_F(LateMaterializingFileBatchReaderTest,
NestedPayloadColumn) {
}
}
+// A payload column that the inner reader hands over dictionary-encoded has to
stay that way in the
+// output. Compacting a batch down to a single matched run passes the run
straight to the assembly
+// step rather than concatenating it, so on that path the encoding survives
only because nothing
+// rewrites the array. Both shapes of a lone run are covered: one starting at
the batch head, which
+// reaches the output untouched, and one starting mid-batch, which the
per-column offset
+// normalization still copies.
+TEST_F(LateMaterializingFileBatchReaderTest,
DictionaryPayloadColumnKeepsEncoding) {
+ auto data = BuildDictionaryPayloadData(8);
+ auto type = data->type();
+ auto mock = std::make_unique<MockFileBatchReader>(data, type,
/*batch_size=*/3);
+ ASSERT_OK_AND_ASSIGN(auto reader, LateMaterializingFileBatchReader::Create(
+ std::move(mock),
GetArrowPool(pool_)));
+ // probe = {k}; payload = {v}. k >= 5 matches only row 5 of the batch
holding rows 3..5, a run
+ // at batch offset 2, and both rows of the batch holding rows 6..7, a run
at batch offset 0.
+ auto predicate =
+ PredicateBuilder::GreaterOrEqual(/*field_index=*/0, "k",
FieldType::BIGINT, Literal(5l));
+ ASSERT_OK(SetReadSchema(reader.get(), arrow::schema(type->fields()),
predicate, std::nullopt));
+
+ std::vector<std::pair<int64_t, std::string>> rows;
+ while (true) {
+ ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatchWithBitmap
batch_with_bitmap,
+ reader->NextBatchWithBitmap());
+ if (BatchReader::IsEofBatch(batch_with_bitmap)) {
+ break;
+ }
+ auto& [batch, bitmap] = batch_with_bitmap;
+ auto& [c_array, c_schema] = batch;
+ arrow::Result<std::shared_ptr<arrow::Array>> imported =
+ arrow::ImportArray(c_array.get(), c_schema.get());
+ ASSERT_TRUE(imported.ok()) << imported.status().ToString();
+ auto struct_array =
arrow::internal::checked_pointer_cast<arrow::StructArray>(*imported);
+ auto k = arrow::internal::checked_pointer_cast<arrow::Int64Array>(
+ struct_array->GetFieldByName("k"));
+ std::shared_ptr<arrow::Array> v = struct_array->GetFieldByName("v");
+ ASSERT_TRUE(k && v);
+ // The index width is checked before the casts below, which a release
build resolves
+ // statically and would otherwise read the index buffer at the wrong
width.
+ ASSERT_TRUE(v->type()->Equals(*arrow::dictionary(arrow::int32(),
arrow::utf8())))
+ << v->type()->ToString();
+ auto dict =
arrow::internal::checked_pointer_cast<arrow::DictionaryArray>(v);
+ auto indices =
arrow::internal::checked_pointer_cast<arrow::Int32Array>(dict->indices());
+ auto values =
arrow::internal::checked_pointer_cast<arrow::StringArray>(dict->dictionary());
+ ASSERT_TRUE(indices && values);
+ for (int64_t i = 0; i < struct_array->length(); ++i) {
+ rows.emplace_back(k->Value(i),
values->GetString(indices->Value(i)));
+ }
+ }
+ ASSERT_EQ(rows.size(), 3u); // k = 5,6,7
+ for (size_t i = 0; i < rows.size(); ++i) {
+ int64_t expected_k = 5 + static_cast<int64_t>(i);
+ EXPECT_EQ(rows[i].first, expected_k);
+ EXPECT_EQ(rows[i].second, "d_" + std::to_string(expected_k % 3));
+ }
+}
+
// The late-materialization reader must work correctly as an inner reader
driven by
// PrefetchFileBatchReaderImpl (schema broadcast, range dispatch, seek, row-id
tracking).
TEST_F(LateMaterializingFileBatchReaderTest, WorksAsInnerOfPrefetchReader) {
diff --git a/src/paimon/common/utils/arrow/arrow_utils.cpp
b/src/paimon/common/utils/arrow/arrow_utils.cpp
index 45884f5b..5a022240 100644
--- a/src/paimon/common/utils/arrow/arrow_utils.cpp
+++ b/src/paimon/common/utils/arrow/arrow_utils.cpp
@@ -19,9 +19,16 @@
#include "paimon/common/utils/arrow/arrow_utils.h"
+#include <algorithm>
+#include <array>
+#include <cstdint>
+#include <cstring>
+
#include "arrow/array/array_base.h"
#include "arrow/array/array_nested.h"
+#include "arrow/array/array_primitive.h"
#include "arrow/array/concatenate.h"
+#include "arrow/array/data.h"
#include "arrow/array/util.h"
#include "arrow/buffer.h"
#include "arrow/c/abi.h"
@@ -334,6 +341,31 @@ Result<std::shared_ptr<arrow::ArrayData>>
RebaseToZeroOffset(
return CopyToZeroOffset(data, pool);
}
+/// The eight bytes one byte of a bitmap expands to:
`kExpandedBytes[b].bytes[i]` is bit `i` of `b`.
+/// Naming the bytes one at a time rather than building a word keeps the
expansion independent of
+/// the host's byte order.
+struct ExpandedByte {
+ char bytes[8];
+};
+
+constexpr ExpandedByte ExpandBitmapByte(uint8_t bits) {
+ ExpandedByte expanded{};
+ for (int32_t i = 0; i < 8; i++) {
+ expanded.bytes[i] = static_cast<char>((bits >> i) & uint8_t{1});
+ }
+ return expanded;
+}
+
+constexpr std::array<ExpandedByte, 256> MakeExpandedBytes() {
+ std::array<ExpandedByte, 256> table{};
+ for (int32_t bits = 0; bits < 256; bits++) {
+ table[static_cast<size_t>(bits)] =
ExpandBitmapByte(static_cast<uint8_t>(bits));
+ }
+ return table;
+}
+
+constexpr std::array<ExpandedByte, 256> kExpandedBytes = MakeExpandedBytes();
+
} // namespace
const char* ArrowUtils::kArrowSchemaMetadataKey = "ARROW:schema";
@@ -432,6 +464,50 @@ uint64_t ArrowUtils::GetArrayMemoryUsage(const
std::shared_ptr<arrow::ArrayData>
return result;
}
+std::vector<char> ArrowUtils::UnpackBooleansToBytes(const arrow::BooleanArray&
array, bool negate) {
+ const int64_t length = array.length();
+ std::vector<char> is_valid(static_cast<size_t>(length));
+ if (length == 0) {
+ return is_valid;
+ }
+
+ const std::shared_ptr<arrow::ArrayData>& data = array.data();
+ // `BooleanArray::Value(i)` and `Array::IsValid(i)` both read their bitmap
at bit `i + offset`
+ // from the start of the buffer, so one bit index serves the value and the
validity alike.
+ const int64_t bit_offset = data->offset;
+ const auto values = data->GetValuesSafe<uint8_t>(/*i=*/1,
/*absolute_offset=*/0);
+ const uint8_t* validity = array.null_bitmap_data();
+
+ auto unpack_row = [&](int64_t row) -> char {
+ const int64_t bit = bit_offset + row;
+ const bool holds_value = validity == nullptr ||
arrow::bit_util::GetBit(validity, bit);
+ return static_cast<char>(holds_value &&
(arrow::bit_util::GetBit(values, bit) != negate));
+ };
+
+ char* out = is_valid.data();
+ int64_t row = 0;
+ // The rows sharing the leading partial byte, which the byte at a time
body cannot take whole.
+ // None when the bit offset is already on a byte boundary, as it is for
the batch a kernel has
+ // just written.
+ const int64_t head = std::min<int64_t>(length, (8 - (bit_offset & 7)) & 7);
+ for (; row < head; row++) {
+ out[row] = unpack_row(row);
+ }
+ for (; row + 8 <= length; row += 8) {
+ const int64_t byte = (bit_offset + row) >> 3;
+ // Every row holds a value when there is no validity bitmap, so all
eight bits pass.
+ const uint8_t valid_byte = validity == nullptr ? uint8_t{0xFF} :
validity[byte];
+ const uint8_t value_byte = values[byte];
+ const uint8_t bits = negate ? static_cast<uint8_t>(~value_byte &
valid_byte)
+ : static_cast<uint8_t>(value_byte &
valid_byte);
+ std::memcpy(out + row, kExpandedBytes[bits].bytes,
sizeof(ExpandedByte));
+ }
+ for (; row < length; row++) {
+ out[row] = unpack_row(row);
+ }
+ return is_valid;
+}
+
bool ArrowUtils::EqualsIgnoreNullable(const std::shared_ptr<arrow::DataType>&
type,
const std::shared_ptr<arrow::DataType>&
other_type) {
if (type->id() != other_type->id() || type->num_fields() !=
other_type->num_fields()) {
diff --git a/src/paimon/common/utils/arrow/arrow_utils.h
b/src/paimon/common/utils/arrow/arrow_utils.h
index f7813507..2a213155 100644
--- a/src/paimon/common/utils/arrow/arrow_utils.h
+++ b/src/paimon/common/utils/arrow/arrow_utils.h
@@ -53,6 +53,22 @@ class PAIMON_EXPORT ArrowUtils {
static uint64_t GetArrayMemoryUsage(const
std::shared_ptr<arrow::ArrayData>& data);
+ /// Expands the booleans of `array` into one byte per row, the shape
`LeafFunction::Test`
+ /// returns. Row `i` becomes 1 when it holds a value and that value, once
`negate` has been
+ /// applied to it, is true; a null row becomes 0.
+ ///
+ /// A kernel a leaf function probes with writes a boolean bitmap, one bit
per row, so the rows
+ /// it selects have to be spread over a byte each before the caller can
use them. Reading that
+ /// bitmap a byte at a time and writing eight rows at once keeps the
spread off the per row
+ /// path, where reading it a bit at a time costs a shift, a mask and a
byte store per row.
+ ///
+ /// @param array The bitmap to expand. Its offset and its validity are
honoured exactly as
+ /// `BooleanArray::Value()` and `Array::IsValid()` honour
them.
+ /// @param negate Whether to take the rows whose bit is clear rather than
the rows whose bit is
+ /// set, which is what `NOT IN` needs. A null row stays 0
either way.
+ /// @return One byte per row of `array`.
+ static std::vector<char> UnpackBooleansToBytes(const arrow::BooleanArray&
array, bool negate);
+
static Result<std::shared_ptr<arrow::StructArray>>
RemoveFieldFromStructArray(
const std::shared_ptr<arrow::StructArray>& struct_array, const
std::string& field_name);
diff --git a/src/paimon/common/utils/arrow/arrow_utils_test.cpp
b/src/paimon/common/utils/arrow/arrow_utils_test.cpp
index bc95a6d6..ea3fd1e3 100644
--- a/src/paimon/common/utils/arrow/arrow_utils_test.cpp
+++ b/src/paimon/common/utils/arrow/arrow_utils_test.cpp
@@ -19,6 +19,8 @@
#include "paimon/common/utils/arrow/arrow_utils.h"
+#include <algorithm>
+#include <cstdint>
#include <memory>
#include <string>
#include <vector>
@@ -1215,4 +1217,125 @@ TEST(ArrowUtilsTest,
TestFlattenUnresolvableDictionaries) {
}
}
+namespace {
+
+/// What `ArrowUtils::UnpackBooleansToBytes` has to agree with: every row
taken one at a time
+/// through the very accessors the helper replaces.
+std::vector<char> UnpackRowByRow(const arrow::BooleanArray& array, bool
negate) {
+ std::vector<char> is_valid(static_cast<size_t>(array.length()), 0);
+ for (int64_t i = 0; i < array.length(); i++) {
+ if (array.IsNull(i)) {
+ continue;
+ }
+ is_valid[i] = static_cast<char>(array.Value(i) != negate);
+ }
+ return is_valid;
+}
+
+/// A boolean array of `values.size()` rows holding the value `values[i]` at
row `i`, with a
+/// validity bitmap that marks row `i` as holding one when `valid[i]` is true.
+std::shared_ptr<arrow::BooleanArray> BooleansOf(const std::vector<bool>&
values,
+ const std::vector<bool>&
valid) {
+ const auto length = static_cast<int64_t>(values.size());
+ std::shared_ptr<arrow::Buffer> value_bits =
arrow::AllocateBitmap(length).ValueOrDie();
+ std::shared_ptr<arrow::Buffer> valid_bits =
arrow::AllocateBitmap(length).ValueOrDie();
+ int64_t null_count = 0;
+ for (int64_t i = 0; i < length; i++) {
+ arrow::bit_util::SetBitTo(value_bits->mutable_data(), i,
values[static_cast<size_t>(i)]);
+ arrow::bit_util::SetBitTo(valid_bits->mutable_data(), i,
valid[static_cast<size_t>(i)]);
+ null_count += valid[static_cast<size_t>(i)] ? 0 : 1;
+ }
+ return std::make_shared<arrow::BooleanArray>(length, value_bits,
valid_bits, null_count);
+}
+
+} // namespace
+
+TEST(ArrowUtilsTest, TestUnpackBooleansToBytes) {
+ std::shared_ptr<arrow::Array> made =
+ arrow::ipc::internal::json::ArrayFromJSON(arrow::boolean(), R"([true,
false, null, true])")
+ .ValueOrDie();
+ const auto& array = checked_cast<const arrow::BooleanArray&>(*made);
+
+ // A null row is left at 0 whatever the value bitmap holds for it.
+ ASSERT_EQ(ArrowUtils::UnpackBooleansToBytes(array, /*negate=*/false),
+ std::vector<char>({1, 0, 0, 1}));
+ ASSERT_EQ(ArrowUtils::UnpackBooleansToBytes(array, /*negate=*/true),
+ std::vector<char>({0, 1, 0, 0}));
+}
+
+TEST(ArrowUtilsTest,
TestUnpackBooleansToBytesAgreesWithTheAccessorsOnEverySlice) {
+ // Neither the row count nor the periods the values and the nulls repeat
on are a multiple of
+ // eight, so the slices below land on a partial leading byte, on whole
bytes and on a partial
+ // trailing one, whichever offset and length they take.
+ constexpr int64_t kRows = 41;
+ std::vector<bool> values;
+ std::vector<bool> valid;
+ for (int64_t i = 0; i < kRows; i++) {
+ values.push_back((i * 7 + 3) % 5 != 0);
+ valid.push_back(i % 4 != 0);
+ }
+ const std::shared_ptr<arrow::BooleanArray> source = BooleansOf(values,
valid);
+
+ for (int64_t offset = 0; offset <= kRows; offset++) {
+ for (int64_t length = 0; length <= kRows - offset; length++) {
+ // `Slice` returns a new owner, so it has to be held for as long
as the slice is read.
+ std::shared_ptr<arrow::Array> sliced = source->Slice(offset,
length);
+ const auto& slice = checked_cast<const
arrow::BooleanArray&>(*sliced);
+ for (bool negate : {false, true}) {
+ ASSERT_EQ(ArrowUtils::UnpackBooleansToBytes(slice, negate),
+ UnpackRowByRow(slice, negate))
+ << "offset=" << offset << " length=" << length << "
negate=" << negate;
+ }
+ }
+ }
+}
+
+TEST(ArrowUtilsTest, TestUnpackBooleansToBytesWithoutAValidityBitmap) {
+ // A bitmap written for a column with no null in it carries no validity
buffer at all, which
+ // `Array::IsValid` reads as every row holding a value.
+ const int64_t length = 10;
+ std::shared_ptr<arrow::Buffer> value_bits =
arrow::AllocateBitmap(length).ValueOrDie();
+ for (int64_t i = 0; i < length; i++) {
+ arrow::bit_util::SetBitTo(value_bits->mutable_data(), i, i % 3 != 0);
+ }
+ const arrow::BooleanArray array(length, value_bits,
/*null_bitmap=*/nullptr,
+ /*null_count=*/0);
+
+ std::vector<char> expected(static_cast<size_t>(length));
+ for (int64_t i = 0; i < length; i++) {
+ expected[static_cast<size_t>(i)] = static_cast<char>(i % 3 != 0);
+ }
+ ASSERT_EQ(ArrowUtils::UnpackBooleansToBytes(array, /*negate=*/false),
expected);
+ ASSERT_EQ(UnpackRowByRow(array, /*negate=*/false), expected);
+
+ // `negate` still applies once every row holds a value.
+ ASSERT_EQ(ArrowUtils::UnpackBooleansToBytes(array, /*negate=*/true),
+ UnpackRowByRow(array, /*negate=*/true));
+}
+
+TEST(ArrowUtilsTest, TestUnpackBooleansToBytesOfAnArrayThatIsNullThroughout) {
+ // A predicate over a column no row holds a value in leaves every row
null. The value bitmap is
+ // all set, so a 1 among the unpacked bytes can only be a null row read as
holding a value.
+ const int64_t length = 9;
+ const std::shared_ptr<arrow::BooleanArray> array =
+ BooleansOf(std::vector<bool>(static_cast<size_t>(length), true),
+ std::vector<bool>(static_cast<size_t>(length), false));
+ const arrow::Status validated = array->ValidateFull();
+ ASSERT_TRUE(validated.ok()) << validated.ToString();
+
+ ASSERT_EQ(ArrowUtils::UnpackBooleansToBytes(*array, /*negate=*/false),
+ std::vector<char>(static_cast<size_t>(length), 0));
+ ASSERT_EQ(ArrowUtils::UnpackBooleansToBytes(*array, /*negate=*/true),
+ std::vector<char>(static_cast<size_t>(length), 0));
+ ASSERT_EQ(UnpackRowByRow(*array, /*negate=*/true),
+ std::vector<char>(static_cast<size_t>(length), 0));
+}
+
+TEST(ArrowUtilsTest, TestUnpackBooleansToBytesOfAnEmptyArray) {
+ const arrow::BooleanArray array(/*length=*/0, /*data=*/nullptr);
+
+ ASSERT_TRUE(ArrowUtils::UnpackBooleansToBytes(array,
/*negate=*/false).empty());
+ ASSERT_TRUE(ArrowUtils::UnpackBooleansToBytes(array,
/*negate=*/true).empty());
+}
+
} // namespace paimon::test