feat: add projection support to SortMergeJoinExec (#24517)

## Which issue does this PR close?

- Closes https://github.com/apache/datafusion/issues/24518

## Rationale for this change

`SortMergeJoinExec` emits every column of both inputs.
We can add projection support to only produce the output columns (saving
some compute / copy).

```sql
select * from t1 right join t2 on t1.c3 = t2.c3   -- c3 needs a cast
```

```text
ProjectionExec: expr=[c1@0 as c1, ..., c4@8 as c4]
  SortMergeJoinExec: join_type=Right, on=[(CAST(t1.c3 AS Decimal128(10, 2))@4, c3@2)]
```

The cast column is only there to be joined on, and every operator above
the join
carries it until the projection removes it.

## What changes are included in this PR?

`SortMergeJoinExec` takes an optional projection, set with
`with_projection`, the same
shape as `HashJoinExec`'s.

`try_swapping_with_projection` embeds the projection into the join when
it cannot push
it into the children, so the query above becomes:

```text
SortMergeJoinExec: join_type=Right, on=[(CAST(t1.c3 AS Decimal128(10, 2))@4, c3@2)], projection=[c1@0, c2@1, c3@2, c4@3, c1@5, c2@6, c3@7, c4@8]
```

The change doesn't bring a big speedup but helps aligning with other
join types and helping simplify join optimization in other areas (e.g.
join enumeration).

## Are these changes tested?

Yes:

- a projection pushdown test for a projection that interleaves the two
sides, which
  the existing pushdown cannot handle
- a serialization round trip
- existing sqllogictests, whose plans lose a `ProjectionExec` in four
places


## Are there any user-facing changes?
Explain / proto changes.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
diff --git a/datafusion/core/tests/physical_optimizer/projection_pushdown.rs b/datafusion/core/tests/physical_optimizer/projection_pushdown.rs
index 113552c..4e8d479 100644
--- a/datafusion/core/tests/physical_optimizer/projection_pushdown.rs
+++ b/datafusion/core/tests/physical_optimizer/projection_pushdown.rs
@@ -49,8 +49,8 @@
 use datafusion_physical_plan::filter::{FilterExec, FilterExecBuilder};
 use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter};
 use datafusion_physical_plan::joins::{
-    HashJoinExec, NestedLoopJoinExec, PartitionMode, StreamJoinPartitionMode,
-    SymmetricHashJoinExec,
+    HashJoinExec, NestedLoopJoinExec, PartitionMode, SortMergeJoinExec,
+    StreamJoinPartitionMode, SymmetricHashJoinExec,
 };
 use datafusion_physical_plan::projection::{ProjectionExec, ProjectionExpr, update_expr};
 use datafusion_physical_plan::repartition::RepartitionExec;
@@ -1875,3 +1875,46 @@
 
     Ok(())
 }
+
+#[test]
+fn test_sort_merge_join_interleaved_projection_embeds() -> Result<()> {
+    // SELECT t1.c, t2.c, t1.a FROM t1 JOIN t2 ON t1.b = t2.c
+    // Taking a column from each side in turn leaves the sides interleaved, which
+    // cannot be pushed into the children, so the join applies it itself.
+    let join = Arc::new(SortMergeJoinExec::try_new(
+        create_simple_csv_exec(),
+        create_simple_csv_exec(),
+        vec![(Arc::new(Column::new("b", 1)), Arc::new(Column::new("c", 2)))],
+        None,
+        JoinType::Inner,
+        vec![SortOptions::default()],
+        NullEquality::NullEqualsNothing,
+    )?);
+    let projection: Arc<dyn ExecutionPlan> = Arc::new(ProjectionExec::try_new(
+        vec![
+            ProjectionExpr::new(Arc::new(Column::new("c", 2)), "c_from_left"),
+            ProjectionExpr::new(Arc::new(Column::new("c", 7)), "c_from_right"),
+            ProjectionExpr::new(Arc::new(Column::new("a", 0)), "a_from_left"),
+        ],
+        join,
+    )?);
+
+    let after_optimize =
+        ProjectionPushdown::new().optimize(projection, &ConfigOptions::new())?;
+    let actual = displayable(after_optimize.as_ref())
+        .indent(true)
+        .to_string()
+        .trim()
+        .to_string();
+    assert_snapshot!(
+        actual,
+        @r"
+    ProjectionExec: expr=[c@0 as c_from_left, c@1 as c_from_right, a@2 as a_from_left]
+      SortMergeJoinExec: join_type=Inner, on=[(b@1, c@2)], projection=[c@2, c@7, a@0]
+        DataSourceExec: file_groups={1 group: [[x]]}, projection=[a, b, c, d, e], file_type=csv, has_header=false
+        DataSourceExec: file_groups={1 group: [[x]]}, projection=[a, b, c, d, e], file_type=csv, has_header=false
+    "
+    );
+
+    Ok(())
+}
diff --git a/datafusion/physical-plan/src/joins/sort_merge_join/exec.rs b/datafusion/physical-plan/src/joins/sort_merge_join/exec.rs
index dc8540f..b2f643f 100644
--- a/datafusion/physical-plan/src/joins/sort_merge_join/exec.rs
+++ b/datafusion/physical-plan/src/joins/sort_merge_join/exec.rs
@@ -29,24 +29,27 @@
 use crate::expressions::PhysicalSortExpr;
 use crate::joins::utils::{
     JoinFilter, JoinOn, JoinOnRef, build_join_schema, check_join_is_valid,
-    estimate_join_statistics, reorder_output_after_swap,
+    estimate_join_statistics, reorder_output_after_swap, swap_join_projection,
     symmetric_join_output_partitioning,
 };
 use crate::metrics::{ExecutionPlanMetricsSet, MetricsSet, SpillMetrics};
 use crate::projection::{
-    ProjectionExec, join_allows_pushdown, join_table_borders, new_join_children,
-    physical_to_column_exprs, update_join_on,
+    EmbeddedProjection, ProjectionExec, join_allows_pushdown, join_table_borders,
+    new_join_children, physical_to_column_exprs, try_embed_projection, update_join_on,
 };
 use crate::spill::spill_manager::SpillManager;
 use crate::statistics::{ChildStats, StatisticsArgs};
