Skip to content

Commit 08da279

Browse files
authored
[branch-54] fix: preserve null_aware on logical JoinNode proto round-trip (backport apache#22104) (apache#22785)
## Which issue does this PR close? - Backport of apache#22104 to `branch-54` (for 54.1.0, tracked in apache#22547). This PR: - Backports apache#22104 to the `branch-54` line so the `null_aware` proto round-trip fix ships in 54.1.0, as requested in apache#22065 (comment) Clean cherry-pick; `datafusion-proto` builds and both round-trip regression tests pass on `branch-54`.
1 parent 45d943d commit 08da279

5 files changed

Lines changed: 147 additions & 37 deletions

File tree

datafusion/proto/proto/datafusion.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ message JoinNode {
252252
repeated LogicalExprNode right_join_key = 6;
253253
datafusion_common.NullEquality null_equality = 7;
254254
LogicalExprNode filter = 8;
255+
bool null_aware = 9;
255256
}
256257

257258
message DistinctNode {

datafusion/proto/src/generated/pbjson.rs

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

datafusion/proto/src/generated/prost.rs

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

datafusion/proto/src/logical_plan/mod.rs

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,8 @@ use datafusion_expr::{
6464
Statement, WindowUDF, dml,
6565
logical_plan::{
6666
Aggregate, CreateCatalog, CreateCatalogSchema, CreateExternalTable, CreateView,
67-
DdlStatement, Distinct, EmptyRelation, Extension, Join, JoinConstraint, Prepare,
68-
Projection, Repartition, Sort, SubqueryAlias, TableScan, Values, Window,
69-
builder::project,
67+
DdlStatement, Distinct, EmptyRelation, Extension, Join, Prepare, Projection,
68+
Repartition, Sort, SubqueryAlias, TableScan, Values, Window, builder::project,
7069
},
7170
};
7271

@@ -850,6 +849,13 @@ impl AsLogicalPlan for LogicalPlanNode {
850849
from_proto::parse_exprs(&join.left_join_key, ctx, extension_codec)?;
851850
let right_keys: Vec<Expr> =
852851
from_proto::parse_exprs(&join.right_join_key, ctx, extension_codec)?;
852+
if left_keys.len() != right_keys.len() {
853+
return Err(proto_error(format!(
854+
"Received a JoinNode message with left_join_key and right_join_key of different lengths: {} and {}",
855+
left_keys.len(),
856+
right_keys.len()
857+
)));
858+
}
853859
let join_type =
854860
protobuf::JoinType::try_from(join.join_type).map_err(|_| {
855861
proto_error(format!(
@@ -866,44 +872,39 @@ impl AsLogicalPlan for LogicalPlanNode {
866872
join.join_constraint
867873
))
868874
})?;
875+
let null_equality = protobuf::NullEquality::try_from(join.null_equality)
876+
.map_err(|_| {
877+
proto_error(format!(
878+
"Received a JoinNode message with unknown NullEquality {}",
879+
join.null_equality
880+
))
881+
})?;
869882
let filter: Option<Expr> = join
870883
.filter
871884
.as_ref()
872885
.map(|expr| from_proto::parse_expr(expr, ctx, extension_codec))
873886
.map_or(Ok(None), |v| v.map(Some))?;
874-
875-
let builder = LogicalPlanBuilder::from(into_logical_plan!(
876-
join.left,
877-
ctx,
878-
extension_codec
879-
)?);
880-
let builder = match join_constraint.into() {
881-
JoinConstraint::On => builder.join_with_expr_keys(
882-
into_logical_plan!(join.right, ctx, extension_codec)?,
883-
join_type.into(),
884-
(left_keys, right_keys),
885-
filter,
886-
)?,
887-
JoinConstraint::Using => {
888-
// The equijoin keys in using-join must be column.
889-
let using_keys = left_keys
890-
.into_iter()
891-
.map(|key| {
892-
key.try_as_col().cloned()
893-
.ok_or_else(|| internal_datafusion_err!(
894-
"Using join keys must be column references, got: {key:?}"
895-
))
896-
})
897-
.collect::<Result<Vec<_>, _>>()?;
898-
builder.join_using(
899-
into_logical_plan!(join.right, ctx, extension_codec)?,
900-
join_type.into(),
901-
using_keys,
902-
)?
903-
}
904-
};
905-
906-
builder.build()
887+
let left = into_logical_plan!(join.left, ctx, extension_codec)?;
888+
let right = into_logical_plan!(join.right, ctx, extension_codec)?;
889+
let on: Vec<(Expr, Expr)> =
890+
left_keys.into_iter().zip(right_keys).collect();
891+
892+
// Construct the Join directly instead of going through
893+
// LogicalPlanBuilder. The builder methods hardcode
894+
// `null_equality` and `null_aware`, so a round trip through
895+
// them silently loses both fields. Both sides of the round
896+
// trip should already have validated keys, so we don't need
897+
// the builder's normalization / equijoin-pair checks.
898+
Ok(LogicalPlan::Join(Join::try_new(
899+
Arc::new(left),
900+
Arc::new(right),
901+
on,
902+
filter,
903+
join_type.into(),
904+
join_constraint.into(),
905+
null_equality.into(),
906+
join.null_aware,
907+
)?))
907908
}
908909
LogicalPlanType::Union(union) => {
909910
assert_or_internal_err!(
@@ -1492,7 +1493,9 @@ impl AsLogicalPlan for LogicalPlanNode {
14921493
join_type,
14931494
join_constraint,
14941495
null_equality,
1495-
..
1496+
null_aware,
1497+
// Not encoded; recomputed by `Join::try_new` on decode.
1498+
schema: _,
14961499
}) => {
14971500
let left: LogicalPlanNode = LogicalPlanNode::try_from_logical_plan(
14981501
left.as_ref(),
@@ -1533,6 +1536,7 @@ impl AsLogicalPlan for LogicalPlanNode {
15331536
right_join_key,
15341537
null_equality: null_equality.into(),
15351538
filter,
1539+
null_aware: *null_aware,
15361540
},
15371541
))),
15381542
})

datafusion/proto/tests/cases/roundtrip_logical_plan.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3173,3 +3173,88 @@ async fn roundtrip_empty_table_scan_with_projection() -> Result<()> {
31733173
);
31743174
Ok(())
31753175
}
3176+
3177+
// Regression test for https://github.com/apache/datafusion/issues/22065:
3178+
// the decoder must preserve `null_aware = true` (NOT IN semantics)
3179+
// across a to_proto -> from_proto round trip. `null_equality` is at
3180+
// its default (`NullEqualsNothing`).
3181+
#[tokio::test]
3182+
async fn roundtrip_join_null_aware() -> Result<()> {
3183+
use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
3184+
use datafusion_expr::JoinType;
3185+
3186+
let ctx = SessionContext::new();
3187+
let sql = "
3188+
SELECT id
3189+
FROM (VALUES (1), (2), (3)) AS t1(id)
3190+
WHERE id NOT IN (
3191+
SELECT bad_id
3192+
FROM (VALUES (CAST(1 AS INT)), (CAST(NULL AS INT))) AS excludes(bad_id)
3193+
)
3194+
";
3195+
3196+
let df = ctx.sql(sql).await?;
3197+
let plan = ctx.state().optimize(df.logical_plan())?;
3198+
3199+
let mut found_null_aware = false;
3200+
plan.apply(|n| {
3201+
if let LogicalPlan::Join(j) = n
3202+
&& j.join_type == JoinType::LeftAnti
3203+
&& j.null_aware
3204+
{
3205+
found_null_aware = true;
3206+
}
3207+
Ok(TreeNodeRecursion::Continue)
3208+
})?;
3209+
assert!(found_null_aware);
3210+
3211+
let bytes = logical_plan_to_bytes(&plan)?;
3212+
let logical_round_trip = logical_plan_from_bytes(&bytes, &ctx.task_ctx())?;
3213+
assert_eq!(format!("{plan:?}"), format!("{logical_round_trip:?}"));
3214+
3215+
Ok(())
3216+
}
3217+
3218+
// Regression test for `null_equality` round-trip (related to #22065):
3219+
// the decoder must preserve a non-default `null_equality`
3220+
// (`NullEqualsNull`) across a to_proto -> from_proto round trip.
3221+
// `null_aware` is at its default (`false`).
3222+
#[tokio::test]
3223+
async fn roundtrip_join_null_equality() -> Result<()> {
3224+
use datafusion_common::NullEquality;
3225+
use datafusion_expr::JoinType;
3226+
use datafusion_expr::logical_plan::{Join, JoinConstraint};
3227+
3228+
let ctx = SessionContext::new();
3229+
3230+
let left_schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, true)]));
3231+
let right_schema =
3232+
Arc::new(Schema::new(vec![Field::new("b", DataType::Int32, true)]));
3233+
ctx.register_table(
3234+
"t1",
3235+
Arc::new(datafusion::datasource::empty::EmptyTable::new(left_schema)),
3236+
)?;
3237+
ctx.register_table(
3238+
"t2",
3239+
Arc::new(datafusion::datasource::empty::EmptyTable::new(right_schema)),
3240+
)?;
3241+
let left = ctx.table("t1").await?.into_optimized_plan()?;
3242+
let right = ctx.table("t2").await?.into_optimized_plan()?;
3243+
3244+
let join = LogicalPlan::Join(Join::try_new(
3245+
Arc::new(left),
3246+
Arc::new(right),
3247+
vec![(col("t1.a"), col("t2.b"))],
3248+
None,
3249+
JoinType::Inner,
3250+
JoinConstraint::On,
3251+
NullEquality::NullEqualsNull,
3252+
false,
3253+
)?);
3254+
3255+
let bytes = logical_plan_to_bytes(&join)?;
3256+
let rt = logical_plan_from_bytes(&bytes, &ctx.task_ctx())?;
3257+
assert_eq!(format!("{join:?}"), format!("{rt:?}"));
3258+
3259+
Ok(())
3260+
}

0 commit comments

Comments
 (0)