This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new ed22a4aa19 feat: support co-partitioned range right-side equi hash 
joins (#23484)
ed22a4aa19 is described below

commit ed22a4aa19693fccfd47a5beded42d4be1ac3dee
Author: Gustavo Schneiter <[email protected]>
AuthorDate: Tue Jul 21 04:06:08 2026 -0300

    feat: support co-partitioned range right-side equi hash joins (#23484)
    
    ## Which issue does this PR close?
    
    - Closes #23453.
    - Part of #22395.
    
    ## Rationale for this change
    
    #23184 let compatible range-partitioned inputs satisfy **inner**
    partitioned hash joins without repartitioning. Right-side partitioned
    equi joins have the same locality guarantee: `Right`, `RightSemi`,
    `RightAnti` and `RightMark` all anchor every output row on the probe
    (right) partition (`on_lr_is_preserved` is probe-side for all four), so
    when both inputs are co-partitioned by the join keys, execution stays
    partition-local and the hash repartition is unnecessary.
    
    This removes unnecessary `RepartitionExec`s for already-co-located
    inputs, extending the inner-join behavior from #23184 to the right-side
    variants. No micro-benchmark included, consistent with #23184;
    correctness is demonstrated by matched/unmatched execution results
    below.
    
    ## What changes are included in this PR?
    
    The only production change is widening the existing inner-only gate in
    `HashJoinExec::input_distribution_requirements` from `join_type ==
    JoinType::Inner` to `matches!(join_type, Inner | Right | RightSemi |
    RightAnti | RightMark)` (under `PartitionMode::Partitioned`). The
    `co_partitioned` / range-satisfaction machinery from #23184 is unchanged
    — the sanity checker and enforce_distribution consume range satisfaction
    join-type-agnostically.
    
    One behavior note for #23376: range co-partitioned right joins now stay
    `Range`/`Range`, so partitioned dynamic filters are disabled for them
    (`has_partitioned_dynamic_filter_routing` returns false), the same safe
    delta #23184 introduced for inner joins. These variants are
    probe-not-preserved for pruning, so dynamic filters were not eligible to
    prune their probe rows regardless.
    
    ## Are these changes tested?
    
    Yes.
    
    - Optimizer (`enforce_distribution.rs`): 4 reuse tests (one per join
    type) proving compatible range/range inputs keep `Range` partitioning
    with no `RepartitionExec`; 4 incompatibility tests proving that
    mismatched split points, sort options, partition counts, or join-key
    expressions still insert a hash repartition; sanity-check pairs
    mirroring #23184.
    - Execution (`range_partitioning.slt`): `EXPLAIN` plan pins plus
    matched/unmatched result checks for `Right` (incl. `NULL` left values),
    `RightSemi`, `RightAnti`, and an incompatible-layout `Right` join
    (repartitions, correct results).
    - The new reuse tests fail on `main` without the gate change (verified
    by reverting the production diff: exactly the 4 reuse tests fail,
    everything else passes).
    - `RightMark` testing note: `RightMark` is not reachable from SQL in
    sqllogictest (`IN`-subquery decorrelation emits `LeftMark`; physical
    `RightMark` only appears via a statistics-based swap), so its
    co-partitioning behavior is pinned at the optimizer/plan level, and mark
    null-marker semantics (matched/unmatched/`NULL` build keys) are pinned
    via the `LeftMark` path in the slt.
    
    ## Are there any user-facing changes?
    
    No API changes. Plans over compatible range-partitioned inputs avoid a
    hash repartition for right-side equi hash joins.
---
 .../physical_optimizer/enforce_distribution.rs     |  73 +++++
 .../tests/physical_optimizer/sanity_checker.rs     |  26 ++
 .../physical-plan/src/joins/hash_join/exec.rs      |  10 +-
 .../src/test_context/range_partitioning.rs         |  28 ++
 .../sqllogictest/test_files/range_partitioning.slt | 338 +++++++++++++++++++--
 5 files changed, 449 insertions(+), 26 deletions(-)

diff --git a/datafusion/core/tests/physical_optimizer/enforce_distribution.rs 
b/datafusion/core/tests/physical_optimizer/enforce_distribution.rs
index 27079011ea..5d405c50cb 100644
--- a/datafusion/core/tests/physical_optimizer/enforce_distribution.rs
+++ b/datafusion/core/tests/physical_optimizer/enforce_distribution.rs
@@ -870,6 +870,79 @@ fn 
range_inner_hash_join_rehashes_incompatible_range_partitioning() -> Result<()
     Ok(())
 }
 
+#[test]
+fn range_right_mark_hash_join_reuses_range_partitioning() -> Result<()> {
+    let left = parquet_exec_with_output_partitioning(range_partitioning(
+        "a",
+        [10, 20, 30],
+        SortOptions::default(),
+    )?);
+    let right = parquet_exec_with_output_partitioning(range_partitioning(
+        "a",
+        [10, 20, 30],
+        SortOptions::default(),
+    )?);
+    let join_on = vec![(
+        Arc::new(Column::new_with_schema("a", &left.schema())?) as _,
+        Arc::new(Column::new_with_schema("a", &right.schema())?) as _,
+    )];
+    let join = hash_join_exec(left, right, &join_on, &JoinType::RightMark);
+
+    let plan = TestConfig::default()
+        .with_query_execution_partitions(4)
+        .to_plan(join, &DISTRIB_DISTRIB_SORT);
+
+    assert_plan!(
+        plan,
+        @r"
+    HashJoinExec: mode=Partitioned, join_type=RightMark, on=[(a@0, a@0)]
+      DataSourceExec: file_groups={4 groups: [[p0], [p1], [p2], [p3]]}, 
projection=[a, b, c, d, e], output_partitioning=Range([a@0 ASC], [(10), (20), 
(30)], 4), file_type=parquet
+      DataSourceExec: file_groups={4 groups: [[p0], [p1], [p2], [p3]]}, 
projection=[a, b, c, d, e], output_partitioning=Range([a@0 ASC], [(10), (20), 
(30)], 4), file_type=parquet
+    "
+    );
+
+    Ok(())
+}
+
+#[test]
+fn range_right_semi_hash_join_rehashes_incompatible_sort_options() -> 
Result<()> {
+    let left = parquet_exec_with_output_partitioning(range_partitioning(
+        "a",
+        [20],
+        SortOptions::default(),
+    )?);
+    let right = parquet_exec_with_output_partitioning(range_partitioning(
+        "a",
+        [20],
+        SortOptions {
+            descending: true,
+            nulls_first: true,
+        },
+    )?);
+    let join_on = vec![(
+        Arc::new(Column::new_with_schema("a", &left.schema())?) as _,
+        Arc::new(Column::new_with_schema("a", &right.schema())?) as _,
+    )];
+    let join = hash_join_exec(left, right, &join_on, &JoinType::RightSemi);
+
+    let plan = TestConfig::default()
+        .with_query_execution_partitions(4)
+        .to_plan(join, &DISTRIB_DISTRIB_SORT);
+
+    assert_plan!(
+        plan,
+        @r"
+    HashJoinExec: mode=Partitioned, join_type=RightSemi, on=[(a@0, a@0)]
+      RepartitionExec: partitioning=Hash([a@0], 4), input_partitions=2
+        DataSourceExec: file_groups={2 groups: [[p0], [p1]]}, projection=[a, 
b, c, d, e], output_partitioning=Range([a@0 ASC], [(20)], 2), file_type=parquet
+      RepartitionExec: partitioning=Hash([a@0], 4), input_partitions=2
+        DataSourceExec: file_groups={2 groups: [[p0], [p1]]}, projection=[a, 
b, c, d, e], output_partitioning=Range([a@0 DESC], [(20)], 2), file_type=parquet
+    "
+    );
+
+    Ok(())
+}
+
 #[test]
 fn range_window_reuses_range_partitioning() -> Result<()> {
     let input = parquet_exec_with_output_partitioning(range_partitioning(
diff --git a/datafusion/core/tests/physical_optimizer/sanity_checker.rs 
b/datafusion/core/tests/physical_optimizer/sanity_checker.rs
index 3c426e2b09..184125dcbe 100644
--- a/datafusion/core/tests/physical_optimizer/sanity_checker.rs
+++ b/datafusion/core/tests/physical_optimizer/sanity_checker.rs
@@ -445,6 +445,32 @@ fn 
test_partitioned_hash_join_requires_co_partitioned_children() -> Result<()> {
     Ok(())
 }
 
+#[test]
+fn test_partitioned_right_hash_join_requires_co_partitioned_children() -> 
Result<()> {
+    let schema = create_test_schema2();
+    let join_on = vec![(col("a", &schema)?, col("a", &schema)?)];
+
+    let compatible_join = hash_join_exec(
+        range_partitioned_exec(&schema, "a", [10])?,
+        range_partitioned_exec(&schema, "a", [10])?,
+        join_on.clone(),
+        None,
+        &JoinType::Right,
+    )?;
+    assert_sanity_check(&compatible_join, true);
+
+    let incompatible_join = hash_join_exec(
+        range_partitioned_exec(&schema, "a", [10])?,
+        range_partitioned_exec(&schema, "a", [20])?,
+        join_on,
+        None,
+        &JoinType::Right,
+    )?;
+    assert_sanity_check(&incompatible_join, false);
+
+    Ok(())
+}
+
 #[test]
 fn test_sort_merge_join_requires_co_partitioned_children() -> Result<()> {
     let schema = create_test_schema2();
diff --git a/datafusion/physical-plan/src/joins/hash_join/exec.rs 
b/datafusion/physical-plan/src/joins/hash_join/exec.rs
index 89fc4b5a81..64eb85ffee 100644
--- a/datafusion/physical-plan/src/joins/hash_join/exec.rs
+++ b/datafusion/physical-plan/src/joins/hash_join/exec.rs
@@ -1294,7 +1294,15 @@ impl ExecutionPlan for HashJoinExec {
         };
 
         if self.mode == PartitionMode::Partitioned
-            && matches!(self.join_type, JoinType::Inner | JoinType::Full)
+            && matches!(
+                self.join_type,
+                JoinType::Inner
+                    | JoinType::Full
+                    | JoinType::Right
+                    | JoinType::RightSemi
+                    | JoinType::RightAnti
+                    | JoinType::RightMark
+            )
         {
             requirements.allow_range_satisfaction_for_key_partitioning()
         } else {
diff --git a/datafusion/sqllogictest/src/test_context/range_partitioning.rs 
b/datafusion/sqllogictest/src/test_context/range_partitioning.rs
index d60fdcecf4..4141e00014 100644
--- a/datafusion/sqllogictest/src/test_context/range_partitioning.rs
+++ b/datafusion/sqllogictest/src/test_context/range_partitioning.rs
@@ -131,6 +131,34 @@ pub(super) fn register_range_partitioned_table(ctx: 
&SessionContext) {
         Some(shifted_output_partitioning),
     );
 
+    // Same rows as `range_partitioned` but split into only three range
+    // partitions on `range_key`. Used to exercise the co-partition check when
+    // two Range inputs disagree on partition count.
+    let narrow_output_partitioning = Partitioning::Range(
+        RangePartitioning::try_new(
+            vec![col("range_key").sort(true, true)],
+            vec![
+                SplitPoint::new(vec![ScalarValue::Int32(Some(10))]),
+                SplitPoint::new(vec![ScalarValue::Int32(Some(20))]),
+            ],
+        )
+        .expect("range partitioning should be valid"),
+    );
+
+    register_csv_listing_table(
+        ctx,
+        "range_partitioned_narrow",
+        Path::new(env!("CARGO_MANIFEST_DIR"))
+            
.join("test_files/scratch_range_partitioning/range_partitioned_narrow"),
+        Arc::clone(&schema),
+        [
+            "1,1,10\n5,2,50\n",
+            "10,1,100\n15,2,150\n",
+            "20,1,200\n25,2,250\n30,1,300\n35,2,350\n",
+        ],
+        Some(narrow_output_partitioning),
+    );
+
     let sparse_output_partitioning = Partitioning::Range(
         RangePartitioning::try_new(
             vec![col("range_key").sort(true, true)],
diff --git a/datafusion/sqllogictest/test_files/range_partitioning.slt 
b/datafusion/sqllogictest/test_files/range_partitioning.slt
index 52bb695dbe..3a84e2c6d0 100644
--- a/datafusion/sqllogictest/test_files/range_partitioning.slt
+++ b/datafusion/sqllogictest/test_files/range_partitioning.slt
@@ -395,9 +395,10 @@ ORDER BY l.non_range_key, l.value, r.value;
 2 350 350
 
 ##########
-# TEST 12: Unsupported
-# Only Inner and Full partitioned hash joins opt in to Range satisfying
-# KeyPartitioned requirements. Other join types keep using Hash repartitioning.
+# TEST 12: Left Range Join Repartitions
+# Only Inner, Full, and right-side (Right/RightSemi/RightAnti/RightMark)
+# partitioned hash joins opt in to Range satisfying KeyPartitioned
+# requirements. Other join types, such as Left, keep using Hash repartitioning.
 ##########
 
 query TT
@@ -629,7 +630,294 @@ ORDER BY l.range_key;
 35 700
 
 ##########
-# TEST 18: Sort Merge Join Avoids Repartition for Compatible Range Inputs
+# TEST 18: Right Join on Range Partition Column
+# Compatible Range inputs satisfy the join's partitioning requirements, so no
+# Hash repartitioning is inserted. The left filter keeps its Range partitioning
+# and the unmatched right rows above 150 are preserved.
+##########
+
+query TT
+EXPLAIN SELECT l.value, r.range_key, r.value
+FROM (SELECT range_key, value FROM range_partitioned WHERE value <= 150) l
+RIGHT JOIN range_partitioned r ON l.range_key = r.range_key;
+----
+physical_plan
+01)HashJoinExec: mode=Partitioned, join_type=Right, on=[(range_key@0, 
range_key@0)], projection=[value@1, range_key@2, value@3]
+02)--FilterExec: value@1 <= 150
+03)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+04)--DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, value], 
output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+
+query III
+SELECT l.value, r.range_key, r.value
+FROM (SELECT range_key, value FROM range_partitioned WHERE value <= 150) l
+RIGHT JOIN range_partitioned r ON l.range_key = r.range_key
+ORDER BY r.range_key;
+----
+10 1 10
+50 5 50
+100 10 100
+150 15 150
+NULL 20 200
+NULL 25 250
+NULL 30 300
+NULL 35 350
+
+##########
+# TEST 19: Right Semi Join on Range Partition Column
+# Compatible Range inputs avoid Hash repartitioning for RightSemi joins.
+# Only right rows with a match on the filtered left side are returned.
+##########
+
+query TT
+EXPLAIN SELECT r.range_key, r.value
+FROM (SELECT range_key FROM range_partitioned WHERE value <= 150) l
+RIGHT SEMI JOIN range_partitioned r ON l.range_key = r.range_key;
+----
+physical_plan
+01)HashJoinExec: mode=Partitioned, join_type=RightSemi, on=[(range_key@0, 
range_key@0)]
+02)--FilterExec: value@1 <= 150, projection=[range_key@0]
+03)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+04)--DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, value], 
output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+
+query II
+SELECT r.range_key, r.value
+FROM (SELECT range_key FROM range_partitioned WHERE value <= 150) l
+RIGHT SEMI JOIN range_partitioned r ON l.range_key = r.range_key
+ORDER BY r.range_key;
+----
+1 10
+5 50
+10 100
+15 150
+
+##########
+# TEST 20: Right Anti Join on Range Partition Column
+# Compatible Range inputs avoid Hash repartitioning for RightAnti joins.
+# Only right rows without a match on the filtered left side are returned.
+##########
+
+query TT
+EXPLAIN SELECT r.range_key, r.value
+FROM (SELECT range_key FROM range_partitioned WHERE value <= 150) l
+RIGHT ANTI JOIN range_partitioned r ON l.range_key = r.range_key;
+----
+physical_plan
+01)HashJoinExec: mode=Partitioned, join_type=RightAnti, on=[(range_key@0, 
range_key@0)]
+02)--FilterExec: value@1 <= 150, projection=[range_key@0]
+03)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+04)--DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, value], 
output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+
+query II
+SELECT r.range_key, r.value
+FROM (SELECT range_key FROM range_partitioned WHERE value <= 150) l
+RIGHT ANTI JOIN range_partitioned r ON l.range_key = r.range_key
+ORDER BY r.range_key;
+----
+20 200
+25 250
+30 300
+35 350
+
+##########
+# TEST 21: Incompatible Range Right Join Repartitions
+# The split points of the two inputs differ, so the co-partitioned layout
+# requirement cannot be satisfied and Hash repartitioning repairs both sides
+# of the right join. Results stay correct on the repartitioned path.
+##########
+
+query TT
+EXPLAIN SELECT l.value, r.range_key, r.value
+FROM (SELECT range_key, value FROM range_partitioned WHERE value <= 150) l
+RIGHT JOIN range_partitioned_shifted r ON l.range_key = r.range_key;
+----
+physical_plan
+01)HashJoinExec: mode=Partitioned, join_type=Right, on=[(range_key@0, 
range_key@0)], projection=[value@1, range_key@2, value@3]
+02)--RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
+03)----FilterExec: value@1 <= 150
+04)------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+05)--RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
+06)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(15), (20), (30)], 4), 
file_type=csv, has_header=false
+
+query III
+SELECT l.value, r.range_key, r.value
+FROM (SELECT range_key, value FROM range_partitioned WHERE value <= 150) l
+RIGHT JOIN range_partitioned_shifted r ON l.range_key = r.range_key
+ORDER BY r.range_key;
+----
+10 1 10
+50 5 50
+100 10 100
+150 15 150
+NULL 20 200
+NULL 25 250
+NULL 30 300
+NULL 35 350
+
+##########
+# TEST 22: Composite-Key Right Join Repartitions
+# Range([range_key]) does not satisfy a partitioned join on
+# (range_key, non_range_key), so both sides repartition on the full key.
+##########
+
+statement ok
+set datafusion.optimizer.subset_repartition_threshold = 4;
+
+query TT
+EXPLAIN SELECT l.range_key, l.non_range_key, l.value, r.value
+FROM range_partitioned l
+RIGHT JOIN range_partitioned r ON l.range_key = r.range_key AND 
l.non_range_key = r.non_range_key;
+----
+physical_plan
+01)HashJoinExec: mode=Partitioned, join_type=Right, on=[(range_key@0, 
range_key@0), (non_range_key@1, non_range_key@1)], projection=[range_key@0, 
non_range_key@1, value@2, value@5]
+02)--RepartitionExec: partitioning=Hash([range_key@0, non_range_key@1], 4), 
input_partitions=4
+03)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
non_range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), 
(20), (30)], 4), file_type=csv, has_header=false
+04)--RepartitionExec: partitioning=Hash([range_key@0, non_range_key@1], 4), 
input_partitions=4
+05)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
non_range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), 
(20), (30)], 4), file_type=csv, has_header=false
+
+query IIII
+SELECT l.range_key, l.non_range_key, l.value, r.value
+FROM range_partitioned l
+RIGHT JOIN range_partitioned r ON l.range_key = r.range_key AND 
l.non_range_key = r.non_range_key
+ORDER BY l.range_key;
+----
+1 1 10 10
+5 2 50 50
+10 1 100 100
+15 2 150 150
+20 1 200 200
+25 2 250 250
+30 1 300 300
+35 2 350 350
+
+statement ok
+reset datafusion.optimizer.subset_repartition_threshold;
+
+##########
+# TEST 23: Right Join with Mismatched Range Partition Counts Repartitions
+# Both inputs are range partitioned on range_key, but declare a different 
number
+# of partitions (four vs three). The per-child key requirements can be 
satisfied
+# by Range, but the co-partitioned layout requirement cannot, so Hash
+# repartitioning repairs both sides of the right join.
+##########
+
+query TT
+EXPLAIN SELECT l.value, r.range_key, r.value
+FROM range_partitioned l
+RIGHT JOIN range_partitioned_narrow r ON l.range_key = r.range_key;
+----
+physical_plan
+01)HashJoinExec: mode=Partitioned, join_type=Right, on=[(range_key@0, 
range_key@0)], projection=[value@1, range_key@2, value@3]
+02)--RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
+03)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+04)--RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=3
+05)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(10), (20)], 3), 
file_type=csv, has_header=false
+
+query III
+SELECT l.value, r.range_key, r.value
+FROM range_partitioned l
+RIGHT JOIN range_partitioned_narrow r ON l.range_key = r.range_key
+ORDER BY r.range_key;
+----
+10 1 10
+50 5 50
+100 10 100
+150 15 150
+200 20 200
+250 25 250
+300 30 300
+350 35 350
+
+##########
+# TEST 24: Right Join on Non-Range Key Repartitions
+# Both inputs expose Range([range_key]), but the join key is non_range_key.
+# Range([range_key]) does not satisfy KeyPartitioned([non_range_key]), so
+# planning inserts Hash repartitioning on the actual join key for the right 
join.
+##########
+
+query TT
+EXPLAIN SELECT l.value, r.range_key, r.value
+FROM (SELECT non_range_key, value FROM range_partitioned WHERE range_key < 10) 
l
+RIGHT JOIN range_partitioned r ON l.non_range_key = r.non_range_key;
+----
+physical_plan
+01)HashJoinExec: mode=Partitioned, join_type=Right, on=[(non_range_key@0, 
non_range_key@1)], projection=[value@1, range_key@2, value@4]
+02)--RepartitionExec: partitioning=Hash([non_range_key@0], 4), 
input_partitions=4
+03)----FilterExec: range_key@0 < 10, projection=[non_range_key@1, value@2]
+04)------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
non_range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), 
(20), (30)], 4), file_type=csv, has_header=false
+05)--RepartitionExec: partitioning=Hash([non_range_key@1], 4), 
input_partitions=4
+06)----DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
non_range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), 
(20), (30)], 4), file_type=csv, has_header=false
+
+query III
+SELECT l.value, r.range_key, r.value
+FROM (SELECT non_range_key, value FROM range_partitioned WHERE range_key < 10) 
l
+RIGHT JOIN range_partitioned r ON l.non_range_key = r.non_range_key
+ORDER BY r.range_key;
+----
+10 1 10
+50 5 50
+10 10 100
+50 15 150
+10 20 200
+50 25 250
+10 30 300
+50 35 350
+
+##########
+# TEST 25: Mark Join Marker Semantics
+# Mark joins preserve matched, unmatched, and NULL-key marker behavior over
+# range-partitioned inputs.
+##########
+
+query TT
+EXPLAIN SELECT r.range_key, r.value
+FROM range_partitioned r
+WHERE r.non_range_key = 2 OR r.range_key IN (
+  SELECT range_key FROM range_partitioned WHERE value <= 150);
+----
+physical_plan
+01)FilterExec: non_range_key@1 = 2 OR mark@3, projection=[range_key@0, value@2]
+02)--HashJoinExec: mode=Partitioned, join_type=LeftMark, on=[(range_key@0, 
range_key@0)]
+03)----RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
+04)------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
non_range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), 
(20), (30)], 4), file_type=csv, has_header=false
+05)----RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
+06)------FilterExec: value@1 <= 150, projection=[range_key@0]
+07)--------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, 
value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), 
file_type=csv, has_header=false
+
+# Matched rows have mark=true and are returned; unmatched rows have
+# mark=false and are only returned when non_range_key = 2.
+query II
+SELECT r.range_key, r.value
+FROM range_partitioned r
+WHERE r.non_range_key = 2 OR r.range_key IN (
+  SELECT range_key FROM range_partitioned WHERE value <= 150)
+ORDER BY r.range_key;
+----
+1 10
+5 50
+10 100
+15 150
+25 250
+35 350
+
+# NULL join keys on the build side never match: rows whose keys only "match"
+# the NULL entries keep a non-true marker and are filtered out unless the
+# non_range_key = 2 disjunct covers them.
+query II
+SELECT r.range_key, r.value
+FROM range_partitioned r
+WHERE r.non_range_key = 2 OR r.range_key IN (
+  SELECT CASE WHEN value <= 150 THEN range_key ELSE NULL END FROM 
range_partitioned)
+ORDER BY r.range_key;
+----
+1 10
+5 50
+10 100
+15 150
+25 250
+35 350
+
+##########
+# TEST 26: Sort Merge Join Avoids Repartition for Compatible Range Inputs
 # Compatible Range inputs satisfy SortMergeJoinExec's co-partitioned
 # KeyPartitioned requirements.
 ##########
