Skip to content

fix(arrow-select): preserve nullability for REE and Union take - #10994

Open
yongster wants to merge 6 commits into
apache:mainfrom
yongster:fix/ree-union-nullability
Open

fix(arrow-select): preserve nullability for REE and Union take#10994
yongster wants to merge 6 commits into
apache:mainfrom
yongster:fix/ree-union-nullability

Conversation

@yongster

@yongster yongster commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

take can introduce output nulls when its indices contain nulls.

Most Arrow arrays represent these with a top-level validity bitmap. However,
RunEndEncoded and Union arrays derive logical nullability from their child
arrays:

  • A RunEndEncoded array derives nullability from its values field.
  • A Dense Union represents a null through a nullable selected child.
  • A Sparse Union stores every child at every output position.

Previously, take could write nulls into children whose corresponding field
metadata was marked as non-nullable. This made the physical output inconsistent
with its declared schema.

  • Closes Define nullability semantics for kernels on REE and Union arrays #10992.

  • Return a compute error when null take indices would introduce nulls into a
    RunEndEncoded array with a non-nullable values field.

  • For Dense Union arrays, select a nullable child to represent null take
    indices.

  • Return a compute error for Sparse Union arrays with non-nullable fields when
    take indices contain nulls, since every child would otherwise receive an
    introduced null.

  • Add regression tests for each behavior.

This complements #10909 and ensures that arrays marked as non-nullable do not
produce output containing newly introduced nulls.

@yongster

yongster commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

I also reviewed filter, interleave, concat, sort, and zip.

I did not identify the same metadata-consistency issue in these kernels: they
do not introduce nulls through null take indices in the way take does for
RunEndEncoded and Union arrays. This PR therefore intentionally limits its
scope to take.

@github-actions github-actions Bot added arrow Changes to the arrow crate arrow-select labels Sep 4, 2026

@Rich-T-kid Rich-T-kid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the arrow-spec, this looks mostly correct.

Comment thread arrow-select/src/take.rs Outdated
}
}
DataType::Union(fields, UnionMode::Sparse) => {
if indices.null_count() > 0 && fields.iter().any(|(_, field)| !field.is_nullable()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems to introduce a bug

#[test]
fn test_take_union_builder_null_index_regression() {
    let mut builder = UnionBuilder::new_dense();
    builder.append::<Int32Type>("a", 10).unwrap();
    builder.append_null::<Int32Type>("a").unwrap();
    let union = builder.build().unwrap();

    // UnionBuilder currently declares union fields as non-nullable.
    let field = union.fields().iter().next().unwrap().1;
    assert!(!field.is_nullable());

    // But it still represents logical nulls through the selected child.
    assert!(union.logical_nulls().unwrap().is_null(1));

    let indices = UInt32Array::from(vec![Some(0), None, Some(1)]);

    // This should preserve take's normal null-index contract:
    // a null index produces a logical null in the output.
    let taken = take(&union, &indices, None).unwrap();
    let taken = taken.as_union();
    let logical_nulls = taken.logical_nulls().unwrap();

    assert!(logical_nulls.is_valid(0));
    assert!(logical_nulls.is_null(1));
    assert!(logical_nulls.is_null(2));
}

with this PR at the take() call would cause an error. is this intended?

@Jefffrey Jefffrey Sep 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is an existing issue with union builders?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, your right. interesting bug

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, intended. take follows the declared field nullability. UnionBuilder marking children as non-nullable after append_null is the existing #1637 issue and is out of scope here.

@Jefffrey Jefffrey added the bug label Sep 5, 2026
Comment thread arrow-select/src/take.rs Outdated
}
}
DataType::Union(fields, UnionMode::Sparse) => {
if indices.null_count() > 0 && fields.iter().any(|(_, field)| !field.is_nullable()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically this might be too strict? as long as we have one child that is nullable we can select that type id, then for the rest we fill in nulls or some default; it shouldnt matter since they arent the selected child, even if their values arent null

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — that was stricter than dense. 4827226 now matches dense: a single nullable child is enough to represent a null index. Unselected children are taken with dummy indices so non-nullable fields do not get introduced nulls.

A sparse union with no nullable child still errors, same as dense.

@Jefffrey

Jefffrey commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

from a codex review it identified 2 edge cases:

  • for structs with non-nullable ree/union, currently we reject these because we try to take on them, even though we can mask them with the structs own null buffer (overly strict)
  • if we have nested unions where inner union has no nullable children, but as a field in the outer union it is marked as nullable, we can incorrect select it to be the null child (e.g. for dense unions)

(these are pretty extreme edge cases honestly, i wouldnt mind them being a separate issue; just wanted to point out for completeness)

yongster and others added 2 commits September 6, 2026 07:04
Null take indices select a nullable child, matching dense unions. Other
children are taken with dummy indices so non-nullable fields do not
receive introduced nulls.
@yongster

yongster commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@Jefffrey I relaxed the sparse path as you suggested: mixed-nullability sparse unions now succeed when at least one child is nullable.

Agreed on the two Codex edge cases (struct-masked non-nullable REE/Union, nested union chosen as the null child). I'll leave those as a follow-up rather than expanding this PR.

Comment thread arrow-select/src/take.rs
.next()
.find(|(_, field)| field.is_nullable())
.map(|(type_id, _)| type_id)
.ok_or_else(|| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could use find_map here

Comment thread arrow-select/src/take.rs
if values.is_empty() {
// Null indices would otherwise take dummy index 0, which is OOB.
return non_null_unspecified_values(values.data_type(), indices.len());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if indices has a non-null index beyond bounds? or is that not a concern since it should already error for that case earlier?

Comment thread arrow-select/src/take.rs
Comment on lines +471 to +480
let dummy = IndexType::Native::from_usize(0).unwrap();
let mut values = indices.values().to_vec();
if let Some(nulls) = indices.nulls() {
for (idx, value) in values.iter_mut().enumerate() {
if nulls.is_null(idx) {
*value = dummy;
}
}
}
PrimitiveArray::new(ScalarBuffer::from(values), None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let dummy = IndexType::Native::from_usize(0).unwrap();
let mut values = indices.values().to_vec();
if let Some(nulls) = indices.nulls() {
for (idx, value) in values.iter_mut().enumerate() {
if nulls.is_null(idx) {
*value = dummy;
}
}
}
PrimitiveArray::new(ScalarBuffer::from(values), None)
let dummy = IndexType::Native::ZERO;
let normalized = indices.iter().map(|idx| idx.unwrap_or(dummy));
PrimitiveArray::from_iter_values(normalized)

Comment thread arrow-select/src/take.rs
/// Builds an all-valid array of `len` whose values are unspecified.
///
/// Used for unused sparse-union child slots when the source child is empty.
fn non_null_unspecified_values(data_type: &DataType, len: usize) -> Result<ArrayRef, ArrowError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

personally id just inline this; that way we dont need a doc comment to explain for what use case this is for

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate arrow-select bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Define nullability semantics for kernels on REE and Union arrays

3 participants