From 9af5c75899993d619556972ca2f2f878df1037f2 Mon Sep 17 00:00:00 2001
From: Jason <940334249@qq.com>
Date: Tue, 24 Feb 2026 03:47:00 +0800
Subject: [PATCH 01/80] refactor: simplify iterator using cloned().map(Some)
(#9449)
# Which issue does this PR close?
# Rationale for this change
Use .cloned().map(Some) instead of .map(|b| Some(b.clone()))
for better readability and idiomatic Rust style.
# What changes are included in this PR?
# Are these changes tested?
# Are there any user-facing changes?
---
parquet/src/arrow/arrow_reader/mod.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/parquet/src/arrow/arrow_reader/mod.rs b/parquet/src/arrow/arrow_reader/mod.rs
index 670f9d80c5a3..1b02c4ae25d3 100644
--- a/parquet/src/arrow/arrow_reader/mod.rs
+++ b/parquet/src/arrow/arrow_reader/mod.rs
@@ -3510,7 +3510,7 @@ pub(crate) mod tests {
})
.collect()
}
- None => values.iter().flatten().map(|b| Some(b.clone())).collect(),
+ None => values.iter().flatten().cloned().map(Some).collect(),
};
data
}
From ff736e0167348ffdd66d7502614cc7749c8690c4 Mon Sep 17 00:00:00 2001
From: Jason <940334249@qq.com>
Date: Tue, 24 Feb 2026 08:53:50 +0800
Subject: [PATCH 02/80] docs(parquet): Fix broken links in README (#9467)
Fix missing link in Parquet README
---
parquet/README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/parquet/README.md b/parquet/README.md
index 8317b4dbd4ff..9e4e91d85d73 100644
--- a/parquet/README.md
+++ b/parquet/README.md
@@ -77,6 +77,7 @@ Please see the [Implementation Status Page] on the [Apache Parquet] website for
information on the status of this implementation.
[implementation status page]: https://parquet.apache.org/docs/file-format/implementationstatus/
+[apache parquet]: https://parquet.apache.org/
## License
From a2cffdbf85c94e6850b725ce2f9d0f2d9b5ebb32 Mon Sep 17 00:00:00 2001
From: Eyad Ibrahim <159264031+Eyad3skr@users.noreply.github.com>
Date: Tue, 24 Feb 2026 14:16:08 +0200
Subject: [PATCH 03/80] Add `NullBuffer::from_unsliced_buffer` helper and
refactor call sites (#9411)
Implements a helper to replace the pattern of creating a `BooleanBuffer`
from an unsliced validity bitmap and filtering by null count. Previously
this was done with `BooleanBuffer::new(...)` plus
`Some(NullBuffer::new(...)).filter(|n| n.null_count() > 0);` now it is a
single call to` NullBuffer::try_from_unsliced(buffer, len)`, which
returns `Some(NullBuffer)` when there are nulls and `None` when all
values are valid.
- Added `try_from_unsliced` in `arrow-buffer/src/buffer/null.rs` with
tests for nulls, all valid, all null, empty
- Refactor `FixedSizeBinaryArray::try_from_iter_with_size` and
`try_from_sparse_iter_with_size` to use it
- Refactor `take_nulls` in `arrow-select` to use it
Closes #9385
---
arrow-array/src/array/boolean_array.rs | 7 +--
.../src/array/fixed_size_binary_array.rs | 8 +--
arrow-array/src/array/primitive_array.rs | 7 +--
arrow-buffer/src/buffer/null.rs | 55 +++++++++++++++++++
arrow-select/src/take.rs | 8 +--
arrow-string/src/regexp.rs | 3 +-
arrow-string/src/substring.rs | 14 ++---
.../src/arrow/array_reader/primitive_array.rs | 2 +-
parquet/src/arrow/buffer/view_buffer.rs | 6 +-
9 files changed, 76 insertions(+), 34 deletions(-)
diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs
index 79865b88fff6..65e19c80f8e8 100644
--- a/arrow-array/src/array/boolean_array.rs
+++ b/arrow-array/src/array/boolean_array.rs
@@ -534,12 +534,7 @@ impl BooleanArray {
});
let values = BooleanBuffer::new(val_builder.into(), 0, data_len);
- let nulls = Some(NullBuffer::new(BooleanBuffer::new(
- null_builder.into(),
- 0,
- data_len,
- )))
- .filter(|n| n.null_count() > 0);
+ let nulls = NullBuffer::from_unsliced_buffer(null_builder, data_len);
BooleanArray::new(values, nulls)
}
}
diff --git a/arrow-array/src/array/fixed_size_binary_array.rs b/arrow-array/src/array/fixed_size_binary_array.rs
index f9a4919b2c30..e3f08c066ee0 100644
--- a/arrow-array/src/array/fixed_size_binary_array.rs
+++ b/arrow-array/src/array/fixed_size_binary_array.rs
@@ -19,7 +19,7 @@ use crate::array::print_long_array;
use crate::iterator::FixedSizeBinaryIter;
use crate::{Array, ArrayAccessor, ArrayRef, FixedSizeListArray, Scalar};
use arrow_buffer::buffer::NullBuffer;
-use arrow_buffer::{ArrowNativeType, BooleanBuffer, Buffer, MutableBuffer, bit_util};
+use arrow_buffer::{ArrowNativeType, Buffer, MutableBuffer, bit_util};
use arrow_data::{ArrayData, ArrayDataBuilder};
use arrow_schema::{ArrowError, DataType};
use std::any::Any;
@@ -328,8 +328,7 @@ impl FixedSizeBinaryArray {
));
}
- let null_buf = BooleanBuffer::new(null_buf.into(), 0, len);
- let nulls = Some(NullBuffer::new(null_buf)).filter(|n| n.null_count() > 0);
+ let nulls = NullBuffer::from_unsliced_buffer(null_buf, len);
let size = size.unwrap_or(0) as i32;
Ok(Self {
@@ -406,8 +405,7 @@ impl FixedSizeBinaryArray {
Ok(())
})?;
- let null_buf = BooleanBuffer::new(null_buf.into(), 0, len);
- let nulls = Some(NullBuffer::new(null_buf)).filter(|n| n.null_count() > 0);
+ let nulls = NullBuffer::from_unsliced_buffer(null_buf, len);
Ok(Self {
data_type: DataType::FixedSizeBinary(size),
diff --git a/arrow-array/src/array/primitive_array.rs b/arrow-array/src/array/primitive_array.rs
index 29189b450a40..d9c8ff66d0cb 100644
--- a/arrow-array/src/array/primitive_array.rs
+++ b/arrow-array/src/array/primitive_array.rs
@@ -25,9 +25,7 @@ use crate::timezone::Tz;
use crate::trusted_len::trusted_len_unzip;
use crate::types::*;
use crate::{Array, ArrayAccessor, ArrayRef, Scalar};
-use arrow_buffer::{
- ArrowNativeType, BooleanBuffer, Buffer, NullBuffer, NullBufferBuilder, ScalarBuffer, i256,
-};
+use arrow_buffer::{ArrowNativeType, Buffer, NullBuffer, NullBufferBuilder, ScalarBuffer, i256};
use arrow_data::bit_iterator::try_for_each_valid_idx;
use arrow_data::{ArrayData, ArrayDataBuilder};
use arrow_schema::{ArrowError, DataType};
@@ -1490,8 +1488,7 @@ impl PrimitiveArray {
let (null, buffer) = unsafe { trusted_len_unzip(iterator) };
- let nulls =
- Some(NullBuffer::new(BooleanBuffer::new(null, 0, len))).filter(|n| n.null_count() > 0);
+ let nulls = NullBuffer::from_unsliced_buffer(null, len);
PrimitiveArray::new(ScalarBuffer::from(buffer), nulls)
}
}
diff --git a/arrow-buffer/src/buffer/null.rs b/arrow-buffer/src/buffer/null.rs
index 64a21d99e830..97034a631ef8 100644
--- a/arrow-buffer/src/buffer/null.rs
+++ b/arrow-buffer/src/buffer/null.rs
@@ -222,6 +222,15 @@ impl NullBuffer {
pub fn buffer(&self) -> &Buffer {
self.buffer.inner()
}
+
+ /// Create a [`NullBuffer`] from an *unsliced* validity bitmap (`offset = 0` **bits**) of length `len`.
+ ///
+ /// Returns `None` if there are no nulls (all values valid).
+ pub fn from_unsliced_buffer(buffer: impl Into, len: usize) -> Option {
+ let bb = BooleanBuffer::new(buffer.into(), 0, len);
+ let nb = NullBuffer::new(bb);
+ (nb.null_count() > 0).then_some(nb)
+ }
}
impl<'a> IntoIterator for &'a NullBuffer {
@@ -266,6 +275,7 @@ impl FromIterator for NullBuffer {
#[cfg(test)]
mod tests {
use super::*;
+
#[test]
fn test_size() {
// This tests that the niche optimisation eliminates the overhead of an option
@@ -274,4 +284,49 @@ mod tests {
std::mem::size_of::>()
);
}
+
+ #[test]
+ fn test_from_unsliced_buffer_with_nulls() {
+ // 0b10110010 → null(0), valid(1), null(2), null(3), valid(4), valid(5), null(6), valid(7)
+ let buf = Buffer::from([0b10110010u8]);
+ let result = NullBuffer::from_unsliced_buffer(buf, 8);
+ assert!(result.is_some());
+ let nb = result.unwrap();
+ assert_eq!(nb.len(), 8);
+ assert_eq!(nb.null_count(), 4);
+ assert!(nb.is_null(0));
+ assert!(nb.is_valid(1));
+ assert!(nb.is_null(2));
+ assert!(nb.is_null(3));
+ assert!(nb.is_valid(4));
+ assert!(nb.is_valid(5));
+ assert!(nb.is_null(6));
+ assert!(nb.is_valid(7));
+ }
+
+ #[test]
+ fn test_from_unsliced_buffer_all_valid() {
+ // All bits set = all valid, no nulls
+ let buf = Buffer::from([0b11111111u8]);
+ let result = NullBuffer::from_unsliced_buffer(buf, 8);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn test_from_unsliced_buffer_all_null() {
+ // No bits set = all null
+ let buf = Buffer::from([0b00000000u8]);
+ let result = NullBuffer::from_unsliced_buffer(buf, 8);
+ assert!(result.is_some());
+ let nb = result.unwrap();
+ assert_eq!(nb.len(), 8);
+ assert_eq!(nb.null_count(), 8);
+ }
+
+ #[test]
+ fn test_from_unsliced_buffer_empty() {
+ let buf = Buffer::from([]);
+ let result = NullBuffer::from_unsliced_buffer(buf, 0);
+ assert!(result.is_none());
+ }
}
diff --git a/arrow-select/src/take.rs b/arrow-select/src/take.rs
index 3e34e794f11f..43c13e66fb0e 100644
--- a/arrow-select/src/take.rs
+++ b/arrow-select/src/take.rs
@@ -415,10 +415,10 @@ fn take_nulls(
indices: &PrimitiveArray,
) -> Option {
match values.filter(|n| n.null_count() > 0) {
- Some(n) => {
- let buffer = take_bits(n.inner(), indices);
- Some(NullBuffer::new(buffer)).filter(|n| n.null_count() > 0)
- }
+ Some(n) => NullBuffer::from_unsliced_buffer(
+ take_bits(n.inner(), indices).into_inner(),
+ indices.len(),
+ ),
None => indices.nulls().cloned(),
}
}
diff --git a/arrow-string/src/regexp.rs b/arrow-string/src/regexp.rs
index ad678598ea6c..07520a209095 100644
--- a/arrow-string/src/regexp.rs
+++ b/arrow-string/src/regexp.rs
@@ -203,8 +203,7 @@ where
let nulls = array
.nulls()
.map(|n| n.inner().sliced())
- .map(|b| NullBuffer::new(BooleanBuffer::new(b, 0, array.len())))
- .filter(|n| n.null_count() > 0);
+ .and_then(|b| NullBuffer::from_unsliced_buffer(b, array.len()));
Ok(BooleanArray::new(values, nulls))
}
diff --git a/arrow-string/src/substring.rs b/arrow-string/src/substring.rs
index 96858ee11763..05b3888a444a 100644
--- a/arrow-string/src/substring.rs
+++ b/arrow-string/src/substring.rs
@@ -22,7 +22,7 @@
use arrow_array::builder::BufferBuilder;
use arrow_array::types::*;
use arrow_array::*;
-use arrow_buffer::{ArrowNativeType, BooleanBuffer, MutableBuffer, NullBuffer, OffsetBuffer};
+use arrow_buffer::{ArrowNativeType, MutableBuffer, NullBuffer, OffsetBuffer};
use arrow_schema::{ArrowError, DataType};
use num_traits::Zero;
use std::cmp::Ordering;
@@ -216,8 +216,7 @@ pub fn substring_by_char(
let nulls = array
.nulls()
.map(|n| n.inner().sliced())
- .map(|b| NullBuffer::new(BooleanBuffer::new(b, 0, array.len())))
- .filter(|n| n.null_count() > 0);
+ .and_then(|b| NullBuffer::from_unsliced_buffer(b, array.len()));
Ok(GenericStringArray::::new(
offsets, values, nulls,
))
@@ -318,8 +317,7 @@ where
let nulls = array
.nulls()
.map(|n| n.inner().sliced())
- .map(|b| NullBuffer::new(BooleanBuffer::new(b, 0, array.len())))
- .filter(|n| n.null_count() > 0);
+ .and_then(|b| NullBuffer::from_unsliced_buffer(b, array.len()));
Ok(Arc::new(GenericByteArray::::new(offsets, values, nulls)))
}
@@ -356,8 +354,8 @@ fn fixed_size_binary_substring(
let mut nulls = array
.nulls()
.map(|n| n.inner().sliced())
- .map(|b| NullBuffer::new(BooleanBuffer::new(b, 0, num_of_elements)))
- .filter(|n| n.null_count() > 0);
+ .and_then(|b| NullBuffer::from_unsliced_buffer(b, num_of_elements));
+
if new_len == 0 && nulls.is_none() {
// FixedSizeBinaryArray::new takes length from the values buffer, except when size == 0.
// In that case it uses the null buffer length, so preserve the original length here.
@@ -365,6 +363,7 @@ fn fixed_size_binary_substring(
// otherwise it collapses to an empty array (len=0).
nulls = Some(NullBuffer::new_valid(num_of_elements));
}
+
Ok(Arc::new(FixedSizeBinaryArray::new(
new_len,
new_values.into(),
@@ -375,6 +374,7 @@ fn fixed_size_binary_substring(
#[cfg(test)]
mod tests {
use super::*;
+ use arrow_buffer::BooleanBuffer;
use arrow_buffer::Buffer;
/// A helper macro to generate test cases.
diff --git a/parquet/src/arrow/array_reader/primitive_array.rs b/parquet/src/arrow/array_reader/primitive_array.rs
index dae42c4c7124..e1c944f60c42 100644
--- a/parquet/src/arrow/array_reader/primitive_array.rs
+++ b/parquet/src/arrow/array_reader/primitive_array.rs
@@ -163,7 +163,7 @@ where
let nulls = self
.record_reader
.consume_bitmap_buffer()
- .map(|b| NullBuffer::new(BooleanBuffer::new(b, 0, len)));
+ .and_then(|b| NullBuffer::from_unsliced_buffer(b, len));
let array: ArrayRef = match T::get_physical_type() {
PhysicalType::BOOLEAN => Arc::new(BooleanArray::new(
diff --git a/parquet/src/arrow/buffer/view_buffer.rs b/parquet/src/arrow/buffer/view_buffer.rs
index 1cccfd0f1b20..a93674663f7b 100644
--- a/parquet/src/arrow/buffer/view_buffer.rs
+++ b/parquet/src/arrow/buffer/view_buffer.rs
@@ -17,7 +17,7 @@
use crate::arrow::record_reader::buffer::ValuesBuffer;
use arrow_array::{ArrayRef, BinaryViewArray, StringViewArray};
-use arrow_buffer::{BooleanBuffer, Buffer, NullBuffer, ScalarBuffer};
+use arrow_buffer::{Buffer, NullBuffer, ScalarBuffer};
use arrow_schema::DataType as ArrowType;
use std::sync::Arc;
@@ -56,9 +56,7 @@ impl ViewBuffer {
pub fn into_array(self, null_buffer: Option, data_type: &ArrowType) -> ArrayRef {
let len = self.views.len();
let views = ScalarBuffer::from(self.views);
- let nulls = null_buffer
- .map(|b| NullBuffer::new(BooleanBuffer::new(b, 0, len)))
- .filter(|n| n.null_count() != 0);
+ let nulls = null_buffer.and_then(|b| NullBuffer::from_unsliced_buffer(b, len));
match data_type {
ArrowType::Utf8View => {
// Safety: views were created correctly, and checked that the data is utf8 when building the buffer
From 2bf6909305091c69edddb0f16c76184edd206141 Mon Sep 17 00:00:00 2001
From: Konstantin Tarasov <33369833+sdf-jkl@users.noreply.github.com>
Date: Wed, 25 Feb 2026 16:56:53 -0500
Subject: [PATCH 04/80] Add list-like types support to VariantArray::try_new
(#9457)
# Which issue does this PR close?
- Closes #9455.
# Rationale for this change
check issue
# What changes are included in this PR?
Added list types support to `VariantArray` data type checking
# Are these changes tested?
# Are there any user-facing changes?
---
parquet-variant-compute/src/variant_array.rs | 110 +++++++++++++++++--
1 file changed, 102 insertions(+), 8 deletions(-)
diff --git a/parquet-variant-compute/src/variant_array.rs b/parquet-variant-compute/src/variant_array.rs
index 250852d021bd..145de5edfb70 100644
--- a/parquet-variant-compute/src/variant_array.rs
+++ b/parquet-variant-compute/src/variant_array.rs
@@ -1181,16 +1181,23 @@ fn canonicalize_and_verify_data_type(data_type: &DataType) -> Result borrow!(),
FixedSizeBinary(_) | FixedSizeList(..) => fail!(),
- // We can _possibly_ allow (some of) these some day?
- ListView(_) | LargeList(_) | LargeListView(_) => {
- fail!()
- }
-
- // Lists and struct are allowed, maps and unions are not
+ // List-like containers and struct are allowed, maps and unions are not
List(field) => match canonicalize_and_verify_field(field)? {
Cow::Borrowed(_) => borrow!(),
Cow::Owned(new_field) => Cow::Owned(DataType::List(new_field)),
},
+ LargeList(field) => match canonicalize_and_verify_field(field)? {
+ Cow::Borrowed(_) => borrow!(),
+ Cow::Owned(new_field) => Cow::Owned(DataType::LargeList(new_field)),
+ },
+ ListView(field) => match canonicalize_and_verify_field(field)? {
+ Cow::Borrowed(_) => borrow!(),
+ Cow::Owned(new_field) => Cow::Owned(DataType::ListView(new_field)),
+ },
+ LargeListView(field) => match canonicalize_and_verify_field(field)? {
+ Cow::Borrowed(_) => borrow!(),
+ Cow::Owned(new_field) => Cow::Owned(DataType::LargeListView(new_field)),
+ },
// Struct is used by the internal layout, and can also represent a shredded variant object.
Struct(fields) => {
// Avoid allocation unless at least one field changes, to avoid unnecessary deep cloning
@@ -1235,9 +1242,10 @@ mod test {
use super::*;
use arrow::array::{
- BinaryViewArray, Decimal32Array, Decimal64Array, Decimal128Array, Int32Array,
- Time64MicrosecondArray,
+ BinaryViewArray, Decimal32Array, Decimal64Array, Decimal128Array, Int32Array, Int64Array,
+ LargeListArray, LargeListViewArray, ListArray, ListViewArray, Time64MicrosecondArray,
};
+ use arrow::buffer::{OffsetBuffer, ScalarBuffer};
use arrow_schema::{Field, Fields};
use parquet_variant::{EMPTY_VARIANT_METADATA_BYTES, ShortString};
@@ -1335,6 +1343,17 @@ mod test {
Arc::new(Int32Array::from(vec![1]))
}
+ fn make_variant_struct_with_typed_value(typed_value: ArrayRef) -> StructArray {
+ let metadata = BinaryViewArray::from_iter_values(std::iter::repeat_n(
+ EMPTY_VARIANT_METADATA_BYTES,
+ typed_value.len(),
+ ));
+ StructArrayBuilder::new()
+ .with_field("metadata", Arc::new(metadata), false)
+ .with_field("typed_value", typed_value, true)
+ .build()
+ }
+
#[test]
fn all_null_shredding_state() {
// Verify the shredding state is AllNull
@@ -1420,6 +1439,81 @@ mod test {
));
}
+ #[test]
+ fn canonicalize_and_verify_list_like_data_types() {
+ // `parquet/tests/variant_integration.rs` validates Parquet shredded-variant fixtures that
+ // use Parquet LIST encoding, but those fixtures do not cover Arrow-specific list container
+ // variants (`LargeList`, `ListView`, `LargeListView`) accepted by `VariantArray::try_new`.
+ let make_item_binary = || Arc::new(Field::new("item", DataType::Binary, true));
+ let make_item_binary_view = || Arc::new(Field::new("item", DataType::BinaryView, true));
+
+ let cases = vec![
+ (
+ DataType::LargeList(make_item_binary()),
+ DataType::LargeList(make_item_binary_view()),
+ ),
+ (
+ DataType::ListView(make_item_binary()),
+ DataType::ListView(make_item_binary_view()),
+ ),
+ (
+ DataType::LargeListView(make_item_binary()),
+ DataType::LargeListView(make_item_binary_view()),
+ ),
+ ];
+
+ for (input, expected) in cases {
+ assert_eq!(
+ canonicalize_and_verify_data_type(&input).unwrap().as_ref(),
+ &expected
+ );
+ }
+ }
+
+ #[test]
+ fn variant_array_try_new_supports_list_like_typed_value() {
+ let item_field = Arc::new(Field::new("item", DataType::Int64, true));
+ let values: ArrayRef = Arc::new(Int64Array::from(vec![Some(1), None, Some(3)]));
+
+ let typed_values = vec![
+ Arc::new(ListArray::new(
+ item_field.clone(),
+ OffsetBuffer::new(ScalarBuffer::from(vec![0, 2, 3])),
+ values.clone(),
+ None,
+ )) as ArrayRef,
+ Arc::new(LargeListArray::new(
+ item_field.clone(),
+ OffsetBuffer::new(ScalarBuffer::from(vec![0_i64, 2, 3])),
+ values.clone(),
+ None,
+ )) as ArrayRef,
+ Arc::new(ListViewArray::new(
+ item_field.clone(),
+ ScalarBuffer::from(vec![0, 2]),
+ ScalarBuffer::from(vec![2, 1]),
+ values.clone(),
+ None,
+ )) as ArrayRef,
+ Arc::new(LargeListViewArray::new(
+ item_field,
+ ScalarBuffer::from(vec![0_i64, 2]),
+ ScalarBuffer::from(vec![2_i64, 1]),
+ values,
+ None,
+ )) as ArrayRef,
+ ];
+
+ for typed_value in typed_values {
+ let input = make_variant_struct_with_typed_value(typed_value.clone());
+ let variant_array = VariantArray::try_new(&input).unwrap();
+ assert_eq!(
+ variant_array.typed_value_field().unwrap().data_type(),
+ typed_value.data_type(),
+ );
+ }
+ }
+
#[test]
fn test_variant_array_iterable() {
let mut b = VariantArrayBuilder::new(6);
From 183f8c1c5361ac5f026d6fbfa8e99a2920dcb652 Mon Sep 17 00:00:00 2001
From: Bruno
Date: Fri, 27 Feb 2026 14:34:49 +0100
Subject: [PATCH 05/80] Add PrimitiveRunBuilder::with_data_type() to customize
the values' DataType (#9473)
This enables setting a timezone or precision & scale on parameterized
DataType values.
Note: I think the panic is unfortunate, and a try_with_data_type() would
be sensible.
# Which issue does this PR close?
- Closes https://github.com/apache/arrow-rs/issues/8042.
# Are these changes tested?
Yes
# Are there any user-facing changes?
- Adds `PrimitiveRunBuilder::with_data_type`.
---
.../src/builder/primitive_run_builder.rs | 54 ++++++++++++++++++-
1 file changed, 52 insertions(+), 2 deletions(-)
diff --git a/arrow-array/src/builder/primitive_run_builder.rs b/arrow-array/src/builder/primitive_run_builder.rs
index 52bdaa6f40e4..c1dc0d8d7d4b 100644
--- a/arrow-array/src/builder/primitive_run_builder.rs
+++ b/arrow-array/src/builder/primitive_run_builder.rs
@@ -108,6 +108,20 @@ where
prev_run_end_index: 0,
}
}
+
+ /// Overrides the data type of the values child array.
+ ///
+ /// By default, `V::DATA_TYPE` is used (via [`PrimitiveBuilder`]). This
+ /// allows setting the timezone of a Timestamp, the precision & scale of a
+ /// Decimal, etc.
+ ///
+ /// # Panics
+ ///
+ /// This method panics if `values_builder` rejects `data_type`.
+ pub fn with_data_type(mut self, data_type: arrow_schema::DataType) -> Self {
+ self.values_builder = self.values_builder.with_data_type(data_type);
+ self
+ }
}
impl ArrayBuilder for PrimitiveRunBuilder
@@ -259,10 +273,12 @@ where
#[cfg(test)]
mod tests {
+ use arrow_schema::DataType;
+
use crate::builder::PrimitiveRunBuilder;
use crate::cast::AsArray;
- use crate::types::{Int16Type, UInt32Type};
- use crate::{Array, UInt32Array};
+ use crate::types::{Decimal128Type, Int16Type, TimestampMicrosecondType, UInt32Type};
+ use crate::{Array, Decimal128Array, TimestampMicrosecondArray, UInt32Array};
#[test]
fn test_primitive_ree_array_builder() {
@@ -310,4 +326,38 @@ mod tests {
&[1, 2, 5, 4, 6, 2]
);
}
+
+ #[test]
+ #[should_panic]
+ fn test_override_data_type_invalid() {
+ PrimitiveRunBuilder::::new().with_data_type(DataType::UInt64);
+ }
+
+ #[test]
+ fn test_override_data_type() {
+ // Noop.
+ PrimitiveRunBuilder::::new().with_data_type(DataType::UInt32);
+
+ // Setting scale & precision.
+ let mut builder = PrimitiveRunBuilder::::new()
+ .with_data_type(DataType::Decimal128(1, 2));
+ builder.append_value(123);
+ let array = builder.finish();
+ let array = array.downcast::().unwrap();
+ let values = array.values();
+ assert_eq!(values.precision(), 1);
+ assert_eq!(values.scale(), 2);
+
+ // Setting timezone.
+ let mut builder = PrimitiveRunBuilder::::new()
+ .with_data_type(DataType::Timestamp(
+ arrow_schema::TimeUnit::Microsecond,
+ Some("Europe/Paris".into()),
+ ));
+ builder.append_value(1);
+ let array = builder.finish();
+ let array = array.downcast::().unwrap();
+ let values = array.values();
+ assert_eq!(values.timezone(), Some("Europe/Paris"));
+ }
}
From ae934888bb87196d272340bc528e93dd516bc9e6 Mon Sep 17 00:00:00 2001
From: Mikhail Zabaluev
Date: Fri, 27 Feb 2026 20:09:41 +0200
Subject: [PATCH 06/80] fix: resolution of complex type variants in Avro unions
(#9328)
# Which issue does this PR close?
- Closes #9336
# Rationale for this change
When an Avro reader schema has a union type that needs to be resolved
against the type in the writer schema, resolution information other than
primitive type promotions is not properly handled when creating the
decoder.
For example, when the reader schema has a nullable record field that has
an added nested field on top of the fields defined in the writer schema,
the record type resolution needs to be applied, using a projection with
the default field value.
# What changes are included in this PR?
Extend the union resolution information in the decoder with variant
data for enum remapping and record projection. The `Projector` data
structure with `Skipper` decoders makes part of this information,
which necessitated some refactoring.
# Are these changes tested?
TODO:
- [x] Debug failing tests including a busy-loop failure mode.
- [ ] Add more unit tests exercising the complex resolutions.
# Are there any user-facing changes?
No.
---
arrow-avro/src/codec.rs | 415 ++++++++++++++++----
arrow-avro/src/reader/mod.rs | 361 +++++++++++++++--
arrow-avro/src/reader/record.rs | 661 ++++++++++++++++++++------------
arrow-avro/src/schema.rs | 28 +-
4 files changed, 1097 insertions(+), 368 deletions(-)
diff --git a/arrow-avro/src/codec.rs b/arrow-avro/src/codec.rs
index d54c6602dad6..d20a71425d3e 100644
--- a/arrow-avro/src/codec.rs
+++ b/arrow-avro/src/codec.rs
@@ -141,7 +141,7 @@ impl Display for Promotion {
pub(crate) struct ResolvedUnion {
/// For each writer branch index, the reader branch index and how to read it.
/// `None` means the writer branch doesn't resolve against the reader.
- pub(crate) writer_to_reader: Arc<[Option<(usize, Promotion)>]>,
+ pub(crate) writer_to_reader: Arc<[Option<(usize, ResolutionInfo)>]>,
/// Whether the writer schema at this site is a union
pub(crate) writer_is_union: bool,
/// Whether the reader schema at this site is a union
@@ -1748,9 +1748,21 @@ impl<'a> Maker<'a> {
nullable_union_variants(writer_variants),
nullable_union_variants(reader_variants),
) {
- (Some((w_nb, w_nonnull)), Some((_r_nb, r_nonnull))) => {
- let mut dt = self.make_data_type(w_nonnull, Some(r_nonnull), namespace)?;
+ (Some((w_nb, w_nonnull)), Some((r_nb, r_nonnull))) => {
+ let mut dt = self.resolve_type(w_nonnull, r_nonnull, namespace)?;
+ let mut writer_to_reader = vec![None, None];
+ writer_to_reader[w_nb.non_null_index()] = Some((
+ r_nb.non_null_index(),
+ dt.resolution
+ .take()
+ .unwrap_or(ResolutionInfo::Promotion(Promotion::Direct)),
+ ));
dt.nullability = Some(w_nb);
+ dt.resolution = Some(ResolutionInfo::Union(ResolvedUnion {
+ writer_to_reader: Arc::from(writer_to_reader),
+ writer_is_union: true,
+ reader_is_union: true,
+ }));
#[cfg(feature = "avro_custom_types")]
Self::propagate_nullability_into_ree(&mut dt, w_nb);
Ok(dt)
@@ -1759,12 +1771,17 @@ impl<'a> Maker<'a> {
}
}
(Schema::Union(writer_variants), reader_non_union) => {
- let writer_to_reader: Vec> = writer_variants
+ let writer_to_reader: Vec > = writer_variants
.iter()
.map(|writer| {
self.resolve_type(writer, reader_non_union, namespace)
.ok()
- .map(|tmp| (0usize, Self::coercion_from(&tmp)))
+ .map(|tmp| {
+ let resolution = tmp
+ .resolution
+ .unwrap_or(ResolutionInfo::Promotion(Promotion::Direct));
+ (0usize, resolution)
+ })
})
.collect();
let mut dt = self.parse_type(reader_non_union, namespace)?;
@@ -1780,54 +1797,44 @@ impl<'a> Maker<'a> {
nullable_union_variants(reader_variants)
{
let mut dt = self.resolve_type(writer_non_union, non_null_branch, namespace)?;
- let non_null_idx = match nullability {
- Nullability::NullFirst => 1,
- Nullability::NullSecond => 0,
- };
#[cfg(feature = "avro_custom_types")]
Self::propagate_nullability_into_ree(&mut dt, nullability);
dt.nullability = Some(nullability);
- let promotion = Self::coercion_from(&dt);
- dt.resolution = Some(ResolutionInfo::Union(ResolvedUnion {
- writer_to_reader: Arc::from(vec![Some((non_null_idx, promotion))]),
- writer_is_union: false,
- reader_is_union: true,
- }));
+ // Ensure resolution is set to a non-Union variant to suppress
+ // reading the union tag which is the default behavior.
+ if dt.resolution.is_none() {
+ dt.resolution = Some(ResolutionInfo::Promotion(Promotion::Direct));
+ }
Ok(dt)
} else {
- let mut best_match: Option<(usize, AvroDataType, Promotion)> = None;
- for (i, variant) in reader_variants.iter().enumerate() {
- if let Ok(resolved_dt) =
- self.resolve_type(writer_non_union, variant, namespace)
- {
- let promotion = Self::coercion_from(&resolved_dt);
- if promotion == Promotion::Direct {
- best_match = Some((i, resolved_dt, promotion));
- break;
- } else if best_match.is_none() {
- best_match = Some((i, resolved_dt, promotion));
- }
- }
- }
- let Some((match_idx, match_dt, promotion)) = best_match else {
+ let Some((match_idx, mut match_dt)) =
+ self.find_best_union_match(writer_non_union, reader_variants, namespace)
+ else {
return Err(ArrowError::SchemaError(
"Writer schema does not match any reader union branch".to_string(),
));
};
- let mut children = Vec::with_capacity(reader_variants.len());
+ // Steal the resolution info from the matching reader branch
+ // for the Union resolution, but preserve possible resolution
+ // information on its inner types.
+ // For other branches, resolution is irrelevant,
+ // so just parse them.
+ let resolution = match_dt
+ .resolution
+ .take()
+ .unwrap_or(ResolutionInfo::Promotion(Promotion::Direct));
let mut match_dt = Some(match_dt);
- for (i, variant) in reader_variants.iter().enumerate() {
- if i == match_idx {
- if let Some(mut dt) = match_dt.take() {
- if matches!(dt.resolution, Some(ResolutionInfo::Promotion(_))) {
- dt.resolution = None;
- }
- children.push(dt);
+ let children = reader_variants
+ .iter()
+ .enumerate()
+ .map(|(idx, variant)| {
+ if idx == match_idx {
+ Ok(match_dt.take().unwrap())
+ } else {
+ self.parse_type(variant, namespace)
}
- } else {
- children.push(self.parse_type(variant, namespace)?);
- }
- }
+ })
+ .collect::, _>>()?;
let union_fields = build_union_fields(&children)?;
let mut dt = AvroDataType::new(
Codec::Union(children.into(), union_fields, UnionMode::Dense),
@@ -1835,7 +1842,7 @@ impl<'a> Maker<'a> {
None,
);
dt.resolution = Some(ResolutionInfo::Union(ResolvedUnion {
- writer_to_reader: Arc::from(vec![Some((match_idx, promotion))]),
+ writer_to_reader: Arc::from(vec![Some((match_idx, resolution))]),
writer_is_union: false,
reader_is_union: true,
}));
@@ -1870,34 +1877,30 @@ impl<'a> Maker<'a> {
}
}
- #[inline]
- fn coercion_from(dt: &AvroDataType) -> Promotion {
- match dt.resolution.as_ref() {
- Some(ResolutionInfo::Promotion(promotion)) => *promotion,
- _ => Promotion::Direct,
- }
- }
-
- fn find_best_promotion(
+ fn find_best_union_match(
&mut self,
writer: &Schema<'a>,
reader_variants: &[Schema<'a>],
namespace: Option<&'a str>,
- ) -> Option<(usize, Promotion)> {
- let mut first_promotion: Option<(usize, Promotion)> = None;
+ ) -> Option<(usize, AvroDataType)> {
+ let mut first_resolution = None;
for (reader_index, reader) in reader_variants.iter().enumerate() {
- if let Ok(tmp) = self.resolve_type(writer, reader, namespace) {
- let promotion = Self::coercion_from(&tmp);
- if promotion == Promotion::Direct {
- // An exact match is best, return immediately.
- return Some((reader_index, promotion));
- } else if first_promotion.is_none() {
- // Store the first valid promotion but keep searching for a direct match.
- first_promotion = Some((reader_index, promotion));
- }
+ if let Ok(dt) = self.resolve_type(writer, reader, namespace) {
+ match &dt.resolution {
+ None | Some(ResolutionInfo::Promotion(Promotion::Direct)) => {
+ // An exact match is best, return immediately.
+ return Some((reader_index, dt));
+ }
+ Some(_) => {
+ if first_resolution.is_none() {
+ // Store the first valid promotion but keep searching for a direct match.
+ first_resolution = Some((reader_index, dt));
+ }
+ }
+ };
}
}
- first_promotion
+ first_resolution
}
fn resolve_unions<'s>(
@@ -1906,15 +1909,34 @@ impl<'a> Maker<'a> {
reader_variants: &'s [Schema<'a>],
namespace: Option<&'a str>,
) -> Result {
+ let mut resolved_reader_encodings = HashMap::new();
+ let writer_to_reader: Vec> = writer_variants
+ .iter()
+ .map(|writer| {
+ self.find_best_union_match(writer, reader_variants, namespace)
+ .map(|(match_idx, mut match_dt)| {
+ let resolution = match_dt
+ .resolution
+ .take()
+ .unwrap_or(ResolutionInfo::Promotion(Promotion::Direct));
+ // TODO: check for overlapping reader variants?
+ // They should not be possible in a valid schema.
+ resolved_reader_encodings.insert(match_idx, match_dt);
+ (match_idx, resolution)
+ })
+ })
+ .collect();
let reader_encodings: Vec = reader_variants
.iter()
- .map(|reader_schema| self.parse_type(reader_schema, namespace))
+ .enumerate()
+ .map(|(reader_idx, reader_schema)| {
+ if let Some(resolved) = resolved_reader_encodings.remove(&reader_idx) {
+ Ok(resolved)
+ } else {
+ self.parse_type(reader_schema, namespace)
+ }
+ })
.collect::>()?;
- let mut writer_to_reader: Vec> =
- Vec::with_capacity(writer_variants.len());
- for writer in writer_variants {
- writer_to_reader.push(self.find_best_promotion(writer, reader_variants, namespace));
- }
let union_fields = build_union_fields(&reader_encodings)?;
let mut dt = AvroDataType::new(
Codec::Union(reader_encodings.into(), union_fields, UnionMode::Dense),
@@ -2179,7 +2201,14 @@ impl<'a> Maker<'a> {
)?;
let writer_ns = writer_record.namespace.or(namespace);
let reader_ns = reader_record.namespace.or(namespace);
- let reader_md = reader_record.attributes.field_metadata();
+ let mut reader_md = reader_record.attributes.field_metadata();
+ reader_md.insert(
+ AVRO_NAME_METADATA_KEY.to_string(),
+ reader_record.name.to_string(),
+ );
+ if let Some(ns) = reader_ns {
+ reader_md.insert(AVRO_NAMESPACE_METADATA_KEY.to_string(), ns.to_string());
+ }
// Build writer lookup and ambiguous alias set.
let (writer_lookup, ambiguous_writer_aliases) = Self::build_writer_lookup(writer_record);
let mut writer_to_reader: Vec > = vec![None; writer_record.fields.len()];
@@ -2620,7 +2649,15 @@ mod tests {
assert!(matches!(result.codec, Codec::Float64));
assert_eq!(
result.resolution,
- Some(ResolutionInfo::Promotion(Promotion::IntToDouble))
+ Some(ResolutionInfo::Union(ResolvedUnion {
+ writer_to_reader: [
+ None,
+ Some((0, ResolutionInfo::Promotion(Promotion::IntToDouble)))
+ ]
+ .into(),
+ writer_is_union: true,
+ reader_is_union: true,
+ }))
);
assert_eq!(result.nullability, Some(Nullability::NullFirst));
}
@@ -2642,7 +2679,10 @@ mod tests {
assert!(resolved.writer_is_union && !resolved.reader_is_union);
assert_eq!(
resolved.writer_to_reader.as_ref(),
- &[Some((0, Promotion::StringToBytes)), None]
+ &[
+ Some((0, ResolutionInfo::Promotion(Promotion::StringToBytes))),
+ None
+ ]
);
}
@@ -2662,7 +2702,7 @@ mod tests {
assert!(!resolved.writer_is_union && resolved.reader_is_union);
assert_eq!(
resolved.writer_to_reader.as_ref(),
- &[Some((0, Promotion::Direct))]
+ &[Some((0, ResolutionInfo::Promotion(Promotion::Direct)))]
);
}
@@ -2682,7 +2722,200 @@ mod tests {
};
assert_eq!(
resolved.writer_to_reader.as_ref(),
- &[Some((1, Promotion::IntToLong))]
+ &[Some((1, ResolutionInfo::Promotion(Promotion::IntToLong)))]
+ );
+ }
+
+ #[test]
+ fn test_resolve_writer_non_union_to_reader_union_preserves_inner_record_defaults() {
+ // Writer: record Inner{a: int}
+ // Reader: union [Inner{a: int, b: int default 42}, string]
+ // The matching child (Inner) should preserve DefaultValue(Int(42)) on field b.
+ let writer = Schema::Complex(ComplexType::Record(Record {
+ name: "Inner",
+ namespace: None,
+ doc: None,
+ aliases: vec![],
+ fields: vec![AvroFieldSchema {
+ name: "a",
+ doc: None,
+ r#type: mk_primitive(PrimitiveType::Int),
+ default: None,
+ aliases: vec![],
+ }],
+ attributes: Attributes::default(),
+ }));
+ let reader = mk_union(vec![
+ Schema::Complex(ComplexType::Record(Record {
+ name: "Inner",
+ namespace: None,
+ doc: None,
+ aliases: vec![],
+ fields: vec![
+ AvroFieldSchema {
+ name: "a",
+ doc: None,
+ r#type: mk_primitive(PrimitiveType::Int),
+ default: None,
+ aliases: vec![],
+ },
+ AvroFieldSchema {
+ name: "b",
+ doc: None,
+ r#type: mk_primitive(PrimitiveType::Int),
+ default: Some(Value::Number(serde_json::Number::from(42))),
+ aliases: vec![],
+ },
+ ],
+ attributes: Attributes::default(),
+ })),
+ mk_primitive(PrimitiveType::String),
+ ]);
+ let mut maker = Maker::new(false, false);
+ let dt = maker
+ .make_data_type(&writer, Some(&reader), None)
+ .expect("resolution should succeed");
+ // Verify the union resolution structure
+ let resolved = match dt.resolution.as_ref() {
+ Some(ResolutionInfo::Union(u)) => u,
+ other => panic!("expected union resolution info, got {other:?}"),
+ };
+ assert!(!resolved.writer_is_union && resolved.reader_is_union);
+ assert_eq!(
+ resolved.writer_to_reader.len(),
+ 1,
+ "expected the non-union record to resolve to a union variant"
+ );
+ let resolution = match resolved.writer_to_reader.first().unwrap() {
+ Some((0, resolution)) => resolution,
+ other => panic!("unexpected writer-to-reader table value {other:?}"),
+ };
+ match resolution {
+ ResolutionInfo::Record(ResolvedRecord {
+ writer_to_reader,
+ default_fields,
+ skip_fields,
+ }) => {
+ assert_eq!(writer_to_reader.len(), 1);
+ assert_eq!(writer_to_reader[0], Some(0));
+ assert_eq!(default_fields.len(), 1);
+ assert_eq!(default_fields[0], 1);
+ assert_eq!(skip_fields.len(), 1);
+ assert_eq!(skip_fields[0], None);
+ }
+ other => panic!("unexpected resolution {other:?}"),
+ }
+ // The matching child (Inner at index 0) should have field b with DefaultValue
+ let children = match dt.codec() {
+ Codec::Union(children, _, _) => children,
+ other => panic!("expected union codec, got {other:?}"),
+ };
+ let inner_fields = match children[0].codec() {
+ Codec::Struct(f) => f,
+ other => panic!("expected struct codec for Inner, got {other:?}"),
+ };
+ assert_eq!(inner_fields.len(), 2);
+ assert_eq!(inner_fields[1].name(), "b");
+ assert_eq!(
+ inner_fields[1].data_type().resolution,
+ Some(ResolutionInfo::DefaultValue(AvroLiteral::Int(42))),
+ "field b should have DefaultValue(Int(42)) from schema resolution"
+ );
+ }
+
+ #[test]
+ fn test_resolve_writer_union_to_reader_union_preserves_inner_record_defaults() {
+ // Writer: record [string, Inner{a: int}]
+ // Reader: union [Inner{a: int, b: int default 42}, string]
+ // The matching child (Inner) should preserve DefaultValue(Int(42)) on field b.
+ let writer = mk_union(vec![
+ mk_primitive(PrimitiveType::String),
+ Schema::Complex(ComplexType::Record(Record {
+ name: "Inner",
+ namespace: None,
+ doc: None,
+ aliases: vec![],
+ fields: vec![AvroFieldSchema {
+ name: "a",
+ doc: None,
+ r#type: mk_primitive(PrimitiveType::Int),
+ default: None,
+ aliases: vec![],
+ }],
+ attributes: Attributes::default(),
+ })),
+ ]);
+ let reader = mk_union(vec![
+ Schema::Complex(ComplexType::Record(Record {
+ name: "Inner",
+ namespace: None,
+ doc: None,
+ aliases: vec![],
+ fields: vec![
+ AvroFieldSchema {
+ name: "a",
+ doc: None,
+ r#type: mk_primitive(PrimitiveType::Int),
+ default: None,
+ aliases: vec![],
+ },
+ AvroFieldSchema {
+ name: "b",
+ doc: None,
+ r#type: mk_primitive(PrimitiveType::Int),
+ default: Some(Value::Number(serde_json::Number::from(42))),
+ aliases: vec![],
+ },
+ ],
+ attributes: Attributes::default(),
+ })),
+ mk_primitive(PrimitiveType::String),
+ ]);
+ let mut maker = Maker::new(false, false);
+ let dt = maker
+ .make_data_type(&writer, Some(&reader), None)
+ .expect("resolution should succeed");
+ // Verify the union resolution structure
+ let resolved = match dt.resolution.as_ref() {
+ Some(ResolutionInfo::Union(u)) => u,
+ other => panic!("expected union resolution info, got {other:?}"),
+ };
+ assert!(resolved.writer_is_union && resolved.reader_is_union);
+ assert_eq!(resolved.writer_to_reader.len(), 2);
+ let resolution = match resolved.writer_to_reader[1].as_ref() {
+ Some((0, resolution)) => resolution,
+ other => panic!("unexpected writer-to-reader table value {other:?}"),
+ };
+ match resolution {
+ ResolutionInfo::Record(ResolvedRecord {
+ writer_to_reader,
+ default_fields,
+ skip_fields,
+ }) => {
+ assert_eq!(writer_to_reader.len(), 1);
+ assert_eq!(writer_to_reader[0], Some(0));
+ assert_eq!(default_fields.len(), 1);
+ assert_eq!(default_fields[0], 1);
+ assert_eq!(skip_fields.len(), 1);
+ assert_eq!(skip_fields[0], None);
+ }
+ other => panic!("unexpected resolution {other:?}"),
+ }
+ // The matching child (Inner at index 0) should have field b with DefaultValue
+ let children = match dt.codec() {
+ Codec::Union(children, _, _) => children,
+ other => panic!("expected union codec, got {other:?}"),
+ };
+ let inner_fields = match children[0].codec() {
+ Codec::Struct(f) => f,
+ other => panic!("expected struct codec for Inner, got {other:?}"),
+ };
+ assert_eq!(inner_fields.len(), 2);
+ assert_eq!(inner_fields[1].name(), "b");
+ assert_eq!(
+ inner_fields[1].data_type().resolution,
+ Some(ResolutionInfo::DefaultValue(AvroLiteral::Int(42))),
+ "field b should have DefaultValue(Int(42)) from schema resolution"
);
}
@@ -2700,7 +2933,18 @@ mod tests {
let dt = maker.make_data_type(&writer, Some(&reader), None).unwrap();
assert!(matches!(dt.codec(), Codec::Utf8));
assert_eq!(dt.nullability, Some(Nullability::NullFirst));
- assert!(dt.resolution.is_none());
+ assert_eq!(
+ dt.resolution,
+ Some(ResolutionInfo::Union(ResolvedUnion {
+ writer_to_reader: [
+ None,
+ Some((0, ResolutionInfo::Promotion(Promotion::Direct)))
+ ]
+ .into(),
+ writer_is_union: true,
+ reader_is_union: true
+ }))
+ );
}
#[test]
@@ -2719,7 +2963,15 @@ mod tests {
assert_eq!(dt.nullability, Some(Nullability::NullFirst));
assert_eq!(
dt.resolution,
- Some(ResolutionInfo::Promotion(Promotion::IntToDouble))
+ Some(ResolutionInfo::Union(ResolvedUnion {
+ writer_to_reader: [
+ None,
+ Some((0, ResolutionInfo::Promotion(Promotion::IntToDouble)))
+ ]
+ .into(),
+ writer_is_union: true,
+ reader_is_union: true
+ }))
);
}
@@ -3316,14 +3568,7 @@ mod tests {
assert_eq!(inner.nullability(), Some(Nullability::NullFirst));
assert!(matches!(inner.codec(), Codec::Int32));
match inner.resolution.as_ref() {
- Some(ResolutionInfo::Union(info)) => {
- assert!(!info.writer_is_union, "writer should be non-union");
- assert!(info.reader_is_union, "reader should be union");
- assert_eq!(
- info.writer_to_reader.as_ref(),
- &[Some((1, Promotion::Direct))]
- );
- }
+ Some(ResolutionInfo::Promotion(Promotion::Direct)) => {}
other => panic!("expected Union resolution, got {other:?}"),
}
} else {
diff --git a/arrow-avro/src/reader/mod.rs b/arrow-avro/src/reader/mod.rs
index aa01f272bfeb..63b61b601e00 100644
--- a/arrow-avro/src/reader/mod.rs
+++ b/arrow-avro/src/reader/mod.rs
@@ -6866,6 +6866,264 @@ mod test {
assert_eq!(int_values.value(1), 2);
}
+ #[test]
+ fn test_nested_record_field_addition() {
+ let file = arrow_test_data("avro/nested_records.avro");
+
+ // Adds fields to the writer schema:
+ // * "ns2.record2" / "f1_4"
+ // - nullable
+ // - added last
+ // - the containing "f1" field is made nullable in the reader
+ // * "ns4.record4" / "f2_3"
+ // - non-nullable with an integer default value
+ // - resolution of a record nested in an array
+ // * "ns5.record5" / "f3_0"
+ // - non-nullable with a string default value
+ // - prepended before existing fields in the schema order
+ let reader_schema = AvroSchema::new(
+ r#"
+ {
+ "type": "record",
+ "name": "record1",
+ "namespace": "ns1",
+ "fields": [
+ {
+ "name": "f1",
+ "type": [
+ "null",
+ {
+ "type": "record",
+ "name": "record2",
+ "namespace": "ns2",
+ "fields": [
+ {
+ "name": "f1_1",
+ "type": "string"
+ },
+ {
+ "name": "f1_2",
+ "type": "int"
+ },
+ {
+ "name": "f1_3",
+ "type": {
+ "type": "record",
+ "name": "record3",
+ "namespace": "ns3",
+ "fields": [
+ {
+ "name": "f1_3_1",
+ "type": "double"
+ }
+ ]
+ }
+ },
+ {
+ "name": "f1_4",
+ "type": ["null", "int"],
+ "default": null
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "f2",
+ "type": {
+ "type": "array",
+ "items": {
+ "type": "record",
+ "name": "record4",
+ "namespace": "ns4",
+ "fields": [
+ {
+ "name": "f2_1",
+ "type": "boolean"
+ },
+ {
+ "name": "f2_2",
+ "type": "float"
+ },
+ {
+ "name": "f2_3",
+ "type": ["null", "int"],
+ "default": 42
+ }
+ ]
+ }
+ }
+ },
+ {
+ "name": "f3",
+ "type": [
+ "null",
+ {
+ "type": "record",
+ "name": "record5",
+ "namespace": "ns5",
+ "fields": [
+ {
+ "name": "f3_0",
+ "type": "string",
+ "default": "lorem ipsum"
+ },
+ {
+ "name": "f3_1",
+ "type": "string"
+ }
+ ]
+ }
+ ],
+ "default": null
+ },
+ {
+ "name": "f4",
+ "type": {
+ "type": "array",
+ "items": [
+ "null",
+ {
+ "type": "record",
+ "name": "record6",
+ "namespace": "ns6",
+ "fields": [
+ {
+ "name": "f4_1",
+ "type": "long"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ "#
+ .to_string(),
+ );
+
+ let file = File::open(&file).unwrap();
+ let mut reader = ReaderBuilder::new()
+ .with_reader_schema(reader_schema)
+ .build(BufReader::new(file))
+ .expect("reader with evolved reader schema should be built successfully");
+
+ let batch = reader
+ .next()
+ .expect("should have at least one batch")
+ .expect("reading should succeed");
+
+ assert!(batch.num_rows() > 0);
+
+ let schema = batch.schema();
+
+ let f1_field = schema.field_with_name("f1").expect("f1 field should exist");
+ if let DataType::Struct(f1_fields) = f1_field.data_type() {
+ let (_, f1_4) = f1_fields
+ .find("f1_4")
+ .expect("f1_4 field should be present in record2");
+ assert!(f1_4.is_nullable(), "f1_4 should be nullable");
+ assert_eq!(f1_4.data_type(), &DataType::Int32, "f1_4 should be Int32");
+ assert_eq!(
+ f1_4.metadata().get("avro.field.default"),
+ Some(&"null".to_string()),
+ "f1_4 should have null default value in metadata"
+ );
+ } else {
+ panic!("f1 should be a struct");
+ }
+
+ let f2_field = schema.field_with_name("f2").expect("f2 field should exist");
+ if let DataType::List(f2_items_field) = f2_field.data_type() {
+ if let DataType::Struct(f2_items_fields) = f2_items_field.data_type() {
+ let (_, f2_3) = f2_items_fields
+ .find("f2_3")
+ .expect("f2_3 field should be present in record4");
+ assert!(f2_3.is_nullable(), "f2_3 should be nullable");
+ assert_eq!(f2_3.data_type(), &DataType::Int32, "f2_3 should be Int32");
+ assert_eq!(
+ f2_3.metadata().get("avro.field.default"),
+ Some(&"42".to_string()),
+ "f2_3 should have 42 default value in metadata"
+ );
+ } else {
+ panic!("f2 array items should be a struct");
+ }
+ } else {
+ panic!("f2 should be a list");
+ }
+
+ let f3_field = schema.field_with_name("f3").expect("f3 field should exist");
+ assert!(f3_field.is_nullable(), "f3 should be nullable");
+ if let DataType::Struct(f3_fields) = f3_field.data_type() {
+ let (_, f3_0) = f3_fields
+ .find("f3_0")
+ .expect("f3_0 field should be present in record5");
+ assert!(!f3_0.is_nullable(), "f3_0 should be non-nullable");
+ assert_eq!(f3_0.data_type(), &DataType::Utf8, "f3_0 should be a string");
+ assert_eq!(
+ f3_0.metadata().get("avro.field.default"),
+ Some(&"\"lorem ipsum\"".to_string()),
+ "f3_0 should have \"lorem ipsum\" default value in metadata"
+ );
+ } else {
+ panic!("f3 should be a struct");
+ }
+
+ // Verify the actual values in the columns match the expected defaults
+ let num_rows = batch.num_rows();
+
+ // Check f1_4 values (should all be null since default is null)
+ let f1_array = batch
+ .column_by_name("f1")
+ .expect("f1 column should exist")
+ .as_struct();
+ let f1_4_array = f1_array
+ .column_by_name("f1_4")
+ .expect("f1_4 column should exist in f1 struct")
+ .as_primitive::();
+
+ assert_eq!(f1_4_array.null_count(), num_rows);
+
+ let f2_array = batch
+ .column_by_name("f2")
+ .expect("f2 column should exist")
+ .as_list::();
+
+ for i in 0..num_rows {
+ assert!(!f2_array.is_null(i));
+ let f2_value = f2_array.value(i);
+ let f2_record_array = f2_value.as_struct();
+ let f2_3_array = f2_record_array
+ .column_by_name("f2_3")
+ .expect("f2_3 column should exist in f2 array items")
+ .as_primitive::();
+
+ for j in 0..f2_3_array.len() {
+ assert!(!f2_3_array.is_null(j));
+ assert_eq!(f2_3_array.value(j), 42);
+ }
+ }
+
+ let f3_array = batch
+ .column_by_name("f3")
+ .expect("f3 column should exist")
+ .as_struct();
+ let f3_0_array = f3_array
+ .column_by_name("f3_0")
+ .expect("f3_0 column should exist in f3 struct")
+ .as_string::();
+
+ for i in 0..num_rows {
+ // Only check f3_0 when the parent f3 struct is not null
+ if !f3_array.is_null(i) {
+ assert!(!f3_0_array.is_null(i));
+ assert_eq!(f3_0_array.value(i), "lorem ipsum");
+ }
+ }
+ }
+
fn corrupt_first_block_payload_byte(
mut bytes: Vec,
field_offset: usize,
@@ -8441,6 +8699,33 @@ mod test {
])),
false,
));
+ let person_md = {
+ let mut m = HashMap::::new();
+ m.insert(AVRO_NAME_METADATA_KEY.to_string(), "Person".to_string());
+ m.insert(
+ AVRO_NAMESPACE_METADATA_KEY.to_string(),
+ "com.example".to_string(),
+ );
+ m
+ };
+ let maybe_auth_md = {
+ let mut m = HashMap::::new();
+ m.insert(AVRO_NAME_METADATA_KEY.to_string(), "MaybeAuth".to_string());
+ m.insert(
+ AVRO_NAMESPACE_METADATA_KEY.to_string(),
+ "org.apache.arrow.avrotests.v1.types".to_string(),
+ );
+ m
+ };
+ let address_md = {
+ let mut m = HashMap::::new();
+ m.insert(AVRO_NAME_METADATA_KEY.to_string(), "Address".to_string());
+ m.insert(
+ AVRO_NAMESPACE_METADATA_KEY.to_string(),
+ "org.apache.arrow.avrotests.v1.types".to_string(),
+ );
+ m
+ };
let rec_a_md = {
let mut m = HashMap::::new();
m.insert(AVRO_NAME_METADATA_KEY.to_string(), "RecA".to_string());
@@ -8576,11 +8861,18 @@ mod test {
true,
),
]);
- let kv_item_field = Arc::new(Field::new(
- item_name,
- DataType::Struct(kv_fields.clone()),
- false,
- ));
+ let kv_md = {
+ let mut m = HashMap::::new();
+ m.insert(AVRO_NAME_METADATA_KEY.to_string(), "KV".to_string());
+ m.insert(
+ AVRO_NAMESPACE_METADATA_KEY.to_string(),
+ "org.apache.arrow.avrotests.v1.types".to_string(),
+ );
+ m
+ };
+ let kv_item_field = Arc::new(
+ Field::new(item_name, DataType::Struct(kv_fields.clone()), false).with_metadata(kv_md),
+ );
let map_int_entries = Arc::new(Field::new(
"entries",
DataType::Struct(Fields::from(vec![
@@ -8652,14 +8944,17 @@ mod test {
#[cfg(not(feature = "small_decimals"))]
let dec10_dt = DataType::Decimal128(10, 2);
let fields: Vec = vec![
- Arc::new(Field::new(
- "person",
- DataType::Struct(Fields::from(vec![
- Field::new("name", DataType::Utf8, false),
- Field::new("age", DataType::Int32, false),
- ])),
- false,
- )),
+ Arc::new(
+ Field::new(
+ "person",
+ DataType::Struct(Fields::from(vec![
+ Field::new("name", DataType::Utf8, false),
+ Field::new("age", DataType::Int32, false),
+ ])),
+ false,
+ )
+ .with_metadata(person_md),
+ ),
Arc::new(Field::new("old_count", DataType::Int32, false)),
Arc::new(Field::new(
"union_map_or_array_int",
@@ -8691,23 +8986,29 @@ mod test {
DataType::Union(uf_union_big.clone(), UnionMode::Dense),
false,
)),
- Arc::new(Field::new(
- "maybe_auth",
- DataType::Struct(Fields::from(vec![
- Field::new("user", DataType::Utf8, false),
- Field::new("token", DataType::Binary, true), // [bytes,null] -> nullable bytes
- ])),
- false,
- )),
- Arc::new(Field::new(
- "address",
- DataType::Struct(Fields::from(vec![
- Field::new("street_name", DataType::Utf8, false),
- Field::new("zip", DataType::Int32, false),
- Field::new("country", DataType::Utf8, false),
- ])),
- false,
- )),
+ Arc::new(
+ Field::new(
+ "maybe_auth",
+ DataType::Struct(Fields::from(vec![
+ Field::new("user", DataType::Utf8, false),
+ Field::new("token", DataType::Binary, true), // [bytes,null] -> nullable bytes
+ ])),
+ false,
+ )
+ .with_metadata(maybe_auth_md),
+ ),
+ Arc::new(
+ Field::new(
+ "address",
+ DataType::Struct(Fields::from(vec![
+ Field::new("street_name", DataType::Utf8, false),
+ Field::new("zip", DataType::Int32, false),
+ Field::new("country", DataType::Utf8, false),
+ ])),
+ false,
+ )
+ .with_metadata(address_md),
+ ),
Arc::new(Field::new(
"map_union",
DataType::Map(map_entries_field.clone(), false),
diff --git a/arrow-avro/src/reader/record.rs b/arrow-avro/src/reader/record.rs
index 7701eeea725a..5e281d1fc6f6 100644
--- a/arrow-avro/src/reader/record.rs
+++ b/arrow-avro/src/reader/record.rs
@@ -18,7 +18,7 @@
//! Avro Decoder for Arrow types.
use crate::codec::{
- AvroDataType, AvroField, AvroLiteral, Codec, Promotion, ResolutionInfo, ResolvedRecord,
+ AvroDataType, AvroLiteral, Codec, EnumMapping, Promotion, ResolutionInfo, ResolvedRecord,
ResolvedUnion,
};
use crate::errors::AvroError;
@@ -38,22 +38,14 @@ use arrow_schema::{
};
#[cfg(feature = "avro_custom_types")]
use arrow_select::take::{TakeOptions, take};
-use std::cmp::Ordering;
-use std::sync::Arc;
use strum_macros::AsRefStr;
use uuid::Uuid;
-const DEFAULT_CAPACITY: usize = 1024;
+use std::cmp::Ordering;
+use std::mem;
+use std::sync::Arc;
-/// Runtime plan for decoding reader-side `["null", T]` types.
-#[derive(Clone, Copy, Debug)]
-enum NullablePlan {
- /// Writer actually wrote a union (branch tag present).
- ReadTag,
- /// Writer wrote a single (non-union) value resolved to the non-null branch
- /// of the reader union; do NOT read a branch tag, but apply any promotion.
- FromSingle { promotion: Promotion },
-}
+const DEFAULT_CAPACITY: usize = 1024;
/// Macro to decode a decimal payload for a given width and integer type.
macro_rules! decode_decimal {
@@ -121,13 +113,22 @@ impl RecordDecoder {
// Build Arrow schema fields and per-child decoders
let mut arrow_fields = Vec::with_capacity(reader_fields.len());
let mut encodings = Vec::with_capacity(reader_fields.len());
+ let mut field_defaults = Vec::with_capacity(reader_fields.len());
for avro_field in reader_fields.iter() {
arrow_fields.push(avro_field.field());
encodings.push(Decoder::try_new(avro_field.data_type())?);
+
+ if let Some(ResolutionInfo::DefaultValue(lit)) =
+ avro_field.data_type().resolution.as_ref()
+ {
+ field_defaults.push(Some(lit.clone()));
+ } else {
+ field_defaults.push(None);
+ }
}
let projector = match data_type.resolution.as_ref() {
Some(ResolutionInfo::Record(rec)) => {
- Some(ProjectorBuilder::try_new(rec, reader_fields).build()?)
+ Some(ProjectorBuilder::try_new(rec, &field_defaults).build()?)
}
_ => None,
};
@@ -179,12 +180,6 @@ impl RecordDecoder {
}
}
-#[derive(Debug)]
-struct EnumResolution {
- mapping: Arc<[i32]>,
- default_index: i32,
-}
-
#[derive(Debug, AsRefStr)]
enum Decoder {
Null(usize),
@@ -249,7 +244,12 @@ enum Decoder {
/// String data encoded as UTF-8 bytes, but mapped to Arrow's StringViewArray
StringView(OffsetBufferBuilder, Vec),
Array(FieldRef, OffsetBufferBuilder, Box),
- Record(Fields, Vec, Option),
+ Record(
+ Fields,
+ Vec,
+ Vec>,
+ Option,
+ ),
Map(
FieldRef,
OffsetBufferBuilder,
@@ -270,7 +270,7 @@ enum Decoder {
#[cfg(feature = "avro_custom_types")]
RunEndEncoded(u8, usize, Box),
Union(UnionDecoder),
- Nullable(Nullability, NullBufferBuilder, Box, NullablePlan),
+ Nullable(NullablePlan, NullBufferBuilder, Box),
}
impl Decoder {
@@ -279,7 +279,7 @@ impl Decoder {
if info.writer_is_union && !info.reader_is_union {
let mut clone = data_type.clone();
clone.resolution = None; // Build target base decoder without Union resolution
- let target = Box::new(Self::try_new_internal(&clone)?);
+ let target = Self::try_new_internal(&clone)?;
let decoder = Self::Union(
UnionDecoderBuilder::new()
.with_resolved_union(info.clone())
@@ -295,7 +295,7 @@ impl Decoder {
fn try_new_internal(data_type: &AvroDataType) -> Result {
// Extract just the Promotion (if any) to simplify pattern matching
let promotion = match data_type.resolution.as_ref() {
- Some(ResolutionInfo::Promotion(p)) => Some(p),
+ Some(ResolutionInfo::Promotion(p)) => Some(*p),
_ => None,
};
let decoder = match (data_type.codec(), promotion) {
@@ -466,10 +466,9 @@ impl Decoder {
}
(Codec::Enum(symbols), _) => {
let res = match data_type.resolution.as_ref() {
- Some(ResolutionInfo::EnumMapping(mapping)) => Some(EnumResolution {
- mapping: mapping.mapping.clone(),
- default_index: mapping.default_index,
- }),
+ Some(ResolutionInfo::EnumMapping(mapping)) => {
+ Some(EnumResolution::new(mapping))
+ }
_ => None,
};
Self::Enum(Vec::with_capacity(DEFAULT_CAPACITY), symbols.clone(), res)
@@ -477,18 +476,27 @@ impl Decoder {
(Codec::Struct(fields), _) => {
let mut arrow_fields = Vec::with_capacity(fields.len());
let mut encodings = Vec::with_capacity(fields.len());
+ let mut field_defaults = Vec::with_capacity(fields.len());
for avro_field in fields.iter() {
let encoding = Self::try_new(avro_field.data_type())?;
arrow_fields.push(avro_field.field());
encodings.push(encoding);
+
+ if let Some(ResolutionInfo::DefaultValue(lit)) =
+ avro_field.data_type().resolution.as_ref()
+ {
+ field_defaults.push(Some(lit.clone()));
+ } else {
+ field_defaults.push(None);
+ }
}
let projector =
if let Some(ResolutionInfo::Record(rec)) = data_type.resolution.as_ref() {
- Some(ProjectorBuilder::try_new(rec, fields).build()?)
+ Some(ProjectorBuilder::try_new(rec, &field_defaults).build()?)
} else {
None
};
- Self::Record(arrow_fields.into(), encodings, projector)
+ Self::Record(arrow_fields.into(), encodings, field_defaults, projector)
}
(Codec::Map(child), _) => {
let val_field = child.field_with_name("value");
@@ -568,20 +576,49 @@ impl Decoder {
};
Ok(match data_type.nullability() {
Some(nullability) => {
- // Default to reading a union branch tag unless the resolution proves otherwise.
- let mut plan = NullablePlan::ReadTag;
- if let Some(ResolutionInfo::Union(info)) = data_type.resolution.as_ref() {
- if !info.writer_is_union && info.reader_is_union {
- if let Some(Some((_reader_idx, promo))) = info.writer_to_reader.first() {
- plan = NullablePlan::FromSingle { promotion: *promo };
+ // Default to reading a union branch tag unless the resolution directs otherwise.
+ let plan = match &data_type.resolution {
+ None => NullablePlan::ReadTag {
+ nullability,
+ resolution: ResolutionPlan::Promotion(Promotion::Direct),
+ },
+ Some(ResolutionInfo::Promotion(_)) => {
+ // Promotions should have been incorporated
+ // into the inner decoder.
+ NullablePlan::FromSingle {
+ resolution: ResolutionPlan::Promotion(Promotion::Direct),
}
}
- }
+ Some(ResolutionInfo::Union(info)) if !info.writer_is_union => {
+ let Some(Some((_, resolution))) = info.writer_to_reader.first() else {
+ return Err(AvroError::SchemaError(
+ "unexpected union resolution info for non-union writer and union reader type".into(),
+ ));
+ };
+ let resolution = ResolutionPlan::try_new(&decoder, resolution)?;
+ NullablePlan::FromSingle { resolution }
+ }
+ Some(ResolutionInfo::Union(info)) => {
+ let Some((_, resolution)) =
+ info.writer_to_reader[nullability.non_null_index()].as_ref()
+ else {
+ return Err(AvroError::SchemaError(
+ "unexpected union resolution info for nullable writer type".into(),
+ ));
+ };
+ NullablePlan::ReadTag {
+ nullability,
+ resolution: ResolutionPlan::try_new(&decoder, resolution)?,
+ }
+ }
+ Some(resolution) => NullablePlan::FromSingle {
+ resolution: ResolutionPlan::try_new(&decoder, resolution)?,
+ },
+ };
Self::Nullable(
- nullability,
+ plan,
NullBufferBuilder::new(DEFAULT_CAPACITY),
Box::new(decoder),
- plan,
)
}
None => decoder,
@@ -645,7 +682,7 @@ impl Decoder {
Self::Array(_, offsets, _) => {
offsets.push_length(0);
}
- Self::Record(_, e, _) => {
+ Self::Record(_, e, _, _) => {
for encoding in e.iter_mut() {
encoding.append_null()?;
}
@@ -670,7 +707,7 @@ impl Decoder {
inner.append_null()?;
}
Self::Union(u) => u.append_null()?,
- Self::Nullable(_, null_buffer, inner, _) => {
+ Self::Nullable(_, null_buffer, inner) => {
null_buffer.append(false);
inner.append_null()?;
}
@@ -681,7 +718,7 @@ impl Decoder {
/// Append a single default literal into the decoder's buffers
fn append_default(&mut self, lit: &AvroLiteral) -> Result<(), AvroError> {
match self {
- Self::Nullable(_, nb, inner, _) => {
+ Self::Nullable(_, nb, inner) => {
if matches!(lit, AvroLiteral::Null) {
nb.append(false);
inner.append_null()
@@ -1087,14 +1124,14 @@ impl Decoder {
inner.append_default(lit)
}
Self::Union(u) => u.append_default(lit),
- Self::Record(field_meta, decoders, projector) => match lit {
+ Self::Record(field_meta, decoders, field_defaults, _) => match lit {
AvroLiteral::Map(entries) => {
for (i, dec) in decoders.iter_mut().enumerate() {
let name = field_meta[i].name();
if let Some(sub) = entries.get(name) {
dec.append_default(sub)?;
- } else if let Some(proj) = projector.as_ref() {
- proj.project_default(dec, i)?;
+ } else if let Some(default_literal) = field_defaults[i].as_ref() {
+ dec.append_default(default_literal)?;
} else {
dec.append_null()?;
}
@@ -1103,8 +1140,8 @@ impl Decoder {
}
AvroLiteral::Null => {
for (i, dec) in decoders.iter_mut().enumerate() {
- if let Some(proj) = projector.as_ref() {
- proj.project_default(dec, i)?;
+ if let Some(default_literal) = field_defaults[i].as_ref() {
+ dec.append_default(default_literal)?;
} else {
dec.append_null()?;
}
@@ -1246,12 +1283,12 @@ impl Decoder {
let total_items = read_blocks(buf, |cursor| encoding.decode(cursor))?;
off.push_length(total_items);
}
- Self::Record(_, encodings, None) => {
+ Self::Record(_, encodings, _, None) => {
for encoding in encodings {
encoding.decode(buf)?;
}
}
- Self::Record(_, encodings, Some(proj)) => {
+ Self::Record(_, encodings, _, Some(proj)) => {
proj.project_record(buf, encodings)?;
}
Self::Map(_, koff, moff, kdata, valdec) => {
@@ -1286,18 +1323,8 @@ impl Decoder {
}
Self::Enum(indices, _, Some(res)) => {
let raw = buf.get_int()?;
- let resolved = usize::try_from(raw)
- .ok()
- .and_then(|idx| res.mapping.get(idx).copied())
- .filter(|&idx| idx >= 0)
- .unwrap_or(res.default_index);
- if resolved >= 0 {
- indices.push(resolved);
- } else {
- return Err(AvroError::ParseError(format!(
- "Enum symbol index {raw} not resolvable and no default provided",
- )));
- }
+ let resolved = res.resolve(raw)?;
+ indices.push(resolved);
}
Self::Duration(builder) => {
let b = buf.get_fixed(12)?;
@@ -1313,26 +1340,31 @@ impl Decoder {
inner.decode(buf)?;
}
Self::Union(u) => u.decode(buf)?,
- Self::Nullable(order, nb, encoding, plan) => match *plan {
- NullablePlan::FromSingle { promotion } => {
- encoding.decode_with_promotion(buf, promotion)?;
- nb.append(true);
- }
- NullablePlan::ReadTag => {
- let branch = buf.read_vlq()?;
- let is_not_null = match *order {
- Nullability::NullFirst => branch != 0,
- Nullability::NullSecond => branch == 0,
- };
- if is_not_null {
- // It is important to decode before appending to null buffer in case of decode error
- encoding.decode(buf)?;
- } else {
- encoding.append_null()?;
+ Self::Nullable(plan, nb, encoding) => {
+ match plan {
+ NullablePlan::FromSingle { resolution } => {
+ encoding.decode_with_resolution(buf, resolution)?;
+ nb.append(true);
+ }
+ NullablePlan::ReadTag {
+ nullability,
+ resolution,
+ } => {
+ let branch = buf.read_vlq()?;
+ let is_not_null = match *nullability {
+ Nullability::NullFirst => branch != 0,
+ Nullability::NullSecond => branch == 0,
+ };
+ if is_not_null {
+ // It is important to decode before appending to null buffer in case of decode error
+ encoding.decode_with_resolution(buf, resolution)?;
+ } else {
+ encoding.append_null()?;
+ }
+ nb.append(is_not_null);
}
- nb.append(is_not_null);
}
- },
+ }
}
Ok(())
}
@@ -1401,10 +1433,49 @@ impl Decoder {
}
}
+ fn decode_with_resolution<'d>(
+ &'d mut self,
+ buf: &mut AvroCursor<'_>,
+ resolution: &'d ResolutionPlan,
+ ) -> Result<(), AvroError> {
+ #[cfg(feature = "avro_custom_types")]
+ if let Self::RunEndEncoded(_, len, inner) = self {
+ *len += 1;
+ return inner.decode_with_resolution(buf, resolution);
+ }
+
+ match resolution {
+ ResolutionPlan::Promotion(promotion) => {
+ let promotion = *promotion;
+ self.decode_with_promotion(buf, promotion)
+ }
+ ResolutionPlan::DefaultValue(lit) => self.append_default(lit),
+ ResolutionPlan::EnumMapping(res) => {
+ let Self::Enum(indices, _, _) = self else {
+ return Err(AvroError::SchemaError(
+ "enum mapping resolution provided for non-enum decoder".into(),
+ ));
+ };
+ let raw = buf.get_int()?;
+ let resolved = res.resolve(raw)?;
+ indices.push(resolved);
+ Ok(())
+ }
+ ResolutionPlan::Record(proj) => {
+ let Self::Record(_, encodings, _, _) = self else {
+ return Err(AvroError::SchemaError(
+ "record projection provided for non-record decoder".into(),
+ ));
+ };
+ proj.project_record(buf, encodings)
+ }
+ }
+ }
+
/// Flush decoded records to an [`ArrayRef`]
fn flush(&mut self, nulls: Option) -> Result {
Ok(match self {
- Self::Nullable(_, n, e, _) => e.flush(n.finish())?,
+ Self::Nullable(_, n, e) => e.flush(n.finish())?,
Self::Null(size) => Arc::new(NullArray::new(std::mem::replace(size, 0))),
Self::Boolean(b) => Arc::new(BooleanArray::new(b.finish(), nulls)),
Self::Int32(values) => Arc::new(flush_primitive::(values, nulls)),
@@ -1533,7 +1604,7 @@ impl Decoder {
let offsets = flush_offsets(offsets);
Arc::new(ListArray::try_new(field.clone(), offsets, values, nulls)?)
}
- Self::Record(fields, encodings, _) => {
+ Self::Record(fields, encodings, _, _) => {
let arrays = encodings
.iter_mut()
.map(|x| x.flush(None))
@@ -1678,6 +1749,83 @@ impl Decoder {
}
}
+/// Runtime plan for decoding reader-side `["null", T]` types.
+#[derive(Debug)]
+enum NullablePlan {
+ /// Writer actually wrote a union (branch tag present).
+ ReadTag {
+ nullability: Nullability,
+ resolution: ResolutionPlan,
+ },
+ /// Writer wrote a single (non-union) value resolved to the non-null branch
+ /// of the reader union; do NOT read a branch tag, but apply any resolution.
+ FromSingle { resolution: ResolutionPlan },
+}
+
+/// Runtime plan for resolving writer-reader type differences.
+#[derive(Debug)]
+enum ResolutionPlan {
+ /// Indicates that the writer's type should be promoted to the reader's type.
+ Promotion(Promotion),
+ /// Provides a default value for the field missing in the writer type.
+ DefaultValue(AvroLiteral),
+ /// Provides mapping information for resolving enums.
+ EnumMapping(EnumResolution),
+ /// Provides projection information for record fields.
+ Record(Projector),
+}
+
+impl ResolutionPlan {
+ fn try_new(decoder: &Decoder, resolution: &ResolutionInfo) -> Result {
+ match (decoder, resolution) {
+ (_, ResolutionInfo::Promotion(p)) => Ok(ResolutionPlan::Promotion(*p)),
+ (_, ResolutionInfo::DefaultValue(lit)) => Ok(ResolutionPlan::DefaultValue(lit.clone())),
+ (_, ResolutionInfo::EnumMapping(m)) => {
+ Ok(ResolutionPlan::EnumMapping(EnumResolution::new(m)))
+ }
+ (Decoder::Record(_, _, field_defaults, _), ResolutionInfo::Record(r)) => Ok(
+ ResolutionPlan::Record(ProjectorBuilder::try_new(r, field_defaults).build()?),
+ ),
+ (_, ResolutionInfo::Record(_)) => Err(AvroError::SchemaError(
+ "record resolution on non-record decoder".into(),
+ )),
+ (_, ResolutionInfo::Union(_)) => Err(AvroError::SchemaError(
+ "union variant cannot be resolved to a union type".into(),
+ )),
+ }
+ }
+}
+
+#[derive(Debug)]
+struct EnumResolution {
+ mapping: Arc<[i32]>,
+ default_index: i32,
+}
+
+impl EnumResolution {
+ fn new(mapping: &EnumMapping) -> Self {
+ EnumResolution {
+ mapping: mapping.mapping.clone(),
+ default_index: mapping.default_index,
+ }
+ }
+
+ fn resolve(&self, index: i32) -> Result {
+ let resolved = usize::try_from(index)
+ .ok()
+ .and_then(|idx| self.mapping.get(idx).copied())
+ .filter(|&idx| idx >= 0)
+ .unwrap_or(self.default_index);
+ if resolved >= 0 {
+ Ok(resolved)
+ } else {
+ Err(AvroError::ParseError(format!(
+ "Enum symbol index {index} not resolvable and no default provided",
+ )))
+ }
+ }
+}
+
// A lookup table for resolving fields between writer and reader schemas during record projection.
#[derive(Debug)]
struct DispatchLookupTable {
@@ -1697,11 +1845,11 @@ struct DispatchLookupTable {
// - `to_reader.len() == promotion.len()` and matches the reader field count.
// - If `to_reader[r] == NO_SOURCE`, `promotion[r]` is ignored.
to_reader: Box<[i8]>,
- // For each reader field `r`, specifies the `Promotion` to apply to the writer's value.
+ // For each reader field `r`, specifies the resolution to apply to the writer's value.
//
// This is used when a writer field's type can be promoted to a reader field's type
// (e.g., `Int` to `Long`). It is ignored if `to_reader[r] == NO_SOURCE`.
- promotion: Box<[Promotion]>,
+ resolution: Box<[ResolutionPlan]>,
}
// Sentinel used in `DispatchLookupTable::to_reader` to mark
@@ -1710,64 +1858,94 @@ const NO_SOURCE: i8 = -1;
impl DispatchLookupTable {
fn from_writer_to_reader(
- promotion_map: &[Option<(usize, Promotion)>],
+ reader_branches: &[Decoder],
+ resolution_map: &[Option<(usize, ResolutionInfo)>],
) -> Result {
- let mut to_reader = Vec::with_capacity(promotion_map.len());
- let mut promotion = Vec::with_capacity(promotion_map.len());
- for map in promotion_map {
- match *map {
- Some((idx, promo)) => {
+ let mut to_reader = Vec::with_capacity(resolution_map.len());
+ let mut resolution = Vec::with_capacity(resolution_map.len());
+ for map in resolution_map {
+ match map {
+ Some((idx, res)) => {
+ let idx = *idx;
let idx_i8 = i8::try_from(idx).map_err(|_| {
AvroError::SchemaError(format!(
"Reader branch index {idx} exceeds i8 range (max {})",
i8::MAX
))
})?;
+ let plan = ResolutionPlan::try_new(&reader_branches[idx], res)?;
to_reader.push(idx_i8);
- promotion.push(promo);
+ resolution.push(plan);
}
None => {
to_reader.push(NO_SOURCE);
- promotion.push(Promotion::Direct);
+ resolution.push(ResolutionPlan::DefaultValue(AvroLiteral::Null));
}
}
}
Ok(Self {
to_reader: to_reader.into_boxed_slice(),
- promotion: promotion.into_boxed_slice(),
+ resolution: resolution.into_boxed_slice(),
})
}
- // Resolve a writer branch index to (reader_idx, promotion)
+ // Resolve a writer branch index to (reader_idx, resolution)
#[inline]
- fn resolve(&self, writer_index: usize) -> Option<(usize, Promotion)> {
+ fn resolve(&self, writer_index: usize) -> Option<(usize, &ResolutionPlan)> {
let reader_index = *self.to_reader.get(writer_index)?;
- (reader_index >= 0).then(|| (reader_index as usize, self.promotion[writer_index]))
+ (reader_index >= 0).then(|| (reader_index as usize, &self.resolution[writer_index]))
}
}
#[derive(Debug)]
struct UnionDecoder {
fields: UnionFields,
- type_ids: Vec,
- offsets: Vec,
- branches: Vec,
- counts: Vec,
- reader_type_codes: Vec,
+ branches: UnionDecoderBranches,
default_emit_idx: usize,
null_emit_idx: usize,
plan: UnionReadPlan,
}
+#[derive(Debug, Default)]
+struct UnionDecoderBranches {
+ decoders: Vec,
+ reader_type_codes: Vec,
+ type_ids: Vec,
+ offsets: Vec,
+ counts: Vec,
+}
+
+impl UnionDecoderBranches {
+ fn new(decoders: Vec, reader_type_codes: Vec) -> Self {
+ let branch_len = decoders.len().max(reader_type_codes.len());
+ Self {
+ decoders,
+ reader_type_codes,
+ type_ids: Vec::with_capacity(DEFAULT_CAPACITY),
+ offsets: Vec::with_capacity(DEFAULT_CAPACITY),
+ counts: vec![0; branch_len],
+ }
+ }
+
+ fn emit_to(&mut self, reader_idx: usize) -> Result<&mut Decoder, AvroError> {
+ let branches_len = self.decoders.len();
+ let Some(reader_branch) = self.decoders.get_mut(reader_idx) else {
+ return Err(AvroError::ParseError(format!(
+ "Union branch index {reader_idx} out of range ({branches_len} branches)"
+ )));
+ };
+ self.type_ids.push(self.reader_type_codes[reader_idx]);
+ self.offsets.push(self.counts[reader_idx]);
+ self.counts[reader_idx] += 1;
+ Ok(reader_branch)
+ }
+}
+
impl Default for UnionDecoder {
fn default() -> Self {
Self {
fields: UnionFields::empty(),
- type_ids: Vec::new(),
- offsets: Vec::new(),
- branches: Vec::new(),
- counts: Vec::new(),
- reader_type_codes: Vec::new(),
+ branches: Default::default(),
default_emit_idx: 0,
null_emit_idx: 0,
plan: UnionReadPlan::Passthrough,
@@ -1782,7 +1960,7 @@ enum UnionReadPlan {
},
FromSingle {
reader_idx: usize,
- promotion: Promotion,
+ resolution: ResolutionPlan,
},
ToSingle {
target: Box,
@@ -1791,6 +1969,47 @@ enum UnionReadPlan {
Passthrough,
}
+impl UnionReadPlan {
+ fn from_resolved(
+ reader_branches: &[Decoder],
+ resolved: Option,
+ ) -> Result {
+ let Some(info) = resolved else {
+ return Ok(Self::Passthrough);
+ };
+ match (info.writer_is_union, info.reader_is_union) {
+ (true, true) => {
+ let lookup_table =
+ DispatchLookupTable::from_writer_to_reader(reader_branches, &info.writer_to_reader)?;
+ Ok(Self::ReaderUnion { lookup_table })
+ }
+ (false, true) => {
+ let Some((idx, resolution)) =
+ info.writer_to_reader.first().and_then(Option::as_ref)
+ else {
+ return Err(AvroError::SchemaError(
+ "Writer type does not match any reader union branch".to_string(),
+ ));
+ };
+ let reader_idx = *idx;
+ Ok(Self::FromSingle {
+ reader_idx,
+ resolution: ResolutionPlan::try_new(&reader_branches[reader_idx], resolution)?,
+ })
+ }
+ (true, false) => Err(AvroError::InvalidArgument(
+ "UnionDecoder::try_new cannot build writer-union to single; use UnionDecoderBuilder with a target"
+ .to_string(),
+ )),
+ // (false, false) is invalid and should never be constructed by the resolver.
+ _ => Err(AvroError::SchemaError(
+ "ResolvedUnion constructed for non-union sides; resolver should return None"
+ .to_string(),
+ )),
+ }
+ }
+}
+
impl UnionDecoder {
fn try_new(
fields: UnionFields,
@@ -1801,7 +2020,6 @@ impl UnionDecoder {
let null_branch = branches.iter().position(|b| matches!(b, Decoder::Null(_)));
let default_emit_idx = 0;
let null_emit_idx = null_branch.unwrap_or(default_emit_idx);
- let branch_len = branches.len().max(reader_type_codes.len());
// Guard against impractically large unions that cannot be indexed by an Avro int
let max_addr = (i32::MAX as usize) + 1;
if branches.len() > max_addr {
@@ -1812,26 +2030,23 @@ impl UnionDecoder {
i32::MAX
)));
}
+ let plan = UnionReadPlan::from_resolved(&branches, resolved)?;
Ok(Self {
fields,
- type_ids: Vec::with_capacity(DEFAULT_CAPACITY),
- offsets: Vec::with_capacity(DEFAULT_CAPACITY),
- branches,
- counts: vec![0; branch_len],
- reader_type_codes,
+ branches: UnionDecoderBranches::new(branches, reader_type_codes),
default_emit_idx,
null_emit_idx,
- plan: Self::plan_from_resolved(resolved)?,
+ plan,
})
}
- fn try_new_from_writer_union(
- info: ResolvedUnion,
- target: Box,
- ) -> Result {
+ fn with_single_target(target: Decoder, info: ResolvedUnion) -> Result {
// This constructor is only for writer-union to single-type resolution
debug_assert!(info.writer_is_union && !info.reader_is_union);
- let lookup_table = DispatchLookupTable::from_writer_to_reader(&info.writer_to_reader)?;
+ let mut reader_branches = [target];
+ let lookup_table =
+ DispatchLookupTable::from_writer_to_reader(&reader_branches, &info.writer_to_reader)?;
+ let target = Box::new(mem::replace(&mut reader_branches[0], Decoder::Null(0)));
Ok(Self {
plan: UnionReadPlan::ToSingle {
target,
@@ -1841,41 +2056,6 @@ impl UnionDecoder {
})
}
- fn plan_from_resolved(resolved: Option) -> Result {
- let Some(info) = resolved else {
- return Ok(UnionReadPlan::Passthrough);
- };
- match (info.writer_is_union, info.reader_is_union) {
- (true, true) => {
- let lookup_table =
- DispatchLookupTable::from_writer_to_reader(&info.writer_to_reader)?;
- Ok(UnionReadPlan::ReaderUnion { lookup_table })
- }
- (false, true) => {
- let Some(&(reader_idx, promotion)) =
- info.writer_to_reader.first().and_then(Option::as_ref)
- else {
- return Err(AvroError::SchemaError(
- "Writer type does not match any reader union branch".to_string(),
- ));
- };
- Ok(UnionReadPlan::FromSingle {
- reader_idx,
- promotion,
- })
- }
- (true, false) => Err(AvroError::InvalidArgument(
- "UnionDecoder::try_new cannot build writer-union to single; use UnionDecoderBuilder with a target"
- .to_string(),
- )),
- // (false, false) is invalid and should never be constructed by the resolver.
- _ => Err(AvroError::SchemaError(
- "ResolvedUnion constructed for non-union sides; resolver should return None"
- .to_string(),
- )),
- }
- }
-
#[inline]
fn read_tag(buf: &mut AvroCursor<'_>) -> Result {
// Avro unions are encoded by first writing the zero-based branch index.
@@ -1896,20 +2076,6 @@ impl UnionDecoder {
})
}
- #[inline]
- fn emit_to(&mut self, reader_idx: usize) -> Result<&mut Decoder, AvroError> {
- let branches_len = self.branches.len();
- let Some(reader_branch) = self.branches.get_mut(reader_idx) else {
- return Err(AvroError::ParseError(format!(
- "Union branch index {reader_idx} out of range ({branches_len} branches)"
- )));
- };
- self.type_ids.push(self.reader_type_codes[reader_idx]);
- self.offsets.push(self.counts[reader_idx]);
- self.counts[reader_idx] += 1;
- Ok(reader_branch)
- }
-
#[inline]
fn on_decoder(&mut self, fallback_idx: usize, action: F) -> Result<(), AvroError>
where
@@ -1922,7 +2088,7 @@ impl UnionDecoder {
UnionReadPlan::FromSingle { reader_idx, .. } => *reader_idx,
_ => fallback_idx,
};
- self.emit_to(reader_idx).and_then(action)
+ self.branches.emit_to(reader_idx).and_then(action)
}
fn append_null(&mut self) -> Result<(), AvroError> {
@@ -1934,35 +2100,42 @@ impl UnionDecoder {
}
fn decode(&mut self, buf: &mut AvroCursor<'_>) -> Result<(), AvroError> {
- let (reader_idx, promotion) = match &mut self.plan {
- UnionReadPlan::Passthrough => (Self::read_tag(buf)?, Promotion::Direct),
+ match &mut self.plan {
+ UnionReadPlan::Passthrough => {
+ let reader_idx = Self::read_tag(buf)?;
+ let decoder = self.branches.emit_to(reader_idx)?;
+ decoder.decode(buf)
+ }
UnionReadPlan::ReaderUnion { lookup_table } => {
let idx = Self::read_tag(buf)?;
- lookup_table.resolve(idx).ok_or_else(|| {
- AvroError::ParseError(format!(
+ let Some((reader_idx, resolution)) = lookup_table.resolve(idx) else {
+ return Err(AvroError::ParseError(format!(
"Union branch index {idx} not resolvable by reader schema"
- ))
- })?
+ )));
+ };
+ let decoder = self.branches.emit_to(reader_idx)?;
+ decoder.decode_with_resolution(buf, resolution)
}
UnionReadPlan::FromSingle {
reader_idx,
- promotion,
- } => (*reader_idx, *promotion),
+ resolution,
+ } => {
+ let decoder = self.branches.emit_to(*reader_idx)?;
+ decoder.decode_with_resolution(buf, resolution)
+ }
UnionReadPlan::ToSingle {
target,
lookup_table,
} => {
let idx = Self::read_tag(buf)?;
- return match lookup_table.resolve(idx) {
- Some((_, promotion)) => target.decode_with_promotion(buf, promotion),
- None => Err(AvroError::ParseError(format!(
- "Writer union branch {idx} does not resolve to reader type"
- ))),
+ let Some((_, resolution)) = lookup_table.resolve(idx) else {
+ return Err(AvroError::ParseError(format!(
+ "Writer union branch index {idx} not resolvable by reader schema"
+ )));
};
+ target.decode_with_resolution(buf, resolution)
}
- };
- let decoder = self.emit_to(reader_idx)?;
- decoder.decode_with_promotion(buf, promotion)
+ }
}
fn flush(&mut self, nulls: Option) -> Result {
@@ -1976,13 +2149,20 @@ impl UnionDecoder {
);
let children = self
.branches
+ .decoders
.iter_mut()
.map(|d| d.flush(None))
.collect::, _>>()?;
let arr = UnionArray::try_new(
self.fields.clone(),
- flush_values(&mut self.type_ids).into_iter().collect(),
- Some(flush_values(&mut self.offsets).into_iter().collect()),
+ flush_values(&mut self.branches.type_ids)
+ .into_iter()
+ .collect(),
+ Some(
+ flush_values(&mut self.branches.offsets)
+ .into_iter()
+ .collect(),
+ ),
children,
)
.map_err(|e| AvroError::ParseError(e.to_string()))?;
@@ -1995,7 +2175,7 @@ struct UnionDecoderBuilder {
fields: Option,
branches: Option>,
resolved: Option,
- target: Option>,
+ target: Option,
}
impl UnionDecoderBuilder {
@@ -2018,7 +2198,7 @@ impl UnionDecoderBuilder {
self
}
- fn with_target(mut self, target: Box) -> Self {
+ fn with_target(mut self, target: Decoder) -> Self {
self.target = Some(target);
self
}
@@ -2031,7 +2211,7 @@ impl UnionDecoderBuilder {
(Some(info), None, None, Some(target))
if info.writer_is_union && !info.reader_is_union =>
{
- UnionDecoder::try_new_from_writer_union(info, target)
+ UnionDecoder::with_single_target(target, info)
}
_ => Err(AvroError::InvalidArgument(
"Invalid UnionDecoderBuilder configuration: expected either \
@@ -2238,42 +2418,31 @@ fn values_equal_at(arr: &dyn Array, i: usize, j: usize) -> bool {
struct Projector {
writer_to_reader: Arc<[Option]>,
skip_decoders: Vec>,
- field_defaults: Vec >,
default_injections: Arc<[(usize, AvroLiteral)]>,
}
#[derive(Debug)]
struct ProjectorBuilder<'a> {
rec: &'a ResolvedRecord,
- reader_fields: Arc<[AvroField]>,
+ field_defaults: &'a [Option],
}
impl<'a> ProjectorBuilder<'a> {
#[inline]
- fn try_new(rec: &'a ResolvedRecord, reader_fields: &Arc<[AvroField]>) -> Self {
+ fn try_new(rec: &'a ResolvedRecord, field_defaults: &'a [Option]) -> Self {
Self {
rec,
- reader_fields: reader_fields.clone(),
+ field_defaults,
}
}
#[inline]
fn build(self) -> Result {
- let reader_fields = self.reader_fields;
- let mut field_defaults: Vec> = Vec::with_capacity(reader_fields.len());
- for avro_field in reader_fields.as_ref() {
- if let Some(ResolutionInfo::DefaultValue(lit)) =
- avro_field.data_type().resolution.as_ref()
- {
- field_defaults.push(Some(lit.clone()));
- } else {
- field_defaults.push(None);
- }
- }
let mut default_injections: Vec<(usize, AvroLiteral)> =
Vec::with_capacity(self.rec.default_fields.len());
for &idx in self.rec.default_fields.as_ref() {
- let lit = field_defaults
+ let lit = self
+ .field_defaults
.get(idx)
.and_then(|lit| lit.clone())
.unwrap_or(AvroLiteral::Null);
@@ -2291,31 +2460,15 @@ impl<'a> ProjectorBuilder<'a> {
Ok(Projector {
writer_to_reader: self.rec.writer_to_reader.clone(),
skip_decoders,
- field_defaults,
default_injections: default_injections.into(),
})
}
}
impl Projector {
- #[inline]
- fn project_default(&self, decoder: &mut Decoder, index: usize) -> Result<(), AvroError> {
- // SAFETY: `index` is obtained by listing the reader's record fields (i.e., from
- // `decoders.iter_mut().enumerate()`), and `field_defaults` was built in
- // `ProjectorBuilder::build` to have exactly one element per reader field.
- // Therefore, `index < self.field_defaults.len()` always holds here, so
- // `self.field_defaults[index]` cannot panic. We only take an immutable reference
- // via `.as_ref()`, and `self` is borrowed immutably.
- if let Some(default_literal) = self.field_defaults[index].as_ref() {
- decoder.append_default(default_literal)
- } else {
- decoder.append_null()
- }
- }
-
#[inline]
fn project_record(
- &mut self,
+ &self,
buf: &mut AvroCursor<'_>,
encodings: &mut [Decoder],
) -> Result<(), AvroError> {
@@ -2327,10 +2480,10 @@ impl Projector {
for (i, (mapping, skipper_opt)) in self
.writer_to_reader
.iter()
- .zip(self.skip_decoders.iter_mut())
+ .zip(self.skip_decoders.iter())
.enumerate()
{
- match (mapping, skipper_opt.as_mut()) {
+ match (mapping, skipper_opt.as_ref()) {
(Some(reader_index), _) => encodings[*reader_index].decode(buf)?,
(None, Some(skipper)) => skipper.skip(buf)?,
(None, None) => {
@@ -2459,7 +2612,7 @@ impl Skipper {
Ok(base)
}
- fn skip(&mut self, buf: &mut AvroCursor<'_>) -> Result<(), AvroError> {
+ fn skip(&self, buf: &mut AvroCursor<'_>) -> Result<(), AvroError> {
match self {
Self::Null => Ok(()),
Self::Boolean => {
@@ -2522,7 +2675,7 @@ impl Skipper {
Ok(())
}
Self::Struct(fields) => {
- for f in fields.iter_mut() {
+ for f in fields.iter() {
f.skip(buf)?
}
Ok(())
@@ -2541,7 +2694,7 @@ impl Skipper {
(usize::BITS as usize)
))
})?;
- let Some(encoding) = encodings.get_mut(idx) else {
+ let Some(encoding) = encodings.get(idx) else {
return Err(AvroError::ParseError(format!(
"Union branch index {idx} out of range for skipper ({} branches)",
encodings.len()
@@ -3488,10 +3641,12 @@ mod tests {
let dt = avro_from_codec(Codec::Decimal(4, Some(1), None));
let inner = Decoder::try_new(&dt).unwrap();
let mut decoder = Decoder::Nullable(
- Nullability::NullSecond,
+ NullablePlan::ReadTag {
+ nullability: Nullability::NullSecond,
+ resolution: ResolutionPlan::Promotion(Promotion::Direct),
+ },
NullBufferBuilder::new(DEFAULT_CAPACITY),
Box::new(inner),
- NullablePlan::ReadTag,
);
let mut data = Vec::new();
data.extend_from_slice(&encode_avro_int(0));
@@ -3531,10 +3686,12 @@ mod tests {
let dt = avro_from_codec(Codec::Decimal(6, Some(2), Some(16)));
let inner = Decoder::try_new(&dt).unwrap();
let mut decoder = Decoder::Nullable(
- Nullability::NullSecond,
+ NullablePlan::ReadTag {
+ nullability: Nullability::NullSecond,
+ resolution: ResolutionPlan::Promotion(Promotion::Direct),
+ },
NullBufferBuilder::new(DEFAULT_CAPACITY),
Box::new(inner),
- NullablePlan::ReadTag,
);
let row1 = [
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
@@ -3992,10 +4149,10 @@ mod tests {
Decoder::Record(
fields,
encodings,
+ vec![None; reader_fields.len()],
Some(Projector {
writer_to_reader: Arc::from(writer_to_reader),
skip_decoders,
- field_defaults: vec![None; reader_fields.len()],
default_injections: Arc::from(Vec::<(usize, AvroLiteral)>::new()),
}),
)
@@ -4374,10 +4531,9 @@ mod tests {
let projector = Projector {
writer_to_reader: Arc::from(vec![None; writer_to_reader_len]),
skip_decoders,
- field_defaults,
default_injections: Arc::from(default_injections),
};
- Decoder::Record(fields, encodings, Some(projector))
+ Decoder::Record(fields, encodings, field_defaults, Some(projector))
}
#[cfg(feature = "avro_custom_types")]
@@ -4631,10 +4787,12 @@ mod tests {
fn test_default_append_nullable_int32_null_and_value() {
let inner = Decoder::Int32(Vec::with_capacity(DEFAULT_CAPACITY));
let mut dec = Decoder::Nullable(
- Nullability::NullFirst,
+ NullablePlan::ReadTag {
+ nullability: Nullability::NullFirst,
+ resolution: ResolutionPlan::Promotion(Promotion::Direct),
+ },
NullBufferBuilder::new(DEFAULT_CAPACITY),
Box::new(inner),
- NullablePlan::ReadTag,
);
dec.append_default(&AvroLiteral::Null).unwrap();
dec.append_default(&AvroLiteral::Int(11)).unwrap();
@@ -4885,29 +5043,33 @@ mod tests {
field_refs.push(Arc::new(ArrowField::new(*name, dt.clone(), *nullable)));
}
let enc_a = Decoder::Nullable(
- Nullability::NullSecond,
+ NullablePlan::ReadTag {
+ nullability: Nullability::NullSecond,
+ resolution: ResolutionPlan::Promotion(Promotion::Direct),
+ },
NullBufferBuilder::new(DEFAULT_CAPACITY),
Box::new(Decoder::Int32(Vec::with_capacity(DEFAULT_CAPACITY))),
- NullablePlan::ReadTag,
);
let enc_b = Decoder::Nullable(
- Nullability::NullSecond,
+ NullablePlan::ReadTag {
+ nullability: Nullability::NullSecond,
+ resolution: ResolutionPlan::Promotion(Promotion::Direct),
+ },
NullBufferBuilder::new(DEFAULT_CAPACITY),
Box::new(Decoder::String(
OffsetBufferBuilder::new(DEFAULT_CAPACITY),
Vec::with_capacity(DEFAULT_CAPACITY),
)),
- NullablePlan::ReadTag,
);
encoders.push(enc_a);
encoders.push(enc_b);
+ let field_defaults = vec![None, None]; // no defaults -> append_null
let projector = Projector {
writer_to_reader: Arc::from(vec![]),
skip_decoders: vec![],
- field_defaults: vec![None, None], // no defaults -> append_null
default_injections: Arc::from(Vec::<(usize, AvroLiteral)>::new()),
};
- let mut rec = Decoder::Record(field_refs.into(), encoders, Some(projector));
+ let mut rec = Decoder::Record(field_refs.into(), encoders, field_defaults, Some(projector));
let mut map: IndexMap = IndexMap::new();
map.insert("a".to_string(), AvroLiteral::Int(9));
rec.append_default(&AvroLiteral::Map(map)).unwrap();
@@ -5034,7 +5196,7 @@ mod tests {
Codec::DurationSeconds,
] {
let dt = make_avro_dt(codec.clone(), None);
- let mut s = Skipper::from_avro(&dt)?;
+ let s = Skipper::from_avro(&dt)?;
for &v in &values {
let bytes = encode_avro_long(v);
let mut cursor = AvroCursor::new(&bytes);
@@ -5055,7 +5217,7 @@ mod tests {
#[test]
fn skipper_nullable_custom_duration_respects_null_first() -> Result<(), AvroError> {
let dt = make_avro_dt(Codec::DurationNanos, Some(Nullability::NullFirst));
- let mut s = Skipper::from_avro(&dt)?;
+ let s = Skipper::from_avro(&dt)?;
match &s {
Skipper::Nullable(Nullability::NullFirst, inner) => match **inner {
Skipper::Int64 => {}
@@ -5084,7 +5246,7 @@ mod tests {
#[test]
fn skipper_nullable_custom_duration_respects_null_second() -> Result<(), AvroError> {
let dt = make_avro_dt(Codec::DurationMicros, Some(Nullability::NullSecond));
- let mut s = Skipper::from_avro(&dt)?;
+ let s = Skipper::from_avro(&dt)?;
match &s {
Skipper::Nullable(Nullability::NullSecond, inner) => match **inner {
Skipper::Int64 => {}
@@ -5115,7 +5277,7 @@ mod tests {
#[test]
fn skipper_interval_is_fixed12_and_skips_12_bytes() -> Result<(), AvroError> {
let dt = make_avro_dt(Codec::Interval, None);
- let mut s = Skipper::from_avro(&dt)?;
+ let s = Skipper::from_avro(&dt)?;
match s {
Skipper::DurationFixed12 => {}
other => panic!("expected DurationFixed12, got {:?}", other),
@@ -5227,12 +5389,11 @@ mod tests {
Box::new(inner_values),
);
let mut dec = Decoder::Nullable(
- Nullability::NullSecond,
- NullBufferBuilder::new(DEFAULT_CAPACITY),
- Box::new(ree),
NullablePlan::FromSingle {
- promotion: Promotion::IntToDouble,
+ resolution: ResolutionPlan::Promotion(Promotion::IntToDouble),
},
+ NullBufferBuilder::new(DEFAULT_CAPACITY),
+ Box::new(ree),
);
for v in [1, 1, 2, 2, 2] {
let bytes = encode_avro_int(v);
diff --git a/arrow-avro/src/schema.rs b/arrow-avro/src/schema.rs
index 90c0d5a1648d..1b0c2e26f773 100644
--- a/arrow-avro/src/schema.rs
+++ b/arrow-avro/src/schema.rs
@@ -78,6 +78,16 @@ pub(crate) enum Nullability {
NullSecond,
}
+impl Nullability {
+ /// Returns the index of the non-null variant in the union.
+ pub(crate) fn non_null_index(&self) -> usize {
+ match self {
+ Nullability::NullFirst => 1,
+ Nullability::NullSecond => 0,
+ }
+ }
+}
+
/// Either a [`PrimitiveType`] or a reference to a previously defined named type
///
///
@@ -3331,7 +3341,11 @@ mod tests {
false,
)])),
false,
- );
+ )
+ .with_metadata(HashMap::from_iter([(
+ "avro.name".to_owned(),
+ "R".to_owned(),
+ )]));
assert_eq!(resolved.field(), expected);
}
@@ -3393,7 +3407,11 @@ mod tests {
false,
)])),
false,
- );
+ )
+ .with_metadata(HashMap::from_iter([(
+ "avro.name".to_owned(),
+ "R".to_owned(),
+ )]));
assert_eq!(resolved.field(), expected);
}
@@ -3430,7 +3448,11 @@ mod tests {
)])),
])),
false,
- );
+ )
+ .with_metadata(HashMap::from_iter([(
+ "avro.name".to_owned(),
+ "R".to_owned(),
+ )]));
assert_eq!(resolved.field(), expected);
}
From a20753c70c74258831df149e6fb222b6ec501098 Mon Sep 17 00:00:00 2001
From: Andrew Lamb
Date: Fri, 27 Feb 2026 14:48:28 -0500
Subject: [PATCH 07/80] Update planned release schedule in README.md (#9466)
- part of https://github.com/apache/arrow-rs/issues/8466
Update release schedule based on historical reality
---
README.md | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index 27e0ca13c179..70f2f158e2f4 100644
--- a/README.md
+++ b/README.md
@@ -91,14 +91,10 @@ Planned Release Schedule
| Approximate Date | Version | Notes |
| ---------------- | ---------- | --------------------------------------- |
-| December 2025 | [`57.2.0`] | Minor, NO breaking API changes |
-| January 2026 | [`58.0.0`] | Major, potentially breaking API changes |
-| February 2026 | [`58.1.0`] | Minor, NO breaking API changes |
-| March 2026 | [`58.2.0`] | Minor, NO breaking API changes |
-| April 2026 | [`59.0.0`] | Major, potentially breaking API changes |
-
-[`57.2.0`]: https://github.com/apache/arrow-rs/milestone/5
-[`58.0.0`]: https://github.com/apache/arrow-rs/milestone/6
+| March 2026 | [`58.1.0`] | Minor, NO breaking API changes |
+| April 2026 | [`58.2.0`] | Minor, NO breaking API changes |
+| May 2026 | [`59.0.0`] | Major, potentially breaking API changes |
+
[`58.1.0`]: https://github.com/apache/arrow-rs/issues/9108
[`58.2.0`]: https://github.com/apache/arrow-rs/issues/9109
[`59.0.0`]: https://github.com/apache/arrow-rs/issues/9110
From a7acf3d7396d763c0ae2ebba6190358ce574ee5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jochen=20G=C3=B6rtler?=
Date: Mon, 2 Mar 2026 14:11:37 +0100
Subject: [PATCH 08/80] Convert `prettyprint` tests in `arrow-cast` to `insta`
inline snapshots (#9472)
# Rationale for this change
The motivation for this PR is to create to improve the testing
infrastructure as a precursor to the following PR:
- #9221
@Jefffrey seemed to be in favor of using `insta` for more tests:
https://github.com/apache/arrow-rs/pull/9221#discussion_r2735246111
# What changes are included in this PR?
This PR does not do logic changes, but is a straightforward translation
of the current tests. More test cases, especially around escape
sequences can be added in follow up PRs.
# Are these changes tested?
Yes, to review we still need to manually confirm that no test cases
changed accidentally.
# Are there any user-facing changes?
No.
---
Cargo.toml | 2 +
arrow-cast/Cargo.toml | 1 +
arrow-cast/src/base64.rs | 2 +-
arrow-cast/src/pretty.rs | 1019 +++++++++++++++++---------------------
arrow-schema/Cargo.toml | 2 +-
parquet/Cargo.toml | 2 +-
6 files changed, 460 insertions(+), 568 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 8b51c01acab6..1a02830b0b9f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -112,6 +112,8 @@ simdutf8 = { version = "0.1.5", default-features = false }
criterion = { version = "0.8.0", default-features = false }
+insta = { version = "1.46.3", default-features = false }
+
# release inherited profile keeping debug information and symbols
# for mem/cpu profiling
[profile.profiling]
diff --git a/arrow-cast/Cargo.toml b/arrow-cast/Cargo.toml
index 536bc101a816..81649353d182 100644
--- a/arrow-cast/Cargo.toml
+++ b/arrow-cast/Cargo.toml
@@ -58,6 +58,7 @@ ryu = "1.0.16"
[dev-dependencies]
criterion = { workspace = true, default-features = false }
half = { version = "2.1", default-features = false }
+insta = { workspace = true }
rand = "0.9"
[[bench]]
diff --git a/arrow-cast/src/base64.rs b/arrow-cast/src/base64.rs
index 5637bdc689d9..6a8da0141dea 100644
--- a/arrow-cast/src/base64.rs
+++ b/arrow-cast/src/base64.rs
@@ -106,7 +106,7 @@ mod tests {
let data: BinaryArray = (0..len)
.map(|_| {
let len = rng.random_range(0..16);
- Some((0..len).map(|_| rng.random()).collect::>())
+ Some((0..len).map(|_| rng.random::()).collect::>())
})
.collect();
diff --git a/arrow-cast/src/pretty.rs b/arrow-cast/src/pretty.rs
index e7c199dbed97..e63147cd09c1 100644
--- a/arrow-cast/src/pretty.rs
+++ b/arrow-cast/src/pretty.rs
@@ -318,20 +318,16 @@ mod tests {
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+---+-----+",
- "| a | b |",
- "+---+-----+",
- "| a | 1 |",
- "| b | |",
- "| | 10 |",
- "| d | 100 |",
- "+---+-----+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +---+-----+
+ | a | b |
+ +---+-----+
+ | a | 1 |
+ | b | |
+ | | 10 |
+ | d | 100 |
+ +---+-----+
+ ");
}
#[test]
@@ -348,14 +344,19 @@ mod tests {
let table = pretty_format_columns("a", &columns).unwrap().to_string();
- let expected = vec![
- "+---+", "| a |", "+---+", "| a |", "| b |", "| |", "| d |", "| e |", "| |",
- "| g |", "+---+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +---+
+ | a |
+ +---+
+ | a |
+ | b |
+ | |
+ | d |
+ | e |
+ | |
+ | g |
+ +---+
+ ");
}
#[test]
@@ -378,20 +379,16 @@ mod tests {
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+---+---+---+",
- "| a | b | c |",
- "+---+---+---+",
- "| | | |",
- "| | | |",
- "| | | |",
- "| | | |",
- "+---+---+---+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table:#?}");
+ insta::assert_snapshot!(table, @"
+ +---+---+---+
+ | a | b | c |
+ +---+---+---+
+ | | | |
+ | | | |
+ | | | |
+ | | | |
+ +---+---+---+
+ ");
}
#[test]
@@ -411,19 +408,15 @@ mod tests {
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+-------+",
- "| d1 |",
- "+-------+",
- "| one |",
- "| |",
- "| three |",
- "+-------+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +-------+
+ | d1 |
+ +-------+
+ | one |
+ | |
+ | three |
+ +-------+
+ ");
}
#[test]
@@ -447,19 +440,16 @@ mod tests {
let batch = RecordBatch::try_new(schema, vec![array]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+-----------+",
- "| d1 |",
- "+-----------+",
- "| [1, 2, 3] |",
- "| |",
- "| [7, 8, 9] |",
- "+-----------+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +-----------+
+ | d1 |
+ +-----------+
+ | [1, 2, 3] |
+ | |
+ | [7, 8, 9] |
+ +-----------+
+ ");
}
#[test]
@@ -482,22 +472,19 @@ mod tests {
let array: ArrayRef = Arc::new(builder.finish());
let batch = RecordBatch::try_new(schema, vec![array]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+-----------------------+",
- "| d1 |",
- "+-----------------------+",
- "| hello |",
- "| |",
- "| longer than 12 bytes |",
- "| another than 12 bytes |",
- "| |",
- "| small |",
- "+-----------------------+",
- ];
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table:#?}");
+ insta::assert_snapshot!(table, @"
+ +-----------------------+
+ | d1 |
+ +-----------------------+
+ | hello |
+ | |
+ | longer than 12 bytes |
+ | another than 12 bytes |
+ | |
+ | small |
+ +-----------------------+
+ ");
}
#[test]
@@ -520,22 +507,19 @@ mod tests {
let array: ArrayRef = Arc::new(builder.finish());
let batch = RecordBatch::try_new(schema, vec![array]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+--------------------------------------------+",
- "| d1 |",
- "+--------------------------------------------+",
- "| 68656c6c6f |",
- "| |",
- "| 6c6f6e676572207468616e203132206279746573 |",
- "| 616e6f74686572207468616e203132206279746573 |",
- "| |",
- "| 736d616c6c |",
- "+--------------------------------------------+",
- ];
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n\n{table:#?}");
+ insta::assert_snapshot!(table, @"
+ +--------------------------------------------+
+ | d1 |
+ +--------------------------------------------+
+ | 68656c6c6f |
+ | |
+ | 6c6f6e676572207468616e203132206279746573 |
+ | 616e6f74686572207468616e203132206279746573 |
+ | |
+ | 736d616c6c |
+ +--------------------------------------------+
+ ");
}
#[test]
@@ -554,47 +538,34 @@ mod tests {
let batch = RecordBatch::try_new(schema, vec![array]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+--------+",
- "| d1 |",
- "+--------+",
- "| 010203 |",
- "| |",
- "| 070809 |",
- "+--------+",
- ];
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +--------+
+ | d1 |
+ +--------+
+ | 010203 |
+ | |
+ | 070809 |
+ +--------+
+ ");
}
- /// Generate an array with type $ARRAYTYPE with a numeric value of
- /// $VALUE, and compare $EXPECTED_RESULT to the output of
- /// formatting that array with `pretty_format_batches`
- macro_rules! check_datetime {
- ($ARRAYTYPE:ident, $VALUE:expr, $EXPECTED_RESULT:expr) => {
- let mut builder = $ARRAYTYPE::builder(10);
- builder.append_value($VALUE);
- builder.append_null();
- let array = builder.finish();
-
- let schema = Arc::new(Schema::new(vec![Field::new(
- "f",
- array.data_type().clone(),
- true,
- )]));
- let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
-
- let table = pretty_format_batches(&[batch])
- .expect("formatting batches")
- .to_string();
-
- let expected = $EXPECTED_RESULT;
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n\n{actual:#?}\n\n");
- };
+ /// Generate an array of [`ArrowPrimitiveType`] with a numeric `value`,
+ /// then format it with `pretty_format_batches`.
+ fn format_primitive_batch(value: T::Native) -> String {
+ let mut builder = PrimitiveBuilder::::with_capacity(10);
+ builder.append_value(value);
+ builder.append_null();
+ let array = builder.finish();
+ let schema = Arc::new(Schema::new(vec![Field::new(
+ "f",
+ array.data_type().clone(),
+ true,
+ )]));
+ let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
+ pretty_format_batches(&[batch])
+ .expect("formatting batches")
+ .to_string()
}
fn timestamp_batch(timezone: &str, value: T::Native) -> RecordBatch {
@@ -617,158 +588,151 @@ mod tests {
let batch = timestamp_batch::("+08:00", 11111111);
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+---------------------------+",
- "| f |",
- "+---------------------------+",
- "| 1970-05-09T22:25:11+08:00 |",
- "| |",
- "+---------------------------+",
- ];
- let actual: Vec<&str> = table.lines().collect();
- assert_eq!(expected, actual, "Actual result:\n\n{actual:#?}\n\n");
+ insta::assert_snapshot!(table, @"
+ +---------------------------+
+ | f |
+ +---------------------------+
+ | 1970-05-09T22:25:11+08:00 |
+ | |
+ +---------------------------+
+ ");
}
#[test]
fn test_pretty_format_timestamp_second() {
- let expected = vec![
- "+---------------------+",
- "| f |",
- "+---------------------+",
- "| 1970-05-09T14:25:11 |",
- "| |",
- "+---------------------+",
- ];
- check_datetime!(TimestampSecondArray, 11111111, expected);
+ let table = format_primitive_batch::(11111111);
+ insta::assert_snapshot!(table, @"
+ +---------------------+
+ | f |
+ +---------------------+
+ | 1970-05-09T14:25:11 |
+ | |
+ +---------------------+
+ ");
}
#[test]
fn test_pretty_format_timestamp_millisecond() {
- let expected = vec![
- "+-------------------------+",
- "| f |",
- "+-------------------------+",
- "| 1970-01-01T03:05:11.111 |",
- "| |",
- "+-------------------------+",
- ];
- check_datetime!(TimestampMillisecondArray, 11111111, expected);
+ let table = format_primitive_batch::(11111111);
+ insta::assert_snapshot!(table, @"
+ +-------------------------+
+ | f |
+ +-------------------------+
+ | 1970-01-01T03:05:11.111 |
+ | |
+ +-------------------------+
+ ");
}
#[test]
fn test_pretty_format_timestamp_microsecond() {
- let expected = vec![
- "+----------------------------+",
- "| f |",
- "+----------------------------+",
- "| 1970-01-01T00:00:11.111111 |",
- "| |",
- "+----------------------------+",
- ];
- check_datetime!(TimestampMicrosecondArray, 11111111, expected);
+ let table = format_primitive_batch::(11111111);
+ insta::assert_snapshot!(table, @"
+ +----------------------------+
+ | f |
+ +----------------------------+
+ | 1970-01-01T00:00:11.111111 |
+ | |
+ +----------------------------+
+ ");
}
#[test]
fn test_pretty_format_timestamp_nanosecond() {
- let expected = vec![
- "+-------------------------------+",
- "| f |",
- "+-------------------------------+",
- "| 1970-01-01T00:00:00.011111111 |",
- "| |",
- "+-------------------------------+",
- ];
- check_datetime!(TimestampNanosecondArray, 11111111, expected);
+ let table = format_primitive_batch::(11111111);
+ insta::assert_snapshot!(table, @"
+ +-------------------------------+
+ | f |
+ +-------------------------------+
+ | 1970-01-01T00:00:00.011111111 |
+ | |
+ +-------------------------------+
+ ");
}
#[test]
fn test_pretty_format_date_32() {
- let expected = vec![
- "+------------+",
- "| f |",
- "+------------+",
- "| 1973-05-19 |",
- "| |",
- "+------------+",
- ];
- check_datetime!(Date32Array, 1234, expected);
+ let table = format_primitive_batch::(1234);
+ insta::assert_snapshot!(table, @"
+ +------------+
+ | f |
+ +------------+
+ | 1973-05-19 |
+ | |
+ +------------+
+ ");
}
#[test]
fn test_pretty_format_date_64() {
- let expected = vec![
- "+---------------------+",
- "| f |",
- "+---------------------+",
- "| 2005-03-18T01:58:20 |",
- "| |",
- "+---------------------+",
- ];
- check_datetime!(Date64Array, 1111111100000, expected);
+ let table = format_primitive_batch::(1111111100000);
+ insta::assert_snapshot!(table, @"
+ +---------------------+
+ | f |
+ +---------------------+
+ | 2005-03-18T01:58:20 |
+ | |
+ +---------------------+
+ ");
}
#[test]
fn test_pretty_format_time_32_second() {
- let expected = vec![
- "+----------+",
- "| f |",
- "+----------+",
- "| 00:18:31 |",
- "| |",
- "+----------+",
- ];
- check_datetime!(Time32SecondArray, 1111, expected);
+ let table = format_primitive_batch::(1111);
+ insta::assert_snapshot!(table, @"
+ +----------+
+ | f |
+ +----------+
+ | 00:18:31 |
+ | |
+ +----------+
+ ");
}
#[test]
fn test_pretty_format_time_32_millisecond() {
- let expected = vec![
- "+--------------+",
- "| f |",
- "+--------------+",
- "| 03:05:11.111 |",
- "| |",
- "+--------------+",
- ];
- check_datetime!(Time32MillisecondArray, 11111111, expected);
+ let table = format_primitive_batch::(11111111);
+ insta::assert_snapshot!(table, @"
+ +--------------+
+ | f |
+ +--------------+
+ | 03:05:11.111 |
+ | |
+ +--------------+
+ ");
}
#[test]
fn test_pretty_format_time_64_microsecond() {
- let expected = vec![
- "+-----------------+",
- "| f |",
- "+-----------------+",
- "| 00:00:11.111111 |",
- "| |",
- "+-----------------+",
- ];
- check_datetime!(Time64MicrosecondArray, 11111111, expected);
+ let table = format_primitive_batch::(11111111);
+ insta::assert_snapshot!(table, @"
+ +-----------------+
+ | f |
+ +-----------------+
+ | 00:00:11.111111 |
+ | |
+ +-----------------+
+ ");
}
#[test]
fn test_pretty_format_time_64_nanosecond() {
- let expected = vec![
- "+--------------------+",
- "| f |",
- "+--------------------+",
- "| 00:00:00.011111111 |",
- "| |",
- "+--------------------+",
- ];
- check_datetime!(Time64NanosecondArray, 11111111, expected);
+ let table = format_primitive_batch::(11111111);
+ insta::assert_snapshot!(table, @"
+ +--------------------+
+ | f |
+ +--------------------+
+ | 00:00:00.011111111 |
+ | |
+ +--------------------+
+ ");
}
#[test]
fn test_int_display() {
let array = Arc::new(Int32Array::from(vec![6, 3])) as ArrayRef;
- let actual_one = array_value_to_string(&array, 0).unwrap();
- let expected_one = "6";
-
- let actual_two = array_value_to_string(&array, 1).unwrap();
- let expected_two = "3";
- assert_eq!(actual_one, expected_one);
- assert_eq!(actual_two, expected_two);
+ insta::assert_snapshot!(array_value_to_string(&array, 0).unwrap(), @"6");
+ insta::assert_snapshot!(array_value_to_string(&array, 1).unwrap(), @"3");
}
#[test]
@@ -794,19 +758,16 @@ mod tests {
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+-------+",
- "| f |",
- "+-------+",
- "| 1.01 |",
- "| |",
- "| 2.00 |",
- "| 30.40 |",
- "+-------+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +-------+
+ | f |
+ +-------+
+ | 1.01 |
+ | |
+ | 2.00 |
+ | 30.40 |
+ +-------+
+ ");
}
#[test]
@@ -831,13 +792,17 @@ mod tests {
let batch = RecordBatch::try_new(schema, vec![dm]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+------+", "| f |", "+------+", "| 101 |", "| |", "| 200 |", "| 3040 |",
- "+------+",
- ];
- let actual: Vec<&str> = table.lines().collect();
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +------+
+ | f |
+ +------+
+ | 101 |
+ | |
+ | 200 |
+ | 3040 |
+ +------+
+ ");
}
#[test]
@@ -881,18 +846,16 @@ mod tests {
RecordBatch::try_new(Arc::new(schema), vec![Arc::new(c1), Arc::new(c2)]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+--------------------------+----+",
- "| c1 | c2 |",
- "+--------------------------+----+",
- "| {c11: 1, c12: {c121: e}} | a |",
- "| {c11: , c12: {c121: f}} | b |",
- "| {c11: 5, c12: {c121: g}} | c |",
- "+--------------------------+----+",
- ];
- let actual: Vec<&str> = table.lines().collect();
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +--------------------------+----+
+ | c1 | c2 |
+ +--------------------------+----+
+ | {c11: 1, c12: {c121: e}} | a |
+ | {c11: , c12: {c121: f}} | b |
+ | {c11: 5, c12: {c121: g}} | c |
+ +--------------------------+----+
+ ");
}
#[test]
@@ -916,19 +879,17 @@ mod tests {
let batch = RecordBatch::try_new(Arc::new(schema), vec![Arc::new(union)]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let actual: Vec<&str> = table.lines().collect();
- let expected = vec![
- "+------------+",
- "| Teamsters |",
- "+------------+",
- "| {a=1} |",
- "| {b=3.2234} |",
- "| {b=} |",
- "| {a=} |",
- "+------------+",
- ];
- assert_eq!(expected, actual);
+ insta::assert_snapshot!(table, @"
+ +------------+
+ | Teamsters |
+ +------------+
+ | {a=1} |
+ | {b=3.2234} |
+ | {b=} |
+ | {a=} |
+ +------------+
+ ");
}
#[test]
@@ -952,19 +913,17 @@ mod tests {
let batch = RecordBatch::try_new(Arc::new(schema), vec![Arc::new(union)]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let actual: Vec<&str> = table.lines().collect();
- let expected = vec![
- "+------------+",
- "| Teamsters |",
- "+------------+",
- "| {a=1} |",
- "| {b=3.2234} |",
- "| {b=} |",
- "| {a=} |",
- "+------------+",
- ];
- assert_eq!(expected, actual);
+ insta::assert_snapshot!(table, @"
+ +------------+
+ | Teamsters |
+ +------------+
+ | {a=1} |
+ | {b=3.2234} |
+ | {b=} |
+ | {a=} |
+ +------------+
+ ");
}
#[test]
@@ -1012,19 +971,18 @@ mod tests {
let batch = RecordBatch::try_new(Arc::new(schema), vec![Arc::new(outer)]).unwrap();
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let actual: Vec<&str> = table.lines().collect();
- let expected = vec![
- "+-----------------------------+",
- "| Teamsters |",
- "+-----------------------------+",
- "| {European Union={b=1}} |",
- "| {European Union={c=3.2234}} |",
- "| {a=} |",
- "| {a=1234} |",
- "| {European Union={c=}} |",
- "+-----------------------------+",
- ];
- assert_eq!(expected, actual);
+
+ insta::assert_snapshot!(table, @"
+ +-----------------------------+
+ | Teamsters |
+ +-----------------------------+
+ | {European Union={b=1}} |
+ | {European Union={c=3.2234}} |
+ | {a=} |
+ | {a=1234} |
+ | {European Union={c=}} |
+ +-----------------------------+
+ ");
}
#[test]
@@ -1055,21 +1013,18 @@ mod tests {
)
.unwrap();
- let mut buf = String::new();
- write!(&mut buf, "{}", pretty_format_batches(&[batch]).unwrap()).unwrap();
-
- let s = [
- "+---+-----+",
- "| a | b |",
- "+---+-----+",
- "| a | 1 |",
- "| b | |",
- "| | 10 |",
- "| d | 100 |",
- "+---+-----+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ let table = pretty_format_batches(&[batch]).unwrap().to_string();
+
+ insta::assert_snapshot!(table, @"
+ +---+-----+
+ | a | b |
+ +---+-----+
+ | a | 1 |
+ | b | |
+ | | 10 |
+ | d | 100 |
+ +---+-----+
+ ");
}
#[test]
@@ -1091,12 +1046,15 @@ mod tests {
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+------+", "| f16 |", "+------+", "| NaN |", "| 4 |", "| -inf |", "+------+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +------+
+ | f16 |
+ +------+
+ | NaN |
+ | 4 |
+ | -inf |
+ +------+
+ ");
}
#[test]
@@ -1121,23 +1079,19 @@ mod tests {
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+------------------+",
- "| IntervalDayTime |",
- "+------------------+",
- "| -1 days -10 mins |",
- "| -1.001 secs |",
- "| -0.001 secs |",
- "| 0.001 secs |",
- "| 0.010 secs |",
- "| 0.100 secs |",
- "| 0 secs |",
- "+------------------+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +------------------+
+ | IntervalDayTime |
+ +------------------+
+ | -1 days -10 mins |
+ | -1.001 secs |
+ | -0.001 secs |
+ | 0.001 secs |
+ | 0.010 secs |
+ | 0.100 secs |
+ | 0 secs |
+ +------------------+
+ ");
}
#[test]
@@ -1169,30 +1123,26 @@ mod tests {
let table = pretty_format_batches(&[batch]).unwrap().to_string();
- let expected = vec![
- "+--------------------------+",
- "| IntervalMonthDayNano |",
- "+--------------------------+",
- "| -1 mons -1 days -10 mins |",
- "| -1.000000001 secs |",
- "| -0.000000001 secs |",
- "| 0.000000001 secs |",
- "| 0.000000010 secs |",
- "| 0.000000100 secs |",
- "| 0.000001000 secs |",
- "| 0.000010000 secs |",
- "| 0.000100000 secs |",
- "| 0.001000000 secs |",
- "| 0.010000000 secs |",
- "| 0.100000000 secs |",
- "| 1.000000000 secs |",
- "| 0 secs |",
- "+--------------------------+",
- ];
-
- let actual: Vec<&str> = table.lines().collect();
-
- assert_eq!(expected, actual, "Actual result:\n{table}");
+ insta::assert_snapshot!(table, @"
+ +--------------------------+
+ | IntervalMonthDayNano |
+ +--------------------------+
+ | -1 mons -1 days -10 mins |
+ | -1.000000001 secs |
+ | -0.000000001 secs |
+ | 0.000000001 secs |
+ | 0.000000010 secs |
+ | 0.000000100 secs |
+ | 0.000001000 secs |
+ | 0.000010000 secs |
+ | 0.000100000 secs |
+ | 0.001000000 secs |
+ | 0.010000000 secs |
+ | 0.100000000 secs |
+ | 1.000000000 secs |
+ | 0 secs |
+ +--------------------------+
+ ");
}
#[test]
@@ -1218,40 +1168,34 @@ mod tests {
.unwrap()
.to_string();
- let expected_column = vec![
- "+----------------+",
- "| my_column_name |",
- "+----------------+",
- "| 1 |",
- "| 2 |",
- "| null |",
- "| 3 |",
- "| 4 |",
- "+----------------+",
- ];
-
- let actual: Vec<&str> = column.lines().collect();
- assert_eq!(expected_column, actual, "Actual result:\n{column}");
-
- let batch = pretty_format_batches_with_options(&[batch], &options)
+ insta::assert_snapshot!(column, @"
+ +----------------+
+ | my_column_name |
+ +----------------+
+ | 1 |
+ | 2 |
+ | null |
+ | 3 |
+ | 4 |
+ +----------------+
+ ");
+
+ let table = pretty_format_batches_with_options(&[batch], &options)
.unwrap()
.to_string();
- let expected_table = vec![
- "+---------------+----------------+",
- "| my_int32_name | my_string_name |",
- "| Int32 | Utf8 |",
- "+---------------+----------------+",
- "| 1 | foo |",
- "| 2 | bar |",
- "| null | null |",
- "| 3 | baz |",
- "| 4 | null |",
- "+---------------+----------------+",
- ];
-
- let actual: Vec<&str> = batch.lines().collect();
- assert_eq!(expected_table, actual, "Actual result:\n{batch}");
+ insta::assert_snapshot!(table, @"
+ +---------------+----------------+
+ | my_int32_name | my_string_name |
+ | Int32 | Utf8 |
+ +---------------+----------------+
+ | 1 | foo |
+ | 2 | bar |
+ | null | null |
+ | 3 | baz |
+ | 4 | null |
+ +---------------+----------------+
+ ");
}
#[test]
@@ -1268,20 +1212,16 @@ mod tests {
.unwrap()
.to_string();
- // Expected output
- let expected_pretty = vec![
- "+------------------------------+",
- "| pretty |",
- "+------------------------------+",
- "| |",
- "| |",
- "| 0 days 1 hours 1 mins 1 secs |",
- "| null |",
- "+------------------------------+",
- ];
-
- let actual: Vec<&str> = pretty.lines().collect();
- assert_eq!(expected_pretty, actual, "Actual result:\n{pretty}");
+ insta::assert_snapshot!(pretty, @"
+ +------------------------------+
+ | pretty |
+ +------------------------------+
+ | |
+ | |
+ | 0 days 1 hours 1 mins 1 secs |
+ | null |
+ +------------------------------+
+ ");
// ISO8601 formatting
let opts_iso = FormatOptions::default()
@@ -1291,20 +1231,16 @@ mod tests {
.unwrap()
.to_string();
- // Expected output
- let expected_iso = vec![
- "+-----------+",
- "| iso |",
- "+-----------+",
- "| |",
- "| |",
- "| PT3661S |",
- "| null |",
- "+-----------+",
- ];
-
- let actual: Vec<&str> = iso.lines().collect();
- assert_eq!(expected_iso, actual, "Actual result:\n{iso}");
+ insta::assert_snapshot!(iso, @"
+ +-----------+
+ | iso |
+ +-----------+
+ | |
+ | |
+ | PT3661S |
+ | null |
+ +-----------+
+ ");
}
//
@@ -1408,26 +1344,20 @@ mod tests {
)
.unwrap();
- let mut buf = String::new();
- write!(
- &mut buf,
- "{}",
- pretty_format_batches_with_options(&[batch], &options).unwrap()
- )
- .unwrap();
+ let table = pretty_format_batches_with_options(&[batch], &options)
+ .unwrap()
+ .to_string();
- let s = [
- "+--------+",
- "| income |",
- "+--------+",
- "| 1 € |",
- "| |",
- "| 10 € |",
- "| 100 € |",
- "+--------+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ insta::assert_snapshot!(table, @"
+ +--------+
+ | income |
+ +--------+
+ | 1 € |
+ | |
+ | 10 € |
+ | 100 € |
+ +--------+
+ ");
}
#[test]
@@ -1466,24 +1396,18 @@ mod tests {
// define data.
let batch = RecordBatch::try_new(schema, vec![Arc::new(outer_list)]).unwrap();
- let mut buf = String::new();
- write!(
- &mut buf,
- "{}",
- pretty_format_batches_with_options(&[batch], &options).unwrap()
- )
- .unwrap();
+ let table = pretty_format_batches_with_options(&[batch], &options)
+ .unwrap()
+ .to_string();
- let s = [
- "+----------------------------------+",
- "| income |",
- "+----------------------------------+",
- "| [[1 €], ] |",
- "| [[2 €, 8 €], [50 €, 25 €, 25 €]] |",
- "+----------------------------------+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ insta::assert_snapshot!(table, @"
+ +----------------------------------+
+ | income |
+ +----------------------------------+
+ | [[1 €], ] |
+ | [[2 €, 8 €], [50 €, 25 €, 25 €]] |
+ +----------------------------------+
+ ");
}
#[test]
@@ -1530,25 +1454,19 @@ mod tests {
// define data.
let batch = RecordBatch::try_new(schema, vec![Arc::new(nested_data.finish())]).unwrap();
- let mut buf = String::new();
- write!(
- &mut buf,
- "{}",
- pretty_format_batches_with_options(&[batch], &options).unwrap()
- )
- .unwrap();
+ let table = pretty_format_batches_with_options(&[batch], &options)
+ .unwrap()
+ .to_string();
- let s = [
- "+---------------------------------+",
- "| income |",
- "+---------------------------------+",
- "| {name: Gimli, income: 10 €} |",
- "| {name: Legolas, income: } |",
- "| {name: Aragorn, income: 30 €} |",
- "+---------------------------------+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ insta::assert_snapshot!(table, @"
+ +---------------------------------+
+ | income |
+ +---------------------------------+
+ | {name: Gimli, income: 10 €} |
+ | {name: Legolas, income: } |
+ | {name: Aragorn, income: 30 €} |
+ +---------------------------------+
+ ");
}
#[test]
@@ -1585,23 +1503,17 @@ mod tests {
)]));
let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
- let mut buf = String::new();
- write!(
- &mut buf,
- "{}",
- pretty_format_batches_with_options(&[batch], &options).unwrap()
- )
- .unwrap();
+ let table = pretty_format_batches_with_options(&[batch], &options)
+ .unwrap()
+ .to_string();
- let s = [
- "+-----------------------------------------------+",
- "| income |",
- "+-----------------------------------------------+",
- "| {Gimli: 10 €, Legolas: , Aragorn: 30 €} |",
- "+-----------------------------------------------+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ insta::assert_snapshot!(table, @"
+ +-----------------------------------------------+
+ | income |
+ +-----------------------------------------------+
+ | {Gimli: 10 €, Legolas: , Aragorn: 30 €} |
+ +-----------------------------------------------+
+ ");
}
#[test]
@@ -1635,23 +1547,17 @@ mod tests {
// define data.
let batch = RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap();
- let mut buf = String::new();
- write!(
- &mut buf,
- "{}",
- pretty_format_batches_with_options(&[batch], &options).unwrap()
- )
- .unwrap();
+ let table = pretty_format_batches_with_options(&[batch], &options)
+ .unwrap()
+ .to_string();
- let s = [
- "+--------------+",
- "| income |",
- "+--------------+",
- "| {income=1 €} |",
- "+--------------+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ insta::assert_snapshot!(table, @"
+ +--------------+
+ | income |
+ +--------------+
+ | {income=1 €} |
+ +--------------+
+ ");
}
#[test]
@@ -1678,37 +1584,30 @@ mod tests {
)
.unwrap();
- let mut buf = String::new();
- write!(
- &mut buf,
- "{}",
- create_table(
- // No metadata compared to test_format_batches_with_custom_formatters
- Some(Arc::new(Schema::new(vec![Field::new(
- "income",
- DataType::Int32,
- true
- ),]))),
- &[batch],
- &options,
- )
- .unwrap()
+ let table = create_table(
+ // No metadata compared to test_format_batches_with_custom_formatters
+ Some(Arc::new(Schema::new(vec![Field::new(
+ "income",
+ DataType::Int32,
+ true,
+ )]))),
+ &[batch],
+ &options,
)
- .unwrap();
+ .unwrap()
+ .to_string();
// No € formatting as in test_format_batches_with_custom_formatters
- let s = [
- "+--------------+",
- "| income |",
- "+--------------+",
- "| 1 (32-Bit) |",
- "| |",
- "| 10 (32-Bit) |",
- "| 100 (32-Bit) |",
- "+--------------+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ insta::assert_snapshot!(table, @"
+ +--------------+
+ | income |
+ +--------------+
+ | 1 (32-Bit) |
+ | |
+ | 10 (32-Bit) |
+ | 100 (32-Bit) |
+ +--------------+
+ ");
}
#[test]
@@ -1721,31 +1620,24 @@ mod tests {
Some(100),
]));
- let mut buf = String::new();
- write!(
- &mut buf,
- "{}",
- pretty_format_columns_with_options(
- "income",
- &[array],
- &FormatOptions::default().with_formatter_factory(Some(&TestFormatters {}))
- )
- .unwrap()
+ let table = pretty_format_columns_with_options(
+ "income",
+ &[array],
+ &FormatOptions::default().with_formatter_factory(Some(&TestFormatters {})),
)
- .unwrap();
+ .unwrap()
+ .to_string();
- let s = [
- "+--------------+",
- "| income |",
- "+--------------+",
- "| 1 (32-Bit) |",
- "| |",
- "| 10 (32-Bit) |",
- "| 100 (32-Bit) |",
- "+--------------+",
- ];
- let expected = s.join("\n");
- assert_eq!(expected, buf);
+ insta::assert_snapshot!(table, @"
+ +--------------+
+ | income |
+ +--------------+
+ | 1 (32-Bit) |
+ | |
+ | 10 (32-Bit) |
+ | 100 (32-Bit) |
+ +--------------+
+ ");
}
#[test]
@@ -1771,9 +1663,6 @@ mod tests {
let error = pretty_format_batches_with_schema(schema_a, &[batch])
.err()
.unwrap();
- assert_eq!(
- &error.to_string(),
- "Invalid argument error: Expected the same number of columns in a record batch (1) as the number of fields (2) in the schema"
- );
+ insta::assert_snapshot!(error, @"Invalid argument error: Expected the same number of columns in a record batch (1) as the number of fields (2) in the schema");
}
}
diff --git a/arrow-schema/Cargo.toml b/arrow-schema/Cargo.toml
index fb6461a9e9ae..2991e2aa46b6 100644
--- a/arrow-schema/Cargo.toml
+++ b/arrow-schema/Cargo.toml
@@ -54,7 +54,7 @@ all-features = true
[dev-dependencies]
criterion = { workspace = true, default-features = false }
-insta = "1.43.1"
+insta = { workspace = true, default-features = true }
postcard = { version = "1.0.10", default-features = false, features = ["use-std"] }
[[bench]]
diff --git a/parquet/Cargo.toml b/parquet/Cargo.toml
index d1ada01c3773..75ab432cceb8 100644
--- a/parquet/Cargo.toml
+++ b/parquet/Cargo.toml
@@ -83,7 +83,7 @@ base64 = { version = "0.22", default-features = false, features = ["std"] }
criterion = { workspace = true, default-features = false, features = ["async_futures"] }
snap = { version = "1.0", default-features = false }
tempfile = { version = "3.0", default-features = false }
-insta = "1.43.1"
+insta = { workspace = true, default-features = true }
brotli = { version = "8.0", default-features = false, features = ["std"] }
flate2 = { version = "1.0", default-features = false, features = ["rust_backend"] }
lz4_flex = { version = "0.12", default-features = false, features = ["std", "frame"] }
From 9ec9f578fc7e1fa38534e3cf4859822c50001be5 Mon Sep 17 00:00:00 2001
From: Yan Tingwang
Date: Tue, 3 Mar 2026 04:24:31 +0800
Subject: [PATCH 09/80] Deprecate ArrowTimestampType::make_value in favor of
from_naive_datetime (#9491)
Mark ArrowTimestampType::make_value as deprecated and migrate internal
callers to the newer from_naive_datetime API.
# Which issue does this PR close?
- Closes #9490 .
# Rationale for this change
Follow-up from PR #9345.
# What changes are included in this PR?
Mark ArrowTimestampType::make_value as deprecated and migrate internal
callers to the newer from_naive_datetime API.
# Are these changes tested?
YES.
# Are there any user-facing changes?
Migration Path: Users should replace:
```rust
// Old
TimestampSecondType::make_value(naive)
```
With:
```rust
// New
TimestampSecondType::from_naive_datetime(naive, None)
```
---
arrow-arith/src/numeric.rs | 5 ++++-
arrow-array/src/types.rs | 21 +++++++------------
arrow-cast/src/cast/mod.rs | 2 +-
arrow-cast/src/cast/string.rs | 4 ++--
arrow/tests/arithmetic.rs | 2 +-
.../src/type_conversion.rs | 16 +++++++-------
6 files changed, 24 insertions(+), 26 deletions(-)
diff --git a/arrow-arith/src/numeric.rs b/arrow-arith/src/numeric.rs
index a57ba67544b7..f5a844ffd280 100644
--- a/arrow-arith/src/numeric.rs
+++ b/arrow-arith/src/numeric.rs
@@ -1320,7 +1320,10 @@ mod tests {
"1960-01-30T04:23:20Z",
]
.into_iter()
- .map(|x| T::make_value(DateTime::parse_from_rfc3339(x).unwrap().naive_utc()).unwrap())
+ .map(|x| {
+ T::from_naive_datetime(DateTime::parse_from_rfc3339(x).unwrap().naive_utc(), None)
+ .unwrap()
+ })
.collect();
let a = PrimitiveArray::::new(values, None);
diff --git a/arrow-array/src/types.rs b/arrow-array/src/types.rs
index ff1caaacaecc..267011d8af80 100644
--- a/arrow-array/src/types.rs
+++ b/arrow-array/src/types.rs
@@ -324,6 +324,7 @@ pub trait ArrowTimestampType: ArrowTemporalType {
/// Creates a ArrowTimestampType::Native from the provided [`NaiveDateTime`]
///
/// See [`DataType::Timestamp`] for more information on timezone handling
+ #[deprecated(since = "58.1.0", note = "Use from_naive_datetime instead")]
fn make_value(naive: NaiveDateTime) -> Option;
/// Creates a timestamp value from a [`DateTime`] in any timezone.
@@ -350,7 +351,7 @@ pub trait ArrowTimestampType: ArrowTemporalType {
chrono::offset::LocalResult::Ambiguous(dt1, _) => Self::from_datetime(dt1),
chrono::offset::LocalResult::None => None,
},
- None => Self::make_value(naive),
+ None => Self::from_datetime(naive.and_utc()),
}
}
}
@@ -416,8 +417,7 @@ fn add_year_months(
let months = IntervalYearMonthType::to_months(delta);
let res = as_datetime_with_timezone::(timestamp, tz)?;
let res = add_months_datetime(res, months)?;
- let res = res.naive_utc();
- T::make_value(res)
+ T::from_naive_datetime(res.naive_utc(), None)
}
fn add_day_time(
@@ -429,8 +429,7 @@ fn add_day_time(
let res = as_datetime_with_timezone::(timestamp, tz)?;
let res = add_days_datetime(res, days)?;
let res = res.checked_add_signed(Duration::try_milliseconds(ms as i64)?)?;
- let res = res.naive_utc();
- T::make_value(res)
+ T::from_naive_datetime(res.naive_utc(), None)
}
fn add_month_day_nano(
@@ -443,8 +442,7 @@ fn add_month_day_nano(
let res = add_months_datetime(res, months)?;
let res = add_days_datetime(res, days)?;
let res = res.checked_add_signed(Duration::nanoseconds(nanos))?;
- let res = res.naive_utc();
- T::make_value(res)
+ T::from_naive_datetime(res.naive_utc(), None)
}
fn subtract_year_months(
@@ -455,8 +453,7 @@ fn subtract_year_months(
let months = IntervalYearMonthType::to_months(delta);
let res = as_datetime_with_timezone::(timestamp, tz)?;
let res = sub_months_datetime(res, months)?;
- let res = res.naive_utc();
- T::make_value(res)
+ T::from_naive_datetime(res.naive_utc(), None)
}
fn subtract_day_time(
@@ -468,8 +465,7 @@ fn subtract_day_time(
let res = as_datetime_with_timezone::(timestamp, tz)?;
let res = sub_days_datetime(res, days)?;
let res = res.checked_sub_signed(Duration::try_milliseconds(ms as i64)?)?;
- let res = res.naive_utc();
- T::make_value(res)
+ T::from_naive_datetime(res.naive_utc(), None)
}
fn subtract_month_day_nano(
@@ -482,8 +478,7 @@ fn subtract_month_day_nano(
let res = sub_months_datetime(res, months)?;
let res = sub_days_datetime(res, days)?;
let res = res.checked_sub_signed(Duration::nanoseconds(nanos))?;
- let res = res.naive_utc();
- T::make_value(res)
+ T::from_naive_datetime(res.naive_utc(), None)
}
impl TimestampSecondType {
diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs
index 67efb5742485..9f1eba1057fd 100644
--- a/arrow-cast/src/cast/mod.rs
+++ b/arrow-cast/src/cast/mod.rs
@@ -2507,7 +2507,7 @@ fn adjust_timestamp_to_timezone(
let adjust = |o| {
let local = as_datetime::(o)?;
let offset = to_tz.offset_from_local_datetime(&local).single()?;
- T::make_value(local - offset.fix())
+ T::from_naive_datetime(local - offset.fix(), None)
};
let adjusted = if cast_options.safe {
array.unary_opt::<_, Int64Type>(adjust)
diff --git a/arrow-cast/src/cast/string.rs b/arrow-cast/src/cast/string.rs
index 77696ae0d8cc..68fce85cb436 100644
--- a/arrow-cast/src/cast/string.rs
+++ b/arrow-cast/src/cast/string.rs
@@ -168,7 +168,7 @@ fn cast_string_to_timestamp_impl<
let iter = iter.map(|v| {
v.and_then(|v| {
let naive = string_to_datetime(tz, v).ok()?.naive_utc();
- T::make_value(naive)
+ T::from_naive_datetime(naive, None)
})
});
// Benefit:
@@ -182,7 +182,7 @@ fn cast_string_to_timestamp_impl<
.map(|v| {
v.map(|v| {
let naive = string_to_datetime(tz, v)?.naive_utc();
- T::make_value(naive).ok_or_else(|| match T::UNIT {
+ T::from_naive_datetime(naive, None).ok_or_else(|| match T::UNIT {
TimeUnit::Nanosecond => ArrowError::CastError(format!(
"Overflow converting {naive} to Nanosecond. The dates that can be represented as nanoseconds have to be between 1677-09-21T00:12:44.0 and 2262-04-11T23:47:16.854775804"
)),
diff --git a/arrow/tests/arithmetic.rs b/arrow/tests/arithmetic.rs
index cc6a97e123f8..5d024f715a1e 100644
--- a/arrow/tests/arithmetic.rs
+++ b/arrow/tests/arithmetic.rs
@@ -76,7 +76,7 @@ fn test_timestamp_with_timezone_impl(tz_str: &str) {
.naive_utc(),
]
.into_iter()
- .map(|x| T::make_value(x).unwrap())
+ .map(|x| T::from_naive_datetime(x, None).unwrap())
.collect();
let a = PrimitiveArray::::new(values, None).with_timezone(tz_str);
diff --git a/parquet-variant-compute/src/type_conversion.rs b/parquet-variant-compute/src/type_conversion.rs
index 6a0a743c9029..42bac5727aa5 100644
--- a/parquet-variant-compute/src/type_conversion.rs
+++ b/parquet-variant-compute/src/type_conversion.rs
@@ -109,7 +109,7 @@ impl_timestamp_from_variant!(
if timestamp.nanosecond() != 0 {
None
} else {
- Self::make_value(timestamp)
+ Self::from_naive_datetime(timestamp, None)
}
}
);
@@ -122,7 +122,7 @@ impl_timestamp_from_variant!(
if timestamp.nanosecond() != 0 {
None
} else {
- Self::make_value(timestamp.naive_utc())
+ Self::from_naive_datetime(timestamp.naive_utc(), None)
}
}
);
@@ -135,7 +135,7 @@ impl_timestamp_from_variant!(
if timestamp.nanosecond() % 1_000_000 != 0 {
None
} else {
- Self::make_value(timestamp)
+ Self::from_naive_datetime(timestamp, None)
}
}
);
@@ -148,7 +148,7 @@ impl_timestamp_from_variant!(
if timestamp.nanosecond() % 1_000_000 != 0 {
None
} else {
- Self::make_value(timestamp.naive_utc())
+ Self::from_naive_datetime(timestamp.naive_utc(), None)
}
}
);
@@ -156,25 +156,25 @@ impl_timestamp_from_variant!(
datatypes::TimestampMicrosecondType,
as_timestamp_ntz_micros,
ntz = true,
- Self::make_value,
+ |timestamp| Self::from_naive_datetime(timestamp, None),
);
impl_timestamp_from_variant!(
datatypes::TimestampMicrosecondType,
as_timestamp_micros,
ntz = false,
- |timestamp| Self::make_value(timestamp.naive_utc())
+ |timestamp| Self::from_naive_datetime(timestamp.naive_utc(), None)
);
impl_timestamp_from_variant!(
datatypes::TimestampNanosecondType,
as_timestamp_ntz_nanos,
ntz = true,
- Self::make_value
+ |timestamp| Self::from_naive_datetime(timestamp, None)
);
impl_timestamp_from_variant!(
datatypes::TimestampNanosecondType,
as_timestamp_nanos,
ntz = false,
- |timestamp| Self::make_value(timestamp.naive_utc())
+ |timestamp| Self::from_naive_datetime(timestamp.naive_utc(), None)
);
/// Returns the unscaled integer representation for Arrow decimal type `O`
From 4d8e8baed0a712f875d7ee83536be2c983261631 Mon Sep 17 00:00:00 2001
From: Yan Tingwang
Date: Tue, 3 Mar 2026 05:48:19 +0800
Subject: [PATCH 10/80] chore: remove duplicate macro
`partially_shredded_variant_array_gen` (#9498)
# Which issue does this PR close?
- Closes #9492 .
# What changes are included in this PR?
See title.
# Are these changes tested?
YES
# Are there any user-facing changes?
NO
---
parquet-variant-compute/src/variant_get.rs | 48 +---------------------
1 file changed, 2 insertions(+), 46 deletions(-)
diff --git a/parquet-variant-compute/src/variant_get.rs b/parquet-variant-compute/src/variant_get.rs
index f9985084cc49..e02518057be1 100644
--- a/parquet-variant-compute/src/variant_get.rs
+++ b/parquet-variant-compute/src/variant_get.rs
@@ -466,6 +466,8 @@ mod test {
macro_rules! partially_shredded_variant_array_gen {
($func_name:ident, $typed_value_array_gen: expr) => {
fn $func_name() -> ArrayRef {
+ // At the time of writing, the `VariantArrayBuilder` does not support shredding.
+ // so we must construct the array manually. see https://github.com/apache/arrow-rs/issues/7895
let (metadata, string_value) = {
let mut builder = parquet_variant::VariantBuilder::new();
builder.append_value("n/a");
@@ -1674,52 +1676,6 @@ mod test {
};
}
- macro_rules! partially_shredded_variant_array_gen {
- ($func:ident, $typed_array_gen: expr) => {
- fn $func() -> ArrayRef {
- // At the time of writing, the `VariantArrayBuilder` does not support shredding.
- // so we must construct the array manually. see https://github.com/apache/arrow-rs/issues/7895
- let (metadata, string_value) = {
- let mut builder = parquet_variant::VariantBuilder::new();
- builder.append_value("n/a");
- builder.finish()
- };
-
- let nulls = NullBuffer::from(vec![
- true, // row 0 non null
- false, // row 1 is null
- true, // row 2 non null
- true, // row 3 non null
- ]);
-
- // metadata is the same for all rows
- let metadata = BinaryViewArray::from_iter_values(std::iter::repeat_n(&metadata, 4));
-
- // See https://docs.google.com/document/d/1pw0AWoMQY3SjD7R4LgbPvMjG_xSCtXp3rZHkVp9jpZ4/edit?disco=AAABml8WQrY
- // about why row1 is an empty but non null, value.
- let values = BinaryViewArray::from(vec![
- None, // row 0 is shredded, so no value
- Some(b"" as &[u8]), // row 1 is null, so empty value (why?)
- Some(&string_value), // copy the string value "N/A"
- None, // row 3 is shredded, so no value
- ]);
-
- let typed_value = $typed_array_gen();
-
- let struct_array = StructArrayBuilder::new()
- .with_field("metadata", Arc::new(metadata), false)
- .with_field("typed_value", Arc::new(typed_value), true)
- .with_field("value", Arc::new(values), true)
- .with_nulls(nulls)
- .build();
-
- ArrayRef::from(
- VariantArray::try_new(&struct_array).expect("should create variant array"),
- )
- }
- };
- }
-
numeric_partially_shredded_variant_array_fn!(
partially_shredded_int8_variant_array,
Int8Array,
From d99043e3c3a30f283cc2b3332770f8e65e8d9d8e Mon Sep 17 00:00:00 2001
From: Congxian Qiu
Date: Tue, 3 Mar 2026 05:49:08 +0800
Subject: [PATCH 11/80] [Variant] Enahcne bracket access for VariantPath
(#9479)
# Which issue does this PR close?
- Closes #9478 .
# What changes are included in this PR?
- Fix the typo
- Enhance the bracket access for the variant path
# Are these changes tested?
- Add some tests to cover the logic
# Are there any user-facing changes?
No
---
parquet-variant/src/path.rs | 33 +++++++++++++++++++++++++++++----
parquet-variant/src/utils.rs | 25 ++++++++++++++++++++-----
2 files changed, 49 insertions(+), 9 deletions(-)
diff --git a/parquet-variant/src/path.rs b/parquet-variant/src/path.rs
index fe10d0451d54..8e68d9efadf2 100644
--- a/parquet-variant/src/path.rs
+++ b/parquet-variant/src/path.rs
@@ -75,14 +75,15 @@ use std::{borrow::Cow, ops::Deref};
/// assert_eq!(path[1], VariantPathElement::field("bar"));
/// ```
///
-/// # Example: Accessing filed with bracket
+/// # Example: Accessing field with bracket
/// ```
/// # use parquet_variant::{VariantPath, VariantPathElement};
-/// let path = VariantPath::try_from("a[b.c].d[2]").unwrap();
+/// let path = VariantPath::try_from("a['b.c'].d[2]['3']").unwrap();
/// let expected = VariantPath::from_iter([VariantPathElement::field("a"),
/// VariantPathElement::field("b.c"),
/// VariantPathElement::field("d"),
-/// VariantPathElement::index(2)]);
+/// VariantPathElement::index(2),
+/// VariantPathElement::field("3")]);
/// assert_eq!(path, expected)
#[derive(Debug, Clone, PartialEq, Default)]
pub struct VariantPath<'a>(Vec>);
@@ -287,11 +288,22 @@ mod tests {
assert_eq!(path, expected);
// invalid index will be treated as field
- let path = VariantPath::try_from("foo.bar[abc]").unwrap();
+ let path = VariantPath::try_from("foo.bar['abc'][\"def\"]").unwrap();
let expected = VariantPath::from_iter([
VariantPathElement::field("foo"),
VariantPathElement::field("bar"),
VariantPathElement::field("abc"),
+ VariantPathElement::field("def"),
+ ]);
+ assert_eq!(path, expected);
+
+ // a number quoted with `'` is treated as field, not index
+ let path = VariantPath::try_from("foo['0'].bar[\"1\"]").unwrap();
+ let expected = VariantPath::from_iter([
+ VariantPathElement::field("foo"),
+ VariantPathElement::field("0"),
+ VariantPathElement::field("bar"),
+ VariantPathElement::field("1"),
]);
assert_eq!(path, expected);
}
@@ -321,5 +333,18 @@ mod tests {
// No '[' before ']'
let err = VariantPath::try_from("foo.bar]baz").unwrap_err();
assert_eq!(err.to_string(), "Parser error: Unexpected ']' at byte 7");
+
+ // Invalid number(without quote) parse
+ let err = VariantPath::try_from("foo.bar[123abc]").unwrap_err();
+ assert_eq!(
+ err.to_string(),
+ "Parser error: Invalid token in bracket request: `123abc`. Expected a quoted string or a number(e.g., `['field']` or `[123]`)"
+ );
+
+ let err = VariantPath::try_from("foo.bar[abc]").unwrap_err();
+ assert_eq!(
+ err.to_string(),
+ "Parser error: Invalid token in bracket request: `abc`. Expected a quoted string or a number(e.g., `['field']` or `[123]`)"
+ );
}
}
diff --git a/parquet-variant/src/utils.rs b/parquet-variant/src/utils.rs
index 0984a601b213..85d79ed8aea0 100644
--- a/parquet-variant/src/utils.rs
+++ b/parquet-variant/src/utils.rs
@@ -170,9 +170,10 @@ pub(crate) fn fits_precision(n: impl Into) -> bool {
/// - `"foo"` -> single field `foo`
/// - `"foo.bar"` -> nested fields `foo`, `bar`
/// - `"[1]"` -> array index 1
+/// - `"['1']"` or `"["1"]"`-> field `1`
/// - `"foo[1].bar"` -> field `foo`, index 1, field `bar`
-/// - `"[a.b]"` -> field `a.b` (dot is literal inside bracket)
-/// - `"[a\\]b]"` -> field `a]b` (escaped `]`
+/// - `"['a.b']"` -> field `a.b` (dot is literal inside bracket)
+/// - `"['a\]b']"` -> field `a]b` (escaped `]`
/// - etc.
///
/// # Errors
@@ -267,9 +268,23 @@ fn parse_in_bracket(s: &str, i: usize) -> Result<(VariantPathElement<'_>, usize)
}
};
- let element = match unescaped.parse() {
- Ok(idx) => VariantPathElement::index(idx),
- Err(_) => VariantPathElement::field(unescaped),
+ let element = if let Some(inner) = unescaped
+ .strip_prefix('\'')
+ .and_then(|s| s.strip_suffix('\''))
+ .or_else(|| {
+ unescaped
+ .strip_prefix('"')
+ .and_then(|s| s.strip_suffix('"'))
+ }) {
+ // Quoted field name, e.g., ['field'] or ['123'] or ["123"]
+ VariantPathElement::field(inner.to_string())
+ } else {
+ let Ok(idx) = unescaped.parse() else {
+ return Err(ArrowError::ParseError(format!(
+ "Invalid token in bracket request: `{unescaped}`. Expected a quoted string or a number(e.g., `['field']` or `[123]`)"
+ )));
+ };
+ VariantPathElement::index(idx)
};
Ok((element, end + 1))
From 73a516e3bc9d3850f16b66d6cb65d01e6b080c97 Mon Sep 17 00:00:00 2001
From: Liam Bao
Date: Mon, 2 Mar 2026 16:49:56 -0500
Subject: [PATCH 12/80] Move `ListLikeArray` to arrow-array to be shared with
json writer and parquet unshredding (#9437)
# Which issue does this PR close?
- Part of #9340.
# Rationale for this change
Json writers for ListLike types (List/ListView/FixedSizeList) are pretty
similar apart from the element range representation. We already had a
good way to abstract this kind of encoder in parquet variant
unshredding. Given this, it would be good to move this `ListLikeArray`
trait to arrow-array to be shared with json/parquet
# What changes are included in this PR?
Move `ListLikeArray` trait from parquet-variant-compute to arrow-array
# Are these changes tested?
Covered by existing tests
# Are there any user-facing changes?
New pub trait in arrow-array
---
.../src/array/fixed_size_list_array.rs | 12 +++++
arrow-array/src/array/list_array.rs | 13 +++++
arrow-array/src/array/list_view_array.rs | 12 +++++
arrow-array/src/array/mod.rs | 15 ++++++
.../src/arrow_to_variant.rs | 53 +------------------
parquet-variant-compute/src/shred_variant.rs | 4 +-
.../src/unshred_variant.rs | 4 +-
7 files changed, 58 insertions(+), 55 deletions(-)
diff --git a/arrow-array/src/array/fixed_size_list_array.rs b/arrow-array/src/array/fixed_size_list_array.rs
index ce75855c6815..a3db33d61b56 100644
--- a/arrow-array/src/array/fixed_size_list_array.rs
+++ b/arrow-array/src/array/fixed_size_list_array.rs
@@ -530,6 +530,18 @@ unsafe impl Array for FixedSizeListArray {
}
}
+impl super::ListLikeArray for FixedSizeListArray {
+ fn values(&self) -> &ArrayRef {
+ self.values()
+ }
+
+ fn element_range(&self, index: usize) -> std::ops::Range {
+ let value_length = self.value_length().as_usize();
+ let offset = index * value_length;
+ offset..(offset + value_length)
+ }
+}
+
impl ArrayAccessor for FixedSizeListArray {
type Item = ArrayRef;
diff --git a/arrow-array/src/array/list_array.rs b/arrow-array/src/array/list_array.rs
index e4c603e0d921..d9613c6809ac 100644
--- a/arrow-array/src/array/list_array.rs
+++ b/arrow-array/src/array/list_array.rs
@@ -622,6 +622,19 @@ unsafe impl Array for GenericListArray
}
}
+impl super::ListLikeArray for GenericListArray {
+ fn values(&self) -> &ArrayRef {
+ self.values()
+ }
+
+ fn element_range(&self, index: usize) -> std::ops::Range {
+ let offsets = self.offsets();
+ let start = offsets[index].as_usize();
+ let end = offsets[index + 1].as_usize();
+ start..end
+ }
+}
+
impl ArrayAccessor for &GenericListArray {
type Item = ArrayRef;
diff --git a/arrow-array/src/array/list_view_array.rs b/arrow-array/src/array/list_view_array.rs
index b8d427d829c8..eda3be11ac39 100644
--- a/arrow-array/src/array/list_view_array.rs
+++ b/arrow-array/src/array/list_view_array.rs
@@ -488,6 +488,18 @@ unsafe impl Array for GenericListViewArray super::ListLikeArray for GenericListViewArray {
+ fn values(&self) -> &ArrayRef {
+ self.values()
+ }
+
+ fn element_range(&self, index: usize) -> std::ops::Range {
+ let offset = self.value_offsets()[index].as_usize();
+ let size = self.value_sizes()[index].as_usize();
+ offset..(offset + size)
+ }
+}
+
impl std::fmt::Debug for GenericListViewArray {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let prefix = OffsetSize::PREFIX;
diff --git a/arrow-array/src/array/mod.rs b/arrow-array/src/array/mod.rs
index 0d8125a2a1db..ca3a02577f47 100644
--- a/arrow-array/src/array/mod.rs
+++ b/arrow-array/src/array/mod.rs
@@ -667,6 +667,21 @@ impl<'a> BinaryArrayType<'a> for &'a FixedSizeBinaryArray {
}
}
+/// A trait for Arrow list-like arrays, abstracting over
+/// [`GenericListArray`], [`GenericListViewArray`], and [`FixedSizeListArray`].
+///
+/// This trait provides a uniform interface for accessing the child values and
+/// computing the element range for a given index, regardless of the underlying
+/// list layout (offsets, offsets+sizes, or fixed-size).
+pub trait ListLikeArray: Array {
+ /// Returns the child values array.
+ fn values(&self) -> &ArrayRef;
+
+ /// Returns the start and end indices into the values array for the list
+ /// element at `index`.
+ fn element_range(&self, index: usize) -> std::ops::Range;
+}
+
impl PartialEq for dyn Array + '_ {
fn eq(&self, other: &Self) -> bool {
self.to_data().eq(&other.to_data())
diff --git a/parquet-variant-compute/src/arrow_to_variant.rs b/parquet-variant-compute/src/arrow_to_variant.rs
index be241a9a4e00..03a84109ffa0 100644
--- a/parquet-variant-compute/src/arrow_to_variant.rs
+++ b/parquet-variant-compute/src/arrow_to_variant.rs
@@ -16,8 +16,8 @@
// under the License.
use arrow::array::{
- Array, ArrayRef, AsArray, FixedSizeListArray, GenericBinaryArray, GenericListArray,
- GenericListViewArray, GenericStringArray, OffsetSizeTrait, PrimitiveArray,
+ Array, AsArray, FixedSizeListArray, GenericBinaryArray, GenericListArray, GenericListViewArray,
+ GenericStringArray, ListLikeArray, OffsetSizeTrait, PrimitiveArray,
};
use arrow::compute::{CastOptions, kernels::cast};
use arrow::datatypes::{
@@ -32,7 +32,6 @@ use parquet_variant::{
VariantDecimal16, VariantDecimalType,
};
use std::collections::HashMap;
-use std::ops::Range;
// ============================================================================
// Row-oriented builders for efficient Arrow-to-Variant conversion
@@ -552,54 +551,6 @@ impl<'a, L: ListLikeArray> ListArrowToVariantBuilder<'a, L> {
}
}
-/// Trait for list-like arrays that can provide element ranges
-pub(crate) trait ListLikeArray: Array {
- /// Get the values array
- fn values(&self) -> &ArrayRef;
-
- /// Get the start and end indices for a list element
- fn element_range(&self, index: usize) -> Range;
-}
-
-impl ListLikeArray for GenericListArray {
- fn values(&self) -> &ArrayRef {
- self.values()
- }
-
- fn element_range(&self, index: usize) -> Range {
- let offsets = self.offsets();
- let start = offsets[index].as_usize();
- let end = offsets[index + 1].as_usize();
- start..end
- }
-}
-
-impl ListLikeArray for GenericListViewArray {
- fn values(&self) -> &ArrayRef {
- self.values()
- }
-
- fn element_range(&self, index: usize) -> Range {
- let offsets = self.value_offsets();
- let sizes = self.value_sizes();
- let offset = offsets[index].as_usize();
- let size = sizes[index].as_usize();
- offset..(offset + size)
- }
-}
-
-impl ListLikeArray for FixedSizeListArray {
- fn values(&self) -> &ArrayRef {
- self.values()
- }
-
- fn element_range(&self, index: usize) -> Range {
- let value_length = self.value_length().as_usize();
- let offset = index * value_length;
- offset..(offset + value_length)
- }
-}
-
/// Struct builder for StructArray
pub(crate) struct StructArrowToVariantBuilder<'a> {
struct_array: &'a arrow::array::StructArray,
diff --git a/parquet-variant-compute/src/shred_variant.rs b/parquet-variant-compute/src/shred_variant.rs
index c60c602baa37..6fa3a930fc37 100644
--- a/parquet-variant-compute/src/shred_variant.rs
+++ b/parquet-variant-compute/src/shred_variant.rs
@@ -652,10 +652,10 @@ impl VariantSchemaNode {
mod tests {
use super::*;
use crate::VariantArrayBuilder;
- use crate::arrow_to_variant::ListLikeArray;
use arrow::array::{
Array, BinaryViewArray, FixedSizeBinaryArray, Float64Array, GenericListArray,
- GenericListViewArray, Int64Array, ListArray, OffsetSizeTrait, PrimitiveArray, StringArray,
+ GenericListViewArray, Int64Array, ListArray, ListLikeArray, OffsetSizeTrait,
+ PrimitiveArray, StringArray,
};
use arrow::datatypes::{
ArrowPrimitiveType, DataType, Field, Fields, Int64Type, TimeUnit, UnionFields, UnionMode,
diff --git a/parquet-variant-compute/src/unshred_variant.rs b/parquet-variant-compute/src/unshred_variant.rs
index 37363fd9d085..3600662915a5 100644
--- a/parquet-variant-compute/src/unshred_variant.rs
+++ b/parquet-variant-compute/src/unshred_variant.rs
@@ -17,11 +17,11 @@
//! Module for unshredding VariantArray by folding typed_value columns back into the value column.
-use crate::arrow_to_variant::ListLikeArray;
use crate::{BorrowedShreddingState, VariantArray, VariantValueArrayBuilder};
use arrow::array::{
Array, AsArray as _, BinaryViewArray, BooleanArray, FixedSizeBinaryArray, FixedSizeListArray,
- GenericListArray, GenericListViewArray, PrimitiveArray, StringArray, StructArray,
+ GenericListArray, GenericListViewArray, ListLikeArray, PrimitiveArray, StringArray,
+ StructArray,
};
use arrow::buffer::NullBuffer;
use arrow::datatypes::{
From 01d34a8bee7fae52afd167469ef9e75ff9533309 Mon Sep 17 00:00:00 2001
From: Fokko Driesprong
Date: Mon, 2 Mar 2026 22:50:41 +0100
Subject: [PATCH 13/80] Add `append_value_n` to GenericByteBuilder (#9426)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Which issue does this PR close?
- Closes #9425.
# Rationale for this change
I noticed that this method is available on PrimitiveTypeBuilder, but
missing on the GenericByteBuilder, which make sense since the gain is
less, but after benchmarking, it shows a solid 10%. Mostly because the
more efficient allocation of the null-mask.
```
┌───────────────────┬────────────────┬───────────────────┬─────────┐
│ Benchmark │ append_value_n │ append_value loop │ Speedup │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=100/len=5 │ 371 ns │ 408 ns │ 10% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=100/len=30 │ 456 ns │ 507 ns │ 10% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=100/len=1024 │ 1.81 µs │ 1.95 µs │ 8% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=1000/len=5 │ 2.39 µs │ 2.87 µs │ 17% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=1000/len=30 │ 3.41 µs │ 3.89 µs │ 12% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=1000/len=1024 │ 12.3 µs │ 14.4 µs │ 15% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=10000/len=5 │ 23.8 µs │ 29.3 µs │ 19% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=10000/len=30 │ 33.7 µs │ 39.0 µs │ 14% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=10000/len=1024 │ 115.9 µs │ 135.0 µs │ 14% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=100000/len=5 │ 227.5 µs │ 278.6 µs │ 18% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=100000/len=30 │ 328.1 µs │ 377.9 µs │ 13% │
├───────────────────┼────────────────┼───────────────────┼─────────┤
│ n=100000/len=1024 │ 1.16 ms │ 1.34 ms │ 14% │
└───────────────────┴────────────────┴───────────────────┴─────────┘
```
I think this is still worthwhile to be added. Let me know what the
community thinks!
# What changes are included in this PR?
A new public API.
# Are these changes tested?
Yes!
# Are there any user-facing changes?
A new public API.
---
.../src/builder/generic_bytes_builder.rs | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/arrow-array/src/builder/generic_bytes_builder.rs b/arrow-array/src/builder/generic_bytes_builder.rs
index 7ed4bc5826c0..0a83ff989d4d 100644
--- a/arrow-array/src/builder/generic_bytes_builder.rs
+++ b/arrow-array/src/builder/generic_bytes_builder.rs
@@ -110,6 +110,21 @@ impl GenericByteBuilder {
self.offsets_builder.push(self.next_offset());
}
+ /// Appends a value of type `T` into the builder `n` times.
+ ///
+ /// See [`Self::append_value`] for more panic information.
+ #[inline]
+ pub fn append_value_n(&mut self, value: impl AsRef, n: usize) {
+ let bytes: &[u8] = value.as_ref().as_ref();
+ self.value_builder.reserve(bytes.len() * n);
+ self.offsets_builder.reserve(n);
+ for _ in 0..n {
+ self.value_builder.extend_from_slice(bytes);
+ self.offsets_builder.push(self.next_offset());
+ }
+ self.null_buffer_builder.append_n_non_nulls(n);
+ }
+
/// Append an `Option` value into the builder.
///
/// - A `None` value will append a null value.
@@ -939,4 +954,21 @@ mod tests {
assert!(matches!(result, Err(ArrowError::OffsetOverflowError(_))));
}
+
+ #[test]
+ fn test_append_value_n() {
+ let mut builder = GenericStringBuilder::::new();
+ builder.append_value("hello");
+ builder.append_value_n("world", 3);
+ builder.append_null();
+ let array = builder.finish();
+
+ assert_eq!(5, array.len());
+ assert_eq!(1, array.null_count());
+ assert_eq!("hello", array.value(0));
+ assert_eq!("world", array.value(1));
+ assert_eq!("world", array.value(2));
+ assert_eq!("world", array.value(3));
+ assert!(array.is_null(4));
+ }
}
From bee4595c13665b9dfbd2da3dd0232423a4f2b3c9 Mon Sep 17 00:00:00 2001
From: Fokko Driesprong
Date: Mon, 2 Mar 2026 22:51:03 +0100
Subject: [PATCH 14/80] Add `append_nulls` to `MapBuilder` (#9432)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Which issue does this PR close?
Closes #9431
# Rationale for this change
It would be nice to add `append_nulls` to MapBuilder, similar to
`append_nulls` on `GenericListBuilder`. Appending the nulls at once,
instead of using a loop has some nice performance implications:
```
Benchmark results (1,000,000 nulls):
┌─────────────────────────┬─────────┐
│ Method │ Time │
├─────────────────────────┼─────────┤
│ append(false) in a loop │ 2.36 ms │
├─────────────────────────┼─────────┤
│ append_nulls(N) │ 50 µs │
└─────────────────────────┴─────────┘
```
# What changes are included in this PR?
A new public API.
# Are these changes tested?
With some fresh unit tests.
# Are there any user-facing changes?
A nice and convient new public API
---
arrow-array/src/builder/map_builder.rs | 63 ++++++++++++++++++++++++--
1 file changed, 59 insertions(+), 4 deletions(-)
diff --git a/arrow-array/src/builder/map_builder.rs b/arrow-array/src/builder/map_builder.rs
index b70d4b73880b..5ff1625b4992 100644
--- a/arrow-array/src/builder/map_builder.rs
+++ b/arrow-array/src/builder/map_builder.rs
@@ -154,11 +154,9 @@ impl MapBuilder {
(&mut self.key_builder, &mut self.value_builder)
}
- /// Finish the current map array slot
- ///
- /// Returns an error if the key and values builders are in an inconsistent state.
+ /// Validates that key and value builders have equal lengths.
#[inline]
- pub fn append(&mut self, is_valid: bool) -> Result<(), ArrowError> {
+ fn validate_equal_lengths(&self) -> Result<(), ArrowError> {
if self.key_builder.len() != self.value_builder.len() {
return Err(ArrowError::InvalidArgumentError(format!(
"Cannot append to a map builder when its keys and values have unequal lengths of {} and {}",
@@ -166,11 +164,32 @@ impl MapBuilder {
self.value_builder.len()
)));
}
+ Ok(())
+ }
+
+ /// Finish the current map array slot
+ ///
+ /// Returns an error if the key and values builders are in an inconsistent state.
+ #[inline]
+ pub fn append(&mut self, is_valid: bool) -> Result<(), ArrowError> {
+ self.validate_equal_lengths()?;
self.offsets_builder.push(self.key_builder.len() as i32);
self.null_buffer_builder.append(is_valid);
Ok(())
}
+ /// Append `n` nulls to this [`MapBuilder`]
+ ///
+ /// Returns an error if the key and values builders are in an inconsistent state.
+ #[inline]
+ pub fn append_nulls(&mut self, n: usize) -> Result<(), ArrowError> {
+ self.validate_equal_lengths()?;
+ let offset = self.key_builder.len() as i32;
+ self.offsets_builder.extend(std::iter::repeat_n(offset, n));
+ self.null_buffer_builder.append_n_nulls(n);
+ Ok(())
+ }
+
/// Builds the [`MapArray`]
pub fn finish(&mut self) -> MapArray {
let len = self.len();
@@ -436,6 +455,42 @@ mod tests {
);
}
+ #[test]
+ fn test_append_nulls() {
+ let mut builder = MapBuilder::new(None, Int32Builder::new(), Int32Builder::new());
+
+ builder.keys().append_value(1);
+ builder.values().append_value(100);
+ builder.append(true).unwrap();
+
+ builder.append_nulls(3).unwrap();
+
+ builder.keys().append_value(2);
+ builder.values().append_value(200);
+ builder.append(true).unwrap();
+
+ let map = builder.finish();
+ assert_eq!(map.len(), 5);
+ assert_eq!(map.null_count(), 3);
+ assert!(map.is_valid(0));
+ assert!(map.is_null(1));
+ assert!(map.is_null(2));
+ assert!(map.is_null(3));
+ assert!(map.is_valid(4));
+ assert_eq!(map.value_offsets(), &[0, 1, 1, 1, 1, 2]);
+ }
+
+ #[test]
+ fn test_append_nulls_inconsistent_state() {
+ let mut builder = MapBuilder::new(None, Int32Builder::new(), Int32Builder::new());
+ // Add a key without a matching value
+ builder.keys().append_value(1);
+
+ let result = builder.append_nulls(2);
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("unequal lengths"));
+ }
+
#[test]
#[should_panic(expected = "Keys field must not be nullable")]
fn test_with_nullable_keys_field() {
From e4b68e6f82e41d3f06182e39723183c28e47afa4 Mon Sep 17 00:00:00 2001
From: Fokko Driesprong
Date: Mon, 2 Mar 2026 22:51:19 +0100
Subject: [PATCH 15/80] Add `append_non_nulls` to `StructBuilder` (#9430)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Which issue does this PR close?
- Closes #9429
I'm doing some performance optimization, and noticed that we have a loop
adding one value to the null mask at a time. Instead, I'd suggest adding
`append_non_nulls` to do this at once.
```
append_non_nulls(n) vs append(true) in a loop (with bitmap allocated)
┌───────────┬───────────────────┬─────────────────────┬─────────┐
│ n │ append(true) loop │ append_non_nulls(n) │ speedup │
├───────────┼───────────────────┼─────────────────────┼─────────┤
│ 100 │ 251 ns │ 73 ns │ ~3x │
├───────────┼───────────────────┼─────────────────────┼─────────┤
│ 1,000 │ 2.0 µs │ 94 ns │ ~21x │
├───────────┼───────────────────┼─────────────────────┼─────────┤
│ 10,000 │ 19.3 µs │ 119 ns │ ~162x │
├───────────┼───────────────────┼─────────────────────┼─────────┤
│ 100,000 │ 191 µs │ 348 ns │ ~549x │
├───────────┼───────────────────┼─────────────────────┼─────────┤
│ 1,000,000 │ 1.90 ms │ 3.5 µs │ ~543x │
└───────────┴───────────────────┴─────────────────────┴─────────┘
```
# Rationale for this change
It adds a new public API in favor of performance improvements.
# What changes are included in this PR?
A new public API
# Are these changes tested?
Yes, with new unit-tests.
# Are there any user-facing changes?
Just a new convient API.
---
arrow-array/src/builder/struct_builder.rs | 62 +++++++++++++++++++++++
1 file changed, 62 insertions(+)
diff --git a/arrow-array/src/builder/struct_builder.rs b/arrow-array/src/builder/struct_builder.rs
index ad58e008572f..795593c98a8a 100644
--- a/arrow-array/src/builder/struct_builder.rs
+++ b/arrow-array/src/builder/struct_builder.rs
@@ -213,6 +213,12 @@ impl StructBuilder {
self.null_buffer_builder.append(is_valid);
}
+ /// Appends `n` non-null entries into the builder.
+ #[inline]
+ pub fn append_non_nulls(&mut self, n: usize) {
+ self.null_buffer_builder.append_n_non_nulls(n);
+ }
+
/// Appends a null element to the struct.
#[inline]
pub fn append_null(&mut self) {
@@ -727,4 +733,60 @@ mod tests {
assert!(a1.is_valid(0));
assert!(a1.is_null(1));
}
+
+ #[test]
+ fn test_append_non_nulls() {
+ let int_builder = Int32Builder::new();
+ let fields = vec![Field::new("f1", DataType::Int32, false)];
+ let field_builders = vec![Box::new(int_builder) as Box];
+
+ let mut builder = StructBuilder::new(fields, field_builders);
+ builder
+ .field_builder::(0)
+ .unwrap()
+ .append_slice(&[1, 2, 3, 4, 5]);
+ builder.append_non_nulls(5);
+
+ let arr = builder.finish();
+ assert_eq!(arr.len(), 5);
+ assert_eq!(arr.null_count(), 0);
+ for i in 0..5 {
+ assert!(arr.is_valid(i));
+ }
+ }
+
+ #[test]
+ fn test_append_non_nulls_with_nulls() {
+ let mut builder = StructBuilder::new(Fields::empty(), vec![]);
+ builder.append_null();
+ builder.append_non_nulls(3);
+ builder.append_nulls(2);
+ builder.append_non_nulls(1);
+
+ let arr = builder.finish();
+ assert_eq!(arr.len(), 7);
+ assert_eq!(arr.null_count(), 3);
+ assert!(arr.is_null(0));
+ assert!(arr.is_valid(1));
+ assert!(arr.is_valid(2));
+ assert!(arr.is_valid(3));
+ assert!(arr.is_null(4));
+ assert!(arr.is_null(5));
+ assert!(arr.is_valid(6));
+ }
+
+ #[test]
+ fn test_append_non_nulls_zero() {
+ let mut builder = StructBuilder::new(Fields::empty(), vec![]);
+ builder.append_non_nulls(0);
+ assert_eq!(builder.len(), 0);
+
+ builder.append(true);
+ builder.append_non_nulls(0);
+ assert_eq!(builder.len(), 1);
+
+ let arr = builder.finish();
+ assert_eq!(arr.len(), 1);
+ assert_eq!(arr.null_count(), 0);
+ }
}
From 5025e6825971c7618532515b572026c61f8589b8 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 3 Mar 2026 19:22:37 -0700
Subject: [PATCH 16/80] Update strum_macros requirement from 0.27 to 0.28
(#9471)
Updates the requirements on
[strum_macros](https://github.com/Peternator7/strum) to permit the
latest version.
Changelog
Sourced from strum_macros's
changelog .
0.28.0
#461 :
Allow any kind of passthrough attributes on
EnumDiscriminants.
Previously only list-style attributes (e.g.
#[strum_discriminants(derive(...))]) were supported. Now
path-only
(e.g. #[strum_discriminants(non_exhaustive)]) and
name/value (e.g. #[strum_discriminants(doc =
"foo")])
attributes are also supported.
#462 :
Add missing #[automatically_derived] to generated impls not
covered by #444 .
#466 :
Bump MSRV to 1.71, required to keep up with updated syn and
windows-sys dependencies. This is a breaking change if
you're on an old version of rust.
#469 :
Use absolute paths in generated proc macro code to avoid
potential name conflicts.
#465 :
Upgrade phf dependency to v0.13.
#473 :
Fix cargo fmt / clippy issues and add GitHub
Actions CI.
#477 :
strum::ParseError now implements
core::fmt::Display instead
std::fmt::Display to make it #[no_std]
compatible. Note the Error trait wasn't available in core
until 1.81
so strum::ParseError still only implements that in std.
#476 :
Breaking Change - EnumString now
implements From<&str>
(infallible) instead of TryFrom<&str> when the
enum has a #[strum(default)] variant. This more accurately
reflects that parsing cannot fail in that case. If you need the old
TryFrom behavior, you can opt back in using
parse_error_ty and parse_error_fn:
#[derive(EnumString)]
#[strum(parse_error_ty = strum::ParseError, parse_error_fn =
make_error)]
pub enum Color {
Red,
#[strum(default)]
Other(String),
}
fn make_error(x: &str) -> strum::ParseError {
strum::ParseError::VariantNotFound
}
#431 :
Fix bug where EnumString ignored the
parse_err_ty
attribute when the enum had a #[strum(default)]
variant.
#474 :
EnumDiscriminants will now copy default over from the
original enum to the Discriminant enum.
#[derive(Debug, Default, EnumDiscriminants)]
#[strum_discriminants(derive(Default))] // <- Remove this in 0.28.
enum MyEnum {
#[default] // <- Will be the #[default] on the MyEnumDiscriminant
#[strum_discriminants(default)] // <- Remove this in 0.28
Variant0,
Variant1 { a: NonDefault },
}
... (truncated)
Commits
7376771
Peternator7/0.28 (#475 )
26e63cd
Display exists in core (#477 )
9334c72
Make TryFrom and FromStr infallible if there's a default (#476 )
0ccbbf8
Honor parse_err_ty attribute when the enum has a default variant (#431 )
2c9e5a9
Automatically add Default implementation to EnumDiscriminant if it
exists on ...
e241243
Fix existing cargo fmt + clippy issues and add GH actions (#473 )
639b67f
feat: allow any kind of passthrough attributes on
EnumDiscriminants (#461 )
0ea1e2d
docs: Fix typo (#463 )
36c051b
Upgrade phf to v0.13 (#465 )
9328b38
Use absolute paths in proc macro (#469 )
Additional commits viewable in compare
view
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
arrow-avro/Cargo.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/arrow-avro/Cargo.toml b/arrow-avro/Cargo.toml
index b7cd7eeb1984..93eb825f9a7b 100644
--- a/arrow-avro/Cargo.toml
+++ b/arrow-avro/Cargo.toml
@@ -70,7 +70,7 @@ zstd = { version = "0.13", default-features = false, optional = true }
bzip2 = { version = "0.6.0", optional = true }
xz = { package = "liblzma", version = "0.4", default-features = false, optional = true }
crc = { version = "3.0", optional = true }
-strum_macros = "0.27"
+strum_macros = "0.28"
uuid = "1.17"
indexmap = "2.10"
rand = "0.9"
From 8c89814ef12be9603eee6aa6edeacedef0a6c5a3 Mon Sep 17 00:00:00 2001
From: Mikhail Zabaluev
Date: Thu, 5 Mar 2026 01:56:08 +0200
Subject: [PATCH 17/80] refactor: simplify dynamic state for Avro record
projection (#9419)
# Rationale for this change
The inner loop in `Projector::project_record` gives the optimizer
somewhat complicated dynamic data to branch through.
The sparse arrays in `Projector` are redundantly coded: `None` in the
index positions of `writer_to_reader` must match `Some` in
`skip_decoders` and vice versa.
# What changes are included in this PR?
Refactor record projection state with a single array of directive-like
enums corresponding to each writer schema field.
# Are these changes tested?
Added a benchmark for record projection (the benchmark code is partially
shared with #9397).
Somewhat counterintuitively for me, it does not show improvement on a
more complex case with a mix of projected fields, but does improve the
simpler one-field projection cases.
Passes the existing tests.
---
arrow-avro/benches/project_record.rs | 65 ++++++++++++++--
arrow-avro/src/codec.rs | 74 ++++++++++--------
arrow-avro/src/reader/record.rs | 108 ++++++++++++---------------
3 files changed, 149 insertions(+), 98 deletions(-)
diff --git a/arrow-avro/benches/project_record.rs b/arrow-avro/benches/project_record.rs
index 9bddfea93bb8..91bece6d7e21 100644
--- a/arrow-avro/benches/project_record.rs
+++ b/arrow-avro/benches/project_record.rs
@@ -121,7 +121,22 @@ fn gen_double(mut rng: impl Rng, sc: &ApacheSchema, n: usize, prefix: &[u8]) ->
)
}
-const READER_SCHEMA: &str = r#"
+fn gen_mixed(mut rng: impl Rng, sc: &ApacheSchema, n: usize, prefix: &[u8]) -> Vec {
+ encode_records_with_prefix(
+ sc,
+ prefix,
+ (0..n).map(|i| {
+ Value::Record(vec![
+ ("f1".into(), Value::Int(rng.random())),
+ ("f2".into(), Value::Long(rng.random())),
+ ("f3".into(), Value::String(format!("name-{i}"))),
+ ("f4".into(), Value::Double(rng.random())),
+ ])
+ }),
+ )
+}
+
+const SKIP_READER_SCHEMA: &str = r#"
{
"type":"record",
"name":"table",
@@ -175,11 +190,42 @@ const DOUBLE_SCHEMA: &str = r#"
}
"#;
-fn new_decoder(schema_json: &'static str, batch_size: usize) -> Decoder {
+const MIX_SCHEMA: &str = r#"
+ {
+ "type":"record",
+ "name":"Mix",
+ "fields": [
+ { "name": "f1", "type": "int" },
+ { "name": "f2", "type": "long" },
+ { "name": "f3", "type": "string" },
+ { "name": "f4", "type": "double" }
+ ]
+ }
+ "#;
+
+// Project the record type writen to MIX_SCHEMA:
+// skip "f2" and "f4", add "f5" with a default
+const PROJECT_READER_SCHEMA: &str = r#"
+ {
+ "type":"record",
+ "name":"Mix",
+ "fields": [
+ { "name": "f1", "type": "int" },
+ { "name": "f3", "type": "string" },
+ { "name": "f5", "type": "long", "default": 0 }
+ ]
+ }
+ "#;
+
+fn new_decoder(
+ schema_json: &'static str,
+ reader_schema_json: &'static str,
+ batch_size: usize,
+) -> Decoder {
let schema = AvroSchema::new(schema_json.to_owned());
let mut store = SchemaStore::new();
store.register(schema).unwrap();
- let reader_schema = AvroSchema::new(READER_SCHEMA.to_owned());
+ let reader_schema = AvroSchema::new(reader_schema_json.to_owned());
ReaderBuilder::new()
.with_writer_schema_store(store)
.with_batch_size(batch_size)
@@ -215,19 +261,24 @@ fn bench_with_decoder(
fn criterion_benches(c: &mut Criterion) {
let data = gen_avro_data_with(INT_SCHEMA, NUM_ROWS, gen_int);
bench_with_decoder(c, "skip_int", &data, NUM_ROWS, || {
- new_decoder(INT_SCHEMA, BATCH_SIZE)
+ new_decoder(INT_SCHEMA, SKIP_READER_SCHEMA, BATCH_SIZE)
});
let data = gen_avro_data_with(LONG_SCHEMA, NUM_ROWS, gen_long);
bench_with_decoder(c, "skip_long", &data, NUM_ROWS, || {
- new_decoder(LONG_SCHEMA, BATCH_SIZE)
+ new_decoder(LONG_SCHEMA, SKIP_READER_SCHEMA, BATCH_SIZE)
});
let data = gen_avro_data_with(FLOAT_SCHEMA, NUM_ROWS, gen_float);
bench_with_decoder(c, "skip_float", &data, NUM_ROWS, || {
- new_decoder(FLOAT_SCHEMA, BATCH_SIZE)
+ new_decoder(FLOAT_SCHEMA, SKIP_READER_SCHEMA, BATCH_SIZE)
});
let data = gen_avro_data_with(DOUBLE_SCHEMA, NUM_ROWS, gen_double);
bench_with_decoder(c, "skip_double", &data, NUM_ROWS, || {
- new_decoder(DOUBLE_SCHEMA, BATCH_SIZE)
+ new_decoder(DOUBLE_SCHEMA, SKIP_READER_SCHEMA, BATCH_SIZE)
+ });
+
+ let data = gen_avro_data_with(MIX_SCHEMA, NUM_ROWS, gen_mixed);
+ bench_with_decoder(c, "project_primitives", &data, NUM_ROWS, || {
+ new_decoder(MIX_SCHEMA, PROJECT_READER_SCHEMA, BATCH_SIZE)
});
}
diff --git a/arrow-avro/src/codec.rs b/arrow-avro/src/codec.rs
index d20a71425d3e..fc2a914d3514 100644
--- a/arrow-avro/src/codec.rs
+++ b/arrow-avro/src/codec.rs
@@ -84,14 +84,20 @@ pub(crate) enum AvroLiteral {
/// Contains the necessary information to resolve a writer's record against a reader's record schema.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ResolvedRecord {
- /// Maps a writer's field index to the corresponding reader's field index.
- /// `None` if the writer's field is not present in the reader's schema.
- pub(crate) writer_to_reader: Arc<[Option]>,
+ /// Maps a writer's field index to the field's resolution against the reader's schema.
+ pub(crate) writer_fields: Arc<[ResolvedField]>,
/// A list of indices in the reader's schema for fields that have a default value.
pub(crate) default_fields: Arc<[usize]>,
+}
+
+/// Resolution information for record fields in the writer schema.
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) enum ResolvedField {
+ /// Resolves to a field indexed in the reader schema.
+ ToReader(usize),
/// For fields present in the writer's schema but not the reader's, this stores their data type.
/// This is needed to correctly skip over these fields during deserialization.
- pub(crate) skip_fields: Arc<[Option]>,
+ Skip(AvroDataType),
}
/// Defines the type of promotion to be applied during schema resolution.
@@ -2281,24 +2287,27 @@ impl<'a> Maker<'a> {
data_type: dt,
});
}
- // Build skip_fields in writer order; pre-size and push.
- let mut skip_fields: Vec> =
- Vec::with_capacity(writer_record.fields.len());
- for (writer_index, writer_field) in writer_record.fields.iter().enumerate() {
- if writer_to_reader[writer_index].is_some() {
- skip_fields.push(None);
- } else {
- skip_fields.push(Some(self.parse_type(&writer_field.r#type, writer_ns)?));
- }
- }
+ // Build writer field map.
+ let writer_fields = writer_record
+ .fields
+ .iter()
+ .enumerate()
+ .map(|(writer_index, writer_field)| {
+ if let Some(reader_index) = writer_to_reader[writer_index] {
+ Ok(ResolvedField::ToReader(reader_index))
+ } else {
+ let dt = self.parse_type(&writer_field.r#type, writer_ns)?;
+ Ok(ResolvedField::Skip(dt))
+ }
+ })
+ .collect::>()?;
let resolved = AvroDataType::new_with_resolution(
Codec::Struct(Arc::from(reader_fields)),
reader_md,
None,
Some(ResolutionInfo::Record(ResolvedRecord {
- writer_to_reader: Arc::from(writer_to_reader),
+ writer_fields,
default_fields: Arc::from(default_fields),
- skip_fields: Arc::from(skip_fields),
})),
);
// Register a resolved record by reader name+namespace for potential named type refs.
@@ -2792,16 +2801,13 @@ mod tests {
};
match resolution {
ResolutionInfo::Record(ResolvedRecord {
- writer_to_reader,
+ writer_fields,
default_fields,
- skip_fields,
}) => {
- assert_eq!(writer_to_reader.len(), 1);
- assert_eq!(writer_to_reader[0], Some(0));
+ assert_eq!(writer_fields.len(), 1);
+ assert_eq!(writer_fields[0], ResolvedField::ToReader(0));
assert_eq!(default_fields.len(), 1);
assert_eq!(default_fields[0], 1);
- assert_eq!(skip_fields.len(), 1);
- assert_eq!(skip_fields[0], None);
}
other => panic!("unexpected resolution {other:?}"),
}
@@ -2888,16 +2894,13 @@ mod tests {
};
match resolution {
ResolutionInfo::Record(ResolvedRecord {
- writer_to_reader,
+ writer_fields,
default_fields,
- skip_fields,
}) => {
- assert_eq!(writer_to_reader.len(), 1);
- assert_eq!(writer_to_reader[0], Some(0));
+ assert_eq!(writer_fields.len(), 1);
+ assert_eq!(writer_fields[0], ResolvedField::ToReader(0));
assert_eq!(default_fields.len(), 1);
assert_eq!(default_fields[0], 1);
- assert_eq!(skip_fields.len(), 1);
- assert_eq!(skip_fields[0], None);
}
other => panic!("unexpected resolution {other:?}"),
}
@@ -3714,11 +3717,18 @@ mod tests {
Some(ResolutionInfo::Record(ref r)) => r.clone(),
other => panic!("expected record resolution, got {other:?}"),
};
- assert_eq!(rec.writer_to_reader.as_ref(), &[Some(1), None, Some(0)]);
+ assert!(matches!(
+ &rec.writer_fields[..],
+ &[
+ ResolvedField::ToReader(1),
+ ResolvedField::Skip(_),
+ ResolvedField::ToReader(0),
+ ]
+ ));
assert_eq!(rec.default_fields.as_ref(), &[2usize, 3usize]);
- assert!(rec.skip_fields[0].is_none());
- assert!(rec.skip_fields[2].is_none());
- let skip1 = rec.skip_fields[1].as_ref().expect("skip field present");
+ let ResolvedField::Skip(skip1) = &rec.writer_fields[1] else {
+ panic!("should skip field 1")
+ };
assert!(matches!(skip1.codec(), Codec::Utf8));
let name_md = &fields[2].data_type().metadata;
assert_eq!(
diff --git a/arrow-avro/src/reader/record.rs b/arrow-avro/src/reader/record.rs
index 5e281d1fc6f6..605d29697392 100644
--- a/arrow-avro/src/reader/record.rs
+++ b/arrow-avro/src/reader/record.rs
@@ -18,8 +18,8 @@
//! Avro Decoder for Arrow types.
use crate::codec::{
- AvroDataType, AvroLiteral, Codec, EnumMapping, Promotion, ResolutionInfo, ResolvedRecord,
- ResolvedUnion,
+ AvroDataType, AvroLiteral, Codec, EnumMapping, Promotion, ResolutionInfo, ResolvedField,
+ ResolvedRecord, ResolvedUnion,
};
use crate::errors::AvroError;
use crate::reader::cursor::AvroCursor;
@@ -2416,11 +2416,16 @@ fn values_equal_at(arr: &dyn Array, i: usize, j: usize) -> bool {
#[derive(Debug)]
struct Projector {
- writer_to_reader: Arc<[Option]>,
- skip_decoders: Vec>,
+ writer_projections: Vec,
default_injections: Arc<[(usize, AvroLiteral)]>,
}
+#[derive(Debug)]
+enum FieldProjection {
+ ToReader(usize),
+ Skip(Skipper),
+}
+
#[derive(Debug)]
struct ProjectorBuilder<'a> {
rec: &'a ResolvedRecord,
@@ -2448,18 +2453,20 @@ impl<'a> ProjectorBuilder<'a> {
.unwrap_or(AvroLiteral::Null);
default_injections.push((idx, lit));
}
- let mut skip_decoders: Vec> =
- Vec::with_capacity(self.rec.skip_fields.len());
- for datatype in self.rec.skip_fields.as_ref() {
- let skipper = match datatype {
- Some(datatype) => Some(Skipper::from_avro(datatype)?),
- None => None,
- };
- skip_decoders.push(skipper);
- }
+ let writer_projections = self
+ .rec
+ .writer_fields
+ .iter()
+ .map(|field| match field {
+ ResolvedField::ToReader(index) => Ok(FieldProjection::ToReader(*index)),
+ ResolvedField::Skip(datatype) => {
+ let skipper = Skipper::from_avro(datatype)?;
+ Ok(FieldProjection::Skip(skipper))
+ }
+ })
+ .collect::>()?;
Ok(Projector {
- writer_to_reader: self.rec.writer_to_reader.clone(),
- skip_decoders,
+ writer_projections,
default_injections: default_injections.into(),
})
}
@@ -2472,25 +2479,10 @@ impl Projector {
buf: &mut AvroCursor<'_>,
encodings: &mut [Decoder],
) -> Result<(), AvroError> {
- debug_assert_eq!(
- self.writer_to_reader.len(),
- self.skip_decoders.len(),
- "internal invariant: mapping and skipper lists must have equal length"
- );
- for (i, (mapping, skipper_opt)) in self
- .writer_to_reader
- .iter()
- .zip(self.skip_decoders.iter())
- .enumerate()
- {
- match (mapping, skipper_opt.as_ref()) {
- (Some(reader_index), _) => encodings[*reader_index].decode(buf)?,
- (None, Some(skipper)) => skipper.skip(buf)?,
- (None, None) => {
- return Err(AvroError::SchemaError(format!(
- "No skipper available for writer-only field at index {i}",
- )));
- }
+ for field_proj in self.writer_projections.iter() {
+ match field_proj {
+ FieldProjection::ToReader(index) => encodings[*index].decode(buf)?,
+ FieldProjection::Skip(skipper) => skipper.skip(buf)?,
}
}
for (reader_index, lit) in self.default_injections.as_ref() {
@@ -4128,8 +4120,7 @@ mod tests {
fn make_record_resolved_decoder(
reader_fields: &[(&str, DataType, bool)],
- writer_to_reader: Vec>,
- skip_decoders: Vec >,
+ writer_projections: Vec,
) -> Decoder {
let mut field_refs: Vec = Vec::with_capacity(reader_fields.len());
let mut encodings: Vec = Vec::with_capacity(reader_fields.len());
@@ -4151,8 +4142,7 @@ mod tests {
encodings,
vec![None; reader_fields.len()],
Some(Projector {
- writer_to_reader: Arc::from(writer_to_reader),
- skip_decoders,
+ writer_projections,
default_injections: Arc::from(Vec::<(usize, AvroLiteral)>::new()),
}),
)
@@ -4162,8 +4152,10 @@ mod tests {
fn test_skip_writer_trailing_field_int32() {
let mut dec = make_record_resolved_decoder(
&[("id", arrow_schema::DataType::Int32, false)],
- vec![Some(0), None],
- vec![None, Some(super::Skipper::Int32)],
+ vec![
+ FieldProjection::ToReader(0),
+ FieldProjection::Skip(super::Skipper::Int32),
+ ],
);
let mut data = Vec::new();
data.extend_from_slice(&encode_avro_int(7));
@@ -4190,8 +4182,11 @@ mod tests {
("id", DataType::Int32, false),
("score", DataType::Int64, false),
],
- vec![Some(0), None, Some(1)],
- vec![None, Some(Skipper::String), None],
+ vec![
+ FieldProjection::ToReader(0),
+ FieldProjection::Skip(Skipper::String),
+ FieldProjection::ToReader(1),
+ ],
);
let mut data = Vec::new();
data.extend_from_slice(&encode_avro_int(42));
@@ -4222,8 +4217,10 @@ mod tests {
fn test_skip_writer_array_with_negative_block_count_fast() {
let mut dec = make_record_resolved_decoder(
&[("id", DataType::Int32, false)],
- vec![None, Some(0)],
- vec![Some(super::Skipper::List(Box::new(Skipper::Int32))), None],
+ vec![
+ FieldProjection::Skip(super::Skipper::List(Box::new(Skipper::Int32))),
+ FieldProjection::ToReader(0),
+ ],
);
let mut array_payload = Vec::new();
array_payload.extend_from_slice(&encode_avro_int(1));
@@ -4254,8 +4251,10 @@ mod tests {
fn test_skip_writer_map_with_negative_block_count_fast() {
let mut dec = make_record_resolved_decoder(
&[("id", DataType::Int32, false)],
- vec![None, Some(0)],
- vec![Some(Skipper::Map(Box::new(Skipper::Int32))), None],
+ vec![
+ FieldProjection::Skip(Skipper::Map(Box::new(Skipper::Int32))),
+ FieldProjection::ToReader(0),
+ ],
);
let mut entries = Vec::new();
entries.extend_from_slice(&encode_avro_bytes(b"k1"));
@@ -4287,13 +4286,12 @@ mod tests {
fn test_skip_writer_nullable_field_union_nullfirst() {
let mut dec = make_record_resolved_decoder(
&[("id", DataType::Int32, false)],
- vec![None, Some(0)],
vec![
- Some(super::Skipper::Nullable(
+ FieldProjection::Skip(super::Skipper::Nullable(
Nullability::NullFirst,
Box::new(super::Skipper::Int32),
)),
- None,
+ FieldProjection::ToReader(0),
],
);
let mut row1 = Vec::new();
@@ -4503,7 +4501,6 @@ mod tests {
reader_fields: &[(&str, DataType, bool)],
field_defaults: Vec>,
default_injections: Vec<(usize, AvroLiteral)>,
- writer_to_reader_len: usize,
) -> Decoder {
assert_eq!(
field_defaults.len(),
@@ -4526,11 +4523,8 @@ mod tests {
encodings.push(enc);
}
let fields: Fields = field_refs.into();
- let skip_decoders: Vec > =
- (0..writer_to_reader_len).map(|_| None::).collect();
let projector = Projector {
- writer_to_reader: Arc::from(vec![None; writer_to_reader_len]),
- skip_decoders,
+ writer_projections: vec![],
default_injections: Arc::from(default_injections),
};
Decoder::Record(fields, encodings, field_defaults, Some(projector))
@@ -4979,7 +4973,6 @@ mod tests {
&[("a", DataType::Int32, false), ("b", DataType::Utf8, false)],
field_defaults,
vec![],
- 0,
);
let mut map: IndexMap = IndexMap::new();
map.insert("a".to_string(), AvroLiteral::Int(7));
@@ -5012,7 +5005,6 @@ mod tests {
&[("a", DataType::Int32, false), ("b", DataType::Utf8, false)],
field_defaults,
vec![],
- 0,
);
rec.append_default(&AvroLiteral::Null).unwrap();
let arr = rec.flush(None).unwrap();
@@ -5065,8 +5057,7 @@ mod tests {
encoders.push(enc_b);
let field_defaults = vec![None, None]; // no defaults -> append_null
let projector = Projector {
- writer_to_reader: Arc::from(vec![]),
- skip_decoders: vec![],
+ writer_projections: vec![],
default_injections: Arc::from(Vec::<(usize, AvroLiteral)>::new()),
};
let mut rec = Decoder::Record(field_refs.into(), encoders, field_defaults, Some(projector));
@@ -5106,7 +5097,6 @@ mod tests {
],
defaults,
injections,
- 0,
);
rec.decode(&mut AvroCursor::new(&[])).unwrap();
let arr = rec.flush(None).unwrap();
From 5ba451531efd2e98de38f6a8443aad605b6b5cc5 Mon Sep 17 00:00:00 2001
From: Bruno
Date: Thu, 5 Mar 2026 04:44:44 +0100
Subject: [PATCH 18/80] Simplify downcast_...!() macro definitions (#9454)
1. Reduce some quantifiers from `*` to `?` when 2+ occurrences would
generate invalid Rust code. `$(if $pred:expr)*`
2. Clean up 4-armed recursive macros:
* put the base case first
* explain the fixups
* fix all at once, going directly to the base case, instead of possibly
multiple hoops
The inital motivation was getting rust-analyzer to stop choking on such
macros usage where the left-hand side was a tuple and the
right-hand-side an expr.
---
arrow-array/src/cast.rs | 107 ++++++++++++++++++++++------------------
1 file changed, 58 insertions(+), 49 deletions(-)
diff --git a/arrow-array/src/cast.rs b/arrow-array/src/cast.rs
index de590ff87c77..d6cc242e0267 100644
--- a/arrow-array/src/cast.rs
+++ b/arrow-array/src/cast.rs
@@ -74,7 +74,7 @@ macro_rules! repeat_pat {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_integer {
- ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
match ($($data_type),+) {
$crate::repeat_pat!($crate::cast::__private::DataType::Int8, $($data_type),+) => {
$m!($crate::types::Int8Type $(, $args)*)
@@ -100,7 +100,7 @@ macro_rules! downcast_integer {
$crate::repeat_pat!($crate::cast::__private::DataType::UInt64, $($data_type),+) => {
$m!($crate::types::UInt64Type $(, $args)*)
}
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
};
}
@@ -138,21 +138,24 @@ macro_rules! downcast_integer {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_integer_array {
- ($values:ident => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_integer_array!($values => {$e} $($p $(if $pred)* => $fallback)*)
- };
- (($($values:ident),+) => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_integer_array!($($values),+ => {$e} $($p $(if $pred)* => $fallback)*)
- };
- ($($values:ident),+ => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_integer_array!(($($values),+) => $e $($p $(if $pred)* => $fallback)*)
- };
- (($($values:ident),+) => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($($values:ident),+ => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
$crate::downcast_integer!{
$($values.data_type()),+ => ($crate::downcast_primitive_array_helper, $($values),+, $e),
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
};
+ // Turn $e into a block.
+ ($values:ident => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_integer_array!($values => {$e} $($p $(if $pred)? => $fallback,)*)
+ };
+ // Remove $values parentheses.
+ (($($values:ident),+) => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_integer_array!($($values),+ => $e $($p $(if $pred)? => $fallback,)*)
+ };
+ // Turn $e into a block & remove $values parentheses.
+ (($($values:ident),+) => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_integer_array!($($values),+ => {$e} $($p $(if $pred)? => $fallback,)*)
+ };
}
/// Given one or more expressions evaluating to an integer [`DataType`] invokes the provided macro
@@ -189,7 +192,7 @@ macro_rules! downcast_integer_array {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_run_end_index {
- ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
match ($($data_type),+) {
$crate::repeat_pat!($crate::cast::__private::DataType::Int16, $($data_type),+) => {
$m!($crate::types::Int16Type $(, $args)*)
@@ -200,7 +203,7 @@ macro_rules! downcast_run_end_index {
$crate::repeat_pat!($crate::cast::__private::DataType::Int64, $($data_type),+) => {
$m!($crate::types::Int64Type $(, $args)*)
}
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
};
}
@@ -234,7 +237,7 @@ macro_rules! downcast_run_end_index {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_temporal {
- ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
match ($($data_type),+) {
$crate::repeat_pat!($crate::cast::__private::DataType::Time32($crate::cast::__private::TimeUnit::Second), $($data_type),+) => {
$m!($crate::types::Time32SecondType $(, $args)*)
@@ -266,7 +269,7 @@ macro_rules! downcast_temporal {
$crate::repeat_pat!($crate::cast::__private::DataType::Timestamp($crate::cast::__private::TimeUnit::Nanosecond, _), $($data_type),+) => {
$m!($crate::types::TimestampNanosecondType $(, $args)*)
}
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
};
}
@@ -304,21 +307,24 @@ macro_rules! downcast_temporal {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_temporal_array {
- ($values:ident => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_temporal_array!($values => {$e} $($p $(if $pred)* => $fallback)*)
- };
- (($($values:ident),+) => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_temporal_array!($($values),+ => {$e} $($p $(if $pred)* => $fallback)*)
- };
- ($($values:ident),+ => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_temporal_array!(($($values),+) => $e $($p $(if $pred)* => $fallback)*)
- };
- (($($values:ident),+) => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($($values:ident),+ => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
$crate::downcast_temporal!{
$($values.data_type()),+ => ($crate::downcast_primitive_array_helper, $($values),+, $e),
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
};
+ // Turn $e into a block.
+ ($values:ident => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_temporal_array!($values => {$e} $($p $(if $pred)? => $fallback,)*)
+ };
+ // Remove $values parentheses.
+ (($($values:ident),+) => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_temporal_array!($($values),+ => $e $($p $(if $pred)? => $fallback,)*)
+ };
+ // Turn $e into a block & remove $values parentheses.
+ (($($values:ident),+) => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_temporal_array!($($values),+ => {$e} $($p $(if $pred)? => $fallback,)*)
+ };
}
/// Given one or more expressions evaluating to primitive [`DataType`] invokes the provided macro
@@ -353,7 +359,7 @@ macro_rules! downcast_temporal_array {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_primitive {
- ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($($data_type:expr),+ => ($m:path $(, $args:tt)*), $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
$crate::downcast_integer! {
$($data_type),+ => ($m $(, $args)*),
$crate::repeat_pat!($crate::cast::__private::DataType::Float16, $($data_type),+) => {
@@ -401,7 +407,7 @@ macro_rules! downcast_primitive {
_ => {
$crate::downcast_temporal! {
$($data_type),+ => ($m $(, $args)*),
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
}
}
@@ -450,21 +456,24 @@ macro_rules! downcast_primitive_array_helper {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_primitive_array {
- ($values:ident => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_primitive_array!($values => {$e} $($p $(if $pred)* => $fallback)*)
- };
- (($($values:ident),+) => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_primitive_array!($($values),+ => {$e} $($p $(if $pred)* => $fallback)*)
- };
- ($($values:ident),+ => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- $crate::downcast_primitive_array!(($($values),+) => $e $($p $(if $pred)* => $fallback)*)
- };
- (($($values:ident),+) => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($($values:ident),+ => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
$crate::downcast_primitive!{
$($values.data_type()),+ => ($crate::downcast_primitive_array_helper, $($values),+, $e),
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
};
+ // Turn $e into a block.
+ ($values:ident => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_primitive_array!($values => {$e} $($p $(if $pred)? => $fallback,)*)
+ };
+ // Remove $values parentheses.
+ (($($values:ident),+) => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_primitive_array!($($values),+ => $e $($p $(if $pred)? => $fallback,)*)
+ };
+ // Turn $e into a block & remove $values parentheses.
+ (($($values:ident),+) => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ $crate::downcast_primitive_array!($($values),+ => {$e} $($p $(if $pred)? => $fallback,)*)
+ };
}
/// Force downcast of an [`Array`], such as an [`ArrayRef`], to
@@ -546,11 +555,11 @@ macro_rules! downcast_dictionary_array_helper {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_dictionary_array {
- ($values:ident => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- downcast_dictionary_array!($values => {$e} $($p $(if $pred)* => $fallback)*)
+ ($values:ident => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ downcast_dictionary_array!($values => {$e} $($p $(if $pred)? => $fallback,)*)
};
- ($values:ident => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($values:ident => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
match $values.data_type() {
$crate::cast::__private::DataType::Dictionary(k, _) => {
$crate::downcast_integer! {
@@ -558,7 +567,7 @@ macro_rules! downcast_dictionary_array {
k => unreachable!("unsupported dictionary key type: {}", k)
}
}
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
}
}
@@ -654,11 +663,11 @@ macro_rules! downcast_run_array_helper {
/// [`DataType`]: arrow_schema::DataType
#[macro_export]
macro_rules! downcast_run_array {
- ($values:ident => $e:expr, $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
- downcast_run_array!($values => {$e} $($p $(if $pred)* => $fallback)*)
+ ($values:ident => $e:expr, $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
+ downcast_run_array!($values => {$e} $($p $(if $pred)? => $fallback,)*)
};
- ($values:ident => $e:block $($p:pat $(if $pred:expr)* => $fallback:expr $(,)*)*) => {
+ ($values:ident => $e:block $($p:pat $(if $pred:expr)? => $fallback:expr $(,)?)*) => {
match $values.data_type() {
$crate::cast::__private::DataType::RunEndEncoded(k, _) => {
$crate::downcast_run_end_index! {
@@ -666,7 +675,7 @@ macro_rules! downcast_run_array {
k => unreachable!("unsupported run end index type: {}", k)
}
}
- $($p $(if $pred)* => $fallback,)*
+ $($p $(if $pred)? => $fallback,)*
}
}
}
From 097c2038971b9306f8a9c3c767f64d1794e2eb2f Mon Sep 17 00:00:00 2001
From: Ed Seidl
Date: Sat, 7 Mar 2026 12:48:33 -0800
Subject: [PATCH 19/80] Add some benchmarks for decoding delta encoded Parquet
(#9500)
# Which issue does this PR close?
- Part of #9476.
# Rationale for this change
Add benchmarks to show benefit of the optimizations in #9477
# What changes are included in this PR?
Adds some benches for DELTA_BINARY_PACKED, DELTA_BYTE_ARRAY, and
DELTA_LENGTH_BYTE_ARRAY. The generated data is meant to show the benefit
of special casing for miniblocks with a bitwidth of 0.
# Are these changes tested?
Just benches
# Are there any user-facing changes?
No
---
parquet/benches/arrow_reader.rs | 251 ++++++++++++++++++++++++++++++++
1 file changed, 251 insertions(+)
diff --git a/parquet/benches/arrow_reader.rs b/parquet/benches/arrow_reader.rs
index 2ea0706e3517..14fa16b3531e 100644
--- a/parquet/benches/arrow_reader.rs
+++ b/parquet/benches/arrow_reader.rs
@@ -326,6 +326,58 @@ where
InMemoryPageIterator::new(pages)
}
+fn build_delta_encoded_incr_primitive_page_iterator(
+ column_desc: ColumnDescPtr,
+ null_density: f32,
+ increment: usize,
+ stepped: bool,
+) -> impl PageIterator + Clone
+where
+ T: parquet::data_type::DataType,
+ T::T: SampleUniform + FromPrimitive,
+{
+ let max_def_level = column_desc.max_def_level();
+ let max_rep_level = column_desc.max_rep_level();
+ let rep_levels = vec![0; VALUES_PER_PAGE];
+ let mut rng = seedable_rng();
+ let mut pages: Vec> = Vec::new();
+ let mut running_val: usize = 1;
+ for _i in 0..NUM_ROW_GROUPS {
+ let mut column_chunk_pages = Vec::new();
+ for _j in 0..PAGES_PER_GROUP {
+ // generate page
+ let mut values = Vec::with_capacity(VALUES_PER_PAGE);
+ let mut def_levels = Vec::with_capacity(VALUES_PER_PAGE);
+ for k in 0..VALUES_PER_PAGE {
+ let def_level = if rng.random::() < null_density {
+ max_def_level - 1
+ } else {
+ max_def_level
+ };
+ if def_level == max_def_level {
+ let value = FromPrimitive::from_usize(running_val).unwrap();
+ running_val = if !stepped || k % 2 == 1 {
+ running_val + increment
+ } else {
+ running_val
+ };
+ values.push(value);
+ }
+ def_levels.push(def_level);
+ }
+ let mut page_builder =
+ DataPageBuilderImpl::new(column_desc.clone(), values.len() as u32, true);
+ page_builder.add_rep_levels(max_rep_level, &rep_levels);
+ page_builder.add_def_levels(max_def_level, &def_levels);
+ page_builder.add_values::(Encoding::DELTA_BINARY_PACKED, &values);
+ column_chunk_pages.push(page_builder.consume());
+ }
+ pages.push(column_chunk_pages);
+ }
+
+ InMemoryPageIterator::new(pages)
+}
+
fn build_dictionary_encoded_primitive_page_iterator(
column_desc: ColumnDescPtr,
null_density: f32,
@@ -439,6 +491,52 @@ fn build_plain_encoded_byte_array_page_iterator_inner(
InMemoryPageIterator::new(pages)
}
+fn build_constant_prefix_byte_array_page_iterator(
+ column_desc: ColumnDescPtr,
+ null_density: f32,
+ encoding: Encoding,
+ const_string: bool,
+) -> impl PageIterator + Clone {
+ let max_def_level = column_desc.max_def_level();
+ let max_rep_level = column_desc.max_rep_level();
+ let rep_levels = vec![0; VALUES_PER_PAGE];
+ let mut rng = seedable_rng();
+ let mut pages: Vec> = Vec::new();
+ for i in 0..NUM_ROW_GROUPS {
+ let mut column_chunk_pages = Vec::new();
+ for j in 0..PAGES_PER_GROUP {
+ // generate page
+ let mut values = Vec::with_capacity(VALUES_PER_PAGE);
+ let mut def_levels = Vec::with_capacity(VALUES_PER_PAGE);
+ for k in 0..VALUES_PER_PAGE {
+ let def_level = if rng.random::() < null_density {
+ max_def_level - 1
+ } else {
+ max_def_level
+ };
+ if def_level == max_def_level {
+ let string_value = if const_string {
+ "01234567890123456789012345678901".to_string()
+ } else {
+ format!("01234567890123456789012345678901:{:x}{j}{i}", (k % 16))
+ };
+ values.push(parquet::data_type::ByteArray::from(string_value.as_str()));
+ }
+ def_levels.push(def_level);
+ }
+ let mut page_builder =
+ DataPageBuilderImpl::new(column_desc.clone(), values.len() as u32, true);
+ page_builder.add_rep_levels(max_rep_level, &rep_levels);
+ page_builder.add_def_levels(max_def_level, &def_levels);
+ page_builder.add_values::(encoding, &values);
+ column_chunk_pages.push(page_builder.consume());
+ }
+ pages.push(column_chunk_pages);
+ }
+
+ InMemoryPageIterator::new(pages)
+}
+
fn build_plain_encoded_byte_array_page_iterator(
column_desc: ColumnDescPtr,
null_density: f32,
@@ -1094,6 +1192,99 @@ fn bench_primitive(
assert_eq!(count, EXPECTED_VALUE_COUNT);
});
+ // binary packed same value
+ let data = build_delta_encoded_incr_primitive_page_iterator::(
+ mandatory_column_desc.clone(),
+ 0.0,
+ 0,
+ false,
+ );
+ group.bench_function("binary packed single value", |b| {
+ b.iter(|| {
+ let array_reader =
+ create_primitive_array_reader(data.clone(), mandatory_column_desc.clone());
+ count = bench_array_reader(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ });
+
+ let data = build_delta_encoded_incr_primitive_page_iterator::(
+ mandatory_column_desc.clone(),
+ 0.0,
+ 0,
+ false,
+ );
+ group.bench_function("binary packed skip single value", |b| {
+ b.iter(|| {
+ let array_reader =
+ create_primitive_array_reader(data.clone(), mandatory_column_desc.clone());
+ count = bench_array_reader_skip(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ });
+
+ // binary packed monotonically increasing
+ let data = build_delta_encoded_incr_primitive_page_iterator::(
+ mandatory_column_desc.clone(),
+ 0.0,
+ 1,
+ false,
+ );
+ group.bench_function("binary packed increasing value", |b| {
+ b.iter(|| {
+ let array_reader =
+ create_primitive_array_reader(data.clone(), mandatory_column_desc.clone());
+ count = bench_array_reader(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ });
+
+ let data = build_delta_encoded_incr_primitive_page_iterator::(
+ mandatory_column_desc.clone(),
+ 0.0,
+ 1,
+ false,
+ );
+ group.bench_function("binary packed skip increasing value", |b| {
+ b.iter(|| {
+ let array_reader =
+ create_primitive_array_reader(data.clone(), mandatory_column_desc.clone());
+ count = bench_array_reader_skip(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ });
+
+ // binary packed increasing stepped
+ let data = build_delta_encoded_incr_primitive_page_iterator::(
+ mandatory_column_desc.clone(),
+ 0.0,
+ 1,
+ true,
+ );
+ group.bench_function("binary packed stepped increasing value", |b| {
+ b.iter(|| {
+ let array_reader =
+ create_primitive_array_reader(data.clone(), mandatory_column_desc.clone());
+ count = bench_array_reader(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ });
+
+ let data = build_delta_encoded_incr_primitive_page_iterator::(
+ mandatory_column_desc.clone(),
+ 0.0,
+ 1,
+ true,
+ );
+ group.bench_function("binary packed skip stepped increasing value", |b| {
+ b.iter(|| {
+ let array_reader =
+ create_primitive_array_reader(data.clone(), mandatory_column_desc.clone());
+ count = bench_array_reader_skip(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ });
+
// dictionary encoded, no NULLs
let data =
build_dictionary_encoded_primitive_page_iterator::(mandatory_column_desc.clone(), 0.0);
@@ -1594,6 +1785,66 @@ fn add_benches(c: &mut Criterion) {
assert_eq!(count, EXPECTED_VALUE_COUNT);
});
+ // delta byte array with constant prefix and suffix lengths
+ let delta_string_const_prefix_no_null_data = build_constant_prefix_byte_array_page_iterator(
+ mandatory_string_column_desc.clone(),
+ 0.0,
+ Encoding::DELTA_BYTE_ARRAY,
+ false,
+ );
+ group.bench_function(
+ "const prefix delta byte array encoded, mandatory, no NULLs",
+ |b| {
+ b.iter(|| {
+ let array_reader = create_byte_array_reader(
+ delta_string_const_prefix_no_null_data.clone(),
+ mandatory_string_column_desc.clone(),
+ );
+ count = bench_array_reader(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ },
+ );
+
+ // delta byte array with constant prefix and no suffix
+ let delta_string_const_no_null_data = build_constant_prefix_byte_array_page_iterator(
+ mandatory_string_column_desc.clone(),
+ 0.0,
+ Encoding::DELTA_BYTE_ARRAY,
+ true,
+ );
+ group.bench_function("const delta byte array encoded, mandatory, no NULLs", |b| {
+ b.iter(|| {
+ let array_reader = create_byte_array_reader(
+ delta_string_const_no_null_data.clone(),
+ mandatory_string_column_desc.clone(),
+ );
+ count = bench_array_reader(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ });
+
+ // delta length byte array with constant lengths
+ let delta_string_const_no_null_data = build_constant_prefix_byte_array_page_iterator(
+ mandatory_string_column_desc.clone(),
+ 0.0,
+ Encoding::DELTA_LENGTH_BYTE_ARRAY,
+ true,
+ );
+ group.bench_function(
+ "const delta length byte array encoded, mandatory, no NULLs",
+ |b| {
+ b.iter(|| {
+ let array_reader = create_byte_array_reader(
+ delta_string_const_no_null_data.clone(),
+ mandatory_string_column_desc.clone(),
+ );
+ count = bench_array_reader(array_reader);
+ });
+ assert_eq!(count, EXPECTED_VALUE_COUNT);
+ },
+ );
+
group.finish();
// binary benchmarks
From fec3c021e85f34723250c413891f580657a1eb4f Mon Sep 17 00:00:00 2001
From: Tim-53 <82676248+Tim-53@users.noreply.github.com>
Date: Mon, 9 Mar 2026 13:45:16 +0100
Subject: [PATCH 20/80] fix: remove incorrect debug assertion in BatchCoalescer
(#9508)
# Which issue does this PR close?
- Closes https://github.com/apache/arrow-rs/issues/9506
# Rationale for this change
`Vec::reserve(n)` does not guarantee exact capacity, Rust's
`MIN_NON_ZERO_CAP` optimization means `reserve(2)` gives capacity = 4
for most numeric types, causing `debug_assert_eq!(capacity, batch_size)`
to panic in debug mode when `batch_size < 4`.
# What changes are included in this PR?
Replace `reserve` with `reserve_exact` in `ensure_capacity` in both
`InProgressPrimitiveArray` and `InProgressByteViewArray`.
`reserve_exact` skips the amortized growth optimization and allocates
exactly the requested capacity, making the assertion correct.
# Are these changes tested?
No. This only fixes an incorrect debug assertion.
# Are there any user-facing changes?
No
---
arrow-select/src/coalesce/byte_view.rs | 1 -
arrow-select/src/coalesce/primitive.rs | 1 -
2 files changed, 2 deletions(-)
diff --git a/arrow-select/src/coalesce/byte_view.rs b/arrow-select/src/coalesce/byte_view.rs
index bca811fff1c6..6062cd5e77aa 100644
--- a/arrow-select/src/coalesce/byte_view.rs
+++ b/arrow-select/src/coalesce/byte_view.rs
@@ -101,7 +101,6 @@ impl InProgressByteViewArray {
if self.views.capacity() == 0 {
self.views.reserve(self.batch_size);
}
- debug_assert_eq!(self.views.capacity(), self.batch_size);
}
/// Finishes in progress buffer, if any
diff --git a/arrow-select/src/coalesce/primitive.rs b/arrow-select/src/coalesce/primitive.rs
index 69dad221bd52..a7f2fb32ce49 100644
--- a/arrow-select/src/coalesce/primitive.rs
+++ b/arrow-select/src/coalesce/primitive.rs
@@ -58,7 +58,6 @@ impl InProgressPrimitiveArray {
if self.current.capacity() == 0 {
self.current.reserve(self.batch_size);
}
- debug_assert_eq!(self.current.capacity(), self.batch_size);
}
}
From edd2c8eef5a7b702947a25e3223539e3723d5aac Mon Sep 17 00:00:00 2001
From: Matthew Kim <38759997+friendlymatthew@users.noreply.github.com>
Date: Mon, 9 Mar 2026 12:57:17 -0400
Subject: [PATCH 21/80] support large string for unshred variant (#9515)
# Which issue does this PR close?
- Closes https://github.com/apache/arrow-rs/issues/9513
# Rationale for this change
`VariantArray::try_new` and `canonicalize_and_verify_data_type` both
accept `LargeUtf8` as a valid shredded variant type. However
unshred_variant currently only handles Utf8 for string typed_value
columns
This means a VariantArray with a LargeUtf8 typed_value column can be
constructed successfully, but calling unshred_variant on it fails
---
.../src/unshred_variant.rs | 44 ++++++++++++++++++-
1 file changed, 42 insertions(+), 2 deletions(-)
diff --git a/parquet-variant-compute/src/unshred_variant.rs b/parquet-variant-compute/src/unshred_variant.rs
index 3600662915a5..0fba53b31539 100644
--- a/parquet-variant-compute/src/unshred_variant.rs
+++ b/parquet-variant-compute/src/unshred_variant.rs
@@ -20,8 +20,8 @@
use crate::{BorrowedShreddingState, VariantArray, VariantValueArrayBuilder};
use arrow::array::{
Array, AsArray as _, BinaryViewArray, BooleanArray, FixedSizeBinaryArray, FixedSizeListArray,
- GenericListArray, GenericListViewArray, ListLikeArray, PrimitiveArray, StringArray,
- StructArray,
+ GenericListArray, GenericListViewArray, LargeStringArray, ListLikeArray, PrimitiveArray,
+ StringArray, StructArray,
};
use arrow::buffer::NullBuffer;
use arrow::datatypes::{
@@ -105,6 +105,7 @@ enum UnshredVariantRowBuilder<'a> {
TimestampNanosecond(TimestampUnshredRowBuilder<'a, TimestampNanosecondType>),
PrimitiveBoolean(UnshredPrimitiveRowBuilder<'a, BooleanArray>),
PrimitiveString(UnshredPrimitiveRowBuilder<'a, StringArray>),
+ PrimitiveLargeString(UnshredPrimitiveRowBuilder<'a, LargeStringArray>),
PrimitiveBinaryView(UnshredPrimitiveRowBuilder<'a, BinaryViewArray>),
PrimitiveUuid(UnshredPrimitiveRowBuilder<'a, FixedSizeBinaryArray>),
List(ListUnshredVariantBuilder<'a, GenericListArray>),
@@ -146,6 +147,7 @@ impl<'a> UnshredVariantRowBuilder<'a> {
Self::TimestampNanosecond(b) => b.append_row(builder, metadata, index),
Self::PrimitiveBoolean(b) => b.append_row(builder, metadata, index),
Self::PrimitiveString(b) => b.append_row(builder, metadata, index),
+ Self::PrimitiveLargeString(b) => b.append_row(builder, metadata, index),
Self::PrimitiveBinaryView(b) => b.append_row(builder, metadata, index),
Self::PrimitiveUuid(b) => b.append_row(builder, metadata, index),
Self::List(b) => b.append_row(builder, metadata, index),
@@ -226,6 +228,7 @@ impl<'a> UnshredVariantRowBuilder<'a> {
}
DataType::Boolean => primitive_builder!(PrimitiveBoolean, as_boolean),
DataType::Utf8 => primitive_builder!(PrimitiveString, as_string),
+ DataType::LargeUtf8 => primitive_builder!(PrimitiveLargeString, as_string),
DataType::BinaryView => primitive_builder!(PrimitiveBinaryView, as_binary_view),
DataType::FixedSizeBinary(16) => {
primitive_builder!(PrimitiveUuid, as_fixed_size_binary)
@@ -405,6 +408,7 @@ macro_rules! impl_append_to_variant_builder {
impl_append_to_variant_builder!(BooleanArray);
impl_append_to_variant_builder!(StringArray);
+impl_append_to_variant_builder!(LargeStringArray);
impl_append_to_variant_builder!(BinaryViewArray);
impl_append_to_variant_builder!(PrimitiveArray);
impl_append_to_variant_builder!(PrimitiveArray);
@@ -666,3 +670,39 @@ impl<'a, L: ListLikeArray> ListUnshredVariantBuilder<'a, L> {
// TODO: This code is covered by tests in `parquet/tests/variant_integration.rs`. Does that suffice?
// Or do we also need targeted stand-alone unit tests for full coverage?
+
+#[cfg(test)]
+mod tests {
+ use crate::VariantArray;
+ use arrow::array::{BinaryViewArray, LargeStringArray};
+ use parquet_variant::Variant;
+
+ #[test]
+ fn test_unshred_largeutf8_typed_value() {
+ let metadata_bytes: &[u8] = &[0x01, 0x00, 0x00];
+ let metadata =
+ BinaryViewArray::from_iter_values(vec![metadata_bytes; 3]);
+
+ let typed_value: arrow::array::ArrayRef = std::sync::Arc::new(
+ LargeStringArray::from(vec![
+ Some("hello"),
+ Some("middle"),
+ Some("world"),
+ ]),
+ );
+
+ let variant_array = VariantArray::from_parts(
+ metadata,
+ None,
+ Some(typed_value),
+ None,
+ );
+
+ let result = crate::unshred_variant(&variant_array).unwrap();
+
+ assert_eq!(result.len(), 3);
+ assert_eq!(result.value(0), Variant::from("hello"));
+ assert_eq!(result.value(1), Variant::from("middle"));
+ assert_eq!(result.value(2), Variant::from("world"));
+ }
+}
From 0b044835a8180100c89b60d856e9f67634b5d5e7 Mon Sep 17 00:00:00 2001
From: Matthew Kim <38759997+friendlymatthew@users.noreply.github.com>
Date: Mon, 9 Mar 2026 14:41:30 -0400
Subject: [PATCH 22/80] support string view unshred variant (#9514)
# Which issue does this PR close?
- Closes https://github.com/apache/arrow-rs/issues/9512
# Rationale for this change
You can build a Variant with a StringView type shredded out, but calling
`unshred_variant` will fail with not yet implemented
---
.../src/unshred_variant.rs | 51 ++++++++++++-------
1 file changed, 33 insertions(+), 18 deletions(-)
diff --git a/parquet-variant-compute/src/unshred_variant.rs b/parquet-variant-compute/src/unshred_variant.rs
index 0fba53b31539..cfe413460086 100644
--- a/parquet-variant-compute/src/unshred_variant.rs
+++ b/parquet-variant-compute/src/unshred_variant.rs
@@ -21,7 +21,7 @@ use crate::{BorrowedShreddingState, VariantArray, VariantValueArrayBuilder};
use arrow::array::{
Array, AsArray as _, BinaryViewArray, BooleanArray, FixedSizeBinaryArray, FixedSizeListArray,
GenericListArray, GenericListViewArray, LargeStringArray, ListLikeArray, PrimitiveArray,
- StringArray, StructArray,
+ StringArray, StringViewArray, StructArray,
};
use arrow::buffer::NullBuffer;
use arrow::datatypes::{
@@ -105,6 +105,7 @@ enum UnshredVariantRowBuilder<'a> {
TimestampNanosecond(TimestampUnshredRowBuilder<'a, TimestampNanosecondType>),
PrimitiveBoolean(UnshredPrimitiveRowBuilder<'a, BooleanArray>),
PrimitiveString(UnshredPrimitiveRowBuilder<'a, StringArray>),
+ PrimitiveStringView(UnshredPrimitiveRowBuilder<'a, StringViewArray>),
PrimitiveLargeString(UnshredPrimitiveRowBuilder<'a, LargeStringArray>),
PrimitiveBinaryView(UnshredPrimitiveRowBuilder<'a, BinaryViewArray>),
PrimitiveUuid(UnshredPrimitiveRowBuilder<'a, FixedSizeBinaryArray>),
@@ -147,6 +148,7 @@ impl<'a> UnshredVariantRowBuilder<'a> {
Self::TimestampNanosecond(b) => b.append_row(builder, metadata, index),
Self::PrimitiveBoolean(b) => b.append_row(builder, metadata, index),
Self::PrimitiveString(b) => b.append_row(builder, metadata, index),
+ Self::PrimitiveStringView(b) => b.append_row(builder, metadata, index),
Self::PrimitiveLargeString(b) => b.append_row(builder, metadata, index),
Self::PrimitiveBinaryView(b) => b.append_row(builder, metadata, index),
Self::PrimitiveUuid(b) => b.append_row(builder, metadata, index),
@@ -228,6 +230,7 @@ impl<'a> UnshredVariantRowBuilder<'a> {
}
DataType::Boolean => primitive_builder!(PrimitiveBoolean, as_boolean),
DataType::Utf8 => primitive_builder!(PrimitiveString, as_string),
+ DataType::Utf8View => primitive_builder!(PrimitiveStringView, as_string_view),
DataType::LargeUtf8 => primitive_builder!(PrimitiveLargeString, as_string),
DataType::BinaryView => primitive_builder!(PrimitiveBinaryView, as_binary_view),
DataType::FixedSizeBinary(16) => {
@@ -408,6 +411,7 @@ macro_rules! impl_append_to_variant_builder {
impl_append_to_variant_builder!(BooleanArray);
impl_append_to_variant_builder!(StringArray);
+impl_append_to_variant_builder!(StringViewArray);
impl_append_to_variant_builder!(LargeStringArray);
impl_append_to_variant_builder!(BinaryViewArray);
impl_append_to_variant_builder!(PrimitiveArray);
@@ -668,35 +672,46 @@ impl<'a, L: ListLikeArray> ListUnshredVariantBuilder<'a, L> {
}
}
-// TODO: This code is covered by tests in `parquet/tests/variant_integration.rs`. Does that suffice?
-// Or do we also need targeted stand-alone unit tests for full coverage?
-
#[cfg(test)]
mod tests {
use crate::VariantArray;
- use arrow::array::{BinaryViewArray, LargeStringArray};
+ use arrow::array::{BinaryViewArray, LargeStringArray, StringViewArray};
use parquet_variant::Variant;
+ #[test]
+ fn test_unshred_utf8view_typed_value() {
+ let metadata_bytes: &[u8] = &[0x01, 0x00, 0x00];
+ let metadata = BinaryViewArray::from_iter_values(vec![metadata_bytes; 3]);
+
+ let typed_value: arrow::array::ArrayRef = std::sync::Arc::new(StringViewArray::from(vec![
+ Some("hello"),
+ Some("middle"),
+ Some("world"),
+ ]));
+
+ let variant_array = VariantArray::from_parts(metadata, None, Some(typed_value), None);
+
+ let result = crate::unshred_variant(&variant_array).unwrap();
+
+ assert_eq!(result.len(), 3);
+ assert_eq!(result.value(0), Variant::from("hello"));
+ assert_eq!(result.value(1), Variant::from("middle"));
+ assert_eq!(result.value(2), Variant::from("world"));
+ }
+
#[test]
fn test_unshred_largeutf8_typed_value() {
let metadata_bytes: &[u8] = &[0x01, 0x00, 0x00];
- let metadata =
- BinaryViewArray::from_iter_values(vec![metadata_bytes; 3]);
+ let metadata = BinaryViewArray::from_iter_values(vec![metadata_bytes; 3]);
- let typed_value: arrow::array::ArrayRef = std::sync::Arc::new(
- LargeStringArray::from(vec![
+ let typed_value: arrow::array::ArrayRef =
+ std::sync::Arc::new(LargeStringArray::from(vec![
Some("hello"),
Some("middle"),
Some("world"),
- ]),
- );
-
- let variant_array = VariantArray::from_parts(
- metadata,
- None,
- Some(typed_value),
- None,
- );
+ ]));
+
+ let variant_array = VariantArray::from_parts(metadata, None, Some(typed_value), None);
let result = crate::unshred_variant(&variant_array).unwrap();
From d2e2cdafed93a8e0152fe1d018ec2cef154ccb20 Mon Sep 17 00:00:00 2001
From: Jonas Dedden
Date: Mon, 9 Mar 2026 21:32:53 +0100
Subject: [PATCH 23/80] Fix skip_records over-counting when partial record
precedes num_rows page skip (#9374)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Which issue does this PR close?
- Closes #9370 .
# Rationale for this change
The bug occurs when using RowSelection with nested types (like
List) when:
1. A column has multiple pages in a row group
2. The selected rows span across page boundaries
3. The first page is entirely consumed during skip operations
The issue was in `arrow-rs/parquet/src/column/reader.rs:287-382`
(`skip_records` function).
**Root cause:** When `skip_records` completed successfully after
crossing page boundaries, the `has_partial` state in the
`RepetitionLevelDecoder` could incorrectly remain true.
This happened when:
- The skip operation exhausted a page where has_record_delimiter was
false
- The skip found the remaining records on the next page by counting a
delimiter at index 0
- When a subsequent read_records(1) was called, the stale
has_partial=true state caused count_records to incorrectly interpret the
first repetition level (0) at index 0 as ending a "phantom" partial
record, returning (1 record, 0 levels, 0 values) instead of properly
reading the actual record data.
For a more descriptive explanation, look here:
https://github.com/apache/arrow-rs/issues/9370#issuecomment-3861143928
# What changes are included in this PR?
Added code at the end of skip_records to reset the partial record state
when all requested records have been successfully skipped.
This ensures that after skip_records completes, we're at a clean record
boundary with no lingering partial record state, fixing the array length
mismatch in StructArrayReader.
# Are these changes tested?
Commit
https://github.com/apache/arrow-rs/commit/365bd9a4ced7897f391e4533930a0c9683952723
introduces a test showcasing this issue with v2 data pages only on a
unit-test level. PR https://github.com/apache/arrow-rs/pull/9399 could
be used to showcase the issue in an end-to-end way.
Previously wrong assumption that thought it had to do with mixing v1 and
v2 data pages:
```
In b52e043 I added a test that I validated to fail whenever I remove my fix.
Bug Mechanism
The bug requires three ingredients:
1. Page 1 (DataPage v1): Contains a nested column (with rep levels). During skip_records, all levels on this page are consumed. count_records sees no following rep=0 delimiter, so it sets
has_partial=true. Since has_record_delimiter is false (the default InMemoryPageReader returns false when more pages exist), flush_partial is not called.
2. Page 2 (DataPage v2): Has num_rows available in its metadata. When num_rows <= remaining_records, the entire page is skipped via skip_next_page() — this does not touch the rep level decoder at all,
so has_partial remains stale true from page 1.
3. Page 3 (DataPage v1): When read_records loads this page, the stale has_partial=true causes the rep=0 at position 0 to be misinterpreted as completing a "phantom" partial record. This produces (1
record, 0 levels, 0 values) instead of reading the actual record data.
Test Verification
- With fix (flush_partial at end of skip_records): read_records(1) correctly returns (1, 2, 2) with values [70, 80]
- Without fix: read_records(1) returns (1, 0, 0) — a phantom record with no data, which is what causes the "Not all children array length are the same!" error when different sibling columns in a struct
produce different record counts
```
---------
Co-authored-by: Ed Seidl
Co-authored-by: Andrew Lamb
---
parquet/src/column/page.rs | 9 +-
parquet/src/column/reader.rs | 131 ++++++++++++++++++
parquet/src/file/serialized_reader.rs | 7 +-
parquet/tests/arrow_reader/row_filter/sync.rs | 2 -
4 files changed, 145 insertions(+), 4 deletions(-)
diff --git a/parquet/src/column/page.rs b/parquet/src/column/page.rs
index f18b296c1c65..4cfc07a02883 100644
--- a/parquet/src/column/page.rs
+++ b/parquet/src/column/page.rs
@@ -406,7 +406,14 @@ pub trait PageReader: Iterator- > + Send {
/// [(#4327)]: https://github.com/apache/arrow-rs/pull/4327
/// [(#4943)]: https://github.com/apache/arrow-rs/pull/4943
fn at_record_boundary(&mut self) -> Result
{
- Ok(self.peek_next_page()?.is_none())
+ match self.peek_next_page()? {
+ // Last page in the column chunk - always a record boundary
+ None => Ok(true),
+ // A V2 data page is required by the parquet spec to start at a
+ // record boundary, so the current page ends at one. V2 pages
+ // are identified by having `num_rows` set in their header.
+ Some(metadata) => Ok(metadata.num_rows.is_some()),
+ }
}
}
diff --git a/parquet/src/column/reader.rs b/parquet/src/column/reader.rs
index 387a0602a60d..29cb50185a58 100644
--- a/parquet/src/column/reader.rs
+++ b/parquet/src/column/reader.rs
@@ -1361,4 +1361,135 @@ mod tests {
);
}
}
+
+ /// Regression test for
+ ///
+ /// Reproduces the production scenario: all DataPage v2 pages for a
+ /// list column (rep_level=1) read without an offset index (i.e.
+ /// `at_record_boundary` returns false for non-last pages).
+ ///
+ /// When a prior operation (here `skip_records(1)`) loads a v2 page,
+ /// and a subsequent `skip_records` exhausts the remaining levels on
+ /// that page, the rep level decoder is left with `has_partial=true`.
+ /// Because `has_record_delimiter` is false, the partial is not
+ /// flushed during level-based processing. When the next v2 page is
+ /// then peeked with `num_rows` available, the whole-page-skip
+ /// shortcut must flush the pending partial first. Otherwise:
+ ///
+ /// 1. The skip over-counts (skips N+1 records instead of N), and
+ /// 2. The stale `has_partial` causes a subsequent `read_records` to
+ /// produce a "phantom" record with 0 values.
+ #[test]
+ fn test_skip_records_v2_page_skip_accounts_for_partial() {
+ use crate::encodings::levels::LevelEncoder;
+
+ let max_rep_level: i16 = 1;
+ let max_def_level: i16 = 1;
+
+ // Column descriptor for a list element column (rep=1, def=1)
+ let primitive_type = SchemaType::primitive_type_builder("element", PhysicalType::INT32)
+ .with_repetition(Repetition::REQUIRED)
+ .build()
+ .unwrap();
+ let desc = Arc::new(ColumnDescriptor::new(
+ Arc::new(primitive_type),
+ max_def_level,
+ max_rep_level,
+ ColumnPath::new(vec!["list".to_string(), "element".to_string()]),
+ ));
+
+ // Helper: build a DataPage v2 for this list column.
+ let make_v2_page =
+ |rep_levels: &[i16], def_levels: &[i16], values: &[i32], num_rows: u32| -> Page {
+ let mut rep_enc = LevelEncoder::v2(max_rep_level, rep_levels.len());
+ rep_enc.put(rep_levels);
+ let rep_bytes = rep_enc.consume();
+
+ let mut def_enc = LevelEncoder::v2(max_def_level, def_levels.len());
+ def_enc.put(def_levels);
+ let def_bytes = def_enc.consume();
+
+ let val_bytes: Vec = values.iter().flat_map(|v| v.to_le_bytes()).collect();
+
+ let mut buf = Vec::new();
+ buf.extend_from_slice(&rep_bytes);
+ buf.extend_from_slice(&def_bytes);
+ buf.extend_from_slice(&val_bytes);
+
+ Page::DataPageV2 {
+ buf: Bytes::from(buf),
+ num_values: rep_levels.len() as u32,
+ encoding: Encoding::PLAIN,
+ num_nulls: 0,
+ num_rows,
+ def_levels_byte_len: def_bytes.len() as u32,
+ rep_levels_byte_len: rep_bytes.len() as u32,
+ is_compressed: false,
+ statistics: None,
+ }
+ };
+
+ // All pages are DataPage v2 (matching the production scenario where
+ // parquet-rs writes only v2 data pages and no offset index is loaded,
+ // so at_record_boundary() returns false for non-last pages).
+
+ // Page 1 (v2): 2 records × 2 elements = [10,20], [30,40]
+ let page1 = make_v2_page(&[0, 1, 0, 1], &[1, 1, 1, 1], &[10, 20, 30, 40], 2);
+
+ // Page 2 (v2): 2 records × 2 elements = [50,60], [70,80]
+ let page2 = make_v2_page(&[0, 1, 0, 1], &[1, 1, 1, 1], &[50, 60, 70, 80], 2);
+
+ // Page 3 (v2): 1 record × 2 elements = [90,100]
+ let page3 = make_v2_page(&[0, 1], &[1, 1], &[90, 100], 1);
+
+ // 5 records total: [10,20], [30,40], [50,60], [70,80], [90,100]
+ let pages = VecDeque::from(vec![page1, page2, page3]);
+ let page_reader = InMemoryPageReader::new(pages);
+ let column_reader: ColumnReader = get_column_reader(desc, Box::new(page_reader));
+ let mut typed_reader = get_typed_column_reader::(column_reader);
+
+ // Step 1 — skip 1 record:
+ // Peek page 1: num_rows=2, remaining=1 → rows(2) > remaining(1),
+ // so the page is LOADED (not whole-page-skipped).
+ // Level-based skip consumes rep levels [0,1] for record [10,20],
+ // stopping at the 0 that starts record [30,40].
+ let skipped = typed_reader.skip_records(1).unwrap();
+ assert_eq!(skipped, 1);
+
+ // Step 2 — skip 2 more records ([30,40] and [50,60]):
+ // Mid-page in page 1 with 2 remaining levels [0,1] for [30,40].
+ // skip_rep_levels(2, 2): the leading 0 does NOT act as a record
+ // delimiter (has_partial=false, idx==0), so count_records returns
+ // (true, 0, 2) — all levels consumed, has_partial=true, 0 records.
+ //
+ // has_record_delimiter is false → no flush at page boundary.
+ // Page 1 exhausted → peek page 2 (v2, num_rows=2).
+ //
+ // With fix: flush_partial → remaining 2→1, page 2 NOT skipped
+ // (rows=2 > remaining=1). Load page 2, skip 1 record [50,60].
+ //
+ // Without fix: rows(2) <= remaining(2) → page 2 whole-page-skipped,
+ // over-counting by 1. has_partial stays true (stale from page 1).
+ let skipped = typed_reader.skip_records(2).unwrap();
+ assert_eq!(skipped, 2);
+
+ // Step 3 — read 1 record:
+ let mut values = Vec::new();
+ let mut def_levels = Vec::new();
+ let mut rep_levels = Vec::new();
+
+ let (records, values_read, levels_read) = typed_reader
+ .read_records(1, Some(&mut def_levels), Some(&mut rep_levels), &mut values)
+ .unwrap();
+
+ // Without the fix: (1, 0, 0) — phantom record from stale has_partial;
+ // the rep=0 on page 3 "completes" the phantom, yielding 0 values.
+ // With the fix: (1, 2, 2) — correctly reads record [70, 80].
+ assert_eq!(records, 1, "should read exactly 1 record");
+ assert_eq!(levels_read, 2, "should read 2 levels for the record");
+ assert_eq!(values_read, 2, "should read 2 non-null values");
+ assert_eq!(values, vec![70, 80], "should contain 4th record's values");
+ assert_eq!(rep_levels, vec![0, 1], "rep levels for a 2-element list");
+ assert_eq!(def_levels, vec![1, 1], "def levels (all non-null)");
+ }
}
diff --git a/parquet/src/file/serialized_reader.rs b/parquet/src/file/serialized_reader.rs
index b3b6383f78bb..254ccb779a4a 100644
--- a/parquet/src/file/serialized_reader.rs
+++ b/parquet/src/file/serialized_reader.rs
@@ -1158,7 +1158,12 @@ impl PageReader for SerializedPageReader {
fn at_record_boundary(&mut self) -> Result {
match &mut self.state {
- SerializedPageReaderState::Values { .. } => Ok(self.peek_next_page()?.is_none()),
+ SerializedPageReaderState::Values { .. } => match self.peek_next_page()? {
+ None => Ok(true),
+ // V2 data pages must start at record boundaries per the parquet
+ // spec, so the current page ends at one.
+ Some(metadata) => Ok(metadata.num_rows.is_some()),
+ },
SerializedPageReaderState::Pages { .. } => Ok(true),
}
}
diff --git a/parquet/tests/arrow_reader/row_filter/sync.rs b/parquet/tests/arrow_reader/row_filter/sync.rs
index e59fa392cfd4..77a75220dc2e 100644
--- a/parquet/tests/arrow_reader/row_filter/sync.rs
+++ b/parquet/tests/arrow_reader/row_filter/sync.rs
@@ -206,7 +206,6 @@ fn test_row_filter_full_page_skip_is_handled() {
/// Without the fix, the list column over-skips by one record, causing
/// struct children to disagree on record counts.
#[test]
-#[should_panic(expected = "StructArrayReader out of sync in read_records, expected 1 read, got 0")]
fn test_row_selection_list_column_v2_page_boundary_skip() {
use arrow_array::builder::{Int32Builder, ListBuilder};
@@ -327,7 +326,6 @@ fn test_row_selection_list_column_v2_page_boundary_skip() {
/// bug causes one leaf to over-skip by one record while the other stays
/// correct.
#[test]
-#[should_panic(expected = "Not all children array length are the same!")]
fn test_list_struct_page_boundary_desync_produces_length_mismatch() {
use arrow_array::Array;
use arrow_array::builder::{Int32Builder, ListBuilder, StringBuilder, StructBuilder};
From 33aed330b962d40e6e6b456bc4cd13ec80967f75 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dani=C3=ABl=20Heres?=
Date: Wed, 11 Mar 2026 08:55:51 +0100
Subject: [PATCH 24/80] Make with_file_decryption_properties pub instead of
pub(crate) (#9532)
# Which issue does this PR close?
- Closes #NNN.
# Rationale for this change
I would like to use `ParquetMetaDataPushDecoder` in arrow-datafusion,
but the `with_file_decryption_properties` function is pub(crate), so I
can't fully implement the encryption feature.,
# What changes are included in this PR?
Make it pub
# Are these changes tested?
Not needed
# Are there any user-facing changes?
Now pub
---
parquet/src/file/metadata/push_decoder.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/parquet/src/file/metadata/push_decoder.rs b/parquet/src/file/metadata/push_decoder.rs
index abc788426260..e322525b7193 100644
--- a/parquet/src/file/metadata/push_decoder.rs
+++ b/parquet/src/file/metadata/push_decoder.rs
@@ -308,7 +308,7 @@ impl ParquetMetaDataPushDecoder {
#[cfg(feature = "encryption")]
/// Provide decryption properties for decoding encrypted Parquet files
- pub(crate) fn with_file_decryption_properties(
+ pub fn with_file_decryption_properties(
mut self,
file_decryption_properties: Option>,
) -> Self {
From d3c79006f2595e144d539f56b3054fe916ab184b Mon Sep 17 00:00:00 2001
From: Qi Zhu <821684824@qq.com>
Date: Wed, 11 Mar 2026 18:37:37 +0800
Subject: [PATCH 25/80] fix: handle Null type in try_merge for Struct, List,
LargeList, and Union (#9524)
# Which issue does this PR close?
Field::try_merge correctly handles DataType::Null for primitive types
and when self is Null, but fails when self is a compound type (Struct,
List, LargeList, Union) and from is Null. This causes Schema::try_merge
to error when merging schemas where one has a Null field and another has
a
concrete compound type for the same field.
This is common in JSON inference where some files have null values for
fields that are structs/lists in other files.
- Closes[ #9523](https://github.com/apache/arrow-rs/issues/9523)
# Rationale for this change
Add `DataType::Null` arms to the Struct, List, LargeList, and Union
branches in `Field::try_merge`, consistent with how primitive types
already handle it.
# What changes are included in this PR?
Add `DataType::Null` arms to the Struct, List, LargeList, and Union
branches in `Field::try_merge`, consistent with how primitive types
already handle it.
# Are these changes tested?
- Added test `test_merge_compound_with_null` covering Struct, List,
LargeList, and Union merging with Null in both directions.
- Existing tests continue to pass.
# Are there any user-facing changes?
No
---
arrow-schema/src/field.rs | 66 +++++++++++++++++++++++++++++++++++++++
1 file changed, 66 insertions(+)
diff --git a/arrow-schema/src/field.rs b/arrow-schema/src/field.rs
index a1c509abf2e0..1f2b57564ded 100644
--- a/arrow-schema/src/field.rs
+++ b/arrow-schema/src/field.rs
@@ -830,6 +830,9 @@ impl Field {
.try_for_each(|f| builder.try_merge(f))?;
*nested_fields = builder.finish().fields;
}
+ DataType::Null => {
+ self.nullable = true;
+ }
_ => {
return Err(ArrowError::SchemaError(format!(
"Fail to merge schema field '{}' because the from data_type = {} is not DataType::Struct",
@@ -841,6 +844,9 @@ impl Field {
DataType::Union(from_nested_fields, _) => {
nested_fields.try_merge(from_nested_fields)?
}
+ DataType::Null => {
+ self.nullable = true;
+ }
_ => {
return Err(ArrowError::SchemaError(format!(
"Fail to merge schema field '{}' because the from data_type = {} is not DataType::Union",
@@ -854,6 +860,9 @@ impl Field {
f.try_merge(from_field)?;
(*field) = Arc::new(f);
}
+ DataType::Null => {
+ self.nullable = true;
+ }
_ => {
return Err(ArrowError::SchemaError(format!(
"Fail to merge schema field '{}' because the from data_type = {} is not DataType::List",
@@ -867,6 +876,9 @@ impl Field {
f.try_merge(from_field)?;
(*field) = Arc::new(f);
}
+ DataType::Null => {
+ self.nullable = true;
+ }
_ => {
return Err(ArrowError::SchemaError(format!(
"Fail to merge schema field '{}' because the from data_type = {} is not DataType::LargeList",
@@ -1461,4 +1473,58 @@ mod test {
assert_binary_serde_round_trip(field)
}
+
+ #[test]
+ fn test_merge_compound_with_null() {
+ // Struct + Null
+ let mut field = Field::new(
+ "s",
+ DataType::Struct(Fields::from(vec![Field::new("a", DataType::Int32, false)])),
+ false,
+ );
+ field
+ .try_merge(&Field::new("s", DataType::Null, true))
+ .expect("Struct should merge with Null");
+ assert!(field.is_nullable());
+ assert!(matches!(field.data_type(), DataType::Struct(_)));
+
+ // List + Null
+ let mut field = Field::new(
+ "l",
+ DataType::List(Field::new("item", DataType::Utf8, false).into()),
+ false,
+ );
+ field
+ .try_merge(&Field::new("l", DataType::Null, true))
+ .expect("List should merge with Null");
+ assert!(field.is_nullable());
+ assert!(matches!(field.data_type(), DataType::List(_)));
+
+ // LargeList + Null
+ let mut field = Field::new(
+ "ll",
+ DataType::LargeList(Field::new("item", DataType::Utf8, false).into()),
+ false,
+ );
+ field
+ .try_merge(&Field::new("ll", DataType::Null, true))
+ .expect("LargeList should merge with Null");
+ assert!(field.is_nullable());
+ assert!(matches!(field.data_type(), DataType::LargeList(_)));
+
+ // Union + Null
+ let mut field = Field::new(
+ "u",
+ DataType::Union(
+ UnionFields::try_new(vec![0], vec![Field::new("f", DataType::Int32, false)])
+ .unwrap(),
+ UnionMode::Dense,
+ ),
+ false,
+ );
+ field
+ .try_merge(&Field::new("u", DataType::Null, true))
+ .expect("Union should merge with Null");
+ assert!(matches!(field.data_type(), DataType::Union(_, _)));
+ }
}
From a475f844d8473eb1d69baebf4337e1c1e1de235c Mon Sep 17 00:00:00 2001
From: Liam Bao
Date: Wed, 11 Mar 2026 13:50:02 -0400
Subject: [PATCH 26/80] [Json] Add benchmarks for list json reader (#9507)
# Which issue does this PR close?
- Relates to #9497.
# Rationale for this change
Add benchmark for `ListArray` in `json_reader` to support the
performance evaluation of #9497
# What changes are included in this PR?
- Benchmarks for decoding and serialize json list to `ListArray`.
- Benchmarks for `ListArray` and `FixedSizeListArray` json writer
# Are these changes tested?
Benchmarks only
# Are there any user-facing changes?
No
---
arrow-json/Cargo.toml | 6 +-
.../{json-reader.rs => json_reader.rs} | 87 +++++++++++-
arrow-json/benches/json_writer.rs | 129 ++++++++++++++++++
3 files changed, 220 insertions(+), 2 deletions(-)
rename arrow-json/benches/{json-reader.rs => json_reader.rs} (73%)
create mode 100644 arrow-json/benches/json_writer.rs
diff --git a/arrow-json/Cargo.toml b/arrow-json/Cargo.toml
index 5fcde480eb6d..be1f8d0ccdca 100644
--- a/arrow-json/Cargo.toml
+++ b/arrow-json/Cargo.toml
@@ -67,5 +67,9 @@ name = "serde"
harness = false
[[bench]]
-name = "json-reader"
+name = "json_reader"
+harness = false
+
+[[bench]]
+name = "json_writer"
harness = false
diff --git a/arrow-json/benches/json-reader.rs b/arrow-json/benches/json_reader.rs
similarity index 73%
rename from arrow-json/benches/json-reader.rs
rename to arrow-json/benches/json_reader.rs
index 504839f8ffe2..f87ba695eb62 100644
--- a/arrow-json/benches/json-reader.rs
+++ b/arrow-json/benches/json_reader.rs
@@ -32,6 +32,8 @@ const BATCH_SIZE: usize = 1 << 13; // 8K rows per batch
const WIDE_FIELDS: usize = 64;
const BINARY_BYTES: usize = 64;
const WIDE_PROJECTION_TOTAL_FIELDS: usize = 100; // 100 fields total, select only 3
+const LIST_SHORT_ELEMENTS: usize = 5;
+const LIST_LONG_ELEMENTS: usize = 100;
fn decode_and_flush(decoder: &mut Decoder, data: &[u8]) {
let mut offset = 0;
@@ -240,11 +242,94 @@ fn bench_wide_projection(c: &mut Criterion) {
);
}
+fn build_list_json(rows: usize, elements: usize) -> Vec {
+ // Builds newline-delimited JSON objects with a single list field.
+ // Example (rows=2, elements=3):
+ // {"list":[0,1,2]}
+ // {"list":[1,2,3]}
+ let mut out = String::with_capacity(rows * (elements * 6 + 16));
+ for row in 0..rows {
+ out.push_str("{\"list\":[");
+ for i in 0..elements {
+ if i > 0 {
+ out.push(',');
+ }
+ write!(&mut out, "{}", (row + i) as i64).unwrap();
+ }
+ out.push_str("]}\n");
+ }
+ out.into_bytes()
+}
+
+fn build_list_values(rows: usize, elements: usize) -> Vec {
+ // Mirrors build_list_json but returns structured serde_json::Value objects.
+ let mut out = Vec::with_capacity(rows);
+ for row in 0..rows {
+ let arr: Vec = (0..elements)
+ .map(|i| Value::Number(Number::from((row + i) as i64)))
+ .collect();
+ let mut map = Map::with_capacity(1);
+ map.insert("list".to_string(), Value::Array(arr));
+ out.push(Value::Object(map));
+ }
+ out
+}
+
+fn build_list_schema() -> Arc {
+ Arc::new(Schema::new(vec![Field::new(
+ "list",
+ DataType::List(Arc::new(Field::new_list_field(DataType::Int64, false))),
+ false,
+ )]))
+}
+
+fn bench_decode_list(c: &mut Criterion) {
+ let schema = build_list_schema();
+
+ // Short lists: tests list handling overhead (few elements per row)
+ let short_data = build_list_json(ROWS, LIST_SHORT_ELEMENTS);
+ bench_decode_schema(c, "decode_list_short_i64_json", &short_data, schema.clone());
+
+ // Long lists: tests child element decode throughput (many elements per row)
+ let long_data = build_list_json(ROWS, LIST_LONG_ELEMENTS);
+ bench_decode_schema(c, "decode_list_long_i64_json", &long_data, schema);
+}
+
+fn bench_serialize_list(c: &mut Criterion) {
+ let schema = build_list_schema();
+
+ let short_values = build_list_values(ROWS, LIST_SHORT_ELEMENTS);
+ c.bench_function("decode_list_short_i64_serialize", |b| {
+ b.iter(|| {
+ let mut decoder = ReaderBuilder::new(schema.clone())
+ .with_batch_size(BATCH_SIZE)
+ .build_decoder()
+ .unwrap();
+ decoder.serialize(&short_values).unwrap();
+ while let Some(_batch) = decoder.flush().unwrap() {}
+ })
+ });
+
+ let long_values = build_list_values(ROWS, LIST_LONG_ELEMENTS);
+ c.bench_function("decode_list_long_i64_serialize", |b| {
+ b.iter(|| {
+ let mut decoder = ReaderBuilder::new(schema.clone())
+ .with_batch_size(BATCH_SIZE)
+ .build_decoder()
+ .unwrap();
+ decoder.serialize(&long_values).unwrap();
+ while let Some(_batch) = decoder.flush().unwrap() {}
+ })
+ });
+}
+
criterion_group!(
benches,
bench_decode_wide_object,
bench_serialize_wide_object,
bench_binary_hex,
- bench_wide_projection
+ bench_wide_projection,
+ bench_decode_list,
+ bench_serialize_list
);
criterion_main!(benches);
diff --git a/arrow-json/benches/json_writer.rs b/arrow-json/benches/json_writer.rs
new file mode 100644
index 000000000000..b37ea542efee
--- /dev/null
+++ b/arrow-json/benches/json_writer.rs
@@ -0,0 +1,129 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use arrow_array::builder::{FixedSizeListBuilder, Int64Builder, ListBuilder};
+use arrow_array::{Array, RecordBatch};
+use arrow_json::LineDelimitedWriter;
+use arrow_schema::{Field, Schema};
+use criterion::{Criterion, Throughput, criterion_group, criterion_main};
+use std::sync::Arc;
+
+const ROWS: usize = 1 << 17; // 128K rows
+const LIST_SHORT_ELEMENTS: usize = 5;
+const LIST_LONG_ELEMENTS: usize = 100;
+
+fn build_list_batch(rows: usize, elements: usize) -> RecordBatch {
+ let mut list_builder = ListBuilder::new(Int64Builder::new());
+ for row in 0..rows {
+ for i in 0..elements {
+ list_builder.values().append_value((row + i) as i64);
+ }
+ list_builder.append(true);
+ }
+ let list_array = list_builder.finish();
+
+ let schema = Arc::new(Schema::new(vec![Field::new(
+ "list",
+ list_array.data_type().clone(),
+ false,
+ )]));
+
+ RecordBatch::try_new(schema, vec![Arc::new(list_array)]).unwrap()
+}
+
+fn bench_write_list(c: &mut Criterion) {
+ let short_batch = build_list_batch(ROWS, LIST_SHORT_ELEMENTS);
+ let long_batch = build_list_batch(ROWS, LIST_LONG_ELEMENTS);
+
+ let mut group = c.benchmark_group("write_list_i64");
+ // Short lists: tests per-list overhead (few elements per row)
+ group.throughput(Throughput::Elements(ROWS as u64));
+ group.bench_function("short", |b| {
+ let mut buf = Vec::with_capacity(ROWS * LIST_SHORT_ELEMENTS * 8);
+ b.iter(|| {
+ buf.clear();
+ let mut writer = LineDelimitedWriter::new(&mut buf);
+ writer.write(&short_batch).unwrap();
+ writer.finish().unwrap();
+ })
+ });
+
+ // Long lists: tests child element encode throughput (many elements per row)
+ group.bench_function("long", |b| {
+ let mut buf = Vec::with_capacity(ROWS * LIST_LONG_ELEMENTS * 8);
+ b.iter(|| {
+ buf.clear();
+ let mut writer = LineDelimitedWriter::new(&mut buf);
+ writer.write(&long_batch).unwrap();
+ writer.finish().unwrap();
+ })
+ });
+
+ group.finish();
+}
+
+fn build_fixed_size_list_batch(rows: usize, elements: usize) -> RecordBatch {
+ let mut builder = FixedSizeListBuilder::new(Int64Builder::new(), elements as i32);
+ for row in 0..rows {
+ for i in 0..elements {
+ builder.values().append_value((row + i) as i64);
+ }
+ builder.append(true);
+ }
+ let list_array = builder.finish();
+
+ let schema = Arc::new(Schema::new(vec![Field::new(
+ "fixed_size_list",
+ list_array.data_type().clone(),
+ false,
+ )]));
+
+ RecordBatch::try_new(schema, vec![Arc::new(list_array)]).unwrap()
+}
+
+fn bench_write_fixed_size_list(c: &mut Criterion) {
+ let short_batch = build_fixed_size_list_batch(ROWS, LIST_SHORT_ELEMENTS);
+ let long_batch = build_fixed_size_list_batch(ROWS, LIST_LONG_ELEMENTS);
+
+ let mut group = c.benchmark_group("write_fixed_size_list_i64");
+ group.throughput(Throughput::Elements(ROWS as u64));
+
+ group.bench_function("short", |b| {
+ let mut buf = Vec::with_capacity(ROWS * LIST_SHORT_ELEMENTS * 8);
+ b.iter(|| {
+ buf.clear();
+ let mut writer = LineDelimitedWriter::new(&mut buf);
+ writer.write(&short_batch).unwrap();
+ writer.finish().unwrap();
+ })
+ });
+
+ group.bench_function("long", |b| {
+ let mut buf = Vec::with_capacity(ROWS * LIST_LONG_ELEMENTS * 8);
+ b.iter(|| {
+ buf.clear();
+ let mut writer = LineDelimitedWriter::new(&mut buf);
+ writer.write(&long_batch).unwrap();
+ writer.finish().unwrap();
+ })
+ });
+
+ group.finish();
+}
+
+criterion_group!(benches, bench_write_list, bench_write_fixed_size_list);
+criterion_main!(benches);
From ba02ab9b339480241de32b90a372fd443bf3ab5b Mon Sep 17 00:00:00 2001
From: Filippo <12383260+notfilippo@users.noreply.github.com>
Date: Wed, 11 Mar 2026 18:59:51 +0100
Subject: [PATCH 27/80] feat(memory-tracking): expose API to NullBuffer,
ArrayData, and Array (#8918)
# Which issue does this PR close?
Part of #8137. Follow up of #7303. Replaces #8040.
# Rationale for this change
#7303 implements the fundamental symbols for tracking memory. This patch
exposes those APIs to a higher level Array and ArrayData.
# What changes are included in this PR?
New `claim` API for NullBuffer, ArrayData, and Array. New `pool`
feature-flag to arrow, arrow-array, and arrow-data.
# Are these changes tested?
Added a doctest on the `Array::claim` method.
# Are there any user-facing changes?
Added API and a new feature-flag for arrow, arrow-array, and arrow-data.
---
arrow-array/Cargo.toml | 2 +
arrow-array/src/array/boolean_array.rs | 8 ++
arrow-array/src/array/byte_array.rs | 9 +++
arrow-array/src/array/byte_view_array.rs | 11 +++
arrow-array/src/array/dictionary_array.rs | 11 +++
.../src/array/fixed_size_binary_array.rs | 8 ++
.../src/array/fixed_size_list_array.rs | 8 ++
arrow-array/src/array/list_array.rs | 9 +++
arrow-array/src/array/list_view_array.rs | 10 +++
arrow-array/src/array/map_array.rs | 9 +++
arrow-array/src/array/mod.rs | 79 +++++++++++++++++++
arrow-array/src/array/null_array.rs | 5 ++
arrow-array/src/array/primitive_array.rs | 8 ++
arrow-array/src/array/run_array.rs | 11 +++
arrow-array/src/array/struct_array.rs | 10 +++
arrow-array/src/array/union_array.rs | 11 +++
arrow-buffer/Cargo.toml | 1 +
arrow-buffer/src/buffer/boolean.rs | 8 ++
arrow-buffer/src/buffer/null.rs | 9 ++-
arrow-buffer/src/buffer/offset.rs | 6 ++
arrow-buffer/src/buffer/run.rs | 6 ++
arrow-buffer/src/buffer/scalar.rs | 8 ++
arrow-data/Cargo.toml | 2 +
arrow-data/src/data.rs | 24 ++++++
arrow/Cargo.toml | 2 +
25 files changed, 274 insertions(+), 1 deletion(-)
diff --git a/arrow-array/Cargo.toml b/arrow-array/Cargo.toml
index a046fea2b0dc..6be5a6daab56 100644
--- a/arrow-array/Cargo.toml
+++ b/arrow-array/Cargo.toml
@@ -58,6 +58,8 @@ all-features = true
async = ["dep:futures"]
ffi = ["arrow-schema/ffi", "arrow-data/ffi"]
force_validate = []
+# Enable memory tracking support
+pool = ["arrow-buffer/pool", "arrow-data/pool"]
[dev-dependencies]
rand = { version = "0.9", default-features = false, features = ["std", "std_rng", "thread_rng"] }
diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs
index 65e19c80f8e8..582627b24396 100644
--- a/arrow-array/src/array/boolean_array.rs
+++ b/arrow-array/src/array/boolean_array.rs
@@ -346,6 +346,14 @@ unsafe impl Array for BooleanArray {
fn get_array_memory_size(&self) -> usize {
std::mem::size_of::() + self.get_buffer_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.values.claim(pool);
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl ArrayAccessor for &BooleanArray {
diff --git a/arrow-array/src/array/byte_array.rs b/arrow-array/src/array/byte_array.rs
index a54e9a5fc781..93924ac76bb2 100644
--- a/arrow-array/src/array/byte_array.rs
+++ b/arrow-array/src/array/byte_array.rs
@@ -525,6 +525,15 @@ unsafe impl Array for GenericByteArray {
fn get_array_memory_size(&self) -> usize {
std::mem::size_of::() + self.get_buffer_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.value_offsets.claim(pool);
+ self.value_data.claim(pool);
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl<'a, T: ByteArrayType> ArrayAccessor for &'a GenericByteArray {
diff --git a/arrow-array/src/array/byte_view_array.rs b/arrow-array/src/array/byte_view_array.rs
index 0275b628e2cf..a4a319df6426 100644
--- a/arrow-array/src/array/byte_view_array.rs
+++ b/arrow-array/src/array/byte_view_array.rs
@@ -897,6 +897,17 @@ unsafe impl Array for GenericByteViewArray {
fn get_array_memory_size(&self) -> usize {
std::mem::size_of::() + self.get_buffer_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.views.claim(pool);
+ for buffer in self.buffers.iter() {
+ buffer.claim(pool);
+ }
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl<'a, T: ByteViewType + ?Sized> ArrayAccessor for &'a GenericByteViewArray {
diff --git a/arrow-array/src/array/dictionary_array.rs b/arrow-array/src/array/dictionary_array.rs
index 97e45cc5d68e..0c465ec14446 100644
--- a/arrow-array/src/array/dictionary_array.rs
+++ b/arrow-array/src/array/dictionary_array.rs
@@ -792,6 +792,12 @@ unsafe impl Array for DictionaryArray {
+ self.keys.get_buffer_memory_size()
+ self.values.get_array_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.keys.claim(pool);
+ self.values.claim(pool);
+ }
}
impl std::fmt::Debug for DictionaryArray {
@@ -911,6 +917,11 @@ unsafe impl Array for TypedDictionaryArray<'
fn get_array_memory_size(&self) -> usize {
self.dictionary.get_array_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.dictionary.claim(pool);
+ }
}
impl IntoIterator for TypedDictionaryArray<'_, K, V>
diff --git a/arrow-array/src/array/fixed_size_binary_array.rs b/arrow-array/src/array/fixed_size_binary_array.rs
index e3f08c066ee0..72e6d022a58a 100644
--- a/arrow-array/src/array/fixed_size_binary_array.rs
+++ b/arrow-array/src/array/fixed_size_binary_array.rs
@@ -662,6 +662,14 @@ unsafe impl Array for FixedSizeBinaryArray {
fn get_array_memory_size(&self) -> usize {
std::mem::size_of::() + self.get_buffer_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.value_data.claim(pool);
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl<'a> ArrayAccessor for &'a FixedSizeBinaryArray {
diff --git a/arrow-array/src/array/fixed_size_list_array.rs b/arrow-array/src/array/fixed_size_list_array.rs
index a3db33d61b56..55a9fb9aa49e 100644
--- a/arrow-array/src/array/fixed_size_list_array.rs
+++ b/arrow-array/src/array/fixed_size_list_array.rs
@@ -528,6 +528,14 @@ unsafe impl Array for FixedSizeListArray {
}
size
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.values.claim(pool);
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl super::ListLikeArray for FixedSizeListArray {
diff --git a/arrow-array/src/array/list_array.rs b/arrow-array/src/array/list_array.rs
index d9613c6809ac..24f7774f2b7d 100644
--- a/arrow-array/src/array/list_array.rs
+++ b/arrow-array/src/array/list_array.rs
@@ -620,6 +620,15 @@ unsafe impl Array for GenericListArray
}
size
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.value_offsets.claim(pool);
+ self.values.claim(pool);
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl super::ListLikeArray for GenericListArray {
diff --git a/arrow-array/src/array/list_view_array.rs b/arrow-array/src/array/list_view_array.rs
index eda3be11ac39..75ff6117eeba 100644
--- a/arrow-array/src/array/list_view_array.rs
+++ b/arrow-array/src/array/list_view_array.rs
@@ -486,6 +486,16 @@ unsafe impl Array for GenericListViewArray super::ListLikeArray for GenericListViewArray {
diff --git a/arrow-array/src/array/map_array.rs b/arrow-array/src/array/map_array.rs
index 07758d59bb14..7a5fe0b46843 100644
--- a/arrow-array/src/array/map_array.rs
+++ b/arrow-array/src/array/map_array.rs
@@ -430,6 +430,15 @@ unsafe impl Array for MapArray {
}
size
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.value_offsets.claim(pool);
+ self.entries.claim(pool);
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl ArrayAccessor for &MapArray {
diff --git a/arrow-array/src/array/mod.rs b/arrow-array/src/array/mod.rs
index ca3a02577f47..e389b462fbe1 100644
--- a/arrow-array/src/array/mod.rs
+++ b/arrow-array/src/array/mod.rs
@@ -354,6 +354,75 @@ pub unsafe trait Array: std::fmt::Debug + Send + Sync {
/// This value will always be greater than returned by `get_buffer_memory_size()` and
/// includes the overhead of the data structures that contain the pointers to the various buffers.
fn get_array_memory_size(&self) -> usize;
+
+ /// Claim memory used by this array in the provided memory pool.
+ ///
+ /// This recursively claims memory for:
+ /// - All data buffers in this array
+ /// - All child arrays (for nested types like List, Struct, etc.)
+ /// - The null bitmap buffer if present
+ ///
+ /// This method guarantees that the memory pool will only compute occupied memory
+ /// exactly once. For example, if this array is derived from operations like `slice`,
+ /// calling `claim` on it would not change the memory pool's usage if the underlying buffers
+ /// are already counted before.
+ ///
+ /// # Example
+ /// ```
+ /// # use arrow_array::{Int32Array, Array};
+ /// # use arrow_buffer::TrackingMemoryPool;
+ /// # use arrow_buffer::MemoryPool;
+ ///
+ /// let pool = TrackingMemoryPool::default();
+ ///
+ /// let small_array = Int32Array::from(vec![1, 2, 3, 4, 5]);
+ /// let small_array_size = small_array.get_buffer_memory_size();
+ ///
+ /// // Claim the array's memory in the pool
+ /// small_array.claim(&pool);
+ ///
+ /// // Create and claim slices of `small_array`; should not increase memory usage
+ /// let slice1 = small_array.slice(0, 2);
+ /// let slice2 = small_array.slice(2, 2);
+ /// slice1.claim(&pool);
+ /// slice2.claim(&pool);
+ ///
+ /// assert_eq!(pool.used(), small_array_size);
+ ///
+ /// // Create a `large_array` which does not derive from the original `small_array`
+ ///
+ /// let large_array = Int32Array::from((0..1000).collect::>());
+ /// let large_array_size = large_array.get_buffer_memory_size();
+ ///
+ /// large_array.claim(&pool);
+ ///
+ /// // Trying to claim more than once is a no-op
+ /// large_array.claim(&pool);
+ /// large_array.claim(&pool);
+ ///
+ /// assert_eq!(pool.used(), small_array_size + large_array_size);
+ ///
+ /// let sum_of_all_sizes = small_array_size + large_array_size + slice1.get_buffer_memory_size() + slice2.get_buffer_memory_size();
+ ///
+ /// // `get_buffer_memory_size` works independently of the memory pool, so a sum of all the
+ /// // arrays in scope will always be >= the memory used reported by the memory pool.
+ /// assert_ne!(pool.used(), sum_of_all_sizes);
+ ///
+ /// // Until the final claim is dropped the buffer size remains accounted for
+ /// drop(small_array);
+ /// drop(slice1);
+ ///
+ /// assert_eq!(pool.used(), small_array_size + large_array_size);
+ ///
+ /// // Dropping this finally releases the buffer that was backing `small_array`
+ /// drop(slice2);
+ ///
+ /// assert_eq!(pool.used(), large_array_size);
+ /// ```
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.to_data().claim(pool)
+ }
}
/// A reference-counted reference to a generic `Array`
@@ -437,6 +506,11 @@ unsafe impl Array for ArrayRef {
fn get_array_memory_size(&self) -> usize {
self.as_ref().get_array_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.as_ref().claim(pool)
+ }
}
unsafe impl Array for &T {
@@ -507,6 +581,11 @@ unsafe impl Array for &T {
fn get_array_memory_size(&self) -> usize {
T::get_array_memory_size(self)
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ T::claim(self, pool)
+ }
}
/// A generic trait for accessing the values of an [`Array`]
diff --git a/arrow-array/src/array/null_array.rs b/arrow-array/src/array/null_array.rs
index 00b30935d425..05dd114be71b 100644
--- a/arrow-array/src/array/null_array.rs
+++ b/arrow-array/src/array/null_array.rs
@@ -133,6 +133,11 @@ unsafe impl Array for NullArray {
fn get_array_memory_size(&self) -> usize {
std::mem::size_of::()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, _pool: &dyn arrow_buffer::MemoryPool) {
+ // NullArray has no buffers to claim
+ }
}
impl From for NullArray {
diff --git a/arrow-array/src/array/primitive_array.rs b/arrow-array/src/array/primitive_array.rs
index d9c8ff66d0cb..b51f5f518668 100644
--- a/arrow-array/src/array/primitive_array.rs
+++ b/arrow-array/src/array/primitive_array.rs
@@ -1246,6 +1246,14 @@ unsafe impl Array for PrimitiveArray {
fn get_array_memory_size(&self) -> usize {
std::mem::size_of::() + self.get_buffer_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.values.claim(pool);
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl ArrayAccessor for &PrimitiveArray {
diff --git a/arrow-array/src/array/run_array.rs b/arrow-array/src/array/run_array.rs
index 4770bad05e7d..a3cb4565f413 100644
--- a/arrow-array/src/array/run_array.rs
+++ b/arrow-array/src/array/run_array.rs
@@ -375,6 +375,12 @@ unsafe impl Array for RunArray {
+ self.run_ends.inner().inner().capacity()
+ self.values.get_array_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.run_ends.claim(pool);
+ self.values.claim(pool);
+ }
}
impl std::fmt::Debug for RunArray {
@@ -603,6 +609,11 @@ unsafe impl Array for TypedRunArray<'_, R, V> {
fn get_array_memory_size(&self) -> usize {
self.run_array.get_array_memory_size()
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.run_array.claim(pool);
+ }
}
// Array accessor converts the index of logical array to the index of the physical array
diff --git a/arrow-array/src/array/struct_array.rs b/arrow-array/src/array/struct_array.rs
index b5f25fff181c..da837ba16b75 100644
--- a/arrow-array/src/array/struct_array.rs
+++ b/arrow-array/src/array/struct_array.rs
@@ -468,6 +468,16 @@ unsafe impl Array for StructArray {
}
size
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ for field in &self.fields {
+ field.claim(pool);
+ }
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+ }
}
impl From> for StructArray {
diff --git a/arrow-array/src/array/union_array.rs b/arrow-array/src/array/union_array.rs
index 03d69a584524..5ba7b947c724 100644
--- a/arrow-array/src/array/union_array.rs
+++ b/arrow-array/src/array/union_array.rs
@@ -946,6 +946,17 @@ unsafe impl Array for UnionArray {
.sum::()
+ sum
}
+
+ #[cfg(feature = "pool")]
+ fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ self.type_ids.claim(pool);
+ if let Some(offsets) = &self.offsets {
+ offsets.claim(pool);
+ }
+ for field in self.fields.iter().flatten() {
+ field.claim(pool);
+ }
+ }
}
impl std::fmt::Debug for UnionArray {
diff --git a/arrow-buffer/Cargo.toml b/arrow-buffer/Cargo.toml
index 02ea49c37c46..1400c1986361 100644
--- a/arrow-buffer/Cargo.toml
+++ b/arrow-buffer/Cargo.toml
@@ -36,6 +36,7 @@ bench = false
all-features = true
[features]
+# Enable memory tracking support
pool = []
[dependencies]
diff --git a/arrow-buffer/src/buffer/boolean.rs b/arrow-buffer/src/buffer/boolean.rs
index f9148c7eb245..c1c7529e0a2d 100644
--- a/arrow-buffer/src/buffer/boolean.rs
+++ b/arrow-buffer/src/buffer/boolean.rs
@@ -489,6 +489,14 @@ impl BooleanBuffer {
self.buffer
}
+ /// Claim memory used by this buffer in the provided memory pool.
+ ///
+ /// See [`Buffer::claim`] for details.
+ #[cfg(feature = "pool")]
+ pub fn claim(&self, pool: &dyn crate::MemoryPool) {
+ self.buffer.claim(pool);
+ }
+
/// Returns an iterator over the bits in this [`BooleanBuffer`]
pub fn iter(&self) -> BitIterator<'_> {
self.into_iter()
diff --git a/arrow-buffer/src/buffer/null.rs b/arrow-buffer/src/buffer/null.rs
index 97034a631ef8..6046369c62a7 100644
--- a/arrow-buffer/src/buffer/null.rs
+++ b/arrow-buffer/src/buffer/null.rs
@@ -26,7 +26,7 @@ use crate::{Buffer, MutableBuffer};
/// that it is null.
///
/// # See also
-/// * [`NullBufferBuilder`] for creating `NullBuffer`s
+/// * [`NullBufferBuilder`] for creating `NullBuffer`s
///
/// [Arrow specification]: https://arrow.apache.org/docs/format/Columnar.html#validity-bitmaps
/// [`NullBufferBuilder`]: crate::NullBufferBuilder
@@ -231,6 +231,13 @@ impl NullBuffer {
let nb = NullBuffer::new(bb);
(nb.null_count() > 0).then_some(nb)
}
+
+ /// Claim memory used by this null buffer in the provided memory pool.
+ #[cfg(feature = "pool")]
+ pub fn claim(&self, pool: &dyn crate::MemoryPool) {
+ // NullBuffer wraps a BooleanBuffer which wraps a Buffer
+ self.buffer.inner().claim(pool);
+ }
}
impl<'a> IntoIterator for &'a NullBuffer {
diff --git a/arrow-buffer/src/buffer/offset.rs b/arrow-buffer/src/buffer/offset.rs
index 66fa7dd22ec5..bb34c8b23892 100644
--- a/arrow-buffer/src/buffer/offset.rs
+++ b/arrow-buffer/src/buffer/offset.rs
@@ -220,6 +220,12 @@ impl OffsetBuffer {
self.0
}
+ /// Claim memory used by this buffer in the provided memory pool.
+ #[cfg(feature = "pool")]
+ pub fn claim(&self, pool: &dyn crate::MemoryPool) {
+ self.0.claim(pool);
+ }
+
/// Returns a zero-copy slice of this buffer with length `len` and starting at `offset`
pub fn slice(&self, offset: usize, len: usize) -> Self {
Self(self.0.slice(offset, len.saturating_add(1)))
diff --git a/arrow-buffer/src/buffer/run.rs b/arrow-buffer/src/buffer/run.rs
index 0f4d9234e4cf..703ae913801d 100644
--- a/arrow-buffer/src/buffer/run.rs
+++ b/arrow-buffer/src/buffer/run.rs
@@ -294,6 +294,12 @@ where
self.run_ends
}
+ /// Claim memory used by this buffer in the provided memory pool.
+ #[cfg(feature = "pool")]
+ pub fn claim(&self, pool: &dyn crate::MemoryPool) {
+ self.run_ends.claim(pool);
+ }
+
/// Returns the physical indices corresponding to the provided logical indices.
///
/// Given a slice of logical indices, this method returns a `Vec` containing the
diff --git a/arrow-buffer/src/buffer/scalar.rs b/arrow-buffer/src/buffer/scalar.rs
index 3c5334ca5118..f74b93ab8914 100644
--- a/arrow-buffer/src/buffer/scalar.rs
+++ b/arrow-buffer/src/buffer/scalar.rs
@@ -126,6 +126,14 @@ impl ScalarBuffer {
self.buffer
}
+ /// Claim memory used by this buffer in the provided memory pool.
+ ///
+ /// See [`Buffer::claim`] for details.
+ #[cfg(feature = "pool")]
+ pub fn claim(&self, pool: &dyn crate::MemoryPool) {
+ self.buffer.claim(pool);
+ }
+
/// Returns true if this [`ScalarBuffer`] is equal to `other`, using pointer comparisons
/// to determine buffer equality. This is cheaper than `PartialEq::eq` but may
/// return false when the arrays are logically equal
diff --git a/arrow-data/Cargo.toml b/arrow-data/Cargo.toml
index 9c7a5206b2f4..9f1b50ed14d9 100644
--- a/arrow-data/Cargo.toml
+++ b/arrow-data/Cargo.toml
@@ -39,6 +39,8 @@ bench = false
force_validate = []
# Enable ffi support
ffi = ["arrow-schema/ffi"]
+# Enable memory tracking support
+pool = ["arrow-buffer/pool"]
[package.metadata.docs.rs]
all-features = true
diff --git a/arrow-data/src/data.rs b/arrow-data/src/data.rs
index 21cf4e5b5e2c..a5a64dfe9f38 100644
--- a/arrow-data/src/data.rs
+++ b/arrow-data/src/data.rs
@@ -1659,6 +1659,30 @@ impl ArrayData {
pub fn into_builder(self) -> ArrayDataBuilder {
self.into()
}
+
+ /// Claim memory used by this ArrayData in the provided memory pool.
+ ///
+ /// This claims memory for:
+ /// - All buffers in self.buffers
+ /// - All child ArrayData recursively
+ /// - The null buffer if present
+ #[cfg(feature = "pool")]
+ pub fn claim(&self, pool: &dyn arrow_buffer::MemoryPool) {
+ // Claim all data buffers
+ for buffer in &self.buffers {
+ buffer.claim(pool);
+ }
+
+ // Claim null buffer if present
+ if let Some(nulls) = &self.nulls {
+ nulls.claim(pool);
+ }
+
+ // Recursively claim child data
+ for child in &self.child_data {
+ child.claim(pool);
+ }
+ }
}
/// Return the expected [`DataTypeLayout`] Arrays of this data
diff --git a/arrow/Cargo.toml b/arrow/Cargo.toml
index 137d785eee88..8e56457ff0a5 100644
--- a/arrow/Cargo.toml
+++ b/arrow/Cargo.toml
@@ -82,6 +82,8 @@ force_validate = ["arrow-array/force_validate", "arrow-data/force_validate"]
ffi = ["arrow-schema/ffi", "arrow-data/ffi", "arrow-array/ffi"]
chrono-tz = ["arrow-array/chrono-tz"]
canonical_extension_types = ["arrow-schema/canonical_extension_types"]
+# Enable memory tracking support
+pool = ["arrow-array/pool"]
[dev-dependencies]
chrono = { workspace = true }
From b3e047f59a562020a0fd50e7c68c4e6cbd53687d Mon Sep 17 00:00:00 2001
From: Peter L
Date: Thu, 12 Mar 2026 05:15:07 +1030
Subject: [PATCH 28/80] Fix Invalid offset in sparse column chunk data error
for multiple predicates (#9509)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Which issue does this PR close?
Raised an issue at https://github.com/apache/arrow-rs/issues/9516 for
this one
Same issue as https://github.com/apache/arrow-rs/issues/9239 but
extended to another scenario
# Rationale for this change
When there are multiple predicates being evaluated, we need to reset the
row selection policy before overriding the strategy.
Scenario:
- Dense initial RowSelection (alternating select/skip) covers all pages
→ Auto resolves to Mask
- Predicate 1 evaluates on column A, narrows selection to skip middle
pages
- Predicate 2's column B is fetched sparsely with the narrowed selection
(missing middle pages)
- Without the fix, the override for predicate 2 returns early
(policy=Mask, not Auto), so Mask is used and tries to read missing pages
→ "Invalid offset" error
# What changes are included in this PR?
This is a one line change to reset the selection policy in the
`RowGroupDecoderState::WaitingOnFilterData` arm
# Are these changes tested?
Yes a new test added that fails currently on `main`, but as you can see
it's a doozy to set up.
# Are there any user-facing changes?
Nope
---
.../arrow/push_decoder/reader_builder/mod.rs | 7 ++
.../tests/arrow_reader/row_filter/async.rs | 111 +++++++++++++++++-
2 files changed, 117 insertions(+), 1 deletion(-)
diff --git a/parquet/src/arrow/push_decoder/reader_builder/mod.rs b/parquet/src/arrow/push_decoder/reader_builder/mod.rs
index 8fa299be884f..d3d78ca7c263 100644
--- a/parquet/src/arrow/push_decoder/reader_builder/mod.rs
+++ b/parquet/src/arrow/push_decoder/reader_builder/mod.rs
@@ -437,6 +437,13 @@ impl RowGroupReaderBuilder {
.with_parquet_metadata(&self.metadata)
.build_array_reader(self.fields.as_deref(), predicate.projection())?;
+ // Reset to original policy before each predicate so the override
+ // can detect page skipping for THIS predicate's columns.
+ // Without this reset, a prior predicate's override (e.g. Mask)
+ // carries forward and the check returns early, missing unfetched
+ // pages for subsequent predicates.
+ plan_builder = plan_builder.with_row_selection_policy(self.row_selection_policy);
+
// Prepare to evaluate the filter.
// Note: first update the selection strategy to properly handle any pages
// pruned during fetch
diff --git a/parquet/tests/arrow_reader/row_filter/async.rs b/parquet/tests/arrow_reader/row_filter/async.rs
index 6fa616d714f1..66840bb8147b 100644
--- a/parquet/tests/arrow_reader/row_filter/async.rs
+++ b/parquet/tests/arrow_reader/row_filter/async.rs
@@ -21,7 +21,7 @@ use std::sync::Arc;
use arrow::{
array::AsArray,
compute::{concat_batches, kernels::cmp::eq, or},
- datatypes::TimestampNanosecondType,
+ datatypes::{Int32Type, TimestampNanosecondType},
};
use arrow_array::{
ArrayRef, BooleanArray, Int8Array, Int32Array, Int64Array, RecordBatch, Scalar, StringArray,
@@ -525,3 +525,112 @@ async fn test_predicate_pushdown_with_skipped_pages() {
assert_eq!(batch.column(0).as_string(), &expected);
}
}
+
+/// Regression test: when multiple predicates are used, the first predicate's
+/// override of the selection strategy (to Mask) must NOT carry forward to
+/// subsequent predicates. Each predicate must get a fresh Auto policy so the
+/// override can detect page skipping for that predicate's specific columns.
+///
+/// Scenario:
+/// - Dense initial RowSelection (alternating select/skip) covers all pages → Auto resolves to Mask
+/// - Predicate 1 evaluates on column A, narrows selection to skip middle pages
+/// - Predicate 2's column B is fetched sparsely with the narrowed selection (missing middle pages)
+/// - Without the fix, the override for predicate 2 returns early (policy=Mask, not Auto),
+/// so Mask is used and tries to read missing pages → "Invalid offset" error
+#[tokio::test]
+async fn test_multi_predicate_mask_policy_carryover() {
+ // 300 rows, 1 row group, 100 rows per page (3 pages)
+ let num_rows = 300usize;
+ let rows_per_page = 100;
+
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("filter_col", DataType::Int32, false),
+ Field::new("value_col", DataType::Int32, false),
+ ]));
+
+ let props = WriterProperties::builder()
+ .set_max_row_group_row_count(Some(num_rows))
+ .set_data_page_row_count_limit(rows_per_page)
+ .set_write_batch_size(rows_per_page)
+ .set_dictionary_enabled(false)
+ .build();
+
+ // filter_col: 0 for first and last 100 rows, 1 for middle 100 rows
+ // value_col: just row index
+ let filter_values: Vec = (0..num_rows as i32)
+ .map(|i| if (100..200).contains(&i) { 1 } else { 0 })
+ .collect();
+ let value_values: Vec = (0..num_rows as i32).collect();
+
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(filter_values)) as ArrayRef,
+ Arc::new(Int32Array::from(value_values)) as ArrayRef,
+ ],
+ )
+ .unwrap();
+
+ let mut buffer = Vec::new();
+ let mut writer = ArrowWriter::try_new(&mut buffer, schema.clone(), Some(props)).unwrap();
+ writer.write(&batch).unwrap();
+ writer.close().unwrap();
+ let buffer = Bytes::from(buffer);
+
+ let reader = TestReader::new(buffer);
+ let options = ArrowReaderOptions::default().with_page_index_policy(PageIndexPolicy::Required);
+ let builder = ParquetRecordBatchStreamBuilder::new_with_options(reader, options)
+ .await
+ .unwrap();
+
+ let schema_descr = builder.metadata().file_metadata().schema_descr_ptr();
+
+ // Dense initial selection: Select(1), Skip(1) repeated → triggers Mask strategy
+ // Covers all pages since every page has selected rows
+ let selectors: Vec = (0..num_rows / 2)
+ .flat_map(|_| vec![RowSelector::select(1), RowSelector::skip(1)])
+ .collect();
+ let selection = RowSelection::from(selectors);
+
+ // Predicate 1 on filter_col: keeps only rows where filter_col == 0
+ // (first 100 and last 100 rows). After this, middle page is excluded.
+ let pred1 = ArrowPredicateFn::new(ProjectionMask::roots(&schema_descr, [0]), |batch| {
+ let col = batch.column(0).as_primitive::();
+ Ok(BooleanArray::from_iter(
+ col.iter().map(|v| v.map(|val| val == 0)),
+ ))
+ });
+
+ // Predicate 2 on value_col: keeps rows where value_col < 250
+ // This column is fetched AFTER predicate 1 narrows the selection.
+ // Its sparse data will be missing the middle page.
+ let pred2 = ArrowPredicateFn::new(ProjectionMask::roots(&schema_descr, [1]), |batch| {
+ let col = batch.column(0).as_primitive::();
+ Ok(BooleanArray::from_iter(
+ col.iter().map(|v| v.map(|val| val < 250)),
+ ))
+ });
+
+ let row_filter = RowFilter::new(vec![Box::new(pred1), Box::new(pred2)]);
+
+ // Output projection: both columns
+ let projection = ProjectionMask::roots(&schema_descr, [0, 1]);
+
+ let stream = builder
+ .with_row_filter(row_filter)
+ .with_row_selection(selection)
+ .with_projection(projection)
+ .with_max_predicate_cache_size(0)
+ .build()
+ .unwrap();
+
+ // Without the fix, this panics with:
+ // "Invalid offset in sparse column chunk data: ..., no matching page found."
+ let batches: Vec = stream.try_collect().await.unwrap();
+ let batch = concat_batches(&batches[0].schema(), &batches).unwrap();
+
+ // Verify results: rows where filter_col==0 AND value_col<250 AND original alternating selection
+ // That's even-indexed rows in [0,100) with value<250 → rows 0,2,4,...,98 (50 rows)
+ // Plus even-indexed rows in [200,250) with value<250 → rows 200,202,...,248 (25 rows)
+ assert_eq!(batch.num_rows(), 75);
+}
From 2956dbf30fe5b50f8f76e6bad93505a8e7b86eb5 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Wed, 11 Mar 2026 12:46:51 -0600
Subject: [PATCH 29/80] fix: Do not assume missing nullcount stat means zero
nullcount (#9481)
# Which issue does this PR close?
- Closes https://github.com/apache/arrow-rs/issues/9451
- Closes https://github.com/apache/arrow-rs/issues/6256
# Rationale for this change
A reader might be annoyed (performance wart) if a parquet footer lacks
nullcount stats, but inferring nullcount=0 for missing stats makes the
stats untrustworthy and can lead to incorrect behavior.
# What changes are included in this PR?
If a parquet footer nullcount stat is missing, surface it as None,
reserving `Some(0)` for known-no-null cases.
# Are these changes tested?
Fixed one unit test that broke, added a missing unit test that covers
the other change site.
# Are there any user-facing changes?
The stats API doesn't change signature, but there is a behavior change.
The existing doc that called out the incorrect behavior has been removed
to reflect that the incorrect behavior no longer occurs.
---
parquet/src/file/metadata/thrift/mod.rs | 73 ++++++++++++++++++++-----
parquet/src/file/statistics.rs | 56 +++++++++----------
2 files changed, 85 insertions(+), 44 deletions(-)
diff --git a/parquet/src/file/metadata/thrift/mod.rs b/parquet/src/file/metadata/thrift/mod.rs
index ddb5aa16b068..88cb96f35555 100644
--- a/parquet/src/file/metadata/thrift/mod.rs
+++ b/parquet/src/file/metadata/thrift/mod.rs
@@ -192,20 +192,19 @@ fn convert_stats(
use crate::file::statistics::Statistics as FStatistics;
Ok(match thrift_stats {
Some(stats) => {
- // Number of nulls recorded, when it is not available, we just mark it as 0.
- // TODO this should be `None` if there is no information about NULLS.
- // see https://github.com/apache/arrow-rs/pull/6216/files
- let null_count = stats.null_count.unwrap_or(0);
-
- if null_count < 0 {
- return Err(general_err!(
- "Statistics null count is negative {}",
- null_count
- ));
- }
-
// Generic null count.
- let null_count = Some(null_count as u64);
+ let null_count = stats
+ .null_count
+ .map(|null_count| {
+ if null_count < 0 {
+ return Err(general_err!(
+ "Statistics null count is negative {}",
+ null_count
+ ));
+ }
+ Ok(null_count as u64)
+ })
+ .transpose()?;
// Generic distinct count (count of distinct values occurring)
let distinct_count = stats.distinct_count.map(|value| value as u64);
// Whether or not statistics use deprecated min/max fields.
@@ -1722,6 +1721,7 @@ write_thrift_field!(RustBoundingBox, FieldType::Struct);
#[cfg(test)]
pub(crate) mod tests {
+ use crate::basic::Type as PhysicalType;
use crate::errors::Result;
use crate::file::metadata::thrift::{BoundingBox, SchemaElement, write_schema};
use crate::file::metadata::{ColumnChunkMetaData, ParquetMetaDataOptions, RowGroupMetaData};
@@ -1730,7 +1730,8 @@ pub(crate) mod tests {
ElementType, ThriftCompactOutputProtocol, ThriftSliceInputProtocol, read_thrift_vec,
};
use crate::schema::types::{
- ColumnDescriptor, SchemaDescriptor, TypePtr, num_nodes, parquet_schema_from_array,
+ ColumnDescriptor, ColumnPath, SchemaDescriptor, TypePtr, num_nodes,
+ parquet_schema_from_array,
};
use std::sync::Arc;
@@ -1828,4 +1829,48 @@ pub(crate) mod tests {
mmax: Some(42.0.into()),
});
}
+
+ #[test]
+ fn test_convert_stats_preserves_missing_null_count() {
+ let primitive =
+ crate::schema::types::Type::primitive_type_builder("col", PhysicalType::INT32)
+ .build()
+ .unwrap();
+ let column_descr = Arc::new(ColumnDescriptor::new(
+ Arc::new(primitive),
+ 0,
+ 0,
+ ColumnPath::new(vec![]),
+ ));
+
+ let none_null_count = super::Statistics {
+ max: None,
+ min: None,
+ null_count: None,
+ distinct_count: None,
+ max_value: None,
+ min_value: None,
+ is_max_value_exact: None,
+ is_min_value_exact: None,
+ };
+ let decoded_none = super::convert_stats(&column_descr, Some(none_null_count))
+ .unwrap()
+ .unwrap();
+ assert_eq!(decoded_none.null_count_opt(), None);
+
+ let zero_null_count = super::Statistics {
+ max: None,
+ min: None,
+ null_count: Some(0),
+ distinct_count: None,
+ max_value: None,
+ min_value: None,
+ is_max_value_exact: None,
+ is_min_value_exact: None,
+ };
+ let decoded_zero = super::convert_stats(&column_descr, Some(zero_null_count))
+ .unwrap()
+ .unwrap();
+ assert_eq!(decoded_zero.null_count_opt(), Some(0));
+ }
}
diff --git a/parquet/src/file/statistics.rs b/parquet/src/file/statistics.rs
index a813e82d13f2..9682fd54b8df 100644
--- a/parquet/src/file/statistics.rs
+++ b/parquet/src/file/statistics.rs
@@ -125,19 +125,18 @@ pub(crate) fn from_thrift_page_stats(
) -> Result> {
Ok(match thrift_stats {
Some(stats) => {
- // Number of nulls recorded, when it is not available, we just mark it as 0.
- // TODO this should be `None` if there is no information about NULLS.
- // see https://github.com/apache/arrow-rs/pull/6216/files
- let null_count = stats.null_count.unwrap_or(0);
-
- if null_count < 0 {
- return Err(ParquetError::General(format!(
- "Statistics null count is negative {null_count}",
- )));
- }
-
// Generic null count.
- let null_count = Some(null_count as u64);
+ let null_count = stats
+ .null_count
+ .map(|null_count| {
+ if null_count < 0 {
+ return Err(ParquetError::General(format!(
+ "Statistics null count is negative {null_count}",
+ )));
+ }
+ Ok(null_count as u64)
+ })
+ .transpose()?;
// Generic distinct count (count of distinct values occurring)
let distinct_count = stats.distinct_count.map(|value| value as u64);
// Whether or not statistics use deprecated min/max fields.
@@ -431,9 +430,20 @@ impl Statistics {
/// Returns number of null values for the column, if known.
/// Note that this includes all nulls when column is part of the complex type.
///
- /// Note this API returns Some(0) even if the null count was not present
- /// in the statistics.
- /// See
+ /// Note: Versions of this library prior to `58.1.0` returned `0` if the null count
+ /// was not available. This method now returns `None` in that case.
+ ///
+ /// Also, versions of this library prior to `53.1.0` did not store a null count
+ /// statistic when the null count was `0`.
+ ///
+ /// It is unsound to assume that missing nullcount stats mean the column contains no nulls,
+ /// but code that depends on the old behavior can restore it by defaulting to zero:
+ ///
+ /// ```no_run
+ /// # use parquet::file::statistics::Statistics;
+ /// # let statistics: Statistics = todo!();
+ /// let null_count = statistics.null_count_opt().unwrap_or(0);
+ /// ```
pub fn null_count_opt(&self) -> Option {
statistics_enum_func![self, null_count_opt]
}
@@ -1064,21 +1074,7 @@ mod tests {
let round_tripped = from_thrift_page_stats(Type::BOOLEAN, Some(thrift_stats))
.unwrap()
.unwrap();
- // TODO: remove branch when we no longer support assuming null_count==None in the thrift
- // means null_count = Some(0)
- if null_count.is_none() {
- assert_ne!(round_tripped, statistics);
- assert!(round_tripped.null_count_opt().is_some());
- assert_eq!(round_tripped.null_count_opt(), Some(0));
- assert_eq!(round_tripped.min_bytes_opt(), statistics.min_bytes_opt());
- assert_eq!(round_tripped.max_bytes_opt(), statistics.max_bytes_opt());
- assert_eq!(
- round_tripped.distinct_count_opt(),
- statistics.distinct_count_opt()
- );
- } else {
- assert_eq!(round_tripped, statistics);
- }
+ assert_eq!(round_tripped, statistics);
}
fn make_bool_stats(distinct_count: Option, null_count: Option) -> Statistics {
From 6931d881d88b515574133e4edda7757b5ee2dd56 Mon Sep 17 00:00:00 2001
From: Mikhail Zabaluev
Date: Wed, 11 Mar 2026 23:59:10 +0200
Subject: [PATCH 30/80] feat: expose arrow schema on async avro reader (#9534)
# Rationale for this change
Exposes the Arrow schema produced by the async Avro file reader,
similarly to the `schema` method on the synchronous reader.
This allows an application to prepare casting or other schema
transformations with no need to fetch the first record batch to learn
the produced Arrow schema. Since the async reader only parses OCF
content for the moment, the schema does not change from batch to batch.
# What changes are included in this PR?
The `schema` method for `AsyncAvroFileReader` exposes the Arrow schema
of record batches that are produced by the reader.
# Are these changes tested?
Added tests verifying that the returned schema matches the expected.
# Are there any user-facing changes?
Added a `schema` method to `AsyncAvroFileReader`.
---
arrow-avro/src/reader/async_reader/mod.rs | 161 +++++++++++++++++++---
1 file changed, 140 insertions(+), 21 deletions(-)
diff --git a/arrow-avro/src/reader/async_reader/mod.rs b/arrow-avro/src/reader/async_reader/mod.rs
index 53229f8576eb..02c00a60e0ef 100644
--- a/arrow-avro/src/reader/async_reader/mod.rs
+++ b/arrow-avro/src/reader/async_reader/mod.rs
@@ -19,7 +19,7 @@ use crate::compression::CompressionCodec;
use crate::reader::Decoder;
use crate::reader::block::{BlockDecoder, BlockDecoderState};
use arrow_array::RecordBatch;
-use arrow_schema::ArrowError;
+use arrow_schema::{ArrowError, SchemaRef};
use bytes::Bytes;
use futures::future::BoxFuture;
use futures::{FutureExt, Stream};
@@ -173,6 +173,13 @@ impl AsyncAvroFileReader {
}
}
+ /// Returns the Arrow schema for batches produced by this reader.
+ ///
+ /// The schema is determined by the writer schema in the file and the reader schema provided to the builder.
+ pub fn schema(&self) -> SchemaRef {
+ self.decoder.schema()
+ }
+
/// Calculate the byte range needed to complete the current block.
/// Only valid when block_decoder is in Data or Sync state.
/// Returns the range to fetch, or an error if EOF would be reached.
@@ -534,7 +541,9 @@ impl Stream for AsyncAvroFileReader {
#[cfg(all(test, feature = "object_store"))]
mod tests {
use super::*;
- use crate::schema::{AvroSchema, SCHEMA_METADATA_KEY};
+ use crate::schema::{
+ AVRO_NAME_METADATA_KEY, AVRO_NAMESPACE_METADATA_KEY, AvroSchema, SCHEMA_METADATA_KEY,
+ };
use arrow_array::cast::AsArray;
use arrow_array::types::{Int32Type, Int64Type};
use arrow_array::*;
@@ -758,39 +767,63 @@ mod tests {
vec![Field::new("f1_3_1", DataType::Float64, false)].into(),
),
false,
- ),
+ )
+ .with_metadata(HashMap::from([
+ (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns3".to_owned()),
+ (AVRO_NAME_METADATA_KEY.to_owned(), "record3".to_owned()),
+ ])),
]
.into(),
),
false,
- ),
+ )
+ .with_metadata(HashMap::from([
+ (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns2".to_owned()),
+ (AVRO_NAME_METADATA_KEY.to_owned(), "record2".to_owned()),
+ ])),
Field::new(
"f2",
- DataType::List(Arc::new(Field::new(
- "item",
- DataType::Struct(
- vec![
- Field::new("f2_1", DataType::Boolean, false),
- Field::new("f2_2", DataType::Float32, false),
- ]
- .into(),
- ),
- false,
- ))),
+ DataType::List(Arc::new(
+ Field::new(
+ "item",
+ DataType::Struct(
+ vec![
+ Field::new("f2_1", DataType::Boolean, false),
+ Field::new("f2_2", DataType::Float32, false),
+ ]
+ .into(),
+ ),
+ false,
+ )
+ .with_metadata(HashMap::from([
+ (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns4".to_owned()),
+ (AVRO_NAME_METADATA_KEY.to_owned(), "record4".to_owned()),
+ ])),
+ )),
false,
),
Field::new(
"f3",
DataType::Struct(vec![Field::new("f3_1", DataType::Utf8, false)].into()),
true,
- ),
+ )
+ .with_metadata(HashMap::from([
+ (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns5".to_owned()),
+ (AVRO_NAME_METADATA_KEY.to_owned(), "record5".to_owned()),
+ ])),
Field::new(
"f4",
- DataType::List(Arc::new(Field::new(
- "item",
- DataType::Struct(vec![Field::new("f4_1", DataType::Int64, false)].into()),
- true,
- ))),
+ DataType::List(Arc::new(
+ Field::new(
+ "item",
+ DataType::Struct(vec![Field::new("f4_1", DataType::Int64, false)].into()),
+ true,
+ )
+ .with_metadata(HashMap::from([
+ (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns6".to_owned()),
+ (AVRO_NAME_METADATA_KEY.to_owned(), "record6".to_owned()),
+ ])),
+ )),
false,
),
])
@@ -1538,6 +1571,92 @@ mod tests {
assert!(err.to_string().contains("Duplicate projection index"));
}
+ #[tokio::test]
+ async fn test_arrow_schema_from_reader_no_reader_schema() {
+ let file = arrow_test_data("avro/alltypes_plain.avro");
+ let store: Arc = Arc::new(LocalFileSystem::new());
+ let location = Path::from_filesystem_path(&file).unwrap();
+ let file_size = store.head(&location).await.unwrap().size;
+
+ let file_reader = AvroObjectReader::new(store, location);
+ let expected_schema = get_alltypes_schema()
+ .as_ref()
+ .clone()
+ .with_metadata(Default::default());
+
+ // Build reader without providing reader schema - should use writer schema from file
+ let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
+ .try_build()
+ .await
+ .unwrap();
+
+ assert_eq!(reader.schema().as_ref(), &expected_schema);
+
+ let batches: Vec = reader.try_collect().await.unwrap();
+ let batch = &batches[0];
+
+ assert_eq!(batch.schema().as_ref(), &expected_schema);
+ }
+
+ #[tokio::test]
+ async fn test_arrow_schema_from_reader_with_reader_schema() {
+ let file = arrow_test_data("avro/alltypes_plain.avro");
+ let store: Arc = Arc::new(LocalFileSystem::new());
+ let location = Path::from_filesystem_path(&file).unwrap();
+ let file_size = store.head(&location).await.unwrap().size;
+
+ let file_reader = AvroObjectReader::new(store, location);
+ let schema = get_alltypes_schema()
+ .project(&[0, 1, 7])
+ .unwrap()
+ .with_metadata(Default::default());
+ let reader_schema = AvroSchema::try_from(&schema).unwrap();
+ let expected_schema = schema.clone();
+
+ // Build reader with provided reader schema - must apply the projection
+ let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
+ .with_reader_schema(reader_schema)
+ .try_build()
+ .await
+ .unwrap();
+
+ assert_eq!(reader.schema().as_ref(), &expected_schema);
+
+ let batches: Vec = reader.try_collect().await.unwrap();
+ let batch = &batches[0];
+
+ assert_eq!(batch.schema().as_ref(), &expected_schema);
+ }
+
+ #[tokio::test]
+ async fn test_arrow_schema_from_reader_nested_records() {
+ let file = arrow_test_data("avro/nested_records.avro");
+ let store: Arc = Arc::new(LocalFileSystem::new());
+ let location = Path::from_filesystem_path(&file).unwrap();
+ let file_size = store.head(&location).await.unwrap().size;
+
+ let file_reader = AvroObjectReader::new(store, location);
+
+ // The schema produced by the reader should match the expected schema,
+ // attaching Avro type name metadata to fields of record and list types.
+ let expected_schema = get_nested_records_schema()
+ .as_ref()
+ .clone()
+ .with_metadata(Default::default());
+
+ let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
+ .try_build()
+ .await
+ .unwrap();
+
+ assert_eq!(reader.schema().as_ref(), &expected_schema);
+
+ let batches: Vec = reader.try_collect().await.unwrap();
+ let batch = &batches[0];
+
+ assert_eq!(batch.schema().as_ref(), &expected_schema);
+ }
+
#[tokio::test]
async fn test_with_header_size_hint_small() {
// Use a very small header size hint to force multiple fetches
From 92a239a54e33043f05fef98d81d3c7bd2b926467 Mon Sep 17 00:00:00 2001
From: Bruno
Date: Thu, 12 Mar 2026 07:31:45 +0100
Subject: [PATCH 31/80] Implement min, max, sum for run-end-encoded arrays.
(#9409)
Efficient implementations:
* min & max work directly on the values child array.
* sum folds over run lengths & values, without decompressing the array.
In particular, those implementations takes care of the logical offset &
len of the run-end-encoded arrays. This is non-trivial:
* We get the physical start & end indices in O(log(#runs)), but those
are incorrect for empty arrays.
* Slicing can happen in the middle of a run. For sum, we need to track
the logical start & end and reduce the run length accordingly.
Finally, one caveat: the aggregation functions only work when the child
values array is a primitive array. That's fine ~always, but some client
might store the values in an unexpected type. They'll either get None or
an Error, depending on the aggregation function used.
This feature is tracked in
https://github.com/apache/arrow-rs/issues/3520.
---
arrow-arith/src/aggregate.rs | 296 ++++++++++++++++++++++++++++++++++-
1 file changed, 292 insertions(+), 4 deletions(-)
diff --git a/arrow-arith/src/aggregate.rs b/arrow-arith/src/aggregate.rs
index a043259694c1..59792d0c5b1d 100644
--- a/arrow-arith/src/aggregate.rs
+++ b/arrow-arith/src/aggregate.rs
@@ -540,7 +540,7 @@ pub fn min_string_view(array: &StringViewArray) -> Option<&str> {
/// Returns the sum of values in the array.
///
/// This doesn't detect overflow. Once overflowing, the result will wrap around.
-/// For an overflow-checking variant, use `sum_array_checked` instead.
+/// For an overflow-checking variant, use [`sum_array_checked`] instead.
pub fn sum_array>(array: A) -> Option
where
T: ArrowNumericType,
@@ -567,6 +567,12 @@ where
Some(sum)
}
+ DataType::RunEndEncoded(run_ends, _) => match run_ends.data_type() {
+ DataType::Int16 => ree::sum_wrapping::(&array),
+ DataType::Int32 => ree::sum_wrapping::(&array),
+ DataType::Int64 => ree::sum_wrapping::(&array),
+ _ => unreachable!(),
+ },
_ => sum::(as_primitive_array(&array)),
}
}
@@ -574,7 +580,9 @@ where
/// Returns the sum of values in the array.
///
/// This detects overflow and returns an `Err` for that. For an non-overflow-checking variant,
-/// use `sum_array` instead.
+/// use [`sum_array`] instead.
+/// Additionally returns an `Err` on run-end-encoded arrays with a provided
+/// values type parameter that is incorrect.
pub fn sum_array_checked>(
array: A,
) -> Result, ArrowError>
@@ -603,10 +611,110 @@ where
Ok(Some(sum))
}
+ DataType::RunEndEncoded(run_ends, _) => match run_ends.data_type() {
+ DataType::Int16 => ree::sum_checked::(&array),
+ DataType::Int32 => ree::sum_checked::(&array),
+ DataType::Int64 => ree::sum_checked::(&array),
+ _ => unreachable!(),
+ },
_ => sum_checked::(as_primitive_array(&array)),
}
}
+// Logic for summing run-end-encoded arrays.
+mod ree {
+ use std::convert::Infallible;
+
+ use arrow_array::cast::AsArray;
+ use arrow_array::types::RunEndIndexType;
+ use arrow_array::{Array, ArrowNativeTypeOp, ArrowNumericType, PrimitiveArray, TypedRunArray};
+ use arrow_buffer::ArrowNativeType;
+ use arrow_schema::ArrowError;
+
+ /// Downcasts an array to a TypedRunArray.
+ fn downcast<'a, I: RunEndIndexType, V: ArrowNumericType>(
+ array: &'a dyn Array,
+ ) -> Option>> {
+ let array = array.as_run_opt::()?;
+ // We only support RunArray wrapping primitive types.
+ array.downcast::>()
+ }
+
+ /// Computes the sum (wrapping) of the array values.
+ pub(super) fn sum_wrapping(
+ array: &dyn Array,
+ ) -> Option {
+ let ree = downcast::(array)?;
+ let Ok(sum) = fold(ree, |acc, val, len| -> Result {
+ Ok(acc.add_wrapping(val.mul_wrapping(V::Native::usize_as(len))))
+ });
+ sum
+ }
+
+ /// Computes the sum (erroring on overflow) of the array values.
+ pub(super) fn sum_checked(
+ array: &dyn Array,
+ ) -> Result, ArrowError> {
+ let Some(ree) = downcast::(array) else {
+ return Err(ArrowError::InvalidArgumentError(
+ "Input run array values are not a PrimitiveArray".to_string(),
+ ));
+ };
+ fold(ree, |acc, val, len| -> Result {
+ let Some(len) = V::Native::from_usize(len) else {
+ return Err(ArrowError::ArithmeticOverflow(format!(
+ "Cannot convert a run-end index ({:?}) to the value type ({})",
+ len,
+ std::any::type_name::()
+ )));
+ };
+ acc.add_checked(val.mul_checked(len)?)
+ })
+ }
+
+ /// Folds over the values in a run-end-encoded array.
+ fn fold<'a, I: RunEndIndexType, V: ArrowNumericType, F, E>(
+ array: TypedRunArray<'a, I, PrimitiveArray>,
+ mut f: F,
+ ) -> Result, E>
+ where
+ F: FnMut(V::Native, V::Native, usize) -> Result,
+ {
+ let run_ends = array.run_ends();
+ let logical_start = run_ends.offset();
+ let logical_end = run_ends.offset() + run_ends.len();
+ let run_ends = run_ends.sliced_values();
+
+ let values_slice = array.run_array().values_slice();
+ let values = values_slice
+ .as_any()
+ .downcast_ref::>()
+ // Safety: we know the values array is PrimitiveArray.
+ .unwrap();
+
+ let mut prev_end = 0;
+ let mut acc = V::Native::ZERO;
+ let mut has_non_null_value = false;
+
+ for (run_end, value) in run_ends.zip(values) {
+ let current_run_end = run_end.as_usize().clamp(logical_start, logical_end);
+ let run_length = current_run_end - prev_end;
+
+ if let Some(value) = value {
+ has_non_null_value = true;
+ acc = f(acc, value, run_length)?;
+ }
+
+ prev_end = current_run_end;
+ if current_run_end == logical_end {
+ break;
+ }
+ }
+
+ Ok(if has_non_null_value { Some(acc) } else { None })
+ }
+}
+
/// Returns the min of values in the array of `ArrowNumericType` type, or dictionary
/// array with value of `ArrowNumericType` type.
pub fn min_array>(array: A) -> Option
@@ -639,6 +747,20 @@ where
{
match array.data_type() {
DataType::Dictionary(_, _) => min_max_helper::(array, cmp),
+ DataType::RunEndEncoded(run_ends, _) => {
+ // We can directly perform min/max on the values child array, as any
+ // run must have non-zero length.
+ let array: &dyn Array = &array;
+ let values = match run_ends.data_type() {
+ DataType::Int16 => array.as_run_opt::()?.values_slice(),
+ DataType::Int32 => array.as_run_opt::()?.values_slice(),
+ DataType::Int64 => array.as_run_opt::()?.values_slice(),
+ _ => return None,
+ };
+ // We only support RunArray wrapping primitive types.
+ let values = values.as_any().downcast_ref::>()?;
+ m(values)
+ }
_ => m(as_primitive_array(&array)),
}
}
@@ -751,7 +873,7 @@ pub fn bool_or(array: &BooleanArray) -> Option {
/// Returns `Ok(None)` if the array is empty or only contains null values.
///
/// This detects overflow and returns an `Err` for that. For an non-overflow-checking variant,
-/// use `sum` instead.
+/// use [`sum`] instead.
pub fn sum_checked(array: &PrimitiveArray) -> Result, ArrowError>
where
T: ArrowNumericType,
@@ -799,7 +921,7 @@ where
/// Returns `None` if the array is empty or only contains null values.
///
/// This doesn't detect overflow in release mode by default. Once overflowing, the result will
-/// wrap around. For an overflow-checking variant, use `sum_checked` instead.
+/// wrap around. For an overflow-checking variant, use [`sum_checked`] instead.
pub fn sum(array: &PrimitiveArray) -> Option
where
T::Native: ArrowNativeTypeOp,
@@ -1750,4 +1872,170 @@ mod tests {
sum_checked(&a).expect_err("overflow should be detected");
sum_array_checked::(&a).expect_err("overflow should be detected");
}
+
+ /// Helper for building a RunArray.
+ fn make_run_array<'a, I: RunEndIndexType, V: ArrowNumericType, ItemType>(
+ values: impl IntoIterator- ,
+ ) -> RunArray
+ where
+ ItemType: Clone + Into> + 'static,
+ {
+ let mut builder = arrow_array::builder::PrimitiveRunBuilder::::new();
+ for v in values.into_iter() {
+ builder.append_option((*v).clone().into());
+ }
+ builder.finish()
+ }
+
+ #[test]
+ fn test_ree_sum_array_basic() {
+ let run_array = make_run_array::(&[10, 10, 20, 30, 30, 30]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = sum_array::(typed_array);
+ assert_eq!(result, Some(130));
+
+ let result = sum_array_checked::(typed_array).unwrap();
+ assert_eq!(result, Some(130));
+ }
+
+ #[test]
+ fn test_ree_sum_array_empty() {
+ let run_array = make_run_array::(&[]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = sum_array::(typed_array);
+ assert_eq!(result, None);
+
+ let result = sum_array_checked::(typed_array).unwrap();
+ assert_eq!(result, None);
+ }
+
+ #[test]
+ fn test_ree_sum_array_with_nulls() {
+ let run_array =
+ make_run_array::(&[Some(10), None, Some(20), None, Some(30)]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = sum_array::(typed_array);
+ assert_eq!(result, Some(60));
+
+ let result = sum_array_checked::(typed_array).unwrap();
+ assert_eq!(result, Some(60));
+ }
+
+ #[test]
+ fn test_ree_sum_array_with_only_nulls() {
+ let run_array = make_run_array::(&[None, None, None, None, None]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = sum_array::(typed_array);
+ assert_eq!(result, None);
+
+ let result = sum_array_checked::(typed_array).unwrap();
+ assert_eq!(result, None);
+ }
+
+ #[test]
+ fn test_ree_sum_array_overflow() {
+ let run_array = make_run_array::(&[126, 2]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ // i8 range is -128..=127. 126+2 overflows to -128.
+ let result = sum_array::(typed_array);
+ assert_eq!(result, Some(-128));
+
+ let result = sum_array_checked::(typed_array);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_ree_sum_array_sliced() {
+ let run_array = make_run_array::(&[0, 10, 10, 10, 20, 30, 30, 30]);
+ // Skip 2 values at the start and 1 at the end.
+ let sliced = run_array.slice(2, 5);
+ let typed_array = sliced.downcast::().unwrap();
+
+ let result = sum_array::(typed_array);
+ assert_eq!(result, Some(100));
+
+ let result = sum_array_checked::(typed_array).unwrap();
+ assert_eq!(result, Some(100));
+ }
+
+ #[test]
+ fn test_ree_min_max_array_basic() {
+ let run_array = make_run_array::(&[30, 30, 10, 20, 20]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = min_array::(typed_array);
+ assert_eq!(result, Some(10));
+
+ let result = max_array::(typed_array);
+ assert_eq!(result, Some(30));
+ }
+
+ #[test]
+ fn test_ree_min_max_array_empty() {
+ let run_array = make_run_array::(&[]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = min_array::(typed_array);
+ assert_eq!(result, None);
+
+ let result = max_array::(typed_array);
+ assert_eq!(result, None);
+ }
+
+ #[test]
+ fn test_ree_min_max_array_float() {
+ let run_array = make_run_array::(&[5.5, 5.5, 2.1, 8.9, 8.9]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = min_array::(typed_array);
+ assert_eq!(result, Some(2.1));
+
+ let result = max_array::(typed_array);
+ assert_eq!(result, Some(8.9));
+ }
+
+ #[test]
+ fn test_ree_min_max_array_with_nulls() {
+ let run_array = make_run_array::(&[None, Some(10)]);
+ let typed_array = run_array.downcast::().unwrap();
+
+ let result = min_array::(typed_array);
+ assert_eq!(result, Some(10));
+
+ let result = max_array::(typed_array);
+ assert_eq!(result, Some(10));
+ }
+
+ #[test]
+ fn test_ree_min_max_array_sliced() {
+ let run_array = make_run_array::(&[0, 30, 30, 10, 20, 20, 100]);
+ // Skip 1 value at the start and 1 at the end.
+ let sliced = run_array.slice(1, 5);
+ let typed_array = sliced.downcast::().unwrap();
+
+ let result = min_array::(typed_array);
+ assert_eq!(result, Some(10));
+
+ let result = max_array::(typed_array);
+ assert_eq!(result, Some(30));
+ }
+
+ #[test]
+ fn test_ree_min_max_array_sliced_mid_run() {
+ let run_array = make_run_array::(&[0, 0, 30, 10, 20, 100, 100]);
+ // Skip 1 value at the start and 1 at the end.
+ let sliced = run_array.slice(1, 5);
+ let typed_array = sliced.downcast::().unwrap();
+
+ let result = min_array::(typed_array);
+ assert_eq!(result, Some(0));
+
+ let result = max_array::(typed_array);
+ assert_eq!(result, Some(100));
+ }
}
From c214c3c6f539c50ff644a3d92571375c57ffe11b Mon Sep 17 00:00:00 2001
From: Alexander Rafferty
Date: Fri, 13 Mar 2026 20:54:04 +1100
Subject: [PATCH 32/80] Add benchmark for `infer_json_schema` (#9546)
# Which issue does this PR close?
Split out from #9494 to make review easier. It simply adds a benchmark
for JSON schema inference.
# Rationale for this change
I have an open PR that significantly refactors the JSON schema inference
code, so I want confidence that not only is the new code correct, but
also has better performance than the existing code.
# What changes are included in this PR?
Adds a benchmark.
# Are these changes tested?
N/A
# Are there any user-facing changes?
No
---
arrow-json/Cargo.toml | 1 +
arrow-json/benches/json_reader.rs | 76 ++++++++++++++++++++++++++++++-
2 files changed, 75 insertions(+), 2 deletions(-)
diff --git a/arrow-json/Cargo.toml b/arrow-json/Cargo.toml
index be1f8d0ccdca..851f0a244f53 100644
--- a/arrow-json/Cargo.toml
+++ b/arrow-json/Cargo.toml
@@ -61,6 +61,7 @@ tokio = { version = "1.27", default-features = false, features = ["io-util"] }
bytes = "1.4"
criterion = { workspace = true, default-features = false }
rand = { version = "0.9", default-features = false, features = ["std", "std_rng", "thread_rng"] }
+arbitrary = { version = "1.4.2", features = ["derive"] }
[[bench]]
name = "serde"
diff --git a/arrow-json/benches/json_reader.rs b/arrow-json/benches/json_reader.rs
index f87ba695eb62..fccac68d9bfa 100644
--- a/arrow-json/benches/json_reader.rs
+++ b/arrow-json/benches/json_reader.rs
@@ -15,12 +15,14 @@
// specific language governing permissions and limitations
// under the License.
+use arbitrary::{Arbitrary, Unstructured};
use arrow_json::ReaderBuilder;
-use arrow_json::reader::Decoder;
+use arrow_json::reader::{Decoder, infer_json_schema};
use arrow_schema::{DataType, Field, Schema};
use criterion::{
BenchmarkId, Criterion, SamplingMode, Throughput, criterion_group, criterion_main,
};
+use serde::Serialize;
use serde_json::{Map, Number, Value};
use std::fmt::Write;
use std::hint::black_box;
@@ -323,6 +325,75 @@ fn bench_serialize_list(c: &mut Criterion) {
});
}
+fn bench_schema_inference(c: &mut Criterion) {
+ const ROWS: usize = 1000;
+
+ #[derive(Serialize, Arbitrary, Debug)]
+ struct Row {
+ a: Option,
+ b: Option,
+ c: Option<[i16; 8]>,
+ d: Option<[bool; 8]>,
+ e: Option,
+ f: f64,
+ }
+
+ #[derive(Serialize, Arbitrary, Debug)]
+ struct Inner {
+ a: Option,
+ b: Option,
+ c: Option,
+ }
+
+ let mut data = vec![];
+ for row in pseudorandom_sequence::(ROWS) {
+ serde_json::to_writer(&mut data, &row).unwrap();
+ data.push(b'\n');
+ }
+
+ let mut group = c.benchmark_group("infer_json_schema");
+ group.throughput(Throughput::Bytes(data.len() as u64));
+ group.sample_size(50);
+ group.measurement_time(std::time::Duration::from_secs(5));
+ group.warm_up_time(std::time::Duration::from_secs(2));
+ group.sampling_mode(SamplingMode::Flat);
+ group.bench_function(BenchmarkId::from_parameter(ROWS), |b| {
+ b.iter(|| infer_json_schema(black_box(&data[..]), None).unwrap())
+ });
+ group.finish();
+}
+
+fn pseudorandom_sequence Arbitrary<'a>>(len: usize) -> Vec {
+ static RAND_BYTES: &[u8; 255] = &[
+ 12, 135, 254, 243, 18, 5, 38, 175, 60, 58, 204, 103, 15, 88, 201, 199, 57, 63, 56, 234,
+ 106, 111, 238, 119, 214, 50, 110, 89, 129, 185, 112, 115, 35, 239, 188, 189, 49, 184, 194,
+ 146, 108, 131, 213, 43, 236, 81, 61, 20, 21, 52, 223, 220, 215, 74, 210, 27, 190, 107, 174,
+ 142, 237, 66, 75, 1, 53, 181, 82, 158, 68, 134, 176, 229, 157, 116, 233, 153, 84, 139, 151,
+ 8, 171, 59, 105, 242, 40, 69, 94, 170, 4, 187, 212, 156, 65, 90, 192, 216, 29, 222, 122,
+ 230, 198, 154, 155, 245, 45, 178, 123, 23, 117, 168, 149, 17, 177, 48, 54, 241, 202, 44,
+ 232, 64, 221, 252, 161, 91, 93, 143, 240, 102, 172, 209, 224, 186, 197, 219, 247, 71, 36,
+ 101, 133, 113, 6, 137, 231, 162, 31, 7, 22, 138, 47, 136, 2, 244, 141, 173, 99, 25, 95, 96,
+ 85, 249, 42, 251, 217, 16, 205, 98, 203, 92, 114, 14, 163, 150, 144, 10, 125, 13, 195, 72,
+ 41, 67, 246, 11, 77, 132, 83, 37, 24, 183, 226, 250, 109, 248, 33, 76, 9, 55, 159, 34, 62,
+ 196, 87, 3, 39, 28, 166, 167, 255, 206, 79, 191, 228, 193, 179, 97, 182, 148, 73, 120, 211,
+ 253, 70, 227, 51, 169, 130, 145, 218, 78, 180, 165, 46, 127, 152, 26, 140, 207, 19, 100,
+ 104, 80, 164, 126, 118, 200, 128, 86, 160, 32, 30, 225, 147, 124, 121, 235, 208,
+ ];
+
+ let bytes: Vec = RAND_BYTES
+ .iter()
+ .flat_map(|i| RAND_BYTES.map(|j| i.wrapping_add(j)))
+ .take(1000 * len)
+ .collect();
+
+ let mut u = Unstructured::new(&bytes);
+
+ (0..len)
+ .map(|_| u.arbitrary::().unwrap())
+ .take(len)
+ .collect()
+}
+
criterion_group!(
benches,
bench_decode_wide_object,
@@ -330,6 +401,7 @@ criterion_group!(
bench_binary_hex,
bench_wide_projection,
bench_decode_list,
- bench_serialize_list
+ bench_serialize_list,
+ bench_schema_inference
);
criterion_main!(benches);
From 393117979882e97a15125edd142c70a5e2c16386 Mon Sep 17 00:00:00 2001
From: Oleks V
Date: Fri, 13 Mar 2026 02:54:56 -0700
Subject: [PATCH 33/80] chore: Protect `main` branch with required reviews
(#9547)
# Which issue does this PR close?
- Closes #NNN.
# Rationale for this change
Currently any user with `write` access can merge the PR without review.
Good practice to get at least 1 review before the merge
# What changes are included in this PR?
# Are these changes tested?
# Are there any user-facing changes?
---
.asf.yaml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.asf.yaml b/.asf.yaml
index 36f01b88a724..9214924add68 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -46,6 +46,8 @@ github:
strict: true
# don't require any jobs to pass
contexts: []
+ required_pull_request_reviews:
+ required_approving_review_count: 1
pull_requests:
# enable updating head branches of pull requests
allow_update_branch: true
From 002426087ea9106b616194a5d0942aedba2bc884 Mon Sep 17 00:00:00 2001
From: "xudong.w"
Date: Sat, 14 Mar 2026 22:18:18 +0800
Subject: [PATCH 34/80] Replace interleave overflow panic with error (#9549)
# Which issue does this PR close?
- Closes #NNN.
# Rationale for this change
# What changes are included in this PR?
Replace interleave overflow panic with error
# Are these changes tested?
Yes UT
# Are there any user-facing changes?
---
arrow-select/src/interleave.rs | 46 +++++++++++++++++++++++++++++-----
1 file changed, 40 insertions(+), 6 deletions(-)
diff --git a/arrow-select/src/interleave.rs b/arrow-select/src/interleave.rs
index 6598a5eb0da0..be4e98ffccd7 100644
--- a/arrow-select/src/interleave.rs
+++ b/arrow-select/src/interleave.rs
@@ -173,12 +173,15 @@ fn interleave_bytes(
let mut capacity = 0;
let mut offsets = Vec::with_capacity(indices.len() + 1);
offsets.push(T::Offset::from_usize(0).unwrap());
- offsets.extend(indices.iter().map(|(a, b)| {
+ for (a, b) in indices {
let o = interleaved.arrays[*a].value_offsets();
let element_len = o[*b + 1].as_usize() - o[*b].as_usize();
capacity += element_len;
- T::Offset::from_usize(capacity).expect("overflow")
- }));
+ offsets.push(
+ T::Offset::from_usize(capacity)
+ .ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?,
+ );
+ }
let mut values = Vec::with_capacity(capacity);
for (a, b) in indices {
@@ -331,12 +334,14 @@ fn interleave_list(
let mut capacity = 0usize;
let mut offsets = Vec::with_capacity(indices.len() + 1);
offsets.push(O::from_usize(0).unwrap());
- offsets.extend(indices.iter().map(|(array, row)| {
+ for (array, row) in indices {
let o = interleaved.arrays[*array].value_offsets();
let element_len = o[*row + 1].as_usize() - o[*row].as_usize();
capacity += element_len;
- O::from_usize(capacity).expect("offset overflow")
- }));
+ offsets.push(
+ O::from_usize(capacity).ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?,
+ );
+ }
let mut child_indices = Vec::with_capacity(capacity);
for (array, row) in indices {
@@ -1414,4 +1419,33 @@ mod tests {
]
);
}
+
+ #[test]
+ fn test_interleave_bytes_offset_overflow() {
+ let indices: Vec<(usize, usize)> = vec![(0, 0); (i32::MAX >> 4) as usize];
+ let text = ('a'..='z').collect::();
+ let values = StringArray::from(vec![Some(text)]);
+ assert!(matches!(
+ interleave(&[&values], &indices),
+ Err(ArrowError::OffsetOverflowError(_))
+ ));
+ }
+
+ #[test]
+ fn test_interleave_list_offset_overflow() {
+ // Build a ListArray with a single row containing many elements
+ let mut builder = GenericListBuilder::::new(Int32Builder::new());
+ for i in 0..32 {
+ builder.values().append_value(i);
+ }
+ builder.append(true);
+ let list = builder.finish();
+
+ // Interleave enough copies to overflow i32 offsets
+ let indices: Vec<(usize, usize)> = vec![(0, 0); (i32::MAX as usize / 32) + 1];
+ assert!(matches!(
+ interleave(&[&list], &indices),
+ Err(ArrowError::OffsetOverflowError(_))
+ ));
+ }
}
From 83b6908f92de32c6695d95d7dc2b0a0116aa3185 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dani=C3=ABl=20Heres?=
Date: Sat, 14 Mar 2026 18:50:38 +0100
Subject: [PATCH 35/80] Unroll interleave -25-30% (#9542)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Which issue does this PR close?
- Closes #NNN.
# Rationale for this change
```
🤖: Benchmark completed
Details
group main interleave
----- ---- -----------
interleave dict(20, 0.0) 100 [0..100, 100..230, 450..1000] 1.08 805.6±8.28ns ? ?/sec 1.00 748.5±14.05ns ? ?/sec
interleave dict(20, 0.0) 1024 [0..100, 100..230, 450..1000, 0..1000] 1.18 2.6±0.00µs ? ?/sec 1.00 2.2±0.01µs ? ?/sec
interleave dict(20, 0.0) 1024 [0..100, 100..230, 450..1000] 1.21 2.6±0.01µs ? ?/sec 1.00 2.2±0.02µs ? ?/sec
interleave dict(20, 0.0) 400 [0..100, 100..230, 450..1000] 1.16 1431.6±3.11ns ? ?/sec 1.00 1232.9±14.26ns ? ?/sec
interleave dict_distinct 100 1.03 2.9±0.12µs ? ?/sec 1.00 2.9±0.07µs ? ?/sec
interleave dict_distinct 1024 1.02 2.9±0.06µs ? ?/sec 1.00 2.8±0.03µs ? ?/sec
interleave dict_distinct 2048 1.03 2.9±0.02µs ? ?/sec 1.00 2.8±0.08µs ? ?/sec
interleave dict_sparse(20, 0.0) 100 [0..100, 100..230, 450..1000] 1.00 2.7±0.26µs ? ?/sec 1.02 2.8±0.21µs ? ?/sec
interleave dict_sparse(20, 0.0) 1024 [0..100, 100..230, 450..1000, 0..1000] 1.11 5.3±0.31µs ? ?/sec 1.00 4.8±0.40µs ? ?/sec
interleave dict_sparse(20, 0.0) 1024 [0..100, 100..230, 450..1000] 1.16 4.8±0.25µs ? ?/sec 1.00 4.1±0.23µs ? ?/sec
interleave dict_sparse(20, 0.0) 400 [0..100, 100..230, 450..1000] 1.05 3.5±0.31µs ? ?/sec 1.00 3.3±0.29µs ? ?/sec
interleave i32(0.0) 100 [0..100, 100..230, 450..1000] 1.21 313.8±1.03ns ? ?/sec 1.00 258.9±4.98ns ? ?/sec
interleave i32(0.0) 1024 [0..100, 100..230, 450..1000, 0..1000] 1.34 1856.5±17.40ns ? ?/sec 1.00 1385.9±32.73ns ? ?/sec
interleave i32(0.0) 1024 [0..100, 100..230, 450..1000] 1.34 1848.6±8.80ns ? ?/sec 1.00 1382.4±48.64ns ? ?/sec
interleave i32(0.0) 400 [0..100, 100..230, 450..1000] 1.37 843.3±7.37ns ? ?/sec 1.00 615.5±22.71ns ? ?/sec
interleave i32(0.5) 100 [0..100, 100..230, 450..1000] 1.09 604.2±5.60ns ? ?/sec 1.00 555.1±4.48ns ? ?/sec
interleave i32(0.5) 1024 [0..100, 100..230, 450..1000, 0..1000] 1.12 4.3±0.01µs ? ?/sec 1.00 3.8±0.04µs ? ?/sec
interleave i32(0.5) 1024 [0..100, 100..230, 450..1000] 1.13 4.4±0.06µs ? ?/sec 1.00 3.9±0.17µs ? ?/sec
interleave i32(0.5) 400 [0..100, 100..230, 450..1000] 1.12 1889.4±19.68ns ? ?/sec 1.00 1691.5±17.15ns ? ?/sec
interleave list(0.0,0.0,20) 100 [0..100, 100..230, 450..1000] 1.07 2.7±0.03µs ? ?/sec 1.00 2.5±0.03µs ? ?/sec
interleave list(0.0,0.0,20) 1024 [0..100, 100..230, 450..1000, 0..1000] 1.06 26.2±0.11µs ? ?/sec 1.00 24.6±0.31µs ? ?/sec
interleave list(0.0,0.0,20) 1024 [0..100, 100..230, 450..1000] 1.06 25.9±0.14µs ? ?/sec 1.00 24.5±0.29µs ? ?/sec
interleave list