Files
lancedb/python/vendor/lance-arrow/src/struct.rs
T
lancedb-gatefixer[bot] 624defd109 fix: preserve sliced child validity during struct merge (#4261)
## Root cause

The pinned Lance Arrow crate rebuilt a sliced struct child with its
original Arrow offset but supplied a validity bitmap containing only the
visible rows. Arrow requires the bitmap to cover the offset as well, so
filtered vector searches involving nullable nested fields could panic
with `null_bit_buffer size too small`.

## Fix

- Carry an offset-aligned validity bitmap when propagating parent nulls
to a sliced child.
- Vendor the pinned `lance-arrow` crate until a Lance release includes
the fix; include it in Maturin source distributions.
- Add unit regressions for sliced children and parent bitmaps, plus a
record-batch merge regression.

## Validation

- The issue's 8,600-row Python reproducer completes its filtered flat
dot search and returns 8,448 rows without a panic.
- The targeted Rust merge regression and two focused Python query tests
pass.
- Rust formatting, Ruff formatting and lint, the vendored crate tests,
and the workspace Cargo checks passed. The Maturin source archive
includes the patched crate.

Fixes #4260

<!-- lance-gatekeeper-fix:v1 agent=e6045f701d9b708ee26528ba36353aba
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-10-23 01:26:28 +08:00

228 lines
8.0 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
// SPDX-FileCopyrightText: Copyright The Lance Authors
//! Extension to arrow struct arrays
use arrow_array::{Array, StructArray, cast::AsArray, make_array};
use arrow_buffer::NullBuffer;
use arrow_data::{ArrayData, ArrayDataBuilder};
use arrow_schema::{ArrowError, DataType};
pub trait StructArrayExt {
/// Removes the offset / length of the struct array by pushing it into the children
///
/// In arrow-rs when slice is called it recursively slices the children.
/// In arrow-cpp when slice is called it just sets the offset/length of
/// the struct array and leaves the children as-is
///
/// Both are legal approaches (╥﹏╥)
///
/// This method helps reduce complexity by folding into the arrow-rs approach
fn normalize_slicing(&self) -> Result<Self, ArrowError>
where
Self: Sized;
/// Structs are allowed to mask valid items. For example, a struct array might be:
///
/// [ {"items": [1, 2, 3]}, NULL, {"items": NULL}]
///
/// However, the underlying items array might be: [[1, 2, 3], [4, 5], NULL]
///
/// The [4, 5] list is masked out because the struct array is null.
///
/// The struct validity would be [true, false, true] and the list validity would be [true, true, false]
///
/// This method pushes nulls down into all children. In the above example the list validity would become
/// [true, false, false].
///
/// This method is not recursive. If a child is a struct array it will not push that child's nulls down.
///
/// This method does not remove garbage lists. It only updates the validity so a future call to
/// [crate::list::ListArrayExt::filter_garbage_nulls] will remove the garbage lists (without
/// this pushdown it would not)
fn pushdown_nulls(&self) -> Result<Self, ArrowError>
where
Self: Sized;
}
fn normalized_struct_array_data(data: ArrayData) -> Result<ArrayData, ArrowError> {
let parent_offset = data.offset();
let parent_len = data.len();
let modified_children = data
.child_data()
.iter()
.map(|d| {
let d = normalized_struct_array_data(d.clone())?;
let offset = d.offset();
let len = d.len();
if len < parent_len + parent_offset {
return Err(ArrowError::InvalidArgumentError(format!(
"Child array {} has length {} which is less than the parent length {} plus the parent offset {}",
d.data_type(),
len,
parent_len,
parent_offset
)));
}
let new_offset = offset + parent_offset;
d.into_builder().offset(new_offset)
.len(parent_len)
.build()
})
.collect::<Result<Vec<_>, _>>()?;
ArrayDataBuilder::new(data.data_type().clone())
.len(parent_len)
.offset(0)
.buffers(data.buffers().to_vec())
.child_data(modified_children)
.build()
}
impl StructArrayExt for StructArray {
fn normalize_slicing(&self) -> Result<Self, ArrowError>
where
Self: Sized,
{
if self.offset() == 0 && self.columns().iter().all(|c| c.len() == self.len()) {
return Ok(self.clone());
}
let data = normalized_struct_array_data(self.to_data())?;
Ok(Self::from(data))
}
fn pushdown_nulls(&self) -> Result<Self, ArrowError>
where
Self: Sized,
{
let Some(validity) = self.nulls() else {
return Ok(self.clone());
};
let data = self.to_data();
let children = data
.child_data()
.iter()
.map(|c| {
if c.data_type() == &DataType::Null {
// A Null array is already null in every slot, and arrow-rs rejects
// a validity buffer on it.
Ok(c.clone())
} else if let Some(child_validity) = c.nulls() {
let new_validity = child_validity.inner() & validity.inner();
c.clone()
.into_builder()
.nulls(Some(NullBuffer::from(new_validity)))
.build()
} else {
Ok(c.clone()
.into_builder()
.nulls(Some(validity.clone()))
.build()?)
}
})
.collect::<Result<Vec<_>, _>>()?;
let arr = make_array(data.into_builder().child_data(children).build()?);
Ok(arr.as_struct().clone())
}
}
#[cfg(test)]
mod tests {
use arrow_array::{
Array, Int32Array, NullArray, StructArray, cast::AsArray, make_array, types::Int32Type,
};
use arrow_buffer::NullBuffer;
use arrow_schema::{DataType, Field, Fields};
use std::sync::Arc;
use crate::r#struct::StructArrayExt;
#[test]
fn test_normalize_slicing_no_offset() {
let x = Int32Array::from(vec![1, 2, 3]);
let y = Int32Array::from(vec![4, 5, 6]);
let struct_array = StructArray::new(
Fields::from(vec![
Field::new("x", DataType::Int32, true),
Field::new("y", DataType::Int32, true),
]),
vec![Arc::new(x), Arc::new(y)],
None,
);
let normalized = struct_array.normalize_slicing().unwrap();
assert_eq!(normalized, struct_array);
}
#[test]
fn test_arrow_rs_slicing() {
let x = Int32Array::from(vec![1, 2, 3, 4]);
let y = Int32Array::from(vec![5, 6, 7, 8]);
let struct_array = StructArray::new(
Fields::from(vec![
Field::new("x", DataType::Int32, true),
Field::new("y", DataType::Int32, true),
]),
vec![Arc::new(x), Arc::new(y)],
None,
);
// Slicing with arrow-rs propagates the slicing to the children so there should
// be no change needed to the struct array
let sliced = struct_array.slice(1, 2);
let normalized = sliced.normalize_slicing().unwrap();
assert_eq!(normalized, sliced);
}
#[test]
fn test_arrow_cpp_slicing() {
let x = Int32Array::from(vec![1, 2, 3, 4]);
let y = Int32Array::from(vec![5, 6, 7, 8]);
let struct_array = StructArray::new(
Fields::from(vec![
Field::new("x", DataType::Int32, true),
Field::new("y", DataType::Int32, true),
]),
vec![Arc::new(x), Arc::new(y)],
None,
);
let data = struct_array.to_data();
let sliced = data.into_builder().offset(1).len(2).build().unwrap();
let sliced = make_array(sliced);
let normalized = sliced.as_struct().clone().normalize_slicing().unwrap();
assert_eq!(normalized, struct_array.slice(1, 2));
}
#[test]
fn test_pushdown_nulls_with_null_typed_child() {
// `pa.Table.from_pylist([{"s": {"a": 1, "b": None}}, {"s": None}])` infers a
// struct with a Null-typed child. Arrow forbids a validity buffer on a Null
// array, so the struct's nulls must not be pushed into it.
let struct_array = StructArray::new(
Fields::from(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Null, true),
]),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(NullArray::new(3)),
],
Some(NullBuffer::from(vec![true, false, true])),
);
let pushed = struct_array.pushdown_nulls().unwrap();
assert_eq!(pushed.nulls(), struct_array.nulls());
assert_eq!(
pushed.column(0).as_primitive::<Int32Type>(),
&Int32Array::from(vec![Some(1), None, Some(3)])
);
assert_eq!(pushed.column(1).data_type(), &DataType::Null);
assert_eq!(pushed.column(1).logical_null_count(), 3);
}
}