fix: preserve the input list's inner field in array_append/prepend/replace - #24365
Conversation
…place* `array_append`, `array_prepend`, `array_replace`, `array_replace_n` and `array_replace_all` promise the input list type verbatim — inner field name, nullability and metadata included — but their kernels rebuilt the output's inner field from scratch with `Field::new_list_field(..., true)`. On debug builds this trips the return-type assertion added in apache#17515; on release builds it silently yields a batch whose inner field disagrees with the schema the planner recorded. Unlike `array_slice` (apache#24345), threading the input's field through is not sufficient here: the appended, prepended or replacement element can itself be null, so a promise cloned from a `List(non-null T)` input would be wrong at the source and arrow would reject the array with "Non-nullable field of ListArray cannot contain nulls". So each function now implements `return_field_from_args`, carrying the input field's name and metadata through while widening `nullable` when the new element's argument is nullable, and the kernels build their output from `args.return_field` instead of deriving a field of their own. Nullability is therefore only widened when the result can genuinely contain a null: array_append(List(non-null Int64), 3) -> List(non-null Int64) array_append(List(non-null Int64), NULL) -> List(Int64) `array_concat` is unaffected: it derives a fresh return type via `type_union_resolution` rather than cloning an input's, so it keeps passing `None` to `concat_internal` and deriving the field from the aligned inputs. Behaviour for `Null`-typed array arguments is unchanged in all five functions. Closes apache#24347
| pub(crate) fn list_type_with_element( | ||
| array_type: &DataType, | ||
| element_nullable: bool, | ||
| ) -> DataType { | ||
| match array_type { | ||
| DataType::List(field) => { | ||
| DataType::List(widen_nullability(field, element_nullable)) | ||
| } | ||
| DataType::LargeList(field) => { | ||
| DataType::LargeList(widen_nullability(field, element_nullable)) | ||
| } | ||
| other => other.clone(), | ||
| } | ||
| } |
There was a problem hiding this comment.
This is important because if I append a nullable element to a list of non-nullable elements then the output should be a list of nullable elements.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24365 +/- ##
========================================
Coverage 81.18% 81.19%
========================================
Files 1109 1110 +1
Lines 388167 388737 +570
Branches 388167 388737 +570
========================================
+ Hits 315118 315618 +500
- Misses 54507 54530 +23
- Partials 18542 18589 +47 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a type/Arrow-schema mismatch in the nested array functions array_append, array_prepend, array_replace, array_replace_n, and array_replace_all by ensuring their kernels construct output list arrays using the same inner FieldRef (name, metadata, and nullability) that is promised during planning, widening the inner-field nullability only when the appended/replacement element can be null.
Changes:
- Implement
return_field_from_argsfor the five functions to preserve the input list’s inner field and widen element nullability only when required. - Update the corresponding kernels to build
GenericListArrayusing the promised return field (rather than recreatingField::new_list_field(..., true)). - Add shared helpers (
list_inner_field,list_type_with_element) and expand SLT coverage (includingLargeList, nested value types, and guard cases for unaffectedarray_concat).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| datafusion/functions-nested/src/utils.rs | Adds helpers to extract/preserve list inner fields and to widen inner nullability when needed. |
| datafusion/functions-nested/src/concat.rs | Uses return_field_from_args for append/prepend and threads promised inner field into kernels; keeps array_concat behavior unchanged. |
| datafusion/functions-nested/src/replace.rs | Uses return_field_from_args for replace* and constructs output arrays using the promised inner field (including short-circuit/null paths). |
| datafusion/functions-nested/src/extract.rs | Refactors array_slice inner-field extraction to reuse the new list_inner_field helper. |
| datafusion/sqllogictest/test_files/array/array_append.slt | Adds regression tests for field-name/metadata preservation and inner-nullability widening behavior. |
| datafusion/sqllogictest/test_files/array/array_prepend.slt | Adds regression tests mirroring append coverage (including LargeList and nullable element cases). |
| datafusion/sqllogictest/test_files/array/array_replace.slt | Adds regression tests for replace* variants (including max short-circuit and NULL max). |
| datafusion/sqllogictest/test_files/array/array_concat.slt | Adds guard tests confirming array_concat still derives the default inner field regardless of inputs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
alamb
left a comment
There was a problem hiding this comment.
LOooks good to me (and I had claude code review it too)
| // `array` is at index 0 and `to` at index 2 for all three functions. | ||
| // `from` never contributes values to the output, so `to` is the only | ||
| // argument besides `array` that can affect the output's type. | ||
| let [array_field, _from_field, to_field, ..] = arg_fields else { |
There was a problem hiding this comment.
Is the idea that if from is null, then it wouldn't match anything anyways (and leave the array_field) unchanged? So thus the _from_field nullability is ignored?
There was a problem hiding this comment.
Yes, exactly. from is only used to find the replacement match. It cannot impact the output. The one place this may seem non-obvious is the case where you have an array that is not nullable, and you have a from that is null and a to that is not nullable. The output remains a non-nullable list.
> set datafusion.format.types_info = true;
0 row(s) fetched.
Elapsed 0.002 seconds.
> select array_replace(arrow_cast(column1, 'List(non-null Int64)'), NULL, 3) from values (make_array(1, 2, 2));
+-------------------------------------------------------------------------------+
| array_replace(arrow_cast(column1,Utf8("List(non-null Int64)")),NULL,Int64(3)) |
| List(non-null Int64) |
+-------------------------------------------------------------------------------+
| [1, 2, 2] |
+-------------------------------------------------------------------------------+
1 row(s) fetched.
|
Thank you @timsaucer |
Which issue does this PR close?
Rationale for this change
array_append,array_prepend,array_replace,array_replace_nandarray_replace_allpromise the input list type verbatim — inner field name, nullability and metadata included — but their kernels rebuilt the output's inner field from scratch withField::new_list_field(..., true). On debug builds this trips the return-type assertion from #17515; on release builds it silently yields a batch whose inner field disagrees with the schema the planner recorded.This is the other half of #24341, whose
array_slicepart was fixed in #24345. The field-name symptom is a 55.0.0 regression from the same commit (5b22857, #20945); the non-nullable symptom is not a regression.Unlike
array_slice, threading the input's field through is not sufficient here: the appended, prepended or replacement element can itself be null, so a promise cloned from aList(non-null T)input is wrong at the source and arrow rejects the array withNon-nullable field of ListArray cannot contain nulls.What changes are included in this PR?
Each of the five functions now implements
return_field_from_args, carrying the input field's name and metadata through while wideningnullablewhen the new element's argument is nullable. The kernels build their output fromargs.return_fieldinstead of deriving a field of their own, so promise and payload come from a single source. Nullability is therefore only widened when the result can genuinely contain a null:Two paths beyond those listed in the issue turned out to have the same defect and are fixed too:
array_append/array_prependwith a nested value type (which delegates toconcat_internal), and theLargeListvariants of all five.Since these five now implement
return_field_from_args, theirreturn_typebecomes unreachable and returnsinternal_err!("return_field_from_args should be used instead"), matching the guidance onScalarUDFImpl::return_typeand the existing convention inremove.rsandmap_values.rs.Two small cleanups while in here:
List/LargeListinner-field extraction added togeneral_array_sliceby fix: correct list field inner type in array functions #24345 is now shared asutils::list_inner_field, used by all three files. Its error text is unchanged. TheListViewvariant ingeneral_list_view_array_sliceis deliberately left alone — folding all four variants into one helper would letgeneral_array_slicesilently accept aListViewthat its match currently rejects.array_concatis not affected and its behaviour is unchanged: it derives a fresh return type viatype_union_resolutionrather than cloning an input's, so it keeps passingNonetoconcat_internaland deriving the field from the aligned inputs.Behaviour for
Null-typed array arguments is unchanged in all five functions (array_replace*returnNull,array_append/array_prependreturnList(element)).Are these changes tested?
Yes — 14 new SLT tests across
array_append.slt,array_prepend.slt,array_replace.slt, plus two guard cases inarray_concat.sltpinning down that it is unaffected.arrow_castcan express a named inner field ('List(Int64, field: ''element'')'), so these reproduce the field-name half of the bug without needing the Spark dialect. Coverage: inner field name preserved, non-nullable inner field preserved, nullability widened only when the new element is nullable (both literalNULLand a nullable column),LargeList, nested value types, themax <= 0short circuit, and aNULLmax.Every one of these queries fails on
mainwith the return-type assertion.Also run:
cargo clippy --all-targets --all-features -- -D warnings, the full sqllogictest suite, and the extended workspace test suite (68 test binaries, 0 failures). Thearray_replaceandarray_concatbenchmarks show no regression againstmain.Are there any user-facing changes?
The five functions now return the inner field they promise instead of a rebuilt one, which is the bug fix. As a consequence, appending or replacing with a nullable element widens the declared inner nullability of the result (
List(non-null Int64)->List(Int64)), which is required for the result to be representable at all.No breaking changes to public APIs.