@@ -666,7 +954,7 @@ ORDER BY l.range_key;
 35 350 350
 
 ##########
-# TEST 19: Sort Merge Join Repartitions Incompatible Range Inputs
+# TEST 27: Sort Merge Join Repartitions Incompatible Range Inputs
 # Different Range split points do not satisfy SortMergeJoinExec's
 # co-partitioned KeyPartitioned requirements.
 ##########
@@ -705,7 +993,7 @@ statement ok
 reset datafusion.optimizer.prefer_hash_join;
 
 ##########
-# TEST 20: Symmetric Hash Join Avoids Repartition for Compatible Range Inputs
+# TEST 28: Symmetric Hash Join Avoids Repartition for Compatible Range Inputs
 # Compatible Range streams satisfy SymmetricHashJoinExec's co-partitioned
 # KeyPartitioned requirements.
 ##########
@@ -739,7 +1027,7 @@ FULL JOIN unbounded_range_like r ON l.range_key = 
r.range_key;
 5 50 50
 
 ##########
-# TEST 21: Symmetric Hash Join Repartitions Incompatible Range Inputs
+# TEST 29: Symmetric Hash Join Repartitions Incompatible Range Inputs
 # Different Range split points do not satisfy SymmetricHashJoinExec's
 # co-partitioned KeyPartitioned requirements.
 ##########
