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

gianm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 89d0e9b5e84 feat: Pluggable DataSourcePlanners for MSQ. (#20253)
89d0e9b5e84 is described below

commit 89d0e9b5e84942c0dfe00a164719bea956d008c3
Author: Gian Merlino <[email protected]>
AuthorDate: Fri Sep 4 16:06:00 2026 -0700

    feat: Pluggable DataSourcePlanners for MSQ. (#20253)
    
    This patch adds a DataSourcePlanner interface that can be used to
    plug in DataSourcePlan logic, and migrates DataSourcePlan to use
    this interface rather than hardcoding all the logic.
---
 .../org/apache/druid/msq/guice/MSQBinders.java     |  19 +
 .../apache/druid/msq/guice/MSQIndexingModule.java  |   3 +
 .../apache/druid/msq/querykit/DataSourcePlan.java  | 644 +--------------------
 .../druid/msq/querykit/DataSourcePlanner.java      |  53 ++
 .../druid/msq/querykit/DataSourcePlanners.java     |  91 +++
 .../apache/druid/msq/querykit/QueryKitSpec.java    |  21 +-
 .../datasource/DataSourcePlannerUtils.java         |  86 +++
 .../datasource/ExternalDataSourcePlanner.java      |  64 ++
 .../datasource/FilteredDataSourcePlanner.java      |  67 +++
 .../datasource/InlineDataSourcePlanner.java        |  58 ++
 .../querykit/datasource/JoinDataSourcePlanner.java | 322 +++++++++++
 .../datasource/LookupDataSourcePlanner.java        |  56 ++
 .../datasource/QueryDataSourcePlanner.java         |  85 +++
 .../datasource/RestrictedDataSourcePlanner.java    |  58 ++
 .../datasource/TableDataSourcePlanner.java         |  70 +++
 .../datasource/UnionDataSourcePlanner.java         |  86 +++
 .../datasource/UnnestDataSourcePlanner.java        |  77 +++
 .../druid/msq/sql/DartQueryKitSpecFactory.java     |  12 +-
 .../druid/msq/sql/MSQTaskQueryKitSpecFactory.java  |  12 +-
 .../dart/controller/http/DartSqlResourceTest.java  |   3 +-
 .../druid/msq/querykit/DataSourcePlannerTest.java  | 168 ++++++
 21 files changed, 1419 insertions(+), 636 deletions(-)

diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQBinders.java 
b/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQBinders.java
index e3cd3e4028e..12841662094 100644
--- a/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQBinders.java
+++ b/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQBinders.java
@@ -29,7 +29,9 @@ import org.apache.druid.msq.dart.Dart;
 import org.apache.druid.msq.input.InputSlice;
 import org.apache.druid.msq.input.InputSliceReaderProvider;
 import org.apache.druid.msq.input.InputSpecSlicerProvider;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
 import org.apache.druid.msq.querykit.QueryKit;
+import org.apache.druid.query.DataSource;
 import org.apache.druid.query.Query;
 
 import java.lang.annotation.Annotation;
@@ -60,6 +62,23 @@ public class MSQBinders
     );
   }
 