+use crate::stream::RecordBatchStreamAdapter;
 use crate::{
     ChildrenPropertiesMode, DisplayAs, DisplayFormatType, Distribution, ExecutionPlan,
     ExecutionPlanProperties, InputDistributionRequirements, PlanProperties,
-    ReplaceChildrenOptions, SendableRecordBatchStream, Statistics, validate_child_count,
+    ReplaceChildrenOptions, SendableRecordBatchStream, Statistics, common::can_project,
+    validate_child_count,
 };
 
 use arrow::compute::SortOptions;
 use arrow::datatypes::SchemaRef;
+use datafusion_common::project_schema;
 use datafusion_common::tree_node::TreeNodeRecursion;
 use datafusion_common::{
     JoinSide, JoinType, NullEquality, Result, assert_eq_or_internal_err, internal_err,
@@ -54,9 +57,12 @@
 };
 use datafusion_execution::TaskContext;
 use datafusion_execution::memory_pool::MemoryConsumer;
-use datafusion_physical_expr::equivalence::join_equivalence_properties;
+use datafusion_physical_expr::equivalence::{
+    ProjectionMapping, join_equivalence_properties,
+};
 use datafusion_physical_expr_common::physical_expr::{PhysicalExprRef, fmt_sql};
 use datafusion_physical_expr_common::sort_expr::{LexOrdering, OrderingRequirements};
+use futures::StreamExt;
 
 /// Join execution plan that executes equi-join predicates on multiple partitions using Sort-Merge
 /// join algorithm and applies an optional filter post join. Can be used to join arbitrarily large
@@ -128,6 +134,8 @@
     pub sort_options: Vec<SortOptions>,
     /// Defines the null equality for the join.
     pub null_equality: NullEquality,
+    /// The columns of `schema` to emit, in order. `None` emits all of them.
+    pub projection: Option<Vec<usize>>,
     /// Cache holding plan properties like equivalences, output partitioning etc.
     cache: Arc<PlanProperties>,
 }
@@ -187,7 +195,7 @@
         let schema =
             Arc::new(build_join_schema(&left_schema, &right_schema, &join_type).0);
         let cache =
-            Self::compute_properties(&left, &right, Arc::clone(&schema), join_type, &on)?;
+            Self::compute_properties(&left, &right, &schema, join_type, &on, None)?;
         Ok(Self {
             left,
             right,
@@ -200,10 +208,31 @@
             right_sort_exprs,
             sort_options,
             null_equality,
+            projection: None,
             cache: Arc::new(cache),
         })
     }
 
