Skip to content

Commit 1ff782e

Browse files
committed
refactor(join): deprecate positional pushdown
The public helper infers column ownership from output position, so it cannot represent synthetic join columns. It also encourages callers to rely on the left-then-right schema assumption. Keep it as a compatibility wrapper and expose the ColumnIndex-aware helper as its replacement. Document and validate the public mapping so malformed metadata returns planning errors instead of panicking. Refs #23010 Signed-off-by: Jiawei Zhao <Phoenix500526@163.com>
1 parent e9db144 commit 1ff782e

1 file changed

Lines changed: 162 additions & 3 deletions

File tree

datafusion/physical-plan/src/projection.rs

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ use datafusion_common::config::ConfigOptions;
4646
use 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};
5050
use datafusion_execution::TaskContext;
5151
use datafusion_expr::ExpressionPlacement;
5252
use 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+
)]
645649
pub 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

Comments
 (0)