This is an automated email from the ASF dual-hosted git repository.
Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new af18bac759 fix(arrow-cast): respect sliced list offsets in fixed-size
list casts (#11065)
af18bac759 is described below
commit af18bac75927072bdaf6c07f323ff65e3b365144
Author: Coding Cossack <[email protected]>
AuthorDate: Sat Sep 19 15:49:58 2026 +0100
fix(arrow-cast): respect sliced list offsets in fixed-size list casts
(#11065)
# Which issue does this PR close?
Closes #10975.
# Rationale for this change
Slicing a ListArray or LargeListArray retains offsets into the original
child array. Casting the slice to FixedSizeList currently reads from
child
position zero on the fast path and when copying the first pending run on
the padding path. This can return excluded values or overfill the padded
output.
# What changes are included in this PR?
Start child selection and the first pending copy at the first retained
offset. Replace the numeric cursor sentinel and first-row special case
with Option<usize>, distinguishing no replacement from a valid source
position, including for empty rows.
Preserve the existing safe/strict length handling and perform child
conversion after selecting or padding the retained rows.
# Are these changes tested?
Three regression tests cover both list offset widths, fast and padding
paths, non-zero offsets, excluded values during child conversion, nulls,
safe/strict behaviour, empty slices and zero-width output.
Local validation executed by Codex:
- All three regression tests failed before the fix and passed
afterwards.
- cargo test -p arrow-cast --all-features: 425 unit tests and 15
doctests passed.
- cargo test -p arrow-cast --release --lib --tests: 382 tests passed.
- Strict Clippy, unused-dependency checks, formatting, spelling and
git diff --check passed.
# Are there any user-facing changes?
Affected sliced List and LargeList casts now select the correct child
values and avoid padding-path overfill. No public API changes.
# AI assistance
OpenAI Codex generated the implementation and regression tests and ran
the local validation. ChatGPT assisted with an independent static review
and this PR description.
---
arrow-cast/src/cast/list.rs | 27 ++----
arrow-cast/src/cast/mod.rs | 216 +++++++++++++++++++++++++++++++++++++++-----
2 files changed, 203 insertions(+), 40 deletions(-)
diff --git a/arrow-cast/src/cast/list.rs b/arrow-cast/src/cast/list.rs
index 837715e885..ee35b1a9f3 100644
--- a/arrow-cast/src/cast/list.rs
+++ b/arrow-cast/src/cast/list.rs
@@ -155,18 +155,10 @@ where
// Nulls in FixedSizeListArray take up space and so we must pad the values
let values = array.values().to_data();
let mut mutable = MutableArrayData::new(vec![&values], nullable, cap);
- // The end position in values of the last incorrectly-sized list slice
- let mut last_pos = 0;
-
- // Need to flag when previous vector(s) are empty/None to distinguish from
'All slices were correct length' cases.
- let is_prev_empty = if array.offsets().len() < 2 {
- false
- } else {
- let first_offset = array.offsets()[0].as_usize();
- let second_offset = array.offsets()[1].as_usize();
-
- first_offset == 0 && second_offset == 0
- };
+ let first_pos = array.offsets()[0].as_usize();
+ // The end position in values of the last incorrectly-sized list slice,
+ // or None if no padding has been needed (including for empty slices).
+ let mut last_pos = None;
for (idx, w) in array.offsets().windows(2).enumerate() {
let start_pos = w[0].as_usize();
@@ -175,10 +167,11 @@ where
if len != size as usize {
if cast_options.safe || array.is_null(idx) {
- if last_pos != start_pos {
+ let copy_start = last_pos.unwrap_or(first_pos);
+ if copy_start != start_pos {
// Extend with valid slices
mutable
- .try_extend(0, last_pos, start_pos)
+ .try_extend(0, copy_start, start_pos)
.map_err(|e| ArrowError::CastError(e.to_string()))?;
}
// Pad this slice with nulls
@@ -187,7 +180,7 @@ where
.map_err(|e| ArrowError::CastError(e.to_string()))?;
null_builder.set_bit(idx, false);
// Set last_pos to the end of this slice's values
- last_pos = end_pos
+ last_pos = Some(end_pos)
} else {
return Err(ArrowError::CastError(format!(
"Cannot cast to FixedSizeList({size}): value at index
{idx} has length {len}",
@@ -197,8 +190,8 @@ where
}
let values = match last_pos {
- 0 if !is_prev_empty => array.values().slice(0, cap), // All slices
were the correct length
- _ => {
+ None => array.values().slice(first_pos, cap), // All slices were the
correct length
+ Some(last_pos) => {
if mutable.len() != cap {
// Remaining slices were all correct length
let remaining = cap - mutable.len();
diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs
index 08441a9930..407d76cf46 100644
--- a/arrow-cast/src/cast/mod.rs
+++ b/arrow-cast/src/cast/mod.rs
@@ -9794,6 +9794,179 @@ mod tests {
));
let fsl = cast(list.as_ref(), expected.data_type()).unwrap();
assert_eq!(&expected, &fsl);
+
+ // Direct non-zero offsets must retain the row count and validity.
+ let field = Arc::new(Field::new_list_field(DataType::Int32, true));
+ let target = DataType::FixedSizeList(field.clone(), 0);
+ let strict = CastOptions {
+ safe: false,
+ ..Default::default()
+ };
+ for nulls in [None, Some(NullBuffer::from(vec![true, false]))] {
+ let values = Arc::new(Int32Array::from(vec![1, 2, 3]));
+ let inputs: [ArrayRef; 2] = [
+ Arc::new(ListArray::new(
+ field.clone(),
+ OffsetBuffer::new(vec![3; 3].into()),
+ values.clone(),
+ nulls.clone(),
+ )),
+ Arc::new(LargeListArray::new(
+ field.clone(),
+ OffsetBuffer::new(vec![3; 3].into()),
+ values,
+ nulls.clone(),
+ )),
+ ];
+ for input in inputs {
+ let actual = cast_with_options(input.as_ref(), &target,
&strict).unwrap();
+ assert_eq!(actual.len(), 2);
+ assert_eq!(actual.data_type(), &target);
+ assert_eq!(actual.nulls(), nulls.as_ref());
+ assert_eq!(actual.as_fixed_size_list().values().len(), 0);
+ }
+ }
+ }
+
+ #[test]
+ fn test_issue_10975_sliced_list_to_fsl() {
+ fn test<O: OffsetSizeTrait>() {
+ let input =
GenericListArray::<O>::from_iter_primitive::<Int32Type, _, _>([
+ Some(vec![Some(1), Some(2)]),
+ Some(vec![Some(3), Some(4)]),
+ Some(vec![Some(5), Some(6)]),
+ ]);
+ let expected =
FixedSizeListArray::from_iter_primitive::<Int32Type, _, _>(
+ [Some([Some(3), Some(4)]), Some([Some(5), Some(6)])],
+ 2,
+ );
+ for safe in [true, false] {
+ let options = CastOptions {
+ safe,
+ ..Default::default()
+ };
+ let actual =
+ cast_with_options(&input.slice(1, 2),
expected.data_type(), &options).unwrap();
+ assert_eq!(actual.as_ref(), &expected as &dyn Array);
+ }
+ }
+ test::<i32>();
+ test::<i64>();
+ }
+
+ #[test]
+ fn test_issue_10975_sliced_list_to_fsl_subcast() {
+ fn test<O: OffsetSizeTrait>() {
+ // A differently sized prefix and invalid excluded children must
not
+ // affect selection or the recursive child cast.
+ let input =
GenericListArray::<O>::from_iter_primitive::<Int32Type, _, _>([
+ Some(vec![Some(i32::MAX); 3]),
+ Some(vec![Some(3), None]),
+ Some(vec![Some(5), Some(6)]),
+ Some(vec![Some(i32::MAX); 2]),
+ ]);
+ let selected = input.slice(1, 3).slice(0, 2);
+ let expected =
FixedSizeListArray::from_iter_primitive::<Int32Type, _, _>(
+ [Some([Some(3), None]), Some([Some(5), Some(6)])],
+ 2,
+ );
+ for safe in [true, false] {
+ let options = CastOptions {
+ safe,
+ ..Default::default()
+ };
+ for child_type in [DataType::Int32, DataType::Int64,
DataType::Int16] {
+ let target = DataType::FixedSizeList(
+ Arc::new(Field::new_list_field(child_type, true)),
+ 2,
+ );
+ let actual = cast_with_options(&selected, &target,
&options).unwrap();
+ let expected = cast_with_options(&expected, &target,
&options).unwrap();
+ assert_eq!(actual.as_ref(), expected.as_ref());
+ assert_eq!(actual.as_fixed_size_list().values().len(), 4);
+ }
+ }
+ }
+ test::<i32>();
+ test::<i64>();
+ }
+
+ #[test]
+ fn test_issue_10975_sliced_list_to_fsl_padding() {
+ fn test<O: OffsetSizeTrait>() {
+ let field = Arc::new(Field::new_list_field(DataType::Int32, true));
+ let lengths = [3, 0, 0, 2, 1, 3, 2, 2, 0, 2];
+ let values = Int32Array::from_iter_values(0..16).slice(1, 15);
+ let input = GenericListArray::<O>::new(
+ field.clone(),
+ OffsetBuffer::from_lengths(lengths),
+ Arc::new(values),
+ Some(NullBuffer::from(vec![
+ false, false, false, true, false, false, true, false,
false, true,
+ ])),
+ );
+ let target = DataType::FixedSizeList(field, 2);
+ for safe in [true, false] {
+ let options = CastOptions {
+ safe,
+ ..Default::default()
+ };
+ let full = cast_with_options(&input, &target,
&options).unwrap();
+ for (start, len) in [
+ (1, 8), // Leading/consecutive empty nulls, short/long and
exact-width nulls.
+ (1, 2), // Only consecutive empty nulls.
+ (3, 4), // Short and long nulls between valid rows.
+ (6, 2), // Valid row and exact-width null: no padding
needed.
+ (8, 1), // Only one empty null at a non-zero child offset.
+ ] {
+ let selected = input.slice(start, len);
+ let actual = cast_with_options(&selected, &target,
&options).unwrap();
+ assert_eq!(actual.as_ref(), full.slice(start,
len).as_ref());
+ assert_eq!(actual.as_fixed_size_list().values().len(), len
* 2);
+ }
+ }
+ }
+ test::<i32>();
+ test::<i64>();
+ }
+
+ #[test]
+ fn test_issue_10975_sliced_list_to_fsl_safety() {
+ fn test<O: OffsetSizeTrait>() {
+ let input =
GenericListArray::<O>::from_iter_primitive::<Int32Type, _, _>([
+ Some(vec![Some(99); 3]),
+ Some(vec![Some(1), Some(2)]),
+ Some(vec![]),
+ Some(vec![Some(3)]),
+ Some(vec![Some(4); 3]),
+ Some(vec![Some(5), Some(6)]),
+ ]);
+ let expected =
FixedSizeListArray::from_iter_primitive::<Int32Type, _, _>(
+ [
+ Some([Some(1), Some(2)]),
+ None,
+ None,
+ None,
+ Some([Some(5), Some(6)]),
+ ],
+ 2,
+ );
+ let actual = cast(&input.slice(1, 5),
expected.data_type()).unwrap();
+ assert_eq!(actual.as_ref(), &expected as &dyn Array);
+ assert_eq!(actual.as_fixed_size_list().values().len(), 10);
+ let strict = CastOptions {
+ safe: false,
+ ..Default::default()
+ };
+ let error =
+ cast_with_options(&input.slice(1, 5), expected.data_type(),
&strict).unwrap_err();
+ assert_eq!(
+ error.to_string(),
+ "Cast error: Cannot cast to FixedSizeList(2): value at index 1
has length 0"
+ );
+ }
+ test::<i32>();
+ test::<i64>();
}
#[test]
@@ -10138,29 +10311,26 @@ mod tests {
let target_type = DataType::FixedSizeList(inner_field.clone(), 3);
let expected = new_empty_array(&target_type);
- // list
- let array = new_empty_array(&DataType::List(inner_field.clone()));
- assert!(can_cast_types(array.data_type(), &target_type));
- let actual = cast(array.as_ref(), &target_type).unwrap();
- assert_eq!(expected.as_ref(), actual.as_ref());
-
- // largelist
- let array = new_empty_array(&DataType::LargeList(inner_field.clone()));
- assert!(can_cast_types(array.data_type(), &target_type));
- let actual = cast(array.as_ref(), &target_type).unwrap();
- assert_eq!(expected.as_ref(), actual.as_ref());
-
- // listview
- let array = new_empty_array(&DataType::ListView(inner_field.clone()));
- assert!(can_cast_types(array.data_type(), &target_type));
- let actual = cast(array.as_ref(), &target_type).unwrap();
- assert_eq!(expected.as_ref(), actual.as_ref());
-
- // largelistview
- let array =
new_empty_array(&DataType::LargeListView(inner_field.clone()));
- assert!(can_cast_types(array.data_type(), &target_type));
- let actual = cast(array.as_ref(), &target_type).unwrap();
- assert_eq!(expected.as_ref(), actual.as_ref());
+ let cases = [
+ new_empty_array(&DataType::List(inner_field.clone())),
+ new_empty_array(&DataType::LargeList(inner_field.clone())),
+ new_empty_array(&DataType::ListView(inner_field.clone())),
+ new_empty_array(&DataType::LargeListView(inner_field.clone())),
+ // Empty slices with non-zero child offsets (issue #10975).
+ make_list_array().slice(2, 0),
+ make_large_list_array().slice(2, 0),
+ ];
+ for array in cases {
+ assert!(can_cast_types(array.data_type(), &target_type));
+ for safe in [true, false] {
+ let options = CastOptions {
+ safe,
+ ..Default::default()
+ };
+ let actual = cast_with_options(array.as_ref(), &target_type,
&options).unwrap();
+ assert_eq!(expected.as_ref(), actual.as_ref());
+ }
+ }
}
fn make_list_array() -> ArrayRef {