Describe the bug
ScalarValue::new_list takes a data_type parameter but honors it only when values is empty:
// datafusion/common/src/scalar/mod.rs (main)
pub fn new_list(
values: &[ScalarValue],
data_type: &DataType,
nullable: bool,
) -> Arc<ListArray> {
let values = if values.is_empty() {
new_empty_array(data_type)
} else {
Self::iter_to_array(values.iter().cloned()).unwrap() // data_type ignored
};
...
}
For non-empty input the element type comes entirely from the collected scalars, so the returned ListArray can have an element type that differs from the data_type the caller asked for. new_list_nullable and new_large_list inherit the same behaviour.
The practical consequence is that an accumulator built on new_list cannot guarantee the type it advertises. ArrayAggAccumulator and DistinctArrayAggAccumulator pass self.datatype to new_list, and datafusion-spark's collect_list / collect_set declare their return_type / state_fields from the argument's declared type:
// datafusion/spark/src/function/aggregate/collect.rs (main)
fn state_fields(&self, args: StateFieldsArgs) -> Result<Vec<FieldRef>> {
Ok(vec![Field::new_list(
format_state_name(args.name, "collect_set"),
Field::new_list_field(args.input_fields[0].data_type().clone(), true),
true,
).into()])
}
while the accumulator stores ScalarValue::try_from_array(...) slices of the argument array it actually received. Nothing reconciles the two.
So if an aggregate argument expression's evaluate() output type differs from its data_type(schema) — in our case in the nullability of a nested struct field, which DataType::equals_datatype does compare — then GroupedHashAggregateStream::emit fails validating its own output batch:
Invalid argument error: column types must match schema types,
expected List(Struct("flag": Boolean, "items": List(Struct("n": Int32))))
but found List(Struct("flag": non-null Boolean, "items": List(Struct("n": non-null Int32))))
at column index 1
This hits both emit paths: the normal one (validated against AggregateExec's schema, from return_type) and the spill one (validated against spill_state.spill_schema, built from state_fields()).
To Reproduce
Build collect_set over an argument whose declared type has nullable nested leaves, and feed the accumulator an array whose actual type has non-nullable leaves. state_fields()[0].data_type() and the type of acc.state()[0] then disagree, and constructing the output RecordBatch fails with the message above.
Expected behavior
Either:
new_list / new_list_nullable / new_large_list reconcile the concatenated values to data_type (a cast_with_options call, which short-circuits to a shallow clone when the types already match), so the function's declared contract holds; or
- the parameter is documented as advisory and
ArrayAggAccumulator::evaluate / DistinctArrayAggAccumulator::evaluate cast to self.datatype themselves, so an accumulator cannot violate its own state_fields() / return_type().
(1) seems preferable — it fixes every current and future caller rather than two.
Additional context
Downstream, this shows up in Apache DataFusion Comet, which has to pre-normalize collect_list / collect_set arguments with an explicit cast to work around it.
Separately, and more broadly: there is currently no invariant check that a PhysicalExpr's evaluate() output type matches its data_type(schema). A debug-only assertion (in ProjectionExec / aggregate argument evaluation, alongside the existing ExecutionPlan invariant checks) would surface this class of bug at the drifting expression rather than several operators downstream in a spill path.
Describe the bug
ScalarValue::new_listtakes adata_typeparameter but honors it only whenvaluesis empty:For non-empty input the element type comes entirely from the collected scalars, so the returned
ListArraycan have an element type that differs from thedata_typethe caller asked for.new_list_nullableandnew_large_listinherit the same behaviour.The practical consequence is that an accumulator built on
new_listcannot guarantee the type it advertises.ArrayAggAccumulatorandDistinctArrayAggAccumulatorpassself.datatypetonew_list, anddatafusion-spark'scollect_list/collect_setdeclare theirreturn_type/state_fieldsfrom the argument's declared type:while the accumulator stores
ScalarValue::try_from_array(...)slices of the argument array it actually received. Nothing reconciles the two.So if an aggregate argument expression's
evaluate()output type differs from itsdata_type(schema)— in our case in the nullability of a nested struct field, whichDataType::equals_datatypedoes compare — thenGroupedHashAggregateStream::emitfails validating its own output batch:This hits both emit paths: the normal one (validated against
AggregateExec's schema, fromreturn_type) and the spill one (validated againstspill_state.spill_schema, built fromstate_fields()).To Reproduce
Build
collect_setover an argument whose declared type has nullable nested leaves, and feed the accumulator an array whose actual type has non-nullable leaves.state_fields()[0].data_type()and the type ofacc.state()[0]then disagree, and constructing the outputRecordBatchfails with the message above.Expected behavior
Either:
new_list/new_list_nullable/new_large_listreconcile the concatenated values todata_type(acast_with_optionscall, which short-circuits to a shallow clone when the types already match), so the function's declared contract holds; orArrayAggAccumulator::evaluate/DistinctArrayAggAccumulator::evaluatecast toself.datatypethemselves, so an accumulator cannot violate its ownstate_fields()/return_type().(1) seems preferable — it fixes every current and future caller rather than two.
Additional context
Downstream, this shows up in Apache DataFusion Comet, which has to pre-normalize
collect_list/collect_setarguments with an explicit cast to work around it.Separately, and more broadly: there is currently no invariant check that a
PhysicalExpr'sevaluate()output type matches itsdata_type(schema). A debug-only assertion (inProjectionExec/ aggregate argument evaluation, alongside the existingExecutionPlaninvariant checks) would surface this class of bug at the drifting expression rather than several operators downstream in a spill path.