+    /// Returns this join emitting only the columns in `projection`, in that order.
+    /// The indices address the join's own schema, before any projection.
+    pub fn with_projection(&self, projection: Option<Vec<usize>>) -> Result<Self> {
+        can_project(&self.schema, projection.as_deref())?;
+        let cache = Self::compute_properties(
+            &self.left,
+            &self.right,
+            &self.schema,
+            self.join_type,
+            &self.on,
+            projection.as_deref(),
+        )?;
+        Ok(Self {
+            projection,
+            metrics: ExecutionPlanMetricsSet::new(),
+            cache: Arc::new(cache),
+            ..Self::clone(self)
+        })
+    }
+
     /// Get probe side (e.g streaming side) information for this sort merge join.
     /// In current implementation, probe side is determined according to join type.
     pub fn probe_side(join_type: &JoinType) -> JoinSide {
@@ -281,24 +310,32 @@
     fn compute_properties(
         left: &Arc<dyn ExecutionPlan>,
         right: &Arc<dyn ExecutionPlan>,
-        schema: SchemaRef,
+        schema: &SchemaRef,
         join_type: JoinType,
         join_on: JoinOnRef,
+        projection: Option<&[usize]>,
     ) -> Result<PlanProperties> {
         // Calculate equivalence properties:
-        let eq_properties = join_equivalence_properties(
+        let mut eq_properties = join_equivalence_properties(
             left.equivalence_properties().clone(),
             right.equivalence_properties().clone(),
             &join_type,
-            schema,
+            Arc::clone(schema),
             &Self::maintains_input_order(join_type),
             Some(Self::probe_side(&join_type)),
             join_on,
         )?;
 
-        let output_partitioning =
+        let mut output_partitioning =
             symmetric_join_output_partitioning(left, right, &join_type)?;
 
+        if let Some(projection) = projection {
+            let mapping = ProjectionMapping::from_indices(projection, schema)?;
+            let projected = project_schema(schema, Some(projection))?;
+            output_partitioning = output_partitioning.project(&mapping, &eq_properties);
+            eq_properties = eq_properties.project(&mapping, projected);
+        }
+
         Ok(PlanProperties::new(
             eq_properties,
             output_partitioning,
@@ -326,10 +363,16 @@
             self.join_type().swap(),
             self.sort_options.clone(),
             self.null_equality,
-        )?;
+        )?
+        .with_projection(swap_join_projection(
+            left.schema().fields().len(),
+            right.schema().fields().len(),
+            self.projection.as_deref(),
+            &self.join_type(),
+        ))?;
 
-        // TODO: OR this condition with having a built-in projection (like
-        //       ordinary hash join) when we support it.
+        // A semi, anti or mark join emits one side, and a projection already names the
+        // columns to emit, so in both cases swapping leaves the output order alone.
         if matches!(
             self.join_type(),
             JoinType::LeftSemi
@@ -338,7 +381,8 @@
                 | JoinType::RightAnti
                 | JoinType::LeftMark
                 | JoinType::RightMark
-        ) {
+        ) || self.projection.is_some()
+        {
             Ok(Arc::new(new_join))
         } else {
             reorder_output_after_swap(Arc::new(new_join), &left.schema(), &right.schema())
@@ -346,6 +390,12 @@
     }
 }
 
+impl EmbeddedProjection for SortMergeJoinExec {
+    fn with_projection(&self, projection: Option<Vec<usize>>) -> Result<Self> {
+        self.with_projection(projection)
+    }
+}
+
 impl DisplayAs for SortMergeJoinExec {
     fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter) -> std::fmt::Result {
         match t {
@@ -362,9 +412,24 @@
                     } else {
                         ""
                     };
+                let display_projections = match &self.projection {
+                    Some(projection) => format!(
+                        ", projection=[{}]",
+                        projection
+                            .iter()
+                            .map(|index| format!(
+                                "{}@{}",
+                                self.schema.field(*index).name(),
+                                index
+                            ))
+                            .collect::<Vec<_>>()
+                            .join(", ")
+                    ),
+                    None => String::new(),
+                };
                 write!(
                     f,
-                    "{}: join_type={:?}, on=[{}]{}{}",
+                    "{}: join_type={:?}, on=[{}]{}{}{}",
                     Self::static_name(),
                     self.join_type,
                     on,
@@ -373,6 +438,7 @@
                         |f| format!(", filter={}", f.expression())
                     ),
                     display_null_equality,
+                    display_projections,
                 )
             }
             DisplayFormatType::TreeRender => {
@@ -467,15 +533,18 @@
                 }))
             }
             ChildrenPropertiesMode::Recompute => match &children[..] {
-                [left, right] => Ok(Arc::new(SortMergeJoinExec::try_new(
-                    Arc::clone(left),
-                    Arc::clone(right),
-                    self.on.clone(),
-                    self.filter.clone(),
-                    self.join_type,
-                    self.sort_options.clone(),
-                    self.null_equality,
-                )?)),
+                [left, right] => Ok(Arc::new(
+                    SortMergeJoinExec::try_new(
+                        Arc::clone(left),
+                        Arc::clone(right),
+                        self.on.clone(),
+                        self.filter.clone(),
+                        self.join_type,
+                        self.sort_options.clone(),
+                        self.null_equality,
+                    )?
+                    .with_projection(self.projection.clone())?,
+                )),
                 _ => internal_err!("SortMergeJoin wrong number of children"),
             },
         }
