Skip to content

Commit a0e6d49

Browse files
nathanb9alamb
andauthored
Make LogicalPlan::Unnest expression/rebuild contracts consistent (#22783)
## Which issue does this PR close? Closes #22769 ## Rationale for this change `LogicalPlan::Unnest` had an inconsistent API contract: `apply_expressions()` exposed `exec_columns` but `with_new_exprs()` rejected them via `assert_no_expressions`. This broke the standard `node.with_new_exprs(node.expressions(), new_inputs)` pattern. ## What changes are included in this PR? - `with_new_exprs` now accepts expressions from `apply_expressions` (extracts `Column` values back out) - `map_expressions` now properly transforms `exec_columns` instead of treating Unnest as expressionless - Removed stale comment in `extract_leaf_expressions` (semantic barrier remains) ## Are these changes tested? Yes — two new unit tests proving both `with_new_exprs(expressions(), inputs)` and `with_new_exprs(vec![], inputs)` work. All existing optimizer and SLT tests pass. ## Are there any user-facing changes? No. --------- Co-authored-by: Andrew Lamb <andrew@nerdnetworks.org>
1 parent c7e9284 commit a0e6d49

3 files changed

Lines changed: 104 additions & 17 deletions

File tree

datafusion/expr/src/logical_plan/plan.rs

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,12 +1186,20 @@ impl LogicalPlan {
11861186
options,
11871187
..
11881188
}) => {
1189-
self.assert_no_expressions(expr)?;
1189+
let exec_columns = if expr.is_empty() {
1190+
columns.clone()
1191+
} else {
1192+
expr.into_iter()
1193+
.map(|e| match e {
1194+
Expr::Column(c) => Ok(c),
1195+
other => internal_err!(
1196+
"Expected Expr::Column for Unnest exec_columns, got {other:?}"
1197+
),
1198+
})
1199+
.collect::<Result<Vec<_>>>()?
1200+
};
11901201
let input = self.only_input(inputs)?;
1191-
// Update schema with unnested column type.
1192-
let new_plan =
1193-
unnest_with_options(input, columns.clone(), options.clone())?;
1194-
Ok(new_plan)
1202+
Ok(unnest_with_options(input, exec_columns, options.clone())?)
11951203
}
11961204
}
11971205
}
@@ -6602,4 +6610,53 @@ mod tests {
66026610

66036611
Ok(())
66046612
}
6613+
6614+
#[test]
6615+
fn test_unnest_with_new_exprs_accepts_expressions() -> Result<()> {
6616+
use crate::LogicalPlanBuilder;
6617+
use arrow::datatypes::{DataType, Field, Schema};
6618+
6619+
let schema = Schema::new(vec![
6620+
Field::new("list_col", DataType::new_list(DataType::Int32, true), true),
6621+
Field::new("other_col", DataType::Int32, true),
6622+
]);
6623+
let plan = table_scan(Some("t"), &schema, None)?.build()?;
6624+
let unnest_plan = LogicalPlanBuilder::from(plan)
6625+
.unnest_column("list_col")?
6626+
.build()?;
6627+
6628+
let exprs = unnest_plan.expressions();
6629+
assert!(!exprs.is_empty(), "Unnest should expose exec_columns");
6630+
assert_eq!(exprs.len(), 1);
6631+
assert!(matches!(&exprs[0], Expr::Column(c) if c.name == "list_col"));
6632+
6633+
let inputs: Vec<LogicalPlan> =
6634+
unnest_plan.inputs().into_iter().cloned().collect();
6635+
let rebuilt = unnest_plan.with_new_exprs(exprs, inputs)?;
6636+
assert_eq!(rebuilt.schema(), unnest_plan.schema());
6637+
6638+
Ok(())
6639+
}
6640+
6641+
#[test]
6642+
fn test_unnest_with_new_exprs_empty_preserves_columns() -> Result<()> {
6643+
use crate::LogicalPlanBuilder;
6644+
use arrow::datatypes::{DataType, Field, Schema};
6645+
6646+
let schema = Schema::new(vec![
6647+
Field::new("list_col", DataType::new_list(DataType::Int32, true), true),
6648+
Field::new("other_col", DataType::Int32, true),
6649+
]);
6650+
let plan = table_scan(Some("t"), &schema, None)?.build()?;
6651+
let unnest_plan = LogicalPlanBuilder::from(plan)
6652+
.unnest_column("list_col")?
6653+
.build()?;
6654+
6655+
let inputs: Vec<LogicalPlan> =
6656+
unnest_plan.inputs().into_iter().cloned().collect();
6657+
let rebuilt = unnest_plan.with_new_exprs(vec![], inputs)?;
6658+
assert_eq!(rebuilt.schema(), unnest_plan.schema());
6659+
6660+
Ok(())
6661+
}
66056662
}

