Is your feature request related to a problem or challenge?
ExtractLeafExpressions and PushDownLeafProjections resolve columns by name. Every decision the two rules make about which column is which compares strings. This is the root of a series of planning failures and wrong results, and each fix so far has patched one comparison.
The name keyed sites, all in datafusion/optimizer/src/extract_leaf_expressions.rs:
| Site |
Key |
build_projection_replace_map |
Column::flat_name() of the projection output |
replace_cols_by_name (from push_down_filter.rs, called by both loops of build_extraction_projection_impl) |
exact flat_name() string match |
build_extraction_projection_impl, pass-through loop |
existing_cols holds bare Expr::Column entries only |
build_extraction_projection_impl, extracted expression loop |
expression equality after the flat_name() rewrite |
split_and_push_projection, needs_recovery |
set of unqualified field names, BTreeSet<&str> |
find_owning_input and route_to_inputs |
ColumnReference sets per input |
is_pure_extraction_projection, routing_extract, advance_generator_past_existing |
starts_with(EXTRACTED_EXPR_PREFIX) on the alias name |
The bugs these sites produced:
The physical layer has not had this class of bug. A physical expression names a column as col@idx, so a rename cannot make two columns look like one.
OptimizeProjections also has not had this class, and it works over the same plans. It walks down carrying RequiredIndices, a sorted set of column indices into the child schema (datafusion/optimizer/src/optimize_projections/required_indices.rs), and rewrites the plan on the way back up. It never moves a node through another node.
Describe the solution you'd like
Give the leaf rules the same shape as OptimizeProjections.
- Walk the plan top-down. Carry, per scan, the set of leaf expressions that the subtree above needs. Identify each requirement by the child schema index it reads, not by a name.
- Rewrite bottom-up. Build the extraction projection directly above the scan, once, from that set. Rewrite each consumer on the way back up to read the column the new projection produces, again by index.
- Delete the "move a Projection node through one node type at a time" machinery:
push_extraction_pairs, route_to_inputs, split_and_push_projection and the recovery projection logic. A single bottom-up rewrite has no intermediate plan whose schema can drift, so there is nothing to recover.
This is a large refactor. It replaces most of a 3300 line file. I suggest it lands as one change rather than as a migration, because the two designs cannot both own the extraction projection.
Describe alternatives you've considered
Keep the current shape and key every comparison on (qualifier, name, type). This is what the individual fixes do. It closes each shape as it is reported. It does not close the class, because a plan can hold two fields with the same qualifier, name and type.
Keep the current shape and thread a (plan node, index) identity through the existing helpers. This gets the right identity, but the helpers still move a Projection node through Filter, Sort, Limit, Aggregate, Join, Union and SubqueryAlias one at a time. Each move needs its own remap, so the number of places that can be wrong does not change.
Additional context
The guard set for the refactor:
- 55 unit tests in
datafusion/optimizer/src/extract_leaf_expressions.rs. They assert plan text at each of the two passes, so they show any change of shape.
datafusion/sqllogictest/test_files/: struct.slt, map.slt, dictionary_struct.slt, projection_pushdown.slt, projection.slt, cse.slt, parquet_nested_schema_pruning.slt, schema_evolution_nested.slt, subquery_projection.slt.
- The MREs of 25414, 25412, 24241 and 22895 belong in
struct.slt as part of the refactor.
A differential fuzz would raise confidence a lot. Run the same statement with datafusion.optimizer.enable_leaf_expression_pushdown set to true and to false, and compare the rows. A grid of 900 generated statements over a three column table with one struct column found 207 planning errors and two wrong result shapes on main that way.
Tracked in the leaf-pushdown EPIC: #25459
Is your feature request related to a problem or challenge?
ExtractLeafExpressionsandPushDownLeafProjectionsresolve columns by name. Every decision the two rules make about which column is which compares strings. This is the root of a series of planning failures and wrong results, and each fix so far has patched one comparison.The name keyed sites, all in
datafusion/optimizer/src/extract_leaf_expressions.rs:build_projection_replace_mapColumn::flat_name()of the projection outputreplace_cols_by_name(frompush_down_filter.rs, called by both loops ofbuild_extraction_projection_impl)flat_name()string matchbuild_extraction_projection_impl, pass-through loopexisting_colsholds bareExpr::Columnentries onlybuild_extraction_projection_impl, extracted expression loopflat_name()rewritesplit_and_push_projection,needs_recoveryBTreeSet<&str>find_owning_inputandroute_to_inputsColumnReferencesets per inputis_pure_extraction_projection,routing_extract,advance_generator_past_existingstarts_with(EXTRACTED_EXPR_PREFIX)on the alias nameThe bugs these sites produced:
needs_recoverycompares names only, so(- t.a) AS alooks like the table columna, and the recovery projection is dropped. Wrong results in the default configuration.p.__datafusion_extracted_1and a bare__datafusion_extracted_1in one schema. Planning error.optimize_projectionsfails with "No field named ..." when join keys containget_field(ExtractLeafExpressions) #22895: an extracted join key makesoptimize_projectionsfail with "No field named".The physical layer has not had this class of bug. A physical expression names a column as
col@idx, so a rename cannot make two columns look like one.OptimizeProjectionsalso has not had this class, and it works over the same plans. It walks down carryingRequiredIndices, a sorted set of column indices into the child schema (datafusion/optimizer/src/optimize_projections/required_indices.rs), and rewrites the plan on the way back up. It never moves a node through another node.Describe the solution you'd like
Give the leaf rules the same shape as
OptimizeProjections.push_extraction_pairs,route_to_inputs,split_and_push_projectionand the recovery projection logic. A single bottom-up rewrite has no intermediate plan whose schema can drift, so there is nothing to recover.This is a large refactor. It replaces most of a 3300 line file. I suggest it lands as one change rather than as a migration, because the two designs cannot both own the extraction projection.
Describe alternatives you've considered
Keep the current shape and key every comparison on
(qualifier, name, type). This is what the individual fixes do. It closes each shape as it is reported. It does not close the class, because a plan can hold two fields with the same qualifier, name and type.Keep the current shape and thread a
(plan node, index)identity through the existing helpers. This gets the right identity, but the helpers still move a Projection node through Filter, Sort, Limit, Aggregate, Join, Union and SubqueryAlias one at a time. Each move needs its own remap, so the number of places that can be wrong does not change.Additional context
The guard set for the refactor:
datafusion/optimizer/src/extract_leaf_expressions.rs. They assert plan text at each of the two passes, so they show any change of shape.datafusion/sqllogictest/test_files/:struct.slt,map.slt,dictionary_struct.slt,projection_pushdown.slt,projection.slt,cse.slt,parquet_nested_schema_pruning.slt,schema_evolution_nested.slt,subquery_projection.slt.struct.sltas part of the refactor.A differential fuzz would raise confidence a lot. Run the same statement with
datafusion.optimizer.enable_leaf_expression_pushdownset totrueand tofalse, and compare the rows. A grid of 900 generated statements over a three column table with one struct column found 207 planning errors and two wrong result shapes onmainthat way.Tracked in the leaf-pushdown EPIC: #25459