@@ -546,7 +615,7 @@
         )
         .with_compression_type(context.session_config().spill_compression());
 
-        if matches!(
+        let joined = if matches!(
             self.join_type,
             JoinType::LeftSemi
                 | JoinType::LeftAnti
@@ -589,7 +658,16 @@
                 spill_manager,
                 context.runtime_env(),
             )
-        }
+        }?;
+
+        let Some(projection) = self.projection.clone() else {
+            return Ok(joined);
+        };
+        let schema = self.schema();
+        Ok(Box::pin(RecordBatchStreamAdapter::new(
+            Arc::clone(&schema),
+            joined.map(move |batch| Ok(batch?.project(&projection)?)),
+        )))
     }
 
     fn metrics(&self) -> Option<MetricsSet> {
@@ -614,14 +692,18 @@
         // - `A LEFT JOIN B ON A.col=B.col` with `COUNT_DISTINCT(B.col)=COUNT(B.col)`
         let left_stats = input_stats[0].as_ref().clone();
         let right_stats = input_stats[1].as_ref().clone();
-        Ok(Arc::new(estimate_join_statistics(
+        let stats = estimate_join_statistics(
             left_stats,
             right_stats,
             &self.on,
             self.null_equality,
             &self.join_type,
             &self.schema,
-        )?))
+        )?;
+        Ok(Arc::new(match &self.projection {
+            Some(projection) => stats.project(Some(projection)),
+            None => stats,
+        }))
     }
 
     /// Tries to swap the projection with its input [`SortMergeJoinExec`]. If it can be done,
@@ -631,6 +713,9 @@
         &self,
         projection: &ProjectionExec,
     ) -> Result<Option<Arc<dyn ExecutionPlan>>> {
+        if self.projection.is_some() {
+            return Ok(None);
+        }
         // Convert projected PhysicalExpr's to columns. If not possible, we cannot proceed.
         let Some(projection_as_columns) = physical_to_column_exprs(projection.expr())
         else {
@@ -642,13 +727,15 @@
             &projection_as_columns,
         );
 
+        // Pushing into the children needs each side's columns to stay together, which
+        // an arbitrary projection does not. The join can apply that one itself.
         if !join_allows_pushdown(
             &projection_as_columns,
             &self.schema(),
             far_right_left_col_ind,
             far_left_right_col_ind,
         ) {
-            return Ok(None);
+            return try_embed_projection(projection, self);
         }
 
         let Some(new_on) = update_join_on(
@@ -657,7 +744,7 @@
             self.on(),
             self.left().schema().fields().len(),
         ) else {
-            return Ok(None);
+            return try_embed_projection(projection, self);
         };
 
         let (new_left, new_right) = new_join_children(
@@ -696,6 +783,7 @@
             join_type,
             sort_options,
             null_equality,
+            projection,
             // derived from the children's schemas by `try_new` on decode
             schema: _,
             // runtime metrics, not part of the plan
@@ -746,6 +834,20 @@
                         filter,
                         sort_options,
                         null_equality: null_equality.into(),
+                        // Proto3 `repeated` cannot distinguish `None` from
+                        // `Some(vec![])`. `Some(vec![])` (reachable via
+                        // `try_embed_projection` for e.g. `SELECT count(1) … JOIN …`)
+                        // changes the output schema, so it is encoded with the
+                        // single-element sentinel `[u32::MAX]` (never a valid column
+                        // index); every other state is sent as-is. See
+                        // `try_from_proto` for the matching decoder.
+                        projection: match projection.as_ref() {
+                            None => Vec::new(),
+                            Some(indices) if indices.is_empty() => vec![u32::MAX],
+                            Some(indices) => {
+                                indices.iter().map(|index| *index as u32).collect()
+                            }
+                        },
                     },
                 )),
             ),
@@ -781,6 +883,7 @@
             filter,
             sort_options,
             null_equality,
+            projection,
         } = &**sort_join;
 
         let left =