datafusion/expr/src/logical_plan/tree_node.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@
3737
//! * [`LogicalPlan::with_new_exprs`]: Create a new plan with different expressions
3838
//! * [`LogicalPlan::expressions`]: Return a copy of the plan's expressions
3939
40+
use std::sync::Arc;
41+
4042
use crate::logical_plan::plan::RangePartitioning;
4143
use crate::{
4244
Aggregate, Analyze, CreateMemoryTable, CreateView, DdlStatement, Distinct,
4345
DistinctOn, DmlStatement, Execute, Explain, Expr, Extension, Filter, Join, Limit,
4446
LogicalPlan, Partitioning, Prepare, Projection, RecursiveQuery, Repartition, Sort,
4547
Statement, Subquery, SubqueryAlias, TableScan, Union, Unnest, UserDefinedLogicalNode,
46-
Values, Window, dml::CopyTo,
48+
Values, Window, builder::unnest_with_options, dml::CopyTo,
4749
};
4850
use datafusion_common::tree_node::TreeNodeRefContainer;
4951

@@ -686,9 +688,39 @@ impl LogicalPlan {
686688
_ => Transformed::no(stmt),
687689
}
688690
.update_data(LogicalPlan::Statement),
691+
LogicalPlan::Unnest(Unnest {
692+
input,
693+
exec_columns,
694+
options,
695+
..
696+
}) => {
697+
let exprs: Vec<Expr> =
698+
exec_columns.into_iter().map(Expr::Column).collect();
699+
exprs.map_elements(f)?.map_data(|mapped_exprs| {
700+
let new_columns = mapped_exprs
701+
.into_iter()
702+
.map(|e| match e {
703+
Expr::Column(c) => Ok(c),
704+
other => internal_err!(
705+
"Expected Expr::Column for Unnest exec_columns, got {other:?}"
706+
),
707+
})
708+
.collect::<Result<Vec<_>>>()?;
709+
// Rebuild through `unnest_with_options` so the derived
710+
// `list_type_columns`, `struct_type_columns`,
711+
// `dependency_indices`, and `schema` are recomputed from
712+
// the (possibly rewritten) columns rather than carried over
713+
// stale. This keeps `map_expressions` consistent with
714+
// `with_new_exprs`.
715+
unnest_with_options(
716+
Arc::unwrap_or_clone(input),
717+
new_columns,
718+
options,
719+
)
720+
})?
721+
}
689722
// plans without expressions
690723
LogicalPlan::EmptyRelation(_)
691-
| LogicalPlan::Unnest(_)
692724
| LogicalPlan::RecursiveQuery(_)
693725
| LogicalPlan::Subquery(_)
694726
| LogicalPlan::SubqueryAlias(_)

datafusion/optimizer/src/extract_leaf_expressions.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,7 +1151,6 @@ fn try_push_into_inputs(
11511151

11521152
// Unnest may output a column with the same name but different value/type
11531153
// than its input column. Name-based routing cannot distinguish those.
1154-
// On top of that Unnest can't go through the `node.with_new_exprs(node.expressions(), new_inputs)` rebuild
11551154
if matches!(node, LogicalPlan::Unnest(_)) {
11561155
return Ok(None);
11571156
}
@@ -3046,16 +3045,15 @@ mod tests {
30463045
Ok(())
30473046
}
30483047

3049-
/// Regression test for the `Assertion failed: expr.is_empty(): Unnest`
3050-
/// internal error.
3048+
/// Regression test: the optimizer must not push extractions through
3049+
/// `Unnest`.
30513050
///
3052-
/// `try_push_into_inputs` rebuilds the parent node via
3053-
/// `node.with_new_exprs(node.expressions(), new_inputs)`. For `Unnest`,
3054-
/// `apply_expressions` exposes the `exec_columns` as `Expr::Column`s
3055-
/// (so `expressions()` is **non-empty**), but `with_new_exprs` for
3056-
/// `Unnest` immediately calls `assert_no_expressions(expr)?` and errors
3057-
/// out. The optimizer should treat `Unnest` as a barrier and bail
3058-
/// instead of attempting to push through it.
3051+
/// `try_push_into_inputs` routes extracted pairs to inputs by column name.
3052+
/// `Unnest` can emit an output column with the same name as its input
3053+
/// column but a different value/type (the unnested element), so name-based
3054+
/// routing cannot tell the two apart. `try_push_into_inputs` therefore
3055+
/// treats `Unnest` as a barrier and bails instead of pushing through it
3056+
/// (see the `matches!(node, LogicalPlan::Unnest(_))` guard there).
30593057
#[test]
30603058
fn test_no_push_through_unnest() -> Result<()> {
30613059
use arrow::datatypes::{DataType, Field, Schema};

0 commit comments

Comments
 (0)