@@ -772,7 +1060,7 @@ FULL JOIN unbounded_range_like_shifted r ON l.range_key = 
r.range_key;
 5 50 50
 
 ##########
-# TEST 22: Full Outer Join on Range Partition Column
+# TEST 30: Full Outer Join on Range Partition Column
 # Full partitioned hash joins also opt in to Range satisfying KeyPartitioned
 # requirements, so compatible Range layouts avoid Hash repartitioning here too.
 ##########
@@ -803,7 +1091,7 @@ ORDER BY l.range_key;
 35 350 350
 
 ##########
-# TEST 23: Full Outer Join Incompatible Range Repartitions
+# TEST 31: Full Outer Join Incompatible Range Repartitions
 # Same as TEST 10, but for Full: differing split points between the two
 # Range-partitioned inputs still require Hash repartitioning to co-partition.
 ##########
@@ -836,7 +1124,7 @@ ORDER BY l.range_key;
 35 350 350
 
 ##########
-# TEST 24: Full Outer Join Produces Matched and Unmatched Rows
+# TEST 32: Full Outer Join Produces Matched and Unmatched Rows
 # `range_partitioned` and `range_partitioned_sparse` share the same Range
 # split points/partition count but only partially overlapping range_key
 # values, so this exercises matched rows, left-only unmatched rows (NULLs on