@@ -832,14 +935,24 @@
             })
             .collect();
 
-        Ok(Arc::new(Self::try_new(
-            left,
-            right,
-            on,
-            filter,
-            join_type,
-            sort_options,
-            null_equality,
-        )?))
+        // Preserve the empty-projection sentinel written by `try_to_proto`.
+        let projection = match projection.as_slice() {
+            [] => None,
+            [u32::MAX] => Some(Vec::new()),
+            indices => Some(indices.iter().map(|index| *index as usize).collect()),
+        };
+
+        Ok(Arc::new(
+            Self::try_new(
+                left,
+                right,
+                on,
+                filter,
+                join_type,
+                sort_options,
+                null_equality,
+            )?
+            .with_projection(projection)?,
+        ))
     }
 }
diff --git a/datafusion/physical-plan/src/joins/sort_merge_join/tests.rs b/datafusion/physical-plan/src/joins/sort_merge_join/tests.rs
index 91d1b89..0189a0e 100644
--- a/datafusion/physical-plan/src/joins/sort_merge_join/tests.rs
+++ b/datafusion/physical-plan/src/joins/sort_merge_join/tests.rs
@@ -5985,3 +5985,86 @@
 
     Ok(())
 }
+
+/// A projection names the columns to emit, so swapping the inputs must renumber it
+/// rather than leave it pointing at the columns the other side now occupies.
+#[tokio::test]
+async fn swap_inputs_swaps_the_projection() -> Result<()> {
+    let left = build_table(
+        ("a1", &vec![1, 2, 3]),
+        ("b1", &vec![10, 20, 30]),
+        ("c1", &vec![100, 200, 300]),
+    );
+    let right = build_table(
+        ("a2", &vec![1, 2, 3]),
+        ("b2", &vec![11, 22, 33]),
+        ("c2", &vec![111, 222, 333]),
+    );
+    let on: JoinOn = vec![(
+        Arc::new(Column::new("a1", 0)) as _,
+        Arc::new(Column::new("a2", 0)) as _,
+    )];
+    // One column from each side, in an order that tells the two sides apart.
+    let join = SortMergeJoinExec::try_new(
+        left,
+        right,
+        on,
+        None,
+        Inner,
+        vec![SortOptions::default()],
+        NullEquality::NullEqualsNothing,
+    )?
+    .with_projection(Some(vec![4, 2]))?;
+
+    let swapped = join.swap_inputs()?;
+    assert_eq!(
+        swapped.schema().fields(),
+        join.schema().fields(),
+        "swapping must not change what the join emits"
+    );
+
+    let task_ctx = Arc::new(TaskContext::default());
+    let expected = common::collect(join.execute(0, Arc::clone(&task_ctx))?).await?;
+    let actual = common::collect(swapped.execute(0, task_ctx)?).await?;
+    assert_eq!(expected, actual);
+
+    Ok(())
+}
+
+/// An empty projection still changes the output schema, and the row count has to
+/// survive it: `SELECT count(1)` over a join needs the rows but none of the columns.
+#[tokio::test]
+async fn an_empty_projection_keeps_the_rows() -> Result<()> {
+    let left = build_table(
+        ("a1", &vec![1, 2, 3]),
+        ("b1", &vec![10, 20, 30]),
+        ("c1", &vec![100, 200, 300]),
+    );
+    let right = build_table(
+        ("a2", &vec![1, 2, 3]),
+        ("b2", &vec![11, 22, 33]),
+        ("c2", &vec![111, 222, 333]),
+    );
+    let on: JoinOn = vec![(
+        Arc::new(Column::new("a1", 0)) as _,
+        Arc::new(Column::new("a2", 0)) as _,
+    )];
+    let join = SortMergeJoinExec::try_new(
+        left,
+        right,
+        on,
+        None,
+        Inner,
+        vec![SortOptions::default()],
+        NullEquality::NullEqualsNothing,
+    )?
+    .with_projection(Some(vec![]))?;
+
+    assert_eq!(join.schema().fields().len(), 0);
+    let batches =
+        common::collect(join.execute(0, Arc::new(TaskContext::default()))?).await?;
+    let rows: usize = batches.iter().map(|batch| batch.num_rows()).sum();
+    assert_eq!(rows, 3);
+
+    Ok(())
+}
diff --git a/datafusion/proto-models/proto/datafusion.proto b/datafusion/proto-models/proto/datafusion.proto
index d98b67a..7b8f8e3 100644
--- a/datafusion/proto-models/proto/datafusion.proto
+++ b/datafusion/proto-models/proto/datafusion.proto
@@ -1691,6 +1691,7 @@
   JoinFilter filter = 5;
   repeated SortExprNode sort_options = 6;
   datafusion_common.NullEquality null_equality = 7;
