@@ -46,7 +46,7 @@ use datafusion_common::config::ConfigOptions;
4646use datafusion_common:: tree_node:: {
4747 Transformed , TransformedResult , TreeNode , TreeNodeRecursion ,
4848} ;
49- use datafusion_common:: { DataFusionError , JoinSide , Result , internal_err} ;
49+ use datafusion_common:: { DataFusionError , JoinSide , Result , internal_err, plan_err } ;
5050use datafusion_execution:: TaskContext ;
5151use datafusion_expr:: ExpressionPlacement ;
5252use datafusion_physical_expr:: equivalence:: ProjectionMapping ;
@@ -642,6 +642,10 @@ pub struct JoinData {
642642 pub join_on : JoinOn ,
643643}
644644
645+ #[ deprecated(
646+ since = "55.0.0" ,
647+ note = "Use try_pushdown_through_join_with_column_indices instead"
648+ ) ]
645649pub fn try_pushdown_through_join (
646650 projection : & ProjectionExec ,
647651 join_left : & Arc < dyn ExecutionPlan > ,
@@ -681,7 +685,25 @@ pub fn try_pushdown_through_join(
681685 )
682686}
683687
684- pub ( crate ) fn try_pushdown_through_join_with_column_indices (
688+ /// Attempts to move a projection below a join by mapping each join output
689+ /// column to the child column that produced it.
690+ ///
691+ /// `schema` is the complete output schema of the join, not either child's
692+ /// schema. `column_indices` must contain one entry for each field in `schema`.
693+ /// Each [`JoinSide::Left`] or [`JoinSide::Right`] entry identifies the source
694+ /// child and uses an index relative to that child's schema.
695+ ///
696+ /// [`JoinSide::None`] identifies a column produced by the join itself, such as
697+ /// a mark column. If `projection` references such a column, this function
698+ /// returns `Ok(None)` because neither child can produce it.
699+ ///
700+ /// Returns `Ok(None)` when the projection cannot be pushed down safely.
701+ ///
702+ /// # Errors
703+ ///
704+ /// Returns an error if `column_indices` does not match `schema` or contains an
705+ /// index outside the corresponding child schema.
706+ pub fn try_pushdown_through_join_with_column_indices (
685707 projection : & ProjectionExec ,
686708 join_left : & Arc < dyn ExecutionPlan > ,
687709 join_right : & Arc < dyn ExecutionPlan > ,
@@ -690,6 +712,29 @@ pub(crate) fn try_pushdown_through_join_with_column_indices(
690712 filter : Option < & JoinFilter > ,
691713 column_indices : & [ ColumnIndex ] ,
692714) -> Result < Option < JoinData > > {
715+ if column_indices. len ( ) != schema. fields ( ) . len ( ) {
716+ return plan_err ! (
717+ "Column index mapping has {} entries but join schema has {} fields" ,
718+ column_indices. len( ) ,
719+ schema. fields( ) . len( )
720+ ) ;
721+ }
722+ // Validate each output-to-child mapping before using it to rewrite the
723+ // projection. Synthetic outputs have no child index to validate.
724+ for ( output_index, column_index) in column_indices. iter ( ) . enumerate ( ) {
725+ let ( side, child_field_count) = match column_index. side {
726+ JoinSide :: Left => ( "left" , join_left. schema ( ) . fields ( ) . len ( ) ) ,
727+ JoinSide :: Right => ( "right" , join_right. schema ( ) . fields ( ) . len ( ) ) ,
728+ JoinSide :: None => continue ,
729+ } ;
730+ if column_index. index >= child_field_count {
731+ return plan_err ! (
732+ "Join output column {output_index} maps to {side} child column {}, but the child has {child_field_count} fields" ,
733+ column_index. index
734+ ) ;
735+ }
736+ }
737+
693738 // Convert projected expressions to columns. We can not proceed if this is not possible.
694739 let Some ( projection_as_columns) = physical_to_column_exprs ( projection. expr ( ) ) else {
695740 return Ok ( None ) ;
@@ -702,7 +747,13 @@ pub(crate) fn try_pushdown_through_join_with_column_indices(
702747 let mut right_proj: Vec < ( Column , String ) > = Vec :: new ( ) ;
703748 let mut seen_right = false ;
704749 for ( col, alias) in & projection_as_columns {
705- let origin = & column_indices[ col. index ( ) ] ;
750+ let Some ( origin) = column_indices. get ( col. index ( ) ) else {
751+ return plan_err ! (
752+ "Projection column {} is outside the {}-entry column index mapping" ,
753+ col. index( ) ,
754+ column_indices. len( )
755+ ) ;
756+ } ;
706757 match origin. side {
707758 // Keep the "left block before right block" contiguity the current
708759 // pushdown supports; a left column after a right one is "mixed".
@@ -1257,6 +1308,7 @@ mod tests {
12571308 use super :: * ;
12581309
12591310 use crate :: common:: collect;
1311+ use crate :: empty:: EmptyExec ;
12601312
12611313 use crate :: filter_pushdown:: PushedDown ;
12621314 use crate :: statistics:: StatisticsArgs ;
@@ -1292,6 +1344,113 @@ mod tests {
12921344 Ok ( ( ) )
12931345 }
12941346
1347+ #[ test]
1348+ fn test_try_pushdown_through_join_validates_column_indices ( ) -> Result < ( ) > {
1349+ let child_schema =
1350+ Arc :: new ( Schema :: new ( vec ! [ Field :: new( "i" , DataType :: Int32 , false ) ] ) ) ;
1351+ let left: Arc < dyn ExecutionPlan > =
1352+ Arc :: new ( EmptyExec :: new ( Arc :: clone ( & child_schema) ) ) ;
1353+ let right: Arc < dyn ExecutionPlan > = Arc :: new ( EmptyExec :: new ( child_schema) ) ;
1354+ let join_schema = Arc :: new ( Schema :: new ( vec ! [
1355+ Field :: new( "left_i" , DataType :: Int32 , false ) ,
1356+ Field :: new( "right_i" , DataType :: Int32 , false ) ,
1357+ ] ) ) ;
1358+ let join: Arc < dyn ExecutionPlan > =
1359+ Arc :: new ( EmptyExec :: new ( Arc :: clone ( & join_schema) ) ) ;
1360+ let projection = ProjectionExec :: try_new (
1361+ vec ! [ ProjectionExpr {
1362+ expr: Arc :: new( Column :: new( "left_i" , 0 ) ) ,
1363+ alias: "left_i" . to_string( ) ,
1364+ } ] ,
1365+ join,
1366+ ) ?;
1367+
1368+ let Err ( error) = try_pushdown_through_join_with_column_indices (
1369+ & projection,
1370+ & left,
1371+ & right,
1372+ & [ ] ,
1373+ & join_schema,
1374+ None ,
1375+ & [ ] ,
1376+ ) else {
1377+ panic ! ( "expected a mismatched mapping length to return an error" ) ;
1378+ } ;
1379+ assert ! (
1380+ error. to_string( ) . contains(
1381+ "Column index mapping has 0 entries but join schema has 2 fields"
1382+ )
1383+ ) ;
1384+
1385+ let invalid_child_index = [
1386+ ColumnIndex {
1387+ index : 1 ,
1388+ side : JoinSide :: Left ,
1389+ } ,
1390+ ColumnIndex {
1391+ index : 0 ,
1392+ side : JoinSide :: Right ,
1393+ } ,
1394+ ] ;
1395+ let Err ( error) = try_pushdown_through_join_with_column_indices (
1396+ & projection,
1397+ & left,
1398+ & right,
1399+ & [ ] ,
1400+ & join_schema,
1401+ None ,
1402+ & invalid_child_index,
1403+ ) else {
1404+ panic ! ( "expected an invalid child index to return an error" ) ;
1405+ } ;
1406+ assert ! ( error. to_string( ) . contains(
1407+ "Join output column 0 maps to left child column 1, but the child has 1 fields"
1408+ ) ) ;
1409+
1410+ let wider_join_schema = Arc :: new ( Schema :: new ( vec ! [
1411+ Field :: new( "left_i" , DataType :: Int32 , false ) ,
1412+ Field :: new( "right_i" , DataType :: Int32 , false ) ,
1413+ Field :: new( "extra" , DataType :: Int32 , false ) ,
1414+ ] ) ) ;
1415+ let wider_join: Arc < dyn ExecutionPlan > =
1416+ Arc :: new ( EmptyExec :: new ( wider_join_schema) ) ;
1417+ let out_of_mapping_projection = ProjectionExec :: try_new (
1418+ vec ! [ ProjectionExpr {
1419+ expr: Arc :: new( Column :: new( "extra" , 2 ) ) ,
1420+ alias: "extra" . to_string( ) ,
1421+ } ] ,
1422+ wider_join,
1423+ ) ?;
1424+ let valid_child_indices = [
1425+ ColumnIndex {
1426+ index : 0 ,
1427+ side : JoinSide :: Left ,
1428+ } ,
1429+ ColumnIndex {
1430+ index : 0 ,
1431+ side : JoinSide :: Right ,
1432+ } ,
1433+ ] ;
1434+ let Err ( error) = try_pushdown_through_join_with_column_indices (
1435+ & out_of_mapping_projection,
1436+ & left,
1437+ & right,
1438+ & [ ] ,
1439+ & join_schema,
1440+ None ,
1441+ & valid_child_indices,
1442+ ) else {
1443+ panic ! ( "expected an out-of-mapping projection to return an error" ) ;
1444+ } ;
1445+ assert ! (
1446+ error. to_string( ) . contains(
1447+ "Projection column 2 is outside the 2-entry column index mapping"
1448+ )
1449+ ) ;
1450+
1451+ Ok ( ( ) )
1452+ }
1453+
12951454 #[ test]
12961455 fn test_join_table_borders ( ) -> Result < ( ) > {
12971456 let projections = vec ! [
0 commit comments