@@ -881,7 +1169,7 @@ statement ok
 reset datafusion.optimizer.preserve_file_partitions;
 
 ##########
-# TEST 25: Union of Range Partitioned Inputs
+# TEST 33: Union of Range Partitioned Inputs
 # Each input exposes the same Range partitioning on range_key, so the optimizer
 # converts UnionExec to InterleaveExec to avoid redundant repartitioning.
 ##########
@@ -930,7 +1218,7 @@ set datafusion.optimizer.preserve_file_partitions = 0;
 
 
 ##########
-# TEST 26: Window on Range Partition Column
+# TEST 34: Window on Range Partition Column
 # Range([range_key]) colocates equal range_key values, so
 # PARTITION BY range_key is satisfied without a hash repartition.
 ##########
@@ -958,7 +1246,7 @@ SELECT range_key, SUM(value) OVER (PARTITION BY range_key 
ORDER BY value) FROM r
 
 
 ##########
-# TEST 27: Unbounded-Frame Window on Range Partition Column
+# TEST 35: Unbounded-Frame Window on Range Partition Column
 # The unbounded frame makes DataFusion use WindowAggExec instead of
 # BoundedWindowAggExec, which likewise reuses Range partitioning without a
 # hash repartition.
@@ -987,7 +1275,7 @@ SELECT range_key, SUM(value) OVER (PARTITION BY range_key 
ORDER BY value ROWS BE
 
 
 ##########
-# TEST 28: Window on Non-Range Column Rehashes
+# TEST 36: Window on Non-Range Column Rehashes
 # Range([range_key]) does not colocate non_range_key values, so
 # PARTITION BY non_range_key still requires a hash repartition.
 ##########
@@ -1016,7 +1304,7 @@ SELECT non_range_key, value, SUM(value) OVER (PARTITION 
BY non_range_key ORDER B
 
 
 ##########
-# TEST 29: Unbounded-Frame Window on Non-Range Column Rehashes
+# TEST 37: Unbounded-Frame Window on Non-Range Column Rehashes
 # The unbounded frame makes DataFusion use WindowAggExec; Range([range_key])
 # does not colocate non_range_key values, so PARTITION BY non_range_key
 # still requires a hash repartition.
@@ -1046,7 +1334,7 @@ SELECT non_range_key, value, SUM(value) OVER (PARTITION 
BY non_range_key ORDER B
 
 
 ##########
-# TEST 30: Window Subset Satisfaction on Range Partition Column
+# TEST 38: Window Subset Satisfaction on Range Partition Column
 # With the subset threshold met, Range([range_key]) satisfies
 # PARTITION BY (range_key, non_range_key): equal composite keys share the
 # same range_key, so they are already colocated.
@@ -1078,7 +1366,7 @@ SELECT range_key, SUM(value) OVER (PARTITION BY 
range_key, non_range_key ORDER B
 
 
 ##########
-# TEST 31: Window Subset Rehashes Below Subset Threshold
+# TEST 39: Window Subset Rehashes Below Subset Threshold
 # Range([range_key]) is only a subset of PARTITION BY
 # (range_key, non_range_key), so it should not satisfy the window key when
 # subset satisfaction is disabled.
@@ -1117,7 +1405,7 @@ reset datafusion.optimizer.preserve_file_partitions;
 
 
 ##########
-# TEST 32: Window Without Partition Keys Uses a Single Partition
+# TEST 40: Window Without Partition Keys Uses a Single Partition
 # A window with no PARTITION BY requires a single partition; range
 # partitioning is not applicable.
 ##########
@@ -1147,7 +1435,7 @@ SELECT range_key, SUM(value) OVER (ORDER BY value) FROM 
range_partitioned ORDER
 
 
 ##########
-# TEST 33: PartitionedTopK on Range Partition Column
+# TEST 41: PartitionedTopK on Range Partition Column
 # Exact Range([range_key]) satisfies the TopK partition key and avoids 
repartitioning.
 ##########
 
@@ -1190,7 +1478,7 @@ ORDER BY range_key;
 
 
 ##########
-# TEST 34: PartitionedTopK on Non-Range Column
+# TEST 42: PartitionedTopK on Non-Range Column
 # Partitioning on a non-range key cannot reuse Range([range_key]) and
 # requires hash repartitioning.
 ##########
@@ -1226,7 +1514,7 @@ ORDER BY non_range_key;
 
 
 ##########
-# TEST 35: PartitionedTopK Reuses Range Subset Partitioning
+# TEST 43: PartitionedTopK Reuses Range Subset Partitioning
 # With subset threshold met and preserve-file disabled, Range([range_key])
 # satisfies partitioning by (range_key, non_range_key).
 ##########
@@ -1267,7 +1555,7 @@ ORDER BY range_key, non_range_key;
 
 
 ##########
-# TEST 36: Range Subset PartitionedTopK Rehashes Below Subset Threshold
+# TEST 44: Range Subset PartitionedTopK Rehashes Below Subset Threshold
 # Range([range_key]) is only a subset of PARTITION BY (range_key, 
non_range_key),
 # so it should not satisfy the TopK partition key when subset satisfaction is
 # disabled.
@@ -1305,7 +1593,7 @@ statement ok
 reset datafusion.optimizer.enable_window_topn;
 
 ##########
-# TEST 34: Subset of Inputs Compatible Does Not Trigger InterleaveExec
+# TEST 45: Subset of Inputs Compatible Does Not Trigger InterleaveExec
 # In a three-way union, two inputs share the same Range split points [10,20,30]
 # while the third has a partially-overlapping but different set [15,20,30].
 # can_interleave requires ALL inputs to match, so UnionExec is kept.
@@ -1361,7 +1649,7 @@ ORDER BY range_key, value;
 35 350
 
 ##########
-# TEST 35: Incompatible Range Split Points Falls Back to UnionExec
+# TEST 46: Incompatible Range Split Points Falls Back to UnionExec
 # Two range-partitioned inputs with different split points cannot be 
interleaved,
 # so the optimizer keeps UnionExec instead of converting to InterleaveExec.
 ##########
@@ -1403,7 +1691,7 @@ ORDER BY range_key, value;
 35 350
 
 ##########
-# TEST 36: InterleaveExec Propagates Range Partitioning to Aggregate
+# TEST 47: InterleaveExec Propagates Range Partitioning to Aggregate
 # InterleaveExec outputs the same Range partitioning as its compatible inputs,
 # allowing a downstream aggregate on range_key to run SinglePartitioned without
 # a Hash repartition.


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to