+  repeated uint32 projection = 8;
 }
 
 message AsyncFuncExecNode {
diff --git a/datafusion/proto-models/src/generated/pbjson.rs b/datafusion/proto-models/src/generated/pbjson.rs
index 21309bb..bc88f5c 100644
--- a/datafusion/proto-models/src/generated/pbjson.rs
+++ b/datafusion/proto-models/src/generated/pbjson.rs
@@ -25603,6 +25603,9 @@
         if self.null_equality != 0 {
             len += 1;
         }
+        if !self.projection.is_empty() {
+            len += 1;
+        }
         let mut struct_ser = serializer.serialize_struct("datafusion.SortMergeJoinExecNode", len)?;
         if let Some(v) = self.left.as_ref() {
             struct_ser.serialize_field("left", v)?;
@@ -25629,6 +25632,9 @@
                 .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", self.null_equality)))?;
             struct_ser.serialize_field("nullEquality", &v)?;
         }
+        if !self.projection.is_empty() {
+            struct_ser.serialize_field("projection", &self.projection)?;
+        }
         struct_ser.end()
     }
 }
@@ -25649,6 +25655,7 @@
             "sortOptions",
             "null_equality",
             "nullEquality",
+            "projection",
         ];
 
         #[allow(clippy::enum_variant_names)]
@@ -25660,6 +25667,7 @@
             Filter,
             SortOptions,
             NullEquality,
+            Projection,
         }
         impl<'de> serde::Deserialize<'de> for GeneratedField {
             fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
@@ -25688,6 +25696,7 @@
                             "filter" => Ok(GeneratedField::Filter),
                             "sortOptions" | "sort_options" => Ok(GeneratedField::SortOptions),
                             "nullEquality" | "null_equality" => Ok(GeneratedField::NullEquality),
+                            "projection" => Ok(GeneratedField::Projection),
                             _ => Err(serde::de::Error::unknown_field(value, FIELDS)),
                         }
                     }
@@ -25714,6 +25723,7 @@
                 let mut filter__ = None;
                 let mut sort_options__ = None;
                 let mut null_equality__ = None;
+                let mut projection__ = None;
                 while let Some(k) = map_.next_key()? {
                     match k {
                         GeneratedField::Left => {
@@ -25758,6 +25768,15 @@
                             }
                             null_equality__ = Some(map_.next_value::<super::datafusion_common::NullEquality>()? as i32);
                         }
+                        GeneratedField::Projection => {
+                            if projection__.is_some() {
+                                return Err(serde::de::Error::duplicate_field("projection"));
+                            }
+                            projection__ = 
+                                Some(map_.next_value::<Vec<::pbjson::private::NumberDeserialize<_>>>()?
+                                    .into_iter().map(|x| x.0).collect())
+                            ;
+                        }
                     }
                 }
                 Ok(SortMergeJoinExecNode {
@@ -25768,6 +25787,7 @@
                     filter: filter__,
                     sort_options: sort_options__.unwrap_or_default(),
                     null_equality: null_equality__.unwrap_or_default(),
+                    projection: projection__.unwrap_or_default(),
                 })
             }
         }
diff --git a/datafusion/proto-models/src/generated/prost.rs b/datafusion/proto-models/src/generated/prost.rs
index d830624..5bc46df 100644
--- a/datafusion/proto-models/src/generated/prost.rs
+++ b/datafusion/proto-models/src/generated/prost.rs
@@ -2559,6 +2559,8 @@
     pub sort_options: ::prost::alloc::vec::Vec<SortExprNode>,
     #[prost(enumeration = "super::datafusion_common::NullEquality", tag = "7")]
     pub null_equality: i32,
+    #[prost(uint32, repeated, tag = "8")]
+    pub projection: ::prost::alloc::vec::Vec<u32>,
 }
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct AsyncFuncExecNode {
diff --git a/datafusion/proto/tests/cases/plans/joins.rs b/datafusion/proto/tests/cases/plans/joins.rs
index 941e883..9bfe172 100644
--- a/datafusion/proto/tests/cases/plans/joins.rs
+++ b/datafusion/proto/tests/cases/plans/joins.rs
@@ -348,6 +348,53 @@
 }
 
 #[test]
+fn roundtrip_sort_merge_join_with_projection() -> Result<()> {
+    let ctx = SessionContext::new();
+    let codec = DefaultPhysicalExtensionCodec {};
+    let proto_converter = DefaultPhysicalProtoConverter {};
+    let schema_left = Arc::new(Schema::new(vec![Field::new(
+        "col_a",
+        DataType::Int64,
+        false,
+    )]));
+    let schema_right = Arc::new(Schema::new(vec![Field::new(
+        "col_b",
+        DataType::Int64,
+        false,
+    )]));
+    let on = vec![(
+        Arc::new(Column::new("col_a", 0)) as _,
+        Arc::new(Column::new("col_b", 0)) as _,
+    )];
+
+    // An empty projection is not an absent one: it changes the output schema, and
+    // proto3 cannot tell the two apart without a sentinel.
+    for projection in [None, Some(vec![]), Some(vec![1, 0])] {
+        let result = roundtrip_test_and_return(
+            Arc::new(
+                SortMergeJoinExec::try_new(
+                    Arc::new(EmptyExec::new(Arc::clone(&schema_left))),
+                    Arc::new(EmptyExec::new(Arc::clone(&schema_right))),
+                    on.clone(),
+                    None,
+                    JoinType::Inner,
+                    vec![SortOptions::default()],
+                    NullEquality::NullEqualsNothing,
+                )?
+                .with_projection(projection.clone())?,
+            ),
+            &ctx,
+            &codec,
+            &proto_converter,
+        )?;
+        let result = result.downcast_ref::<SortMergeJoinExec>().unwrap();
+        assert_eq!(result.projection, projection);
+    }
+
+    Ok(())
+}
+
+#[test]
 fn roundtrip_sort_merge_join() -> Result<()> {
     let ctx = SessionContext::new();
     let codec = DefaultPhysicalExtensionCodec {};
diff --git a/datafusion/sqllogictest/test_files/joins.slt b/datafusion/sqllogictest/test_files/joins.slt
index 7a70683..e6a4af4 100644
--- a/datafusion/sqllogictest/test_files/joins.slt
+++ b/datafusion/sqllogictest/test_files/joins.slt
@@ -2838,16 +2838,15 @@
 04)--SubqueryAlias: t2
 05)----TableScan: hashjoin_datatype_table_t2 projection=[c1, c2, c3, c4]
 physical_plan
-01)ProjectionExec: expr=[c1@0 as c1, c2@1 as c2, c3@2 as c3, c4@3 as c4, c1@5 as c1, c2@6 as c2, c3@7 as c3, c4@8 as c4]
-02)--SortMergeJoinExec: join_type=Right, on=[(CAST(t1.c3 AS Decimal128(10, 2))@4, c3@2)]
-03)----SortExec: expr=[CAST(t1.c3 AS Decimal128(10, 2))@4 ASC], preserve_partitioning=[true]
-04)------RepartitionExec: partitioning=Hash([CAST(t1.c3 AS Decimal128(10, 2))@4], 2), input_partitions=2
-05)--------ProjectionExec: expr=[c1@0 as c1, c2@1 as c2, c3@2 as c3, c4@3 as c4, CAST(c3@2 AS Decimal128(10, 2)) as CAST(t1.c3 AS Decimal128(10, 2))]
-06)----------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1
-07)------------DataSourceExec: partitions=1, partition_sizes=[1]
-08)----SortExec: expr=[c3@2 ASC], preserve_partitioning=[true]
-09)------RepartitionExec: partitioning=Hash([c3@2], 2), input_partitions=1
-10)--------DataSourceExec: partitions=1, partition_sizes=[1]
+01)SortMergeJoinExec: join_type=Right, on=[(CAST(t1.c3 AS Decimal128(10, 2))@4, c3@2)], projection=[c1@0, c2@1, c3@2, c4@3, c1@5, c2@6, c3@7, c4@8]
+02)--SortExec: expr=[CAST(t1.c3 AS Decimal128(10, 2))@4 ASC], preserve_partitioning=[true]
+03)----RepartitionExec: partitioning=Hash([CAST(t1.c3 AS Decimal128(10, 2))@4], 2), input_partitions=2
+04)------ProjectionExec: expr=[c1@0 as c1, c2@1 as c2, c3@2 as c3, c4@3 as c4, CAST(c3@2 AS Decimal128(10, 2)) as CAST(t1.c3 AS Decimal128(10, 2))]
+05)--------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1
+06)----------DataSourceExec: partitions=1, partition_sizes=[1]
+07)--SortExec: expr=[c3@2 ASC], preserve_partitioning=[true]
+08)----RepartitionExec: partitioning=Hash([c3@2], 2), input_partitions=1
+09)------DataSourceExec: partitions=1, partition_sizes=[1]
 
 # sort_merge_join_on_decimal right join on data type (Decimal)
 query DDRTDDRT rowsort