+  /**
+   * Creates a MapBinder for {@link DataSourcePlanner} implementations, keyed 
on the exact {@link DataSource} class
+   * they plan. Extensions can use this to make their own {@link DataSource} 
types planable by {@link QueryKit}.
+   *
+   * Example usage:
+   * <pre>
+   * MSQBinders.dataSourcePlannerBinder(binder)
+   *     .addBinding(MyCustomDataSource.class)
+   *     .to(MyCustomDataSourcePlanner.class);
+   * </pre>
+   */
+  @SuppressWarnings("rawtypes")
+  public static MapBinder<Class<? extends DataSource>, DataSourcePlanner> 
dataSourcePlannerBinder(Binder binder)
+  {
+    return MapBinder.newMapBinder(binder, new TypeLiteral<>() {}, new 
TypeLiteral<>() {});
+  }
+
   /**
    * Bind an {@link InputSpecSlicerProvider} for use on a controller. The 
annotation should be
    * {@link IndexingService} for providers used by tasks, or {@link Dart} for 
providers used by Dart.
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQIndexingModule.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQIndexingModule.java
index 8781fa5bb0f..8e472e6e7d8 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQIndexingModule.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/guice/MSQIndexingModule.java
@@ -254,6 +254,9 @@ public class MSQIndexingModule implements DruidModule
               .to(WindowOperatorQueryKit.class);
     binder.bind(WindowOperatorQueryKit.class).in(LazySingleton.class);
 
+    // Create the binder no matter what, to ensure we can at least get an 
empty map.
+    MSQBinders.dataSourcePlannerBinder(binder);
+
     MSQBinders.inputSpecSlicerProviderBinder(binder, IndexingService.class)
               .addBinding()
               .to(IndexerTableInputSpecSlicerProvider.class)
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlan.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlan.java
index d3d4d86405b..c782a60c1a0 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlan.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlan.java
@@ -20,81 +20,28 @@
 package org.apache.druid.msq.querykit;
 
 import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
 import it.unimi.dsi.fastutil.ints.IntSet;
-import it.unimi.dsi.fastutil.ints.IntSets;
-import org.apache.druid.frame.key.ClusterBy;
-import org.apache.druid.frame.key.KeyColumn;
 import org.apache.druid.java.util.common.IAE;
-import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.UOE;
-import org.apache.druid.java.util.common.logger.Logger;
 import org.apache.druid.msq.exec.Limits;
 import org.apache.druid.msq.input.InputSpec;
-import org.apache.druid.msq.input.external.ExternalInputSpec;
-import org.apache.druid.msq.input.inline.InlineInputSpec;
-import org.apache.druid.msq.input.lookup.LookupInputSpec;
 import org.apache.druid.msq.input.stage.StageInputSpec;
-import org.apache.druid.msq.input.table.TableInputSpec;
-import org.apache.druid.msq.kernel.HashShuffleSpec;
-import org.apache.druid.msq.kernel.QueryDefinition;
 import org.apache.druid.msq.kernel.QueryDefinitionBuilder;
 import org.apache.druid.msq.kernel.StageDefinition;
-import org.apache.druid.msq.kernel.StageDefinitionBuilder;
-import org.apache.druid.msq.querykit.common.SortMergeJoinStageProcessor;
 import org.apache.druid.query.DataSource;
-import org.apache.druid.query.FilteredDataSource;
-import org.apache.druid.query.InlineDataSource;
-import org.apache.druid.query.JoinAlgorithm;
-import org.apache.druid.query.JoinDataSource;
-import org.apache.druid.query.LookupDataSource;
 import org.apache.druid.query.QueryContext;
-import org.apache.druid.query.QueryDataSource;
-import org.apache.druid.query.RestrictedDataSource;
-import org.apache.druid.query.SegmentDescriptor;
-import org.apache.druid.query.TableDataSource;
-import org.apache.druid.query.UnionDataSource;
-import org.apache.druid.query.UnnestDataSource;
-import org.apache.druid.query.planning.JoinDataSourceAnalysis;
-import org.apache.druid.query.planning.PreJoinableClause;
 import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
-import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
 import org.apache.druid.query.spec.QuerySegmentSpec;
-import org.apache.druid.query.spec.SpecificSegmentSpec;
-import org.apache.druid.segment.column.ColumnHolder;
-import org.apache.druid.segment.column.RowSignature;
-import org.apache.druid.segment.join.JoinConditionAnalysis;
-import org.apache.druid.sql.calcite.external.ExternalDataSource;
-import org.apache.druid.sql.calcite.parser.DruidSqlInsert;
-import org.joda.time.Interval;
 
 import javax.annotation.Nullable;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Optional;
-import java.util.stream.Collectors;
 
 /**
  * Plan for getting data from a {@link DataSource}. Used by {@link QueryKit} 
implementations.
  */
 public class DataSourcePlan
 {
-  /**
-   * A map with {@link DruidSqlInsert#SQL_INSERT_SEGMENT_GRANULARITY} set to 
null, so we can clear it from the context
-   * of subqueries.
-   */
-  private static final Map<String, Object> CONTEXT_MAP_NO_SEGMENT_GRANULARITY 
= new HashMap<>();
-  private static final Logger log = new Logger(DataSourcePlan.class);
-
-  static {
-    
CONTEXT_MAP_NO_SEGMENT_GRANULARITY.put(DruidSqlInsert.SQL_INSERT_SEGMENT_GRANULARITY,
 null);
-  }
-
   private final DataSource newDataSource;
   private final List<InputSpec> inputSpecs;
   private final IntSet broadcastInputs;
@@ -102,7 +49,7 @@ public class DataSourcePlan
   @Nullable
   private final QueryDefinitionBuilder subQueryDefBuilder;
 
-  DataSourcePlan(
+  public DataSourcePlan(
       final DataSource newDataSource,
       final List<InputSpec> inputSpecs,
       final IntSet broadcastInputs,
@@ -121,7 +68,7 @@ public class DataSourcePlan
     }
   }
 
-  DataSourcePlan withDataSource(DataSource newDataSource)
+  public DataSourcePlan withDataSource(DataSource newDataSource)
   {
     return new DataSourcePlan(newDataSource, inputSpecs, broadcastInputs, 
subQueryDefBuilder);
   }
@@ -146,96 +93,21 @@ public class DataSourcePlan
       final boolean broadcast
   )
   {
-
-    if (dataSource instanceof TableDataSource) {
-      return forTable(
-          (TableDataSource) dataSource,
-          querySegmentSpec,
-          broadcast
-      );
-    } else if (dataSource instanceof RestrictedDataSource) {
-      return forRestricted(
-          (RestrictedDataSource) dataSource,
-          querySegmentSpec,
-          broadcast
-      );
-    } else if (dataSource instanceof ExternalDataSource) {
-      checkQuerySegmentSpecIsEternity(dataSource, querySegmentSpec);
-      return forExternal((ExternalDataSource) dataSource, broadcast);
-    } else if (dataSource instanceof InlineDataSource) {
-      checkQuerySegmentSpecIsEternity(dataSource, querySegmentSpec);
-      return forInline((InlineDataSource) dataSource, broadcast);
-    } else if (dataSource instanceof LookupDataSource) {
-      return forLookup((LookupDataSource) dataSource, broadcast);
-    } else if (dataSource instanceof FilteredDataSource) {
-      return forFilteredDataSource(
-          queryKitSpec,
-          queryContext,
-          (FilteredDataSource) dataSource,
-          querySegmentSpec,
-          minStageNumber,
-          broadcast
-      );
-    } else if (dataSource instanceof UnnestDataSource) {
-      return forUnnest(
-          queryKitSpec,
-          queryContext,
-          (UnnestDataSource) dataSource,
-          querySegmentSpec,
-          minStageNumber,
-          broadcast
-      );
-    } else if (dataSource instanceof QueryDataSource) {
-      checkQuerySegmentSpecIsEternity(dataSource, querySegmentSpec);
-      return forQuery(
-          queryKitSpec,
-          (QueryDataSource) dataSource,
-          minStageNumber,
-          broadcast
-      );
-    } else if (dataSource instanceof UnionDataSource) {
-      return forUnion(
-          queryKitSpec,
-          queryContext,
-          (UnionDataSource) dataSource,
-          querySegmentSpec,
-          minStageNumber,
-          broadcast
-      );
-    } else if (dataSource instanceof JoinDataSource) {
-      JoinDataSource joinDataSource = (JoinDataSource) dataSource;
-      final JoinAlgorithm preferredJoinAlgorithm = 
joinDataSource.getJoinAlgorithm();
-      final JoinAlgorithm deducedJoinAlgorithm = deduceJoinAlgorithm(
-          preferredJoinAlgorithm,
-          joinDataSource
-      );
-
-      switch (deducedJoinAlgorithm) {
-        case BROADCAST:
-          return forBroadcastHashJoin(
-              queryKitSpec,
-              queryContext,
-              joinDataSource,
-              querySegmentSpec,
-              minStageNumber,
-              broadcast
-          );
-
-        case SORT_MERGE:
-          return forSortMergeJoin(
-              queryKitSpec,
-              joinDataSource,
-              querySegmentSpec,
-              minStageNumber,
-              broadcast
-          );
-
-        default:
-          throw new UOE("Cannot handle join algorithm [%s]", 
deducedJoinAlgorithm);
-      }
-    } else {
+    //noinspection rawtypes
+    final DataSourcePlanner planner = 
queryKitSpec.getDataSourcePlanners().getPlanner(dataSource.getClass());
+    if (planner == null) {
       throw new UOE("Cannot handle dataSource [%s]", dataSource);
     }
+
+    //noinspection unchecked
+    return planner.planDataSource(
+        queryKitSpec,
+        queryContext,
+        dataSource,
+        querySegmentSpec,
+        minStageNumber,
+        broadcast
+    );
   }
 
   /**
@@ -286,50 +158,6 @@ public class DataSourcePlan
     return Optional.ofNullable(subQueryDefBuilder);
   }
 
-  /**
-   * Contains the logic that deduces the join algorithm to be used. Ideally, 
this should reside while planning the
-   * native query, however we don't have the resources and the structure in 
place (when adding this function) to do so.
-   * Therefore, this is done while planning the MSQ query
-   * It takes into account the algorithm specified by "sqlJoinAlgorithm" in 
the query context and the join condition
-   * that is present in the query.
-   */
-  private static JoinAlgorithm deduceJoinAlgorithm(JoinAlgorithm 
preferredJoinAlgorithm, JoinDataSource joinDataSource)
-  {
-    JoinAlgorithm deducedJoinAlgorithm;
-    if (JoinAlgorithm.BROADCAST.equals(preferredJoinAlgorithm)) {
-      deducedJoinAlgorithm = JoinAlgorithm.BROADCAST;
-    } else if (canUseSortMergeJoin(joinDataSource.getConditionAnalysis())) {
-      deducedJoinAlgorithm = JoinAlgorithm.SORT_MERGE;
-    } else {
-      deducedJoinAlgorithm = JoinAlgorithm.BROADCAST;
-    }
-
-    if (deducedJoinAlgorithm != preferredJoinAlgorithm) {
-      log.debug(
-          "User wanted to plan join [%s] as [%s], however the join will be 
executed as [%s]",
-          joinDataSource,
-          preferredJoinAlgorithm.toString(),
-          deducedJoinAlgorithm.toString()
-      );
-    }
-
-    return deducedJoinAlgorithm;
-  }
-
-  /**
-   * Checks if the sortMerge algorithm can execute a particular join condition.
-   * <p>
-   * One check: join condition on two tables "table1" and "table2" is of the 
form
-   * table1.columnA = table2.columnA && table1.columnB = table2.columnB && ....
-   */
-  private static boolean canUseSortMergeJoin(JoinConditionAnalysis 
joinConditionAnalysis)
-  {
-    return joinConditionAnalysis
-        .getEquiConditions()
-        .stream()
-        .allMatch(equality -> equality.getLeftExpr().isIdentifier());
-  }
-
   /**
    * Whether this datasource must be processed by a single worker. True if, 
and only if, all inputs are broadcast.
    */
@@ -337,446 +165,4 @@ public class DataSourcePlan
   {
     return broadcastInputs.size() == inputSpecs.size();
   }
-
-  private static DataSourcePlan forTable(
-      final TableDataSource dataSource,
-      final QuerySegmentSpec querySegmentSpec,
-      final boolean broadcast
-  )
-  {
-    final List<SegmentDescriptor> segments;
-    if (querySegmentSpec instanceof MultipleSpecificSegmentSpec) {
-      segments = ((MultipleSpecificSegmentSpec) 
querySegmentSpec).getDescriptors();
-    } else if (querySegmentSpec instanceof SpecificSegmentSpec) {
-      segments = List.of(((SpecificSegmentSpec) 
querySegmentSpec).getDescriptor());
-    } else {
-      segments = null;
-    }
-    List<Interval> intervals = querySegmentSpec.getIntervals();
-    return new DataSourcePlan(
-        (broadcast && dataSource.isGlobal()) ? dataSource : new 
InputNumberDataSource(0),
-        List.of(new TableInputSpec(dataSource.getName(), intervals, segments)),
-        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
-        null
-    );
-  }
-
-  private static DataSourcePlan forRestricted(
-      final RestrictedDataSource dataSource,
-      final QuerySegmentSpec querySegmentSpec,
-      final boolean broadcast
-  )
-  {
-    DataSource restricted = (broadcast && dataSource.isGlobal())
-                            ? dataSource
-                            : new RestrictedInputNumberDataSource(0, 
dataSource.getPolicy());
-    return forTable(dataSource.getBase(), querySegmentSpec, 
broadcast).withDataSource(restricted);
-  }
-
-  private static DataSourcePlan forExternal(
-      final ExternalDataSource dataSource,
-      final boolean broadcast
-  )
-  {
-    return new DataSourcePlan(
-        dataSource,
-        Collections.singletonList(
-            new ExternalInputSpec(
-                dataSource.getInputSource(),
-                dataSource.getInputFormat(),
-                dataSource.getSignature()
-            )
-        ),
-        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
-        null
-    );
-  }
-
-  private static DataSourcePlan forInline(
-      final InlineDataSource dataSource,
-      final boolean broadcast
-  )
-  {
-    return new DataSourcePlan(
-        dataSource,
-        Collections.singletonList(new InlineInputSpec(dataSource)),
-        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
-        null
-    );
-  }
-
-  private static DataSourcePlan forLookup(
-      final LookupDataSource dataSource,
-      final boolean broadcast
-  )
-  {
-    return new DataSourcePlan(
-        dataSource,
-        Collections.singletonList(new 
LookupInputSpec(dataSource.getLookupName())),
-        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
-        null
-    );
-  }
-
-  private static DataSourcePlan forQuery(
-      final QueryKitSpec queryKitSpec,
-      final QueryDataSource dataSource,
-      final int minStageNumber,
-      final boolean broadcast
-  )
-  {
-    final QueryDefinition subQueryDef = 
queryKitSpec.getQueryKit().makeQueryDefinition(
-        queryKitSpec,
-        // Subqueries ignore SQL_INSERT_SEGMENT_GRANULARITY, even if set in 
the context. It's only used for the
-        // outermost query, and setting it for the subquery makes us 
erroneously add bucketing where it doesn't belong.
-        
dataSource.getQuery().withOverriddenContext(CONTEXT_MAP_NO_SEGMENT_GRANULARITY),
-        ShuffleSpecFactories.globalSortWithTargetPartitions(),
-        minStageNumber
-    );
-
-    final int stageNumber = 
subQueryDef.getFinalStageDefinition().getStageNumber();
-
-    return new DataSourcePlan(
-        new InputNumberDataSource(0),
-        Collections.singletonList(new StageInputSpec(stageNumber)),
-        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
-        QueryDefinition.builder(subQueryDef)
-    );
-  }
-
-  private static DataSourcePlan forFilteredDataSource(
-      final QueryKitSpec queryKitSpec,
-      final QueryContext queryContext,
-      final FilteredDataSource dataSource,
-      final QuerySegmentSpec querySegmentSpec,
-      final int minStageNumber,
-      final boolean broadcast
-  )
-  {
-    final DataSourcePlan basePlan = forDataSource(
-        queryKitSpec,
-        queryContext,
-        dataSource.getBase(),
-        querySegmentSpec,
-        minStageNumber,
-        broadcast
-    );
-
-    DataSource newDataSource = basePlan.getNewDataSource();
-
-    final List<InputSpec> inputSpecs = new 
ArrayList<>(basePlan.getInputSpecs());
-    newDataSource = FilteredDataSource.create(newDataSource, 
dataSource.getFilter());
-    return new DataSourcePlan(
-        newDataSource,
-        inputSpecs,
-        basePlan.getBroadcastInputs(),
-        basePlan.getSubQueryDefBuilder().orElse(null)
-    );
-
-  }
-
-  /**
-   * Build a plan for Unnest data source
-   */
-  private static DataSourcePlan forUnnest(
-      final QueryKitSpec queryKitSpec,
-      final QueryContext queryContext,
-      final UnnestDataSource dataSource,
-      final QuerySegmentSpec querySegmentSpec,
-      final int minStageNumber,
-      final boolean broadcast
-  )
-  {
-    // Find the plan for base data source by recursing
-    final DataSourcePlan basePlan = forDataSource(
-        queryKitSpec,
-        queryContext,
-        dataSource.getBase(),
-        querySegmentSpec,
-        minStageNumber,
-        broadcast
-    );
-    DataSource newDataSource = basePlan.getNewDataSource();
-
-    final List<InputSpec> inputSpecs = new 
ArrayList<>(basePlan.getInputSpecs());
-
-    // Create the new data source using the data source from the base plan
-    newDataSource = UnnestDataSource.create(
-        newDataSource,
-        dataSource.getVirtualColumn(),
-        dataSource.getUnnestFilter()
-    );
-    // The base data source can be a join and might already have broadcast 
inputs
-    // Need to set the broadcast inputs from the basePlan
-    return new DataSourcePlan(
-        newDataSource,
-        inputSpecs,
-        basePlan.getBroadcastInputs(),
-        basePlan.getSubQueryDefBuilder().orElse(null)
-    );
-  }
-
-  private static DataSourcePlan forUnion(
-      final QueryKitSpec queryKitSpec,
-      final QueryContext queryContext,
-      final UnionDataSource unionDataSource,
-      final QuerySegmentSpec querySegmentSpec,
-      final int minStageNumber,
-      final boolean broadcast
-  )
-  {
-    // This is done to prevent loss of generality since MSQ can plan any type 
of DataSource.
-    List<DataSource> children = unionDataSource.getChildren();
-
-    final QueryDefinitionBuilder subqueryDefBuilder = 
QueryDefinition.builder(queryKitSpec.getQueryId());
-    final List<DataSource> newChildren = new ArrayList<>();
-    final List<InputSpec> inputSpecs = new ArrayList<>();
-    final IntSet broadcastInputs = new IntOpenHashSet();
-
-    for (DataSource child : children) {
-      DataSourcePlan childDataSourcePlan = forDataSource(
-          queryKitSpec,
-          queryContext,
-          child,
-          querySegmentSpec,
-          Math.max(minStageNumber, subqueryDefBuilder.getNextStageNumber()),
-          broadcast
-      );
-
-      int shift = inputSpecs.size();
-
-      
newChildren.add(shiftInputNumbers(childDataSourcePlan.getNewDataSource(), 
shift));
-      inputSpecs.addAll(childDataSourcePlan.getInputSpecs());
-      
childDataSourcePlan.getSubQueryDefBuilder().ifPresent(subqueryDefBuilder::addAll);
-      childDataSourcePlan.getBroadcastInputs().forEach(inp -> 
broadcastInputs.add(inp + shift));
-    }
-    return new DataSourcePlan(
-        new UnionDataSource(newChildren),
-        inputSpecs,
-        broadcastInputs,
-        subqueryDefBuilder
-    );
-  }
-
-  /**
-   * Build a plan for broadcast hash-join.
-   */
-  private static DataSourcePlan forBroadcastHashJoin(
-      final QueryKitSpec queryKitSpec,
-      final QueryContext queryContext,
-      final JoinDataSource dataSource,
-      final QuerySegmentSpec querySegmentSpec,
-      final int minStageNumber,
-      final boolean broadcast
-  )
-  {
-    final QueryDefinitionBuilder subQueryDefBuilder = 
QueryDefinition.builder(queryKitSpec.getQueryId());
-    final JoinDataSourceAnalysis analysis = 
dataSource.getJoinAnalysisForDataSource();
-
-    final DataSourcePlan basePlan = forDataSource(
-        queryKitSpec,
-        queryContext,
-        analysis.getBaseDataSource(),
-        querySegmentSpec,
-        Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
-        broadcast
-    );
-
-    DataSource newDataSource = basePlan.getNewDataSource();
-    final List<InputSpec> inputSpecs = new 
ArrayList<>(basePlan.getInputSpecs());
-    final IntSet broadcastInputs = new 
IntOpenHashSet(basePlan.getBroadcastInputs());
-    basePlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
-
-    for (int i = 0; i < analysis.getPreJoinableClauses().size(); i++) {
-      final PreJoinableClause clause = analysis.getPreJoinableClauses().get(i);
-      final DataSourcePlan clausePlan = forDataSource(
-          queryKitSpec,
-          queryContext,
-          clause.getDataSource(),
-          new MultipleIntervalSegmentSpec(Intervals.ONLY_ETERNITY),
-          Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
-          true // Always broadcast right-hand side of the join.
-      );
-
-      // Shift all input numbers in the clausePlan.
-      final int shift = inputSpecs.size();
-
-      newDataSource = JoinDataSource.create(
-          newDataSource,
-          shiftInputNumbers(clausePlan.getNewDataSource(), shift),
-          clause.getPrefix(),
-          clause.getCondition(),
-          clause.getJoinType(),
-          // First JoinDataSource (i == 0) involves the base table, so we need 
to propagate the base table filter.
-          i == 0 ? analysis.getJoinBaseTableFilter().orElse(null) : null,
-          dataSource.getJoinableFactoryWrapper(),
-          clause.getJoinAlgorithm()
-      );
-      inputSpecs.addAll(clausePlan.getInputSpecs());
-      clausePlan.getBroadcastInputs().intStream().forEach(n -> 
broadcastInputs.add(n + shift));
-      clausePlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
-    }
-
-    return new DataSourcePlan(newDataSource, inputSpecs, broadcastInputs, 
subQueryDefBuilder);
-  }
-
-  /**
-   * Build a plan for sort-merge join.
-   */
-  private static DataSourcePlan forSortMergeJoin(
-      final QueryKitSpec queryKitSpec,
-      final JoinDataSource dataSource,
-      final QuerySegmentSpec querySegmentSpec,
-      final int minStageNumber,
-      final boolean broadcast
-  )
-  {
-    checkQuerySegmentSpecIsEternity(dataSource, querySegmentSpec);
-    
SortMergeJoinStageProcessor.validateCondition(dataSource.getConditionAnalysis());
-
-    // Partition by keys given by the join condition.
-    final List<List<KeyColumn>> partitionKeys = 
SortMergeJoinStageProcessor.toKeyColumns(
-        
SortMergeJoinStageProcessor.validateCondition(dataSource.getConditionAnalysis())
-    );
-
-    final QueryDefinitionBuilder subQueryDefBuilder = 
QueryDefinition.builder(queryKitSpec.getQueryId());
-
-    // Plan the left input.
-    // We're confident that we can cast dataSource.getLeft() to 
QueryDataSource, because DruidJoinQueryRel creates
-    // subqueries when the join algorithm is sortMerge.
-    final DataSourcePlan leftPlan = forQuery(
-        queryKitSpec,
-        (QueryDataSource) dataSource.getLeft(),
-        Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
-        false
-    );
-    leftPlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
-
-    // Plan the right input.
-    // We're confident that we can cast dataSource.getRight() to 
QueryDataSource, because DruidJoinQueryRel creates
-    // subqueries when the join algorithm is sortMerge.
-    final DataSourcePlan rightPlan = forQuery(
-        queryKitSpec,
-        (QueryDataSource) dataSource.getRight(),
-        Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
-        false
-    );
-    rightPlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
-
-    // Build up the left stage.
-    final StageDefinitionBuilder leftBuilder = 
subQueryDefBuilder.getStageBuilder(
-        ((StageInputSpec) 
Iterables.getOnlyElement(leftPlan.getInputSpecs())).getStageNumber()
-    );
-
-    final List<KeyColumn> leftPartitionKey = partitionKeys.get(0);
-    leftBuilder.shuffleSpec(new HashShuffleSpec(new 
ClusterBy(leftPartitionKey, 0), 1, true));
-    
leftBuilder.signature(QueryKitUtils.sortableSignature(leftBuilder.getSignature(),
 leftPartitionKey));
-    leftBuilder.maxWorkerCount(Limits.MAX_WORKERS);
-
-    // Build up the right stage.
-    final StageDefinitionBuilder rightBuilder = 
subQueryDefBuilder.getStageBuilder(
-        ((StageInputSpec) 
Iterables.getOnlyElement(rightPlan.getInputSpecs())).getStageNumber()
-    );
-
-    final List<KeyColumn> rightPartitionKey = partitionKeys.get(1);
-    rightBuilder.shuffleSpec(new HashShuffleSpec(new 
ClusterBy(rightPartitionKey, 0), 1, true));
-    
rightBuilder.signature(QueryKitUtils.sortableSignature(rightBuilder.getSignature(),
 rightPartitionKey));
-    rightBuilder.maxWorkerCount(Limits.MAX_WORKERS);
-
-    // Compute join signature.
-    final RowSignature.Builder joinSignatureBuilder = RowSignature.builder();
-
-    for (String leftColumn : leftBuilder.getSignature().getColumnNames()) {
-      joinSignatureBuilder.add(leftColumn, 
leftBuilder.getSignature().getColumnType(leftColumn).orElse(null));
-    }
-
-    for (String rightColumn : rightBuilder.getSignature().getColumnNames()) {
-      joinSignatureBuilder.add(
-          dataSource.getRightPrefix() + rightColumn,
-          rightBuilder.getSignature().getColumnType(rightColumn).orElse(null)
-      );
-    }
-
-    // Build up the join stage.
-    final int stageNumber = Math.max(minStageNumber, 
subQueryDefBuilder.getNextStageNumber());
-
-    subQueryDefBuilder.add(
-        StageDefinition.builder(stageNumber)
-                       .inputs(
-                           ImmutableList.of(
-                               
Iterables.getOnlyElement(leftPlan.getInputSpecs()),
-                               
Iterables.getOnlyElement(rightPlan.getInputSpecs())
-                           )
-                       )
-                       .maxWorkerCount(Limits.MAX_WORKERS)
-                       .signature(joinSignatureBuilder.build())
-                       .processor(
-                           new SortMergeJoinStageProcessor(
-                               dataSource.getRightPrefix(),
-                               dataSource.getConditionAnalysis(),
-                               dataSource.getJoinType()
-                           )
-                       )
-    );
-
-    return new DataSourcePlan(
-        new InputNumberDataSource(0),
-        Collections.singletonList(new StageInputSpec(stageNumber)),
-        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
-        subQueryDefBuilder
-    );
-  }
-
-  private static DataSource shiftInputNumbers(final DataSource dataSource, 
final int shift)
-  {
-    if (shift < 0) {
-      throw new IAE("Shift must be >= 0");
-    } else if (shift == 0) {
-      return dataSource;
-    } else {
-      if (dataSource instanceof InputNumberDataSource) {
-        return new InputNumberDataSource(((InputNumberDataSource) 
dataSource).getInputNumber() + shift);
-      } else {
-        return dataSource.withChildren(
-            dataSource.getChildren()
-                      .stream()
-                      .map(child -> shiftInputNumbers(child, shift))
-                      .collect(Collectors.toList())
-        );
-      }
-    }
-  }
-
-  private static List<Interval> querySegmentSpecIntervals(final 
QuerySegmentSpec querySegmentSpec)
-  {
-    if (querySegmentSpec instanceof MultipleIntervalSegmentSpec) {
-      return querySegmentSpec.getIntervals();
-    } else {
-      throw new UOE("Cannot handle querySegmentSpec type [%s]", 
querySegmentSpec.getClass().getName());
-    }
-  }
-
-  /**
-   * Verify that the provided {@link QuerySegmentSpec} is a {@link 
MultipleIntervalSegmentSpec} with
-   * interval {@link Intervals#ETERNITY}. If not, throw an {@link 
UnsupportedOperationException}.
-   * <p>
-   * See {@link 
org.apache.druid.sql.calcite.rel.DruidQuery#canUseIntervalFiltering(DataSource)}.
-   */
-  private static void checkQuerySegmentSpecIsEternity(
-      final DataSource dataSource,
-      final QuerySegmentSpec querySegmentSpec
-  )
-  {
-    final boolean querySegmentSpecIsEternity =
-        querySegmentSpec instanceof MultipleIntervalSegmentSpec
-        && querySegmentSpec.getIntervals().equals(Intervals.ONLY_ETERNITY);
-
-    if (!querySegmentSpecIsEternity) {
-      throw new UOE(
-          "Cannot filter datasource [%s] using [%s]",
-          dataSource.getClass().getName(),
-          ColumnHolder.TIME_COLUMN_NAME
-      );
-    }
-  }
 }
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlanner.java
new file mode 100644
index 00000000000..c907e1160ae
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlanner.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit;
+
+import com.google.inject.Binder;
+import org.apache.druid.msq.guice.MSQBinders;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+/**
+ * Builds a {@link DataSourcePlan} for a particular class of {@link 
DataSource}. Register with
+ * {@link MSQBinders#dataSourcePlannerBinder(Binder)}.
+ */
+public interface DataSourcePlanner<T extends DataSource>
+{
+  /**
+   * Same contract as {@link DataSourcePlan#forDataSource}, for the one 
datasource type this planner is registered
+   * against.
+   *
+   * @param queryKitSpec     reference for recursive planning
+   * @param queryContext     query context
+   * @param dataSource       datasource to plan
+   * @param querySegmentSpec intervals for mandatory pruning. The returned 
plan must be filtered to this interval.
+   * @param minStageNumber   starting stage number for subqueries
+   * @param broadcast        whether the plan should broadcast data for this 
datasource
+   */
+  DataSourcePlan planDataSource(
+      QueryKitSpec queryKitSpec,
+      QueryContext queryContext,
+      T dataSource,
+      QuerySegmentSpec querySegmentSpec,
+      int minStageNumber,
+      boolean broadcast
+  );
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlanners.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlanners.java
new file mode 100644
index 00000000000..39fcd44ea3c
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/DataSourcePlanners.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit;
+
+import com.google.inject.Inject;
+import org.apache.druid.guice.LazySingleton;
+import org.apache.druid.msq.querykit.datasource.ExternalDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.FilteredDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.InlineDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.JoinDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.LookupDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.QueryDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.RestrictedDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.TableDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.UnionDataSourcePlanner;
+import org.apache.druid.msq.querykit.datasource.UnnestDataSourcePlanner;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.FilteredDataSource;
+import org.apache.druid.query.GlobalTableDataSource;
+import org.apache.druid.query.InlineDataSource;
+import org.apache.druid.query.JoinDataSource;
+import org.apache.druid.query.LookupDataSource;
+import org.apache.druid.query.QueryDataSource;
+import org.apache.druid.query.RestrictedDataSource;
+import org.apache.druid.query.TableDataSource;
+import org.apache.druid.query.UnionDataSource;
+import org.apache.druid.query.UnnestDataSource;
+import org.apache.druid.sql.calcite.external.ExternalDataSource;
+
+import java.util.Map;
+
+/**
+ * Provider of {@link DataSourcePlanner}.
+ */
+@LazySingleton
+@SuppressWarnings("rawtypes")
+public class DataSourcePlanners
+{
+  private static final TableDataSourcePlanner TABLE_PLANNER = new 
TableDataSourcePlanner();
+
+  /**
+   * Planners for builtin {@link DataSource} types.
+   */
+  private static final Map<Class<? extends DataSource>, DataSourcePlanner> 
BUILTIN =
+      Map.ofEntries(
+          Map.entry(TableDataSource.class, TABLE_PLANNER),
+          Map.entry(GlobalTableDataSource.class, TABLE_PLANNER),
+          Map.entry(RestrictedDataSource.class, new 
RestrictedDataSourcePlanner()),
+          Map.entry(ExternalDataSource.class, new ExternalDataSourcePlanner()),
+          Map.entry(InlineDataSource.class, new InlineDataSourcePlanner()),
+          Map.entry(LookupDataSource.class, new LookupDataSourcePlanner()),
+          Map.entry(FilteredDataSource.class, new FilteredDataSourcePlanner()),
+          Map.entry(UnnestDataSource.class, new UnnestDataSourcePlanner()),
+          Map.entry(QueryDataSource.class, new QueryDataSourcePlanner()),
+          Map.entry(UnionDataSource.class, new UnionDataSourcePlanner()),
+          Map.entry(JoinDataSource.class, new JoinDataSourcePlanner())
+      );
+
+  private final Map<Class<? extends DataSource>, DataSourcePlanner> planners;
+
+  @Inject
+  public DataSourcePlanners(Map<Class<? extends DataSource>, 
DataSourcePlanner> planners)
+  {
+    this.planners = planners;
+  }
+
+  @SuppressWarnings("unchecked")
+  public <T extends DataSource> DataSourcePlanner<T> getPlanner(Class<T> 
dataSourceClass)
+  {
+    // Check extension planners first, so extensions can override builtin 
planners.
+    final DataSourcePlanner extensionPlanner = planners.get(dataSourceClass);
+    return extensionPlanner != null ? extensionPlanner : 
BUILTIN.get(dataSourceClass);
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/QueryKitSpec.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/QueryKitSpec.java
index 4454026fca9..6ace9c4c990 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/QueryKitSpec.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/QueryKitSpec.java
@@ -20,27 +20,32 @@
 package org.apache.druid.msq.querykit;
 
 import org.apache.druid.msq.kernel.QueryDefinition;
+import org.apache.druid.query.DataSource;
 import org.apache.druid.query.Query;
 
 /**
- * Container for {@link QueryKit} plus the queryId that we want to build.
+ * Container for {@link QueryKit}, {@link DataSourcePlanners}, and the queryId 
that we want to build.
  */
 public class QueryKitSpec
 {
   private final QueryKit<Query<?>> queryKit;
   private final String queryId;
+  private final DataSourcePlanners dataSourcePlanners;
 
   /**
-   * @param queryKit              kit that is used to translate native 
subqueries; i.e.,
-   *                              {@link 
org.apache.druid.query.QueryDataSource}. Typically a {@link MultiQueryKit}.
-   * @param queryId               queryId of the resulting {@link 
QueryDefinition}
+   * @param queryKit           kit that is used to translate native 
subqueries; i.e.,
+   *                           {@link org.apache.druid.query.QueryDataSource}. 
Typically a {@link MultiQueryKit}.
+   * @param dataSourcePlanners planners for datasource types that {@link 
DataSourcePlan} does not handle itself
+   * @param queryId            queryId of the resulting {@link QueryDefinition}
    */
   public QueryKitSpec(
       QueryKit<Query<?>> queryKit,
+      DataSourcePlanners dataSourcePlanners,
       String queryId
   )
   {
     this.queryId = queryId;
+    this.dataSourcePlanners = dataSourcePlanners;
     this.queryKit = queryKit;
   }
 
@@ -52,6 +57,14 @@ public class QueryKitSpec
     return queryKit;
   }
 
+  /**
+   * Registered {@link DataSourcePlanner}, keyed on the exact {@link 
DataSource} class each one plans.
+   */
+  public DataSourcePlanners getDataSourcePlanners()
+  {
+    return dataSourcePlanners;
+  }
+
   /**
    * Query ID to use when building {@link QueryDefinition}.
    */
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/DataSourcePlannerUtils.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/DataSourcePlannerUtils.java
new file mode 100644
index 00000000000..59e05a5e1af
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/DataSourcePlannerUtils.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.UOE;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.InputNumberDataSource;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.segment.column.ColumnHolder;
+
+import java.util.stream.Collectors;
+
+/**
+ * Utilities shared by {@link DataSourcePlanner} implementations.
+ */
+public class DataSourcePlannerUtils
+{
+  /**
+   * Shift every {@link InputNumberDataSource} in the provided datasource tree 
upwards by the given amount. Used when
+   * merging the inputs of multiple child plans into a single plan.
+   */
+  public static DataSource shiftInputNumbers(final DataSource dataSource, 
final int shift)
+  {
+    if (shift < 0) {
+      throw new IAE("Shift must be >= 0");
+    } else if (shift == 0) {
+      return dataSource;
+    } else {
+      if (dataSource instanceof InputNumberDataSource) {
+        return new InputNumberDataSource(((InputNumberDataSource) 
dataSource).getInputNumber() + shift);
+      } else {
+        return dataSource.withChildren(
+            dataSource.getChildren()
+                      .stream()
+                      .map(child -> shiftInputNumbers(child, shift))
+                      .collect(Collectors.toList())
+        );
+      }
+    }
+  }
+
+  /**
+   * Verify that the provided {@link QuerySegmentSpec} is a {@link 
MultipleIntervalSegmentSpec} with
+   * interval {@link Intervals#ETERNITY}. If not, throw an {@link 
UnsupportedOperationException}.
+   * <p>
+   * See {@link 
org.apache.druid.sql.calcite.rel.DruidQuery#canUseIntervalFiltering(DataSource)}.
+   */
+  public static void checkQuerySegmentSpecIsEternity(
+      final DataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec
+  )
+  {
+    final boolean querySegmentSpecIsEternity =
+        querySegmentSpec instanceof MultipleIntervalSegmentSpec
+        && querySegmentSpec.getIntervals().equals(Intervals.ONLY_ETERNITY);
+
+    if (!querySegmentSpecIsEternity) {
+      throw new UOE(
+          "Cannot filter datasource [%s] using [%s]",
+          dataSource.getClass().getName(),
+          ColumnHolder.TIME_COLUMN_NAME
+      );
+    }
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/ExternalDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/ExternalDataSourcePlanner.java
new file mode 100644
index 00000000000..ca7c5f39d7b
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/ExternalDataSourcePlanner.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSets;
+import org.apache.druid.msq.input.external.ExternalInputSpec;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.sql.calcite.external.ExternalDataSource;
+
+import java.util.Collections;
+
+/**
+ * Planner for {@link ExternalDataSource}.
+ */
+public class ExternalDataSourcePlanner implements 
DataSourcePlanner<ExternalDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final ExternalDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    DataSourcePlannerUtils.checkQuerySegmentSpecIsEternity(dataSource, 
querySegmentSpec);
+
+    return new DataSourcePlan(
+        dataSource,
+        Collections.singletonList(
+            new ExternalInputSpec(
+                dataSource.getInputSource(),
+                dataSource.getInputFormat(),
+                dataSource.getSignature()
+            )
+        ),
+        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
+        null
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/FilteredDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/FilteredDataSourcePlanner.java
new file mode 100644
index 00000000000..28b9c5ec50c
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/FilteredDataSourcePlanner.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import org.apache.druid.msq.input.InputSpec;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.FilteredDataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Planner for {@link FilteredDataSource}. Plans the base datasource, then 
reapplies the filter.
+ */
+public class FilteredDataSourcePlanner implements 
DataSourcePlanner<FilteredDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final FilteredDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    final DataSourcePlan basePlan = DataSourcePlan.forDataSource(
+        queryKitSpec,
+        queryContext,
+        dataSource.getBase(),
+        querySegmentSpec,
+        minStageNumber,
+        broadcast
+    );
+
+    final List<InputSpec> inputSpecs = new 
ArrayList<>(basePlan.getInputSpecs());
+    final DataSource newDataSource = 
FilteredDataSource.create(basePlan.getNewDataSource(), dataSource.getFilter());
+    return new DataSourcePlan(
+        newDataSource,
+        inputSpecs,
+        basePlan.getBroadcastInputs(),
+        basePlan.getSubQueryDefBuilder().orElse(null)
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/InlineDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/InlineDataSourcePlanner.java
new file mode 100644
index 00000000000..fc7d26dca70
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/InlineDataSourcePlanner.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSets;
+import org.apache.druid.msq.input.inline.InlineInputSpec;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.query.InlineDataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+import java.util.Collections;
+
+/**
+ * Planner for {@link InlineDataSource}.
+ */
+public class InlineDataSourcePlanner implements 
DataSourcePlanner<InlineDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final InlineDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    DataSourcePlannerUtils.checkQuerySegmentSpecIsEternity(dataSource, 
querySegmentSpec);
+
+    return new DataSourcePlan(
+        dataSource,
+        Collections.singletonList(new InlineInputSpec(dataSource)),
+        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
+        null
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/JoinDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/JoinDataSourcePlanner.java
new file mode 100644
index 00000000000..40a40fe1235
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/JoinDataSourcePlanner.java
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
+import it.unimi.dsi.fastutil.ints.IntSets;
+import org.apache.druid.frame.key.ClusterBy;
+import org.apache.druid.frame.key.KeyColumn;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.msq.exec.Limits;
+import org.apache.druid.msq.input.InputSpec;
+import org.apache.druid.msq.input.stage.StageInputSpec;
+import org.apache.druid.msq.kernel.HashShuffleSpec;
+import org.apache.druid.msq.kernel.QueryDefinition;
+import org.apache.druid.msq.kernel.QueryDefinitionBuilder;
+import org.apache.druid.msq.kernel.StageDefinition;
+import org.apache.druid.msq.kernel.StageDefinitionBuilder;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.InputNumberDataSource;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.msq.querykit.QueryKitUtils;
+import org.apache.druid.msq.querykit.common.SortMergeJoinStageProcessor;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.JoinAlgorithm;
+import org.apache.druid.query.JoinDataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.QueryDataSource;
+import org.apache.druid.query.planning.JoinDataSourceAnalysis;
+import org.apache.druid.query.planning.PreJoinableClause;
+import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.join.JoinConditionAnalysis;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Planner for {@link JoinDataSource}. Dispatches to broadcast hash-join or 
sort-merge join, based on
+ * {@link #deduceJoinAlgorithm}.
+ */
+public class JoinDataSourcePlanner implements DataSourcePlanner<JoinDataSource>
+{
+  private static final Logger log = new Logger(JoinDataSourcePlanner.class);
+
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final JoinDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    final JoinAlgorithm preferredJoinAlgorithm = dataSource.getJoinAlgorithm();
+    final JoinAlgorithm deducedJoinAlgorithm = 
deduceJoinAlgorithm(preferredJoinAlgorithm, dataSource);
+
+    return switch (deducedJoinAlgorithm) {
+      case BROADCAST -> planBroadcastHashJoin(
+          queryKitSpec,
+          queryContext,
+          dataSource,
+          querySegmentSpec,
+          minStageNumber,
+          broadcast
+      );
+      case SORT_MERGE -> planSortMergeJoin(
+          queryKitSpec,
+          queryContext,
+          dataSource,
+          querySegmentSpec,
+          minStageNumber,
+          broadcast
+      );
+    };
+  }
+
+  /**
+   * Build a plan for broadcast hash-join.
+   */
+  private static DataSourcePlan planBroadcastHashJoin(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final JoinDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    final QueryDefinitionBuilder subQueryDefBuilder = 
QueryDefinition.builder(queryKitSpec.getQueryId());
+    final JoinDataSourceAnalysis analysis = 
dataSource.getJoinAnalysisForDataSource();
+
+    final DataSourcePlan basePlan = DataSourcePlan.forDataSource(
+        queryKitSpec,
+        queryContext,
+        analysis.getBaseDataSource(),
+        querySegmentSpec,
+        Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
+        broadcast
+    );
+
+    DataSource newDataSource = basePlan.getNewDataSource();
+    final List<InputSpec> inputSpecs = new 
ArrayList<>(basePlan.getInputSpecs());
+    final IntSet broadcastInputs = new 
IntOpenHashSet(basePlan.getBroadcastInputs());
+    basePlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
+
+    for (int i = 0; i < analysis.getPreJoinableClauses().size(); i++) {
+      final PreJoinableClause clause = analysis.getPreJoinableClauses().get(i);
+      final DataSourcePlan clausePlan = DataSourcePlan.forDataSource(
+          queryKitSpec,
+          queryContext,
+          clause.getDataSource(),
+          new MultipleIntervalSegmentSpec(Intervals.ONLY_ETERNITY),
+          Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
+          true // Always broadcast right-hand side of the join.
+      );
+
+      // Shift all input numbers in the clausePlan.
+      final int shift = inputSpecs.size();
+
+      newDataSource = JoinDataSource.create(
+          newDataSource,
+          
DataSourcePlannerUtils.shiftInputNumbers(clausePlan.getNewDataSource(), shift),
+          clause.getPrefix(),
+          clause.getCondition(),
+          clause.getJoinType(),
+          // First JoinDataSource (i == 0) involves the base table, so we need 
to propagate the base table filter.
+          i == 0 ? analysis.getJoinBaseTableFilter().orElse(null) : null,
+          dataSource.getJoinableFactoryWrapper(),
+          clause.getJoinAlgorithm()
+      );
+      inputSpecs.addAll(clausePlan.getInputSpecs());
+      clausePlan.getBroadcastInputs().intStream().forEach(n -> 
broadcastInputs.add(n + shift));
+      clausePlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
+    }
+
+    return new DataSourcePlan(newDataSource, inputSpecs, broadcastInputs, 
subQueryDefBuilder);
+  }
+
+  /**
+   * Build a plan for sort-merge join.
+   */
+  private static DataSourcePlan planSortMergeJoin(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final JoinDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    DataSourcePlannerUtils.checkQuerySegmentSpecIsEternity(dataSource, 
querySegmentSpec);
+    
SortMergeJoinStageProcessor.validateCondition(dataSource.getConditionAnalysis());
+
+    // Partition by keys given by the join condition.
+    final List<List<KeyColumn>> partitionKeys = 
SortMergeJoinStageProcessor.toKeyColumns(
+        
SortMergeJoinStageProcessor.validateCondition(dataSource.getConditionAnalysis())
+    );
+
+    final QueryDefinitionBuilder subQueryDefBuilder = 
QueryDefinition.builder(queryKitSpec.getQueryId());
+
+    // Plan the left input.
+    // We're confident that we can cast dataSource.getLeft() to 
QueryDataSource, because DruidJoinQueryRel creates
+    // subqueries when the join algorithm is sortMerge.
+    final DataSourcePlan leftPlan = DataSourcePlan.forDataSource(
+        queryKitSpec,
+        queryContext,
+        (QueryDataSource) dataSource.getLeft(),
+        querySegmentSpec,
+        Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
+        false
+    );
+    leftPlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
+
+    // Plan the right input.
+    // We're confident that we can cast dataSource.getRight() to 
QueryDataSource, because DruidJoinQueryRel creates
+    // subqueries when the join algorithm is sortMerge.
+    final DataSourcePlan rightPlan = DataSourcePlan.forDataSource(
+        queryKitSpec,
+        queryContext,
+        (QueryDataSource) dataSource.getRight(),
+        querySegmentSpec,
+        Math.max(minStageNumber, subQueryDefBuilder.getNextStageNumber()),
+        false
+    );
+    rightPlan.getSubQueryDefBuilder().ifPresent(subQueryDefBuilder::addAll);
+
+    // Build up the left stage.
+    final StageDefinitionBuilder leftBuilder = 
subQueryDefBuilder.getStageBuilder(
+        ((StageInputSpec) 
Iterables.getOnlyElement(leftPlan.getInputSpecs())).getStageNumber()
+    );
+
+    final List<KeyColumn> leftPartitionKey = partitionKeys.get(0);
+    leftBuilder.shuffleSpec(new HashShuffleSpec(new 
ClusterBy(leftPartitionKey, 0), 1, true));
+    
leftBuilder.signature(QueryKitUtils.sortableSignature(leftBuilder.getSignature(),
 leftPartitionKey));
+    leftBuilder.maxWorkerCount(Limits.MAX_WORKERS);
+
+    // Build up the right stage.
+    final StageDefinitionBuilder rightBuilder = 
subQueryDefBuilder.getStageBuilder(
+        ((StageInputSpec) 
Iterables.getOnlyElement(rightPlan.getInputSpecs())).getStageNumber()
+    );
+
+    final List<KeyColumn> rightPartitionKey = partitionKeys.get(1);
+    rightBuilder.shuffleSpec(new HashShuffleSpec(new 
ClusterBy(rightPartitionKey, 0), 1, true));
+    
rightBuilder.signature(QueryKitUtils.sortableSignature(rightBuilder.getSignature(),
 rightPartitionKey));
+    rightBuilder.maxWorkerCount(Limits.MAX_WORKERS);
+
+    // Compute join signature.
+    final RowSignature.Builder joinSignatureBuilder = RowSignature.builder();
+
+    for (final String leftColumn : 
leftBuilder.getSignature().getColumnNames()) {
+      joinSignatureBuilder.add(leftColumn, 
leftBuilder.getSignature().getColumnType(leftColumn).orElse(null));
+    }
+
+    for (final String rightColumn : 
rightBuilder.getSignature().getColumnNames()) {
+      joinSignatureBuilder.add(
+          dataSource.getRightPrefix() + rightColumn,
+          rightBuilder.getSignature().getColumnType(rightColumn).orElse(null)
+      );
+    }
+
+    // Build up the join stage.
+    final int stageNumber = Math.max(minStageNumber, 
subQueryDefBuilder.getNextStageNumber());
+
+    subQueryDefBuilder.add(
+        StageDefinition.builder(stageNumber)
+                       .inputs(
+                           ImmutableList.of(
+                               
Iterables.getOnlyElement(leftPlan.getInputSpecs()),
+                               
Iterables.getOnlyElement(rightPlan.getInputSpecs())
+                           )
+                       )
+                       .maxWorkerCount(Limits.MAX_WORKERS)
+                       .signature(joinSignatureBuilder.build())
+                       .processor(
+                           new SortMergeJoinStageProcessor(
+                               dataSource.getRightPrefix(),
+                               dataSource.getConditionAnalysis(),
+                               dataSource.getJoinType()
+                           )
+                       )
+    );
+
+    return new DataSourcePlan(
+        new InputNumberDataSource(0),
+        Collections.singletonList(new StageInputSpec(stageNumber)),
+        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
+        subQueryDefBuilder
+    );
+  }
+
+  /**
+   * Contains the logic that deduces the join algorithm to be used. Ideally, 
this should reside while planning the
+   * native query, however we don't have the resources and the structure in 
place (when adding this function) to do so.
+   * Therefore, this is done while planning the MSQ query
+   * It takes into account the algorithm specified by "sqlJoinAlgorithm" in 
the query context and the join condition
+   * that is present in the query.
+   */
+  private static JoinAlgorithm deduceJoinAlgorithm(
+      final JoinAlgorithm preferredJoinAlgorithm,
+      final JoinDataSource joinDataSource
+  )
+  {
+    final JoinAlgorithm deducedJoinAlgorithm;
+    if (JoinAlgorithm.BROADCAST.equals(preferredJoinAlgorithm)) {
+      deducedJoinAlgorithm = JoinAlgorithm.BROADCAST;
+    } else if (canUseSortMergeJoin(joinDataSource.getConditionAnalysis())) {
+      deducedJoinAlgorithm = JoinAlgorithm.SORT_MERGE;
+    } else {
+      deducedJoinAlgorithm = JoinAlgorithm.BROADCAST;
+    }
+
+    if (deducedJoinAlgorithm != preferredJoinAlgorithm) {
+      log.debug(
+          "User wanted to plan join [%s] as [%s], however the join will be 
executed as [%s]",
+          joinDataSource,
+          preferredJoinAlgorithm.toString(),
+          deducedJoinAlgorithm.toString()
+      );
+    }
+
+    return deducedJoinAlgorithm;
+  }
+
+  /**
+   * Checks if the sortMerge algorithm can execute a particular join condition.
+   * <p>
+   * One check: join condition on two tables "table1" and "table2" is of the 
form
+   * table1.columnA = table2.columnA && table1.columnB = table2.columnB && ....
+   */
+  private static boolean canUseSortMergeJoin(final JoinConditionAnalysis 
joinConditionAnalysis)
+  {
+    return joinConditionAnalysis
+        .getEquiConditions()
+        .stream()
+        .allMatch(equality -> equality.getLeftExpr().isIdentifier());
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/LookupDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/LookupDataSourcePlanner.java
new file mode 100644
index 00000000000..80a00782310
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/LookupDataSourcePlanner.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSets;
+import org.apache.druid.msq.input.lookup.LookupInputSpec;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.query.LookupDataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+import java.util.Collections;
+
+/**
+ * Planner for {@link LookupDataSource}.
+ */
+public class LookupDataSourcePlanner implements 
DataSourcePlanner<LookupDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final LookupDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    return new DataSourcePlan(
+        dataSource,
+        Collections.singletonList(new 
LookupInputSpec(dataSource.getLookupName())),
+        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
+        null
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/QueryDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/QueryDataSourcePlanner.java
new file mode 100644
index 00000000000..c33374c61f5
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/QueryDataSourcePlanner.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSets;
+import org.apache.druid.msq.input.stage.StageInputSpec;
+import org.apache.druid.msq.kernel.QueryDefinition;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.InputNumberDataSource;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.msq.querykit.ShuffleSpecFactories;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.QueryDataSource;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.sql.calcite.parser.DruidSqlInsert;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Planner for {@link QueryDataSource}. Plans the subquery as a separate set 
of stages.
+ */
+public class QueryDataSourcePlanner implements 
DataSourcePlanner<QueryDataSource>
+{
+  /**
+   * A map with {@link DruidSqlInsert#SQL_INSERT_SEGMENT_GRANULARITY} set to 
null, so we can clear it from the context
+   * of subqueries.
+   */
+  private static final Map<String, Object> CONTEXT_MAP_NO_SEGMENT_GRANULARITY 
= new HashMap<>();
+
+  static {
+    
CONTEXT_MAP_NO_SEGMENT_GRANULARITY.put(DruidSqlInsert.SQL_INSERT_SEGMENT_GRANULARITY,
 null);
+  }
+
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final QueryDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    DataSourcePlannerUtils.checkQuerySegmentSpecIsEternity(dataSource, 
querySegmentSpec);
+
+    final QueryDefinition subQueryDef = 
queryKitSpec.getQueryKit().makeQueryDefinition(
+        queryKitSpec,
+        // Subqueries ignore SQL_INSERT_SEGMENT_GRANULARITY, even if set in 
the context. It's only used for the
+        // outermost query, and setting it for the subquery makes us 
erroneously add bucketing where it doesn't belong.
+        
dataSource.getQuery().withOverriddenContext(CONTEXT_MAP_NO_SEGMENT_GRANULARITY),
+        ShuffleSpecFactories.globalSortWithTargetPartitions(),
+        minStageNumber
+    );
+
+    final int stageNumber = 
subQueryDef.getFinalStageDefinition().getStageNumber();
+
+    return new DataSourcePlan(
+        new InputNumberDataSource(0),
+        Collections.singletonList(new StageInputSpec(stageNumber)),
+        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
+        QueryDefinition.builder(subQueryDef)
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/RestrictedDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/RestrictedDataSourcePlanner.java
new file mode 100644
index 00000000000..a4d2e699389
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/RestrictedDataSourcePlanner.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.msq.querykit.RestrictedInputNumberDataSource;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.RestrictedDataSource;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+/**
+ * Planner for {@link RestrictedDataSource}. Plans the base table, then 
reapplies the policy.
+ */
+public class RestrictedDataSourcePlanner implements 
DataSourcePlanner<RestrictedDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final RestrictedDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    final DataSource restricted = (broadcast && dataSource.isGlobal())
+                                  ? dataSource
+                                  : new RestrictedInputNumberDataSource(0, 
dataSource.getPolicy());
+    return DataSourcePlan.forDataSource(
+        queryKitSpec,
+        queryContext,
+        dataSource.getBase(),
+        querySegmentSpec,
+        minStageNumber,
+        broadcast
+    ).withDataSource(restricted);
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/TableDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/TableDataSourcePlanner.java
new file mode 100644
index 00000000000..68d46da8451
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/TableDataSourcePlanner.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSets;
+import org.apache.druid.msq.input.table.TableInputSpec;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.InputNumberDataSource;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.SegmentDescriptor;
+import org.apache.druid.query.TableDataSource;
+import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.query.spec.SpecificSegmentSpec;
+import org.joda.time.Interval;
+
+import java.util.List;
+
+/**
+ * Planner for {@link TableDataSource}.
+ */
+public class TableDataSourcePlanner implements 
DataSourcePlanner<TableDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final TableDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    final List<SegmentDescriptor> segments;
+    if (querySegmentSpec instanceof MultipleSpecificSegmentSpec) {
+      segments = ((MultipleSpecificSegmentSpec) 
querySegmentSpec).getDescriptors();
+    } else if (querySegmentSpec instanceof SpecificSegmentSpec) {
+      segments = List.of(((SpecificSegmentSpec) 
querySegmentSpec).getDescriptor());
+    } else {
+      segments = null;
+    }
+    final List<Interval> intervals = querySegmentSpec.getIntervals();
+    return new DataSourcePlan(
+        (broadcast && dataSource.isGlobal()) ? dataSource : new 
InputNumberDataSource(0),
+        List.of(new TableInputSpec(dataSource.getName(), intervals, segments)),
+        broadcast ? IntOpenHashSet.of(0) : IntSets.emptySet(),
+        null
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/UnionDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/UnionDataSourcePlanner.java
new file mode 100644
index 00000000000..edc1c62d890
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/UnionDataSourcePlanner.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
+import org.apache.druid.msq.input.InputSpec;
+import org.apache.druid.msq.kernel.QueryDefinition;
+import org.apache.druid.msq.kernel.QueryDefinitionBuilder;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.UnionDataSource;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Planner for {@link UnionDataSource}. Plans each child, then concatenates 
their inputs.
+ */
+public class UnionDataSourcePlanner implements 
DataSourcePlanner<UnionDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final UnionDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    // This is done to prevent loss of generality since MSQ can plan any type 
of DataSource.
+    final List<DataSource> children = dataSource.getChildren();
+
+    final QueryDefinitionBuilder subqueryDefBuilder = 
QueryDefinition.builder(queryKitSpec.getQueryId());
+    final List<DataSource> newChildren = new ArrayList<>();
+    final List<InputSpec> inputSpecs = new ArrayList<>();
+    final IntSet broadcastInputs = new IntOpenHashSet();
+
+    for (final DataSource child : children) {
+      final DataSourcePlan childDataSourcePlan = DataSourcePlan.forDataSource(
+          queryKitSpec,
+          queryContext,
+          child,
+          querySegmentSpec,
+          Math.max(minStageNumber, subqueryDefBuilder.getNextStageNumber()),
+          broadcast
+      );
+
+      final int shift = inputSpecs.size();
+
+      
newChildren.add(DataSourcePlannerUtils.shiftInputNumbers(childDataSourcePlan.getNewDataSource(),
 shift));
+      inputSpecs.addAll(childDataSourcePlan.getInputSpecs());
+      
childDataSourcePlan.getSubQueryDefBuilder().ifPresent(subqueryDefBuilder::addAll);
+      childDataSourcePlan.getBroadcastInputs().forEach(inp -> 
broadcastInputs.add(inp + shift));
+    }
+
+    return new DataSourcePlan(
+        new UnionDataSource(newChildren),
+        inputSpecs,
+        broadcastInputs,
+        subqueryDefBuilder
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/UnnestDataSourcePlanner.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/UnnestDataSourcePlanner.java
new file mode 100644
index 00000000000..6288a5401ad
--- /dev/null
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/querykit/datasource/UnnestDataSourcePlanner.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit.datasource;
+
+import org.apache.druid.msq.input.InputSpec;
+import org.apache.druid.msq.querykit.DataSourcePlan;
+import org.apache.druid.msq.querykit.DataSourcePlanner;
+import org.apache.druid.msq.querykit.QueryKitSpec;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.UnnestDataSource;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Planner for {@link UnnestDataSource}. Plans the base datasource, then 
reapplies the unnest.
+ */
+public class UnnestDataSourcePlanner implements 
DataSourcePlanner<UnnestDataSource>
+{
+  @Override
+  public DataSourcePlan planDataSource(
+      final QueryKitSpec queryKitSpec,
+      final QueryContext queryContext,
+      final UnnestDataSource dataSource,
+      final QuerySegmentSpec querySegmentSpec,
+      final int minStageNumber,
+      final boolean broadcast
+  )
+  {
+    // Find the plan for base data source by recursing
+    final DataSourcePlan basePlan = DataSourcePlan.forDataSource(
+        queryKitSpec,
+        queryContext,
+        dataSource.getBase(),
+        querySegmentSpec,
+        minStageNumber,
+        broadcast
+    );
+
+    final List<InputSpec> inputSpecs = new 
ArrayList<>(basePlan.getInputSpecs());
+
+    // Create the new data source using the data source from the base plan
+    final DataSource newDataSource = UnnestDataSource.create(
+        basePlan.getNewDataSource(),
+        dataSource.getVirtualColumn(),
+        dataSource.getUnnestFilter()
+    );
+
+    // The base data source can be a join and might already have broadcast 
inputs
+    // Need to set the broadcast inputs from the basePlan
+    return new DataSourcePlan(
+        newDataSource,
+        inputSpecs,
+        basePlan.getBroadcastInputs(),
+        basePlan.getSubQueryDefBuilder().orElse(null)
+    );
+  }
+}
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/sql/DartQueryKitSpecFactory.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/sql/DartQueryKitSpecFactory.java
index 44ade1f09e0..6c4d45a2c1a 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/sql/DartQueryKitSpecFactory.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/sql/DartQueryKitSpecFactory.java
@@ -19,8 +19,10 @@
 
 package org.apache.druid.msq.sql;
 
+import com.google.inject.Inject;
 import org.apache.druid.msq.exec.QueryKitSpecFactory;
 import org.apache.druid.msq.indexing.MSQTuningConfig;
+import org.apache.druid.msq.querykit.DataSourcePlanners;
 import org.apache.druid.msq.querykit.QueryKit;
 import org.apache.druid.msq.querykit.QueryKitSpec;
 import org.apache.druid.query.Query;
@@ -28,6 +30,14 @@ import org.apache.druid.query.QueryContext;
 
 public class DartQueryKitSpecFactory implements QueryKitSpecFactory
 {
+  private final DataSourcePlanners dataSourcePlanners;
+
+  @Inject
+  public DartQueryKitSpecFactory(final DataSourcePlanners dataSourcePlanners)
+  {
+    this.dataSourcePlanners = dataSourcePlanners;
+  }
+
   @Override
   public QueryKitSpec makeQueryKitSpec(
       final QueryKit<Query<?>> queryKit,
@@ -36,6 +46,6 @@ public class DartQueryKitSpecFactory implements 
QueryKitSpecFactory
       final QueryContext queryContext
   )
   {
-    return new QueryKitSpec(queryKit, queryId);
+    return new QueryKitSpec(queryKit, dataSourcePlanners, queryId);
   }
 }
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryKitSpecFactory.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryKitSpecFactory.java
index b1fe9c03157..ed72734a7f0 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryKitSpecFactory.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/sql/MSQTaskQueryKitSpecFactory.java
@@ -19,8 +19,10 @@
 
 package org.apache.druid.msq.sql;
 
+import com.google.inject.Inject;
 import org.apache.druid.msq.exec.QueryKitSpecFactory;
 import org.apache.druid.msq.indexing.MSQTuningConfig;
+import org.apache.druid.msq.querykit.DataSourcePlanners;
 import org.apache.druid.msq.querykit.QueryKit;
 import org.apache.druid.msq.querykit.QueryKitSpec;
 import org.apache.druid.query.Query;
@@ -28,6 +30,14 @@ import org.apache.druid.query.QueryContext;
 
 public class MSQTaskQueryKitSpecFactory implements QueryKitSpecFactory
 {
+  private final DataSourcePlanners dataSourcePlanners;
+
+  @Inject
+  public MSQTaskQueryKitSpecFactory(final DataSourcePlanners 
dataSourcePlanners)
+  {
+    this.dataSourcePlanners = dataSourcePlanners;
+  }
+
   @Override
   public QueryKitSpec makeQueryKitSpec(
       QueryKit<Query<?>> queryKit,
@@ -36,6 +46,6 @@ public class MSQTaskQueryKitSpecFactory implements 
QueryKitSpecFactory
       QueryContext queryContext
   )
   {
-    return new QueryKitSpec(queryKit, queryId);
+    return new QueryKitSpec(queryKit, dataSourcePlanners, queryId);
   }
 }
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/http/DartSqlResourceTest.java
 
b/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/http/DartSqlResourceTest.java
index 3f602511d9b..58b4265430b 100644
--- 
a/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/http/DartSqlResourceTest.java
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/http/DartSqlResourceTest.java
@@ -54,6 +54,7 @@ import org.apache.druid.msq.indexing.error.MSQFaultUtils;
 import org.apache.druid.msq.indexing.report.MSQStatusReport;
 import org.apache.druid.msq.indexing.report.MSQTaskReport;
 import org.apache.druid.msq.kernel.controller.ControllerQueryKernelConfig;
+import org.apache.druid.msq.querykit.DataSourcePlanners;
 import org.apache.druid.msq.querykit.MultiQueryKit;
 import org.apache.druid.msq.sql.DartQueryKitSpecFactory;
 import org.apache.druid.msq.test.MSQTestBase;
@@ -268,7 +269,7 @@ public class DartSqlResourceTest extends MSQTestBase
                 )
             )
         ),
-        new DartQueryKitSpecFactory(),
+        new DartQueryKitSpecFactory(new DataSourcePlanners(Map.of())),
         injector.getInstance(MultiQueryKit.class),
         new ServerConfig(),
         new DefaultQueryConfig(ImmutableMap.of("foo", "bar")),
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/querykit/DataSourcePlannerTest.java
 
b/multi-stage-query/src/test/java/org/apache/druid/msq/querykit/DataSourcePlannerTest.java
new file mode 100644
index 00000000000..133637cf067
--- /dev/null
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/querykit/DataSourcePlannerTest.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.msq.querykit;
+
+import com.google.common.collect.Iterables;
+import it.unimi.dsi.fastutil.ints.IntSets;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.UOE;
+import org.apache.druid.msq.input.InputSpec;
+import org.apache.druid.msq.input.inline.InlineInputSpec;
+import org.apache.druid.msq.input.table.TableInputSpec;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.InlineDataSource;
+import org.apache.druid.query.LeafDataSource;
+import org.apache.druid.query.QueryContext;
+import org.apache.druid.query.TableDataSource;
+import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.segment.column.RowSignature;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class DataSourcePlannerTest
+{
+  private static final QuerySegmentSpec ETERNITY =
+      new MultipleIntervalSegmentSpec(List.of(Intervals.ETERNITY));
+
+  private static final InputSpec PLANNED_SPEC =
+      new InlineInputSpec(InlineDataSource.fromIterable(List.of(), 
RowSignature.empty()));
+
+  @Test
+  public void testUnhandledDataSourceThrowsWithNoPlanners()
+  {
+    Assertions.assertThrows(
+        UOE.class,
+        () -> plan(new TestDataSource(), Map.of())
+    );
+  }
+
+  @Test
+  public void testRegisteredPlannerHandlesItsDataSource()
+  {
+    final DataSourcePlan plan = plan(
+        new TestDataSource(),
+        Map.of(TestDataSource.class, new TestDataSourcePlanner<>())
+    );
+
+    Assertions.assertEquals(List.of(PLANNED_SPEC), plan.getInputSpecs());
+  }
+
+  @Test
+  public void testPlannerDoesNotApplyToSubclassOfItsDataSource()
+  {
+    Assertions.assertThrows(
+        UOE.class,
+        () -> plan(new TestDataSourceSubclass(), Map.of(TestDataSource.class, 
new TestDataSourcePlanner<>()))
+    );
+  }
+
+  @Test
+  public void testRegisteredPlannerOverridesBuiltin()
+  {
+    final DataSourcePlan plan = plan(
+        new TableDataSource("foo"),
+        Map.of(TableDataSource.class, new TestDataSourcePlanner<>())
+    );
+
+    Assertions.assertEquals(List.of(PLANNED_SPEC), plan.getInputSpecs());
+  }
+
+  @Test
+  public void testBuiltinPlannerHandlesTable()
+  {
+    final DataSourcePlan plan = plan(new TableDataSource("foo"), Map.of());
+
+    Assertions.assertInstanceOf(TableInputSpec.class, 
Iterables.getOnlyElement(plan.getInputSpecs()));
+  }
+
+  @SuppressWarnings("rawtypes")
+  private static DataSourcePlan plan(
+      final DataSource dataSource,
+      final Map<Class<? extends DataSource>, DataSourcePlanner> planners
+  )
+  {
+    return DataSourcePlan.forDataSource(
+        new QueryKitSpec(null, new DataSourcePlanners(planners), "queryId"),
+        QueryContext.empty(),
+        dataSource,
+        ETERNITY,
+        0,
+        false
+    );
+  }
+
+  private static class TestDataSource extends LeafDataSource
+  {
+    @Override
+    public Set<String> getTableNames()
+    {
+      return Set.of();
+    }
+
+    @Override
+    public boolean isCacheable(boolean isBroker)
+    {
+      return false;
+    }
+
+    @Override
+    public boolean isGlobal()
+    {
+      return false;
+    }
+
+    @Override
+    public boolean isProcessable()
+    {
+      return true;
+    }
+
+    @Override
+    public byte[] getCacheKey()
+    {
+      return null;
+    }
+  }
+
+  private static class TestDataSourceSubclass extends TestDataSource
+  {
+  }
+
+  private static class TestDataSourcePlanner<T extends DataSource> implements 
DataSourcePlanner<T>
+  {
+    @Override
+    public DataSourcePlan planDataSource(
+        QueryKitSpec queryKitSpec,
+        QueryContext queryContext,
+        T dataSource,
+        QuerySegmentSpec querySegmentSpec,
+        int minStageNumber,
+        boolean broadcast
+    )
+    {
+      return new DataSourcePlan(dataSource, List.of(PLANNED_SPEC), 
IntSets.emptySet(), null);
+    }
+  }
+}


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

Reply via email to