You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/09/15 10:54:21 UTC
(datafusion) 01/01: fix: report a non-nullable return field for named_struct() and struct() (#25306)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-25306-2178f275ed51b137b57b92182a485e1f863ffaef
in repository https://gitbox.apache.org/repos/asf/datafusion.git
commit 32662e750116bbde2aa2d53929aef7a9f19a361c
Author: Adrian Garcia Badaracco <17...@users.noreply.github.com>
AuthorDate: Tue Sep 15 10:53:57 2026 +0000
fix: report a non-nullable return field for named_struct() and struct() (#25306)
## Which issue does this PR close?
- Closes #25305.
## Rationale for this change
`named_struct(...)` and `struct(...)` never produce a NULL row:
`invoke_with_args` builds the output `StructArray` with no null buffer.
Both functions nevertheless reported a nullable return field, and
`ExprSchemable::nullable` for a scalar function reads that field. As a
result the simplifier rule that rewrites `a IS NOT NULL` to `true` for a
non-nullable `a` never fired on a struct constructor.
The user-visible cost is lost pruning. A guard such as `WHERE s IS NOT
NULL` on a struct built by a view cannot remove any row, but it keeps
the whole struct expression alive, so projection pushdown can no longer
prune the scan to the fields that are read:
```sql
SET datafusion.explain.format = 'indent';
CREATE TABLE t (a INT, b INT, c INT) AS VALUES (1, 2, 3), (NULL, 5, 6);
CREATE VIEW v AS SELECT named_struct('a', a, 'b', b, 'c', c) AS s FROM t;
EXPLAIN SELECT s['b'] FROM v WHERE s IS NOT NULL;
```
Before:
```
logical_plan
Projection: __datafusion_extracted_1 AS v.s[b]
SubqueryAlias: v
Projection: __datafusion_extracted_1
Filter: named_struct(Utf8("a"), t.a, Utf8("b"), t.b, Utf8("c"), t.c) IS NOT NULL
Projection: t.b AS __datafusion_extracted_1, t.a, t.b, t.c
TableScan: t projection=[a, b, c]
```
After:
```
logical_plan
Projection: __datafusion_extracted_1 AS v.s[b]
SubqueryAlias: v
Projection: t.b AS __datafusion_extracted_1
TableScan: t projection=[b]
```
This matches the plan the same query already gets without the guard, and
lets the existing `get_field(named_struct(...), 'f')` simplification
(#22239) remove the constructor.
## What changes are included in this PR?
- `datafusion/functions/src/core/named_struct.rs`:
`return_field_from_args` now reports a non-nullable field instead of a
nullable one.
- `datafusion/functions/src/core/struct.rs`: adds a
`return_field_from_args` override that reports a non-nullable field. The
default implementation it previously used made the field nullable.
Both are a one-line nullability correction; the inner struct fields stay
nullable, since an individual member value can still be NULL.
## What is the testing strategy for this PR?
- New `sqllogictest` cases at the end of
`datafusion/sqllogictest/test_files/struct.slt` covering the folded
scalar results (`named_struct(...) IS NOT NULL`, `struct(...) IS NULL`),
the `EXPLAIN` for `WHERE s IS NOT NULL` over a view showing `TableScan:
t projection=[b]`, the values that query returns, and the `CASE WHEN
named_struct(...) IS NOT NULL` fold.
- Existing `datafusion-functions` unit tests: `cargo test -p
datafusion-functions --lib`, 356 passed.
- The full `sqllogictest` suite: all 518 files pass, with no other
expectation changes needed.
## Are there any user-facing changes?
Yes, but no API change.
- The schema nullability of the output of `named_struct()` and
`struct()` changes from nullable to non-nullable. This now matches the
array those functions actually build.
- `IS NULL` and `IS NOT NULL` applied directly to one of these
constructors constant-fold to `false` and `true`. Query results are
unchanged; plans get smaller and can prune more.
---
datafusion/functions/src/core/named_struct.rs | 5 ++-
datafusion/functions/src/core/struct.rs | 18 ++++++++--
datafusion/sqllogictest/test_files/struct.slt | 50 +++++++++++++++++++++++++++
3 files changed, 70 insertions(+), 3 deletions(-)
diff --git a/datafusion/functions/src/core/named_struct.rs b/datafusion/functions/src/core/named_struct.rs
index e1aa441e0b..0d9e133ada 100644
--- a/datafusion/functions/src/core/named_struct.rs
+++ b/datafusion/functions/src/core/named_struct.rs
@@ -141,10 +141,13 @@ impl ScalarUDFImpl for NamedStructFunc {
.map(|(name, data_type)| Ok(Field::new(name, data_type.to_owned(), true)))
.collect::<Result<Vec<Field>>>()?;
+ // The constructed struct row is never NULL: `invoke_with_args` builds
+ // the `StructArray` without a null buffer, so the output field is
+ // non-nullable and `named_struct(...) IS NOT NULL` folds to `true`.
Ok(Field::new(
self.name(),
DataType::Struct(Fields::from(return_fields)),
- true,
+ false,
)
.into())
}
diff --git a/datafusion/functions/src/core/struct.rs b/datafusion/functions/src/core/struct.rs
index 2697cb46b0..164b9d2032 100644
--- a/datafusion/functions/src/core/struct.rs
+++ b/datafusion/functions/src/core/struct.rs
@@ -16,9 +16,11 @@
// under the License.
use arrow::array::StructArray;
-use arrow::datatypes::{DataType, Field};
+use arrow::datatypes::{DataType, Field, FieldRef};
use datafusion_common::{Result, exec_err, internal_err};
-use datafusion_expr::{ColumnarValue, Documentation, ScalarFunctionArgs};
+use datafusion_expr::{
+ ColumnarValue, Documentation, ReturnFieldArgs, ScalarFunctionArgs,
+};
use datafusion_expr::{ScalarUDFImpl, Signature, Volatility};
use datafusion_macros::user_doc;
use std::sync::Arc;
@@ -114,6 +116,18 @@ impl ScalarUDFImpl for StructFunc {
Ok(DataType::Struct(fields))
}
+ fn return_field_from_args(&self, args: ReturnFieldArgs) -> Result<FieldRef> {
+ let arg_types = args
+ .arg_fields
+ .iter()
+ .map(|f| f.data_type().clone())
+ .collect::<Vec<_>>();
+ // The constructed struct row is never NULL: `invoke_with_args` builds
+ // the `StructArray` without a null buffer, so the output field is
+ // non-nullable and `struct(...) IS NOT NULL` folds to `true`.
+ Ok(Field::new(self.name(), self.return_type(&arg_types)?, false).into())
+ }
+
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
let DataType::Struct(fields) = args.return_type() else {
return internal_err!("incorrect struct return type");
diff --git a/datafusion/sqllogictest/test_files/struct.slt b/datafusion/sqllogictest/test_files/struct.slt
index 183bfbd04e..7704b7f214 100644
--- a/datafusion/sqllogictest/test_files/struct.slt
+++ b/datafusion/sqllogictest/test_files/struct.slt
@@ -1752,3 +1752,53 @@ limit 2;
statement ok
drop table list_cast_limit;
+
+# named_struct() and struct() never produce a NULL row, so their return field
+# is non-nullable and `IS NOT NULL` on the constructor folds to `true`. A guard
+# on a struct built by a view then no longer keeps the whole struct alive, and
+# the scan is pruned to the fields that are actually read.
+statement ok
+create table struct_ctor_null (a int, b int, c int) as values (1, 2, 3), (NULL, 5, 6);
+
+statement ok
+create view struct_ctor_view as select named_struct('a', a, 'b', b, 'c', c) as s from struct_ctor_null;
+
+query BB
+select named_struct('a', a) is not null, struct(a) is null from struct_ctor_null;
+----
+true false
+true false
+
+query TT
+explain select s['b'] from struct_ctor_view where s is not null;
+----
+logical_plan
+01)Projection: __datafusion_extracted_1 AS struct_ctor_view.s[b]
+02)--SubqueryAlias: struct_ctor_view
+03)----Projection: struct_ctor_null.b AS __datafusion_extracted_1
+04)------TableScan: struct_ctor_null projection=[b]
+physical_plan
+01)ProjectionExec: expr=[b@0 as struct_ctor_view.s[b]]
+02)--DataSourceExec: partitions=1, partition_sizes=[1]
+
+query I
+select s['b'] from struct_ctor_view where s is not null;
+----
+2
+5
+
+query TT
+explain select case when named_struct('a', a) is not null then b end from struct_ctor_null;
+----
+logical_plan
+01)Projection: struct_ctor_null.b AS CASE WHEN named_struct(Utf8("a"),struct_ctor_null.a) IS NOT NULL THEN struct_ctor_null.b END
+02)--TableScan: struct_ctor_null projection=[b]
+physical_plan
+01)ProjectionExec: expr=[b@0 as CASE WHEN named_struct(Utf8("a"),struct_ctor_null.a) IS NOT NULL THEN struct_ctor_null.b END]
+02)--DataSourceExec: partitions=1, partition_sizes=[1]
+
+statement ok
+drop view struct_ctor_view;
+
+statement ok
+drop table struct_ctor_null;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org