diff --git a/datafusion/sqllogictest/test_files/range_partitioning.slt b/datafusion/sqllogictest/test_files/range_partitioning.slt
index dd80fde..e4c6265 100644
--- a/datafusion/sqllogictest/test_files/range_partitioning.slt
+++ b/datafusion/sqllogictest/test_files/range_partitioning.slt
@@ -1041,12 +1041,11 @@
 JOIN range_partitioned r ON l.range_key = r.range_key;
 ----
 physical_plan
-01)ProjectionExec: expr=[range_key@0 as range_key, value@1 as value, value@3 as value]
-02)--SortMergeJoinExec: join_type=Inner, on=[(range_key@0, range_key@0)]
-03)----SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
-04)------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), file_type=parquet, sort_order_for_reorder=[range_key@0 ASC]
-05)----SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
-06)------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), file_type=parquet, sort_order_for_reorder=[range_key@0 ASC]
+01)SortMergeJoinExec: join_type=Inner, on=[(range_key@0, range_key@0)], projection=[range_key@0, value@1, value@3]
+02)--SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
+03)----DataSourceExec: file_groups={4 groups: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-0.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-1.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-2.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-3.parquet]]}, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), file_type=parquet, sort_order_for_reorder=[range_key@0 ASC]
+04)--SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
+05)----DataSourceExec: file_groups={4 groups: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-0.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-1.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-2.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-3.parquet]]}, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), file_type=parquet, sort_order_for_reorder=[range_key@0 ASC]
 
 query III
 SELECT l.range_key, l.value, r.value
@@ -1075,14 +1074,13 @@
 JOIN range_partitioned_shifted r ON l.range_key = r.range_key;
 ----
 physical_plan
-01)ProjectionExec: expr=[range_key@0 as range_key, value@1 as value, value@3 as value]
-02)--SortMergeJoinExec: join_type=Inner, on=[(range_key@0, range_key@0)]
-03)----SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
-04)------RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
-05)--------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), file_type=parquet
-06)----SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
-07)------RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
-08)--------DataSourceExec: file_groups=<slt:ignore>, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(15), (20), (30)], 4), file_type=parquet
+01)SortMergeJoinExec: join_type=Inner, on=[(range_key@0, range_key@0)], projection=[range_key@0, value@1, value@3]
+02)--SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
+03)----RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
+04)------DataSourceExec: file_groups={4 groups: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-0.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-1.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-2.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned/part-3.parquet]]}, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(10), (20), (30)], 4), file_type=parquet
+05)--SortExec: expr=[range_key@0 ASC], preserve_partitioning=[true]
+06)----RepartitionExec: partitioning=Hash([range_key@0], 4), input_partitions=4
+07)------DataSourceExec: file_groups={4 groups: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned_shifted/part-0.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned_shifted/part-1.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned_shifted/part-2.parquet], [WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch_range_partitioning/range_partitioned_shifted/part-3.parquet]]}, projection=[range_key, value], output_partitioning=Range([range_key@0 ASC], [(15), (20), (30)], 4), file_type=parquet
 
 query III
 SELECT l.range_key, l.value, r.value
diff --git a/datafusion/sqllogictest/test_files/sort_merge_join_spill.slt b/datafusion/sqllogictest/test_files/sort_merge_join_spill.slt
index 69bb718..ab5fb15 100644
--- a/datafusion/sqllogictest/test_files/sort_merge_join_spill.slt
+++ b/datafusion/sqllogictest/test_files/sort_merge_join_spill.slt
@@ -274,7 +274,7 @@
 )
 ----
 Plan with Metrics
-<slt:ignore>SortMergeJoinExec: join_type=LeftSemi, on=[(k@0, k@0)], filter=v@1 <= x@0 - 300, metrics=[output_rows=1,<slt:ignore>spill_count=1, spilled_bytes=<slt:ignore> KB, spilled_rows=<slt:ignore> K, peak_mem_used=<slt:ignore>
+<slt:ignore>SortMergeJoinExec: join_type=LeftSemi, on=[(k@0, k@0)], filter=v@1 <= x@0 - 300, projection=[k@0], metrics=[output_rows=1,<slt:ignore>spill_count=1, spilled_bytes=<slt:ignore> KB, spilled_rows=<slt:ignore> K, peak_mem_used=<slt:ignore>
 
 # The same query must retain the matching first slice after later overflows.
 query I