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

asf-gitbox-commits pushed a commit to branch PHOENIX-7876-feature
in repository https://gitbox.apache.org/repos/asf/phoenix.git

commit 07a8afcb3304de501818866f07e0926260b72a61
Author: Andrew Purtell <[email protected]>
AuthorDate: Sat Jun 6 00:24:41 2026 -0700

    [WIP] Convert full-text EXPLAIN ITs to fluent API; reshape SMJ + add 
clientSteps
    
    Convert the four remaining full-text EXPLAIN ITs to pure fluent assertPlan
    assertions.
    
    In ExplainPlanAttributs sort-merge-join is reshaped into a synthetic root
    carrying the join operator with separate lhs and rhs children. Every node
    carries an ordered clientSteps list. Production builders populate the new
    fields. The emitted text is unchanged.
---
 .../phoenix/compile/ExplainPlanAttributes.java     |  61 +++-
 .../phoenix/execute/ClientAggregatePlan.java       |  77 +++--
 .../org/apache/phoenix/execute/ClientScanPlan.java |  30 +-
 .../apache/phoenix/execute/SortMergeJoinPlan.java  |  20 +-
 .../phoenix/execute/TupleProjectionPlan.java       |   4 +-
 .../phoenix/iterate/CursorResultIterator.java      |   4 +-
 .../iterate/DistinctAggregatingResultIterator.java |   4 +-
 .../iterate/FilterAggregatingResultIterator.java   |   4 +-
 .../phoenix/iterate/FilterResultIterator.java      |   4 +-
 .../phoenix/iterate/LimitingResultIterator.java    |   4 +-
 .../iterate/MergeSortRowKeyResultIterator.java     |   1 +
 .../iterate/MergeSortTopNResultIterator.java       |   9 +-
 .../phoenix/iterate/OffsetResultIterator.java      |   4 +-
 .../phoenix/iterate/OrderedResultIterator.java     |   6 +-
 .../phoenix/iterate/SegmentResultIterator.java     |   1 +
 .../phoenix/iterate/SequenceResultIterator.java    |   6 +-
 .../phoenix/end2end/CostBasedDecisionIT.java       | 379 +++++++++------------
 .../org/apache/phoenix/end2end/DerivedTableIT.java | 108 +++---
 .../phoenix/end2end/SortMergeJoinMoreIT.java       |   6 +-
 .../phoenix/end2end/TenantSpecificViewIndexIT.java |  41 +--
 .../org/apache/phoenix/end2end/UnionAllIT.java     | 107 ++----
 .../end2end/join/SortMergeJoinGlobalIndexIT.java   |  35 +-
 .../end2end/join/SortMergeJoinLocalIndexIT.java    |  30 +-
 .../end2end/join/SortMergeJoinNoIndexIT.java       |  20 +-
 .../end2end/join/SubqueryUsingSortMergeJoinIT.java |   6 +-
 .../query/explain/ExplainJsonNormalizer.java       |   5 +
 .../phoenix/query/explain/ExplainPlanTest.java     |  81 ++++-
 .../phoenix/query/explain/ExplainPlanTestUtil.java |  34 ++
 28 files changed, 578 insertions(+), 513 deletions(-)

diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
index 9f69b2bc73..5bc1fe53b7 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
@@ -19,6 +19,8 @@ package org.apache.phoenix.compile;
 
 import com.fasterxml.jackson.annotation.JsonPropertyOrder;
 import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import org.apache.hadoop.hbase.HRegionLocation;
@@ -27,11 +29,6 @@ import org.apache.phoenix.parse.HintNode;
 import org.apache.phoenix.parse.HintNode.Hint;
 import org.apache.phoenix.schema.PColumn;
 
-/**
- * ExplainPlan attributes that contain individual attributes of ExplainPlan 
that we can assert
- * against. This also makes attribute retrieval easier as an API rather than 
retrieving list of
- * Strings containing entire plan.
- */
 @JsonPropertyOrder({ "abstractExplainPlan", "hint", "explainScanType", 
"consistency", "tableName",
   "keyRanges", "scanTimeRangeMin", "scanTimeRangeMax", "splitsChunk", 
"useRoundRobinIterator",
   "samplingRate", "hexStringRVCOffset", "iteratorTypeAndScanSize", 
"estimatedRows",
@@ -41,8 +38,9 @@ import org.apache.phoenix.schema.PColumn;
   "serverGroupByLimit", "serverSortedBy", "serverOffset", "serverRowLimit", 
"clientFilterBy",
   "clientAggregate", "clientDistinctFilter", "clientAfterAggregate", 
"clientSortAlgo",
   "clientSortedBy", "clientOffset", "clientRowLimit", "clientSequenceCount", 
"clientCursorName",
-  "rhsJoinQueryExplainPlan", "subPlans", "dynamicServerFilter", 
"afterJoinFilter",
-  "joinScannerLimit", "sortMergeSkipMerge", "regionLocations", 
"numRegionLocationLookups" })
+  "clientSteps", "lhsJoinQueryExplainPlan", "rhsJoinQueryExplainPlan", 
"subPlans",
+  "dynamicServerFilter", "afterJoinFilter", "joinScannerLimit", 
"sortMergeSkipMerge",
+  "regionLocations", "numRegionLocationLookups" })
 public class ExplainPlanAttributes {
 
   // Plan identity and scan-level metadata
@@ -92,8 +90,11 @@ public class ExplainPlanAttributes {
   private final Integer clientRowLimit;
   private final Integer clientSequenceCount;
   private final String clientCursorName;
+  // Ordered client-side pipeline (trimmed CLIENT* lines in emission order).
+  private final List<String> clientSteps;
 
   // Join / sub-plan
+  private final ExplainPlanAttributes lhsJoinQueryExplainPlan;
   private final ExplainPlanAttributes rhsJoinQueryExplainPlan;
   private final List<ExplainPlanAttributes> subPlans;
   private final String dynamicServerFilter;
@@ -148,6 +149,8 @@ public class ExplainPlanAttributes {
     this.clientRowLimit = null;
     this.clientSequenceCount = null;
     this.clientCursorName = null;
+    this.clientSteps = null;
+    this.lhsJoinQueryExplainPlan = null;
     this.rhsJoinQueryExplainPlan = null;
     this.subPlans = null;
     this.dynamicServerFilter = null;
@@ -170,6 +173,7 @@ public class ExplainPlanAttributes {
     String clientFilterBy, String clientAggregate, String clientDistinctFilter,
     String clientAfterAggregate, String clientSortAlgo, String clientSortedBy, 
Integer clientOffset,
     Integer clientRowLimit, Integer clientSequenceCount, String 
clientCursorName,
+    List<String> clientSteps, ExplainPlanAttributes lhsJoinQueryExplainPlan,
     ExplainPlanAttributes rhsJoinQueryExplainPlan, List<ExplainPlanAttributes> 
subPlans,
     String dynamicServerFilter, String afterJoinFilter, Long joinScannerLimit,
     boolean sortMergeSkipMerge, List<HRegionLocation> regionLocations,
@@ -214,6 +218,10 @@ public class ExplainPlanAttributes {
     this.clientRowLimit = clientRowLimit;
     this.clientSequenceCount = clientSequenceCount;
     this.clientCursorName = clientCursorName;
+    this.clientSteps = (clientSteps == null || clientSteps.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(clientSteps));
+    this.lhsJoinQueryExplainPlan = lhsJoinQueryExplainPlan;
     this.rhsJoinQueryExplainPlan = rhsJoinQueryExplainPlan;
     this.subPlans = subPlans;
     this.dynamicServerFilter = dynamicServerFilter;
@@ -385,6 +393,14 @@ public class ExplainPlanAttributes {
     return clientCursorName;
   }
 
+  public List<String> getClientSteps() {
+    return clientSteps;
+  }
+
+  public ExplainPlanAttributes getLhsJoinQueryExplainPlan() {
+    return lhsJoinQueryExplainPlan;
+  }
+
   public ExplainPlanAttributes getRhsJoinQueryExplainPlan() {
     return rhsJoinQueryExplainPlan;
   }
@@ -463,6 +479,8 @@ public class ExplainPlanAttributes {
     private Integer clientRowLimit;
     private Integer clientSequenceCount;
     private String clientCursorName;
+    private List<String> clientSteps;
+    private ExplainPlanAttributes lhsJoinQueryExplainPlan;
     private ExplainPlanAttributes rhsJoinQueryExplainPlan;
     private List<ExplainPlanAttributes> subPlans;
     private String dynamicServerFilter;
@@ -518,6 +536,9 @@ public class ExplainPlanAttributes {
       this.clientRowLimit = explainPlanAttributes.getClientRowLimit();
       this.clientSequenceCount = 
explainPlanAttributes.getClientSequenceCount();
       this.clientCursorName = explainPlanAttributes.getClientCursorName();
+      List<String> srcClientSteps = explainPlanAttributes.getClientSteps();
+      this.clientSteps = srcClientSteps == null ? null : new 
ArrayList<>(srcClientSteps);
+      this.lhsJoinQueryExplainPlan = 
explainPlanAttributes.getLhsJoinQueryExplainPlan();
       this.rhsJoinQueryExplainPlan = 
explainPlanAttributes.getRhsJoinQueryExplainPlan();
       this.subPlans = explainPlanAttributes.getSubPlans();
       this.dynamicServerFilter = 
explainPlanAttributes.getDynamicServerFilter();
@@ -731,6 +752,25 @@ public class ExplainPlanAttributes {
       return this;
     }
 
+    public ExplainPlanAttributesBuilder setClientSteps(List<String> 
clientSteps) {
+      this.clientSteps = clientSteps == null ? null : new 
ArrayList<>(clientSteps);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder addClientStep(String step) {
+      if (this.clientSteps == null) {
+        this.clientSteps = new ArrayList<>();
+      }
+      this.clientSteps.add(step);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder
+      setLhsJoinQueryExplainPlan(ExplainPlanAttributes 
lhsJoinQueryExplainPlan) {
+      this.lhsJoinQueryExplainPlan = lhsJoinQueryExplainPlan;
+      return this;
+    }
+
     public ExplainPlanAttributesBuilder
       setRhsJoinQueryExplainPlan(ExplainPlanAttributes 
rhsJoinQueryExplainPlan) {
       this.rhsJoinQueryExplainPlan = rhsJoinQueryExplainPlan;
@@ -781,9 +821,10 @@ public class ExplainPlanAttributes {
         serverDistinctFilter, serverMergeColumns, 
serverArrayElementProjection, serverAggregate,
         serverGroupByLimit, serverSortedBy, serverOffset, serverRowLimit, 
clientFilterBy,
         clientAggregate, clientDistinctFilter, clientAfterAggregate, 
clientSortAlgo, clientSortedBy,
-        clientOffset, clientRowLimit, clientSequenceCount, clientCursorName,
-        rhsJoinQueryExplainPlan, subPlans, dynamicServerFilter, 
afterJoinFilter, joinScannerLimit,
-        sortMergeSkipMerge, regionLocations, numRegionLocationLookups);
+        clientOffset, clientRowLimit, clientSequenceCount, clientCursorName, 
clientSteps,
+        lhsJoinQueryExplainPlan, rhsJoinQueryExplainPlan, subPlans, 
dynamicServerFilter,
+        afterJoinFilter, joinScannerLimit, sortMergeSkipMerge, regionLocations,
+        numRegionLocationLookups);
     }
   }
 }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientAggregatePlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientAggregatePlan.java
index e89bf515cc..4a3d1b5d28 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientAggregatePlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientAggregatePlan.java
@@ -226,63 +226,86 @@ public class ClientAggregatePlan extends 
ClientProcessingPlan {
     ExplainPlanAttributesBuilder newBuilder =
       new ExplainPlanAttributesBuilder(explainPlanAttributes);
     if (where != null) {
-      planSteps.add("CLIENT FILTER BY " + where.toString());
+      String step = "CLIENT FILTER BY " + where.toString();
+      planSteps.add(step);
       newBuilder.setClientFilterBy(where.toString());
+      newBuilder.addClientStep(step);
     }
     if (groupBy.isEmpty()) {
-      planSteps.add("CLIENT AGGREGATE INTO SINGLE ROW");
-      newBuilder.setClientAggregate("CLIENT AGGREGATE INTO SINGLE ROW");
+      String step = "CLIENT AGGREGATE INTO SINGLE ROW";
+      planSteps.add(step);
+      newBuilder.setClientAggregate(step);
+      newBuilder.addClientStep(step);
     } else if (groupBy.isOrderPreserving()) {
-      planSteps.add(
-        "CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY " + 
groupBy.getExpressions().toString());
-      newBuilder.setClientAggregate(
-        "CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY " + 
groupBy.getExpressions().toString());
+      String step =
+        "CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY " + 
groupBy.getExpressions().toString();
+      planSteps.add(step);
+      newBuilder.setClientAggregate(step);
+      newBuilder.addClientStep(step);
     } else if (useHashAgg) {
-      planSteps
-        .add("CLIENT HASH AGGREGATE INTO DISTINCT ROWS BY " + 
groupBy.getExpressions().toString());
-      newBuilder.setClientAggregate(
-        "CLIENT HASH AGGREGATE INTO DISTINCT ROWS BY " + 
groupBy.getExpressions().toString());
+      String step =
+        "CLIENT HASH AGGREGATE INTO DISTINCT ROWS BY " + 
groupBy.getExpressions().toString();
+      planSteps.add(step);
+      newBuilder.setClientAggregate(step);
+      newBuilder.addClientStep(step);
       if (orderBy == OrderBy.FWD_ROW_KEY_ORDER_BY || orderBy == 
OrderBy.REV_ROW_KEY_ORDER_BY) {
-        planSteps.add("CLIENT SORTED BY " + 
groupBy.getKeyExpressions().toString());
+        String sortStep = "CLIENT SORTED BY " + 
groupBy.getKeyExpressions().toString();
+        planSteps.add(sortStep);
         newBuilder.setClientSortedBy(groupBy.getKeyExpressions().toString());
+        newBuilder.addClientStep(sortStep);
       }
     } else {
-      planSteps.add("CLIENT SORTED BY " + 
groupBy.getKeyExpressions().toString());
-      planSteps
-        .add("CLIENT AGGREGATE INTO DISTINCT ROWS BY " + 
groupBy.getExpressions().toString());
+      String sortStep = "CLIENT SORTED BY " + 
groupBy.getKeyExpressions().toString();
+      String aggStep =
+        "CLIENT AGGREGATE INTO DISTINCT ROWS BY " + 
groupBy.getExpressions().toString();
+      planSteps.add(sortStep);
+      planSteps.add(aggStep);
       newBuilder.setClientSortedBy(groupBy.getKeyExpressions().toString());
-      newBuilder.setClientAggregate(
-        "CLIENT AGGREGATE INTO DISTINCT ROWS BY " + 
groupBy.getExpressions().toString());
+      newBuilder.setClientAggregate(aggStep);
+      newBuilder.addClientStep(sortStep);
+      newBuilder.addClientStep(aggStep);
     }
     if (having != null) {
-      planSteps.add("CLIENT AFTER-AGGREGATION FILTER BY " + having.toString());
-      newBuilder.setClientAfterAggregate("CLIENT AFTER-AGGREGATION FILTER BY " 
+ having.toString());
+      String step = "CLIENT AFTER-AGGREGATION FILTER BY " + having.toString();
+      planSteps.add(step);
+      newBuilder.setClientAfterAggregate(step);
+      newBuilder.addClientStep(step);
     }
     if (statement.isDistinct() && statement.isAggregate()) {
-      planSteps.add("CLIENT DISTINCT ON " + projector.toString());
+      String step = "CLIENT DISTINCT ON " + projector.toString();
+      planSteps.add(step);
       newBuilder.setClientDistinctFilter(projector.toString());
+      newBuilder.addClientStep(step);
     }
     if (offset != null) {
-      planSteps.add("CLIENT OFFSET " + offset);
+      String step = "CLIENT OFFSET " + offset;
+      planSteps.add(step);
       newBuilder.setClientOffset(offset);
+      newBuilder.addClientStep(step);
     }
     if (orderBy.getOrderByExpressions().isEmpty()) {
       if (limit != null) {
-        planSteps.add("CLIENT " + limit + " ROW LIMIT");
+        String step = "CLIENT " + limit + " ROW LIMIT";
+        planSteps.add(step);
         newBuilder.setClientRowLimit(limit);
+        newBuilder.addClientStep(step);
       }
     } else {
-      planSteps
-        .add("CLIENT" + (limit == null ? "" : " TOP " + limit + " ROW" + 
(limit == 1 ? "" : "S"))
-          + " SORTED BY " + orderBy.getOrderByExpressions().toString());
+      String step =
+        "CLIENT" + (limit == null ? "" : " TOP " + limit + " ROW" + (limit == 
1 ? "" : "S"))
+          + " SORTED BY " + orderBy.getOrderByExpressions().toString();
+      planSteps.add(step);
       newBuilder.setClientRowLimit(limit);
       newBuilder.setClientSortedBy(orderBy.getOrderByExpressions().toString());
+      newBuilder.addClientStep(step);
     }
     if (context.getSequenceManager().getSequenceCount() > 0) {
       int nSequences = context.getSequenceManager().getSequenceCount();
-      planSteps.add(
-        "CLIENT RESERVE VALUES FROM " + nSequences + " SEQUENCE" + (nSequences 
== 1 ? "" : "S"));
+      String step =
+        "CLIENT RESERVE VALUES FROM " + nSequences + " SEQUENCE" + (nSequences 
== 1 ? "" : "S");
+      planSteps.add(step);
       newBuilder.setClientSequenceCount(nSequences);
+      newBuilder.addClientStep(step);
     }
 
     return new ExplainPlan(planSteps, newBuilder.build());
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientScanPlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientScanPlan.java
index 8bc7198960..9807ba8ef9 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientScanPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/ClientScanPlan.java
@@ -123,34 +123,46 @@ public class ClientScanPlan extends ClientProcessingPlan {
     ExplainPlanAttributesBuilder newBuilder =
       new ExplainPlanAttributesBuilder(explainPlanAttributes);
     if (where != null) {
-      planSteps.add("CLIENT FILTER BY " + where.toString());
+      String step = "CLIENT FILTER BY " + where.toString();
+      planSteps.add(step);
       newBuilder.setClientFilterBy(where.toString());
+      newBuilder.addClientStep(step);
     }
     if (!orderBy.getOrderByExpressions().isEmpty()) {
       if (offset != null) {
-        planSteps.add("CLIENT OFFSET " + offset);
+        String step = "CLIENT OFFSET " + offset;
+        planSteps.add(step);
         newBuilder.setClientOffset(offset);
+        newBuilder.addClientStep(step);
       }
-      planSteps
-        .add("CLIENT" + (limit == null ? "" : " TOP " + limit + " ROW" + 
(limit == 1 ? "" : "S"))
-          + " SORTED BY " + orderBy.getOrderByExpressions().toString());
+      String step =
+        "CLIENT" + (limit == null ? "" : " TOP " + limit + " ROW" + (limit == 
1 ? "" : "S"))
+          + " SORTED BY " + orderBy.getOrderByExpressions().toString();
+      planSteps.add(step);
       newBuilder.setClientRowLimit(limit);
       newBuilder.setClientSortedBy(orderBy.getOrderByExpressions().toString());
+      newBuilder.addClientStep(step);
     } else {
       if (offset != null) {
-        planSteps.add("CLIENT OFFSET " + offset);
+        String step = "CLIENT OFFSET " + offset;
+        planSteps.add(step);
         newBuilder.setClientOffset(offset);
+        newBuilder.addClientStep(step);
       }
       if (limit != null) {
-        planSteps.add("CLIENT " + limit + " ROW LIMIT");
+        String step = "CLIENT " + limit + " ROW LIMIT";
+        planSteps.add(step);
         newBuilder.setClientRowLimit(limit);
+        newBuilder.addClientStep(step);
       }
     }
     if (context.getSequenceManager().getSequenceCount() > 0) {
       int nSequences = context.getSequenceManager().getSequenceCount();
-      planSteps.add(
-        "CLIENT RESERVE VALUES FROM " + nSequences + " SEQUENCE" + (nSequences 
== 1 ? "" : "S"));
+      String step =
+        "CLIENT RESERVE VALUES FROM " + nSequences + " SEQUENCE" + (nSequences 
== 1 ? "" : "S");
+      planSteps.add(step);
       newBuilder.setClientSequenceCount(nSequences);
+      newBuilder.addClientStep(step);
     }
 
     return new ExplainPlan(planSteps, newBuilder.build());
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/SortMergeJoinPlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/SortMergeJoinPlan.java
index ea210c09a4..9e1cd2290b 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/SortMergeJoinPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/SortMergeJoinPlan.java
@@ -192,11 +192,6 @@ public class SortMergeJoinPlan implements QueryPlan {
     ExplainPlan lhsExplainPlan = lhsPlan.getExplainPlan();
     List<String> lhsPlanSteps = lhsExplainPlan.getPlanSteps();
     ExplainPlanAttributes lhsPlanAttributes = 
lhsExplainPlan.getPlanStepsAsAttributes();
-    ExplainPlanAttributesBuilder lhsPlanBuilder =
-      new ExplainPlanAttributesBuilder(lhsPlanAttributes);
-    lhsPlanBuilder
-      .setAbstractExplainPlan("SORT-MERGE-JOIN (" + 
joinType.toString().toUpperCase() + ")");
-    lhsPlanBuilder.setSortMergeSkipMerge(rhsSchema.getFieldCount() == 0);
 
     for (String step : lhsPlanSteps) {
       steps.add("    " + step);
@@ -206,15 +201,20 @@ public class SortMergeJoinPlan implements QueryPlan {
     ExplainPlan rhsExplainPlan = rhsPlan.getExplainPlan();
     List<String> rhsPlanSteps = rhsExplainPlan.getPlanSteps();
     ExplainPlanAttributes rhsPlanAttributes = 
rhsExplainPlan.getPlanStepsAsAttributes();
-    ExplainPlanAttributesBuilder rhsPlanBuilder =
-      new ExplainPlanAttributesBuilder(rhsPlanAttributes);
-
-    lhsPlanBuilder.setRhsJoinQueryExplainPlan(rhsPlanBuilder.build());
 
     for (String step : rhsPlanSteps) {
       steps.add("    " + step);
     }
-    return new ExplainPlan(steps, lhsPlanBuilder.build());
+
+    // Build a synthetic root that holds the join operator and its two 
operands as separate
+    // child plans so nested sort-merge-joins can be represented.
+    ExplainPlanAttributesBuilder rootBuilder = new 
ExplainPlanAttributesBuilder();
+    rootBuilder
+      .setAbstractExplainPlan("SORT-MERGE-JOIN (" + 
joinType.toString().toUpperCase() + ")");
+    rootBuilder.setSortMergeSkipMerge(rhsSchema.getFieldCount() == 0);
+    rootBuilder.setLhsJoinQueryExplainPlan(lhsPlanAttributes);
+    rootBuilder.setRhsJoinQueryExplainPlan(rhsPlanAttributes);
+    return new ExplainPlan(steps, rootBuilder.build());
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
index ac38657f18..413f046d22 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
@@ -147,10 +147,12 @@ public class TupleProjectionPlan extends 
DelegateQueryPlan {
     List<String> planSteps = Lists.newArrayList(explainPlan.getPlanSteps());
     ExplainPlanAttributes explainPlanAttributes = 
explainPlan.getPlanStepsAsAttributes();
     if (postFilter != null) {
-      planSteps.add("CLIENT FILTER BY " + postFilter.toString());
+      String step = "CLIENT FILTER BY " + postFilter.toString();
+      planSteps.add(step);
       ExplainPlanAttributesBuilder newBuilder =
         new ExplainPlanAttributesBuilder(explainPlanAttributes);
       newBuilder.setClientFilterBy(postFilter.toString());
+      newBuilder.addClientStep(step);
       explainPlanAttributes = newBuilder.build();
     }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/CursorResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/CursorResultIterator.java
index 9c44579366..113c6fad45 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/CursorResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/CursorResultIterator.java
@@ -60,7 +60,9 @@ public class CursorResultIterator implements ResultIterator {
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     delegate.explain(planSteps, explainPlanAttributesBuilder);
     explainPlanAttributesBuilder.setClientCursorName(cursorName);
-    planSteps.add("CLIENT CURSOR " + cursorName);
+    String step = "CLIENT CURSOR " + cursorName;
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/DistinctAggregatingResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/DistinctAggregatingResultIterator.java
index 10f081c8a4..6dfdfb8857 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/DistinctAggregatingResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/DistinctAggregatingResultIterator.java
@@ -134,7 +134,9 @@ public class DistinctAggregatingResultIterator implements 
AggregatingResultItera
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     targetAggregatingResultIterator.explain(planSteps, 
explainPlanAttributesBuilder);
     
explainPlanAttributesBuilder.setClientDistinctFilter(rowProjector.toString());
-    planSteps.add("CLIENT DISTINCT ON " + rowProjector.toString());
+    String step = "CLIENT DISTINCT ON " + rowProjector.toString();
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
index fb3609066c..82d6ba6824 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
@@ -82,7 +82,9 @@ public class FilterAggregatingResultIterator implements 
AggregatingResultIterato
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     delegate.explain(planSteps, explainPlanAttributesBuilder);
     explainPlanAttributesBuilder.setClientFilterBy(expression.toString());
-    planSteps.add("CLIENT FILTER BY " + expression.toString());
+    String step = "CLIENT FILTER BY " + expression.toString();
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
index f6631dee43..9435f20464 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
@@ -80,7 +80,9 @@ public class FilterResultIterator extends 
LookAheadResultIterator {
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     delegate.explain(planSteps, explainPlanAttributesBuilder);
     explainPlanAttributesBuilder.setClientFilterBy(expression.toString());
-    planSteps.add("CLIENT FILTER BY " + expression.toString());
+    String step = "CLIENT FILTER BY " + expression.toString();
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/LimitingResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/LimitingResultIterator.java
index 0d60c3b04d..1a90c87d3b 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/LimitingResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/LimitingResultIterator.java
@@ -55,7 +55,9 @@ public class LimitingResultIterator extends 
DelegateResultIterator {
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     super.explain(planSteps, explainPlanAttributesBuilder);
     explainPlanAttributesBuilder.setClientRowLimit(limit);
-    planSteps.add("CLIENT " + limit + " ROW LIMIT");
+    String step = "CLIENT " + limit + " ROW LIMIT";
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortRowKeyResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortRowKeyResultIterator.java
index 2412c2a092..7691504d93 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortRowKeyResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortRowKeyResultIterator.java
@@ -59,6 +59,7 @@ public class MergeSortRowKeyResultIterator extends 
MergeSortResultIterator {
     resultIterators.explain(planSteps, explainPlanAttributesBuilder);
     explainPlanAttributesBuilder.setClientSortAlgo("CLIENT MERGE SORT");
     planSteps.add("CLIENT MERGE SORT");
+    explainPlanAttributesBuilder.addClientStep("CLIENT MERGE SORT");
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortTopNResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortTopNResultIterator.java
index 2c72682e19..767656a950 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortTopNResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/MergeSortTopNResultIterator.java
@@ -116,13 +116,18 @@ public class MergeSortTopNResultIterator extends 
MergeSortResultIterator {
     resultIterators.explain(planSteps, explainPlanAttributesBuilder);
     explainPlanAttributesBuilder.setClientSortAlgo("CLIENT MERGE SORT");
     planSteps.add("CLIENT MERGE SORT");
+    explainPlanAttributesBuilder.addClientStep("CLIENT MERGE SORT");
     if (offset > 0) {
       explainPlanAttributesBuilder.setClientOffset(offset);
-      planSteps.add("CLIENT OFFSET " + offset);
+      String step = "CLIENT OFFSET " + offset;
+      planSteps.add(step);
+      explainPlanAttributesBuilder.addClientStep(step);
     }
     if (limit > 0) {
       explainPlanAttributesBuilder.setClientRowLimit(limit);
-      planSteps.add("CLIENT LIMIT " + limit);
+      String step = "CLIENT LIMIT " + limit;
+      planSteps.add(step);
+      explainPlanAttributesBuilder.addClientStep(step);
     }
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OffsetResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OffsetResultIterator.java
index bc092a0a2e..fc369eed25 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OffsetResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OffsetResultIterator.java
@@ -92,7 +92,9 @@ public class OffsetResultIterator extends 
DelegateResultIterator {
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     super.explain(planSteps, explainPlanAttributesBuilder);
     explainPlanAttributesBuilder.setClientOffset(offset);
-    planSteps.add("CLIENT OFFSET " + offset);
+    String step = "CLIENT OFFSET " + offset;
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OrderedResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OrderedResultIterator.java
index 2cc3ccb3f6..1662b5b465 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OrderedResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/OrderedResultIterator.java
@@ -514,9 +514,11 @@ public class OrderedResultIterator implements 
PeekingResultIterator {
     explainPlanAttributesBuilder.setClientOffset(offset);
     explainPlanAttributesBuilder.setClientRowLimit(limit);
     
explainPlanAttributesBuilder.setClientSortedBy(orderByExpressions.toString());
-    planSteps.add("CLIENT" + (offset == null || offset == 0 ? "" : " OFFSET " 
+ offset)
+    String step = "CLIENT" + (offset == null || offset == 0 ? "" : " OFFSET " 
+ offset)
       + (limit == null ? "" : " TOP " + limit + " ROW" + (limit == 1 ? "" : 
"S")) + " SORTED BY "
-      + orderByExpressions.toString());
+      + orderByExpressions.toString();
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SegmentResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SegmentResultIterator.java
index 1bed34afbd..752600a296 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SegmentResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SegmentResultIterator.java
@@ -120,5 +120,6 @@ public class SegmentResultIterator extends 
BaseResultIterator {
   public void explain(List<String> planSteps,
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     planSteps.add("CLIENT SEGMENT SCAN");
+    explainPlanAttributesBuilder.addClientStep("CLIENT SEGMENT SCAN");
   }
 }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SequenceResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SequenceResultIterator.java
index b58ba58610..ae98654cfa 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SequenceResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/SequenceResultIterator.java
@@ -60,8 +60,10 @@ public class SequenceResultIterator extends 
DelegateResultIterator {
     super.explain(planSteps, explainPlanAttributesBuilder);
     int nSequences = sequenceManager.getSequenceCount();
     explainPlanAttributesBuilder.setClientSequenceCount(nSequences);
-    planSteps
-      .add("CLIENT RESERVE VALUES FROM " + nSequences + " SEQUENCE" + 
(nSequences == 1 ? "" : "S"));
+    String step =
+      "CLIENT RESERVE VALUES FROM " + nSequences + " SEQUENCE" + (nSequences 
== 1 ? "" : "S");
+    planSteps.add(step);
+    explainPlanAttributesBuilder.addClientStep(step);
   }
 
   @Override
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
index fa08004c32..02d85a4f2e 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
@@ -17,23 +17,18 @@
  */
 package org.apache.phoenix.end2end;
 
+import static 
org.apache.phoenix.query.explain.ExplainPlanTestUtil.assertMutationPlan;
+import static org.apache.phoenix.query.explain.ExplainPlanTestUtil.assertPlan;
 import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
 
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.PreparedStatement;
-import java.sql.ResultSet;
 import java.util.Map;
 import java.util.Properties;
-import org.apache.phoenix.compile.ExplainPlan;
-import org.apache.phoenix.compile.ExplainPlanAttributes;
-import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.util.PropertiesUtil;
-import org.apache.phoenix.util.QueryUtil;
 import org.apache.phoenix.util.ReadOnlyProps;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -79,7 +74,7 @@ public class CostBasedDecisionIT extends BaseTest {
       String query =
         "SELECT rowkey, c1, c2 FROM " + tableName + " where c1 LIKE 'X0%' 
ORDER BY rowkey";
       // Use the data table plan that opts out order-by when stats are not 
available.
-      verifyQueryPlan(query, "FULL SCAN");
+      assertPlan(conn, query).scanType("FULL SCAN");
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) 
VALUES (?, ?, ?)");
@@ -94,7 +89,7 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the index table plan that has a lower cost when stats become 
available.
-      verifyQueryPlan(query, "RANGE SCAN");
+      assertPlan(conn, query).scanType("RANGE SCAN");
     } finally {
       conn.close();
     }
@@ -116,16 +111,9 @@ public class CostBasedDecisionIT extends BaseTest {
       String query =
         "SELECT c1, max(rowkey), max(c2) FROM " + tableName + " where rowkey 
<= 'z' GROUP BY c1";
       // Use the index table plan that opts out order-by when stats are not 
available.
-      ExplainPlan plan = 
conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      ExplainPlanAttributes explainPlanAttributes = 
plan.getPlanStepsAsAttributes();
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("RANGE SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(tableName, explainPlanAttributes.getTableName());
-      assertEquals(" [*] - ['z']", explainPlanAttributes.getKeyRanges());
-      assertEquals("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]",
-        explainPlanAttributes.getServerAggregate());
-      assertEquals("CLIENT MERGE SORT", 
explainPlanAttributes.getClientSortAlgo());
+      assertPlan(conn, query).iteratorType("PARALLEL").scanType("RANGE 
SCAN").table(tableName)
+        .keyRanges(" [*] - ['z']").serverAggregate("SERVER AGGREGATE INTO 
DISTINCT ROWS BY [C1]")
+        .clientSortAlgo("CLIENT MERGE SORT");
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) 
VALUES (?, ?, ?)");
@@ -142,18 +130,11 @@ public class CostBasedDecisionIT extends BaseTest {
       // Given that the range on C1 is meaningless and group-by becomes
       // order-preserving if using the data table, the data table plan should
       // come out as the best plan based on the costs.
-      plan = 
conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class).optimizeQuery()
-        .getExplainPlan();
-      explainPlanAttributes = plan.getPlanStepsAsAttributes();
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("RANGE SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(indexName + "(" + tableName + ")", 
explainPlanAttributes.getTableName());
-      assertEquals(" [1]", explainPlanAttributes.getKeyRanges());
-      assertTrue(explainPlanAttributes.isServerFirstKeyOnlyProjection());
-      assertEquals("\"ROWKEY\" <= 'z'", 
explainPlanAttributes.getServerWhereFilter());
-      assertEquals("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"C1\"]",
-        explainPlanAttributes.getServerAggregate());
-      assertEquals("CLIENT MERGE SORT", 
explainPlanAttributes.getClientSortAlgo());
+      assertPlan(conn, query).iteratorType("PARALLEL").scanType("RANGE SCAN")
+        .table(indexName + "(" + tableName + ")").keyRanges(" [1]")
+        .serverFirstKeyOnlyProjection(true).serverWhereFilter("\"ROWKEY\" <= 
'z'")
+        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]")
+        .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
       conn.close();
     }
@@ -178,16 +159,10 @@ public class CostBasedDecisionIT extends BaseTest {
       String query =
         "SELECT * FROM " + tableName + " where c1 BETWEEN 10 AND 20 AND c2 < 
9000 AND C3 < 5000";
       // Use the idx2 plan with a wider PK slot span when stats are not 
available.
-      ExplainPlan plan = 
conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      ExplainPlanAttributes explainPlanAttributes = 
plan.getPlanStepsAsAttributes();
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("RANGE SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(indexName2 + "(" + tableName + ")", 
explainPlanAttributes.getTableName());
-      assertEquals(" [2,*] - [2,9,000]", explainPlanAttributes.getKeyRanges());
-      assertEquals("((\"C1\" >= 10 AND \"C1\" <= 20) AND TO_INTEGER(\"C3\") < 
5000)",
-        explainPlanAttributes.getServerWhereFilter());
-      assertEquals("CLIENT MERGE SORT", 
explainPlanAttributes.getClientSortAlgo());
+      assertPlan(conn, query).iteratorType("PARALLEL").scanType("RANGE SCAN")
+        .table(indexName2 + "(" + tableName + ")").keyRanges(" [2,*] - 
[2,9,000]")
+        .serverWhereFilter("((\"C1\" >= 10 AND \"C1\" <= 20) AND 
TO_INTEGER(\"C3\") < 5000)")
+        .clientSortAlgo("CLIENT MERGE SORT");
 
       PreparedStatement stmt = conn
         .prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2, c3) 
VALUES (?, ?, ?, ?)");
@@ -202,16 +177,9 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the idx2 plan that scans less data when stats become available.
-      plan = 
conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class).optimizeQuery()
-        .getExplainPlan();
-      explainPlanAttributes = plan.getPlanStepsAsAttributes();
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("RANGE SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(indexName1 + "(" + tableName + ")", 
explainPlanAttributes.getTableName());
-      assertEquals(" [1,10] - [1,20]", explainPlanAttributes.getKeyRanges());
-      assertEquals("(\"C2\" < 9000 AND \"C3\" < 5000)",
-        explainPlanAttributes.getServerWhereFilter());
-      assertEquals("CLIENT MERGE SORT", 
explainPlanAttributes.getClientSortAlgo());
+      assertPlan(conn, query).iteratorType("PARALLEL").scanType("RANGE SCAN")
+        .table(indexName1 + "(" + tableName + ")").keyRanges(" [1,10] - 
[1,20]")
+        .serverWhereFilter("(\"C2\" < 9000 AND \"C3\" < 
5000)").clientSortAlgo("CLIENT MERGE SORT");
     } finally {
       conn.close();
     }
@@ -236,11 +204,11 @@ public class CostBasedDecisionIT extends BaseTest {
       String query = "UPSERT INTO " + tableName + " SELECT * FROM " + tableName
         + " where c1 BETWEEN 10 AND 20 AND c2 < 9000 AND C3 < 5000";
       // Use the idx2 plan with a wider PK slot span when stats are not 
available.
-      verifyQueryPlan(query,
-        "UPSERT SELECT\n" + "CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + 
indexName2 + "(" + tableName
-          + ")" + " [2,*] - [2,9,000]\n"
-          + "    SERVER FILTER BY ((\"C1\" >= 10 AND \"C1\" <= 20) AND 
TO_INTEGER(\"C3\") < 5000)\n"
-          + "CLIENT MERGE SORT");
+      assertMutationPlan(conn, query).abstractExplainPlan("UPSERT 
SELECT").iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(indexName2 + "(" + tableName + ")")
+        .keyRanges(" [2,*] - [2,9,000]")
+        .serverWhereFilter("((\"C1\" >= 10 AND \"C1\" <= 20) AND 
TO_INTEGER(\"C3\") < 5000)")
+        .clientSortAlgo("CLIENT MERGE SORT");
 
       PreparedStatement stmt = conn
         .prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2, c3) 
VALUES (?, ?, ?, ?)");
@@ -255,10 +223,10 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the idx2 plan that scans less data when stats become available.
-      verifyQueryPlan(query,
-        "UPSERT SELECT\n" + "CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + 
indexName1 + "(" + tableName
-          + ")" + " [1,10] - [1,20]\n" + "    SERVER FILTER BY (\"C2\" < 9000 
AND \"C3\" < 5000)\n"
-          + "CLIENT MERGE SORT");
+      assertMutationPlan(conn, query).abstractExplainPlan("UPSERT 
SELECT").iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(indexName1 + "(" + tableName + ")")
+        .keyRanges(" [1,10] - [1,20]").serverWhereFilter("(\"C2\" < 9000 AND 
\"C3\" < 5000)")
+        .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
       conn.close();
     }
@@ -283,11 +251,11 @@ public class CostBasedDecisionIT extends BaseTest {
       String query =
         "DELETE FROM " + tableName + " where c1 BETWEEN 10 AND 20 AND c2 < 
9000 AND C3 < 5000";
       // Use the idx2 plan with a wider PK slot span when stats are not 
available.
-      verifyQueryPlan(query,
-        "DELETE ROWS CLIENT SELECT\n" + "CLIENT PARALLEL 1-WAY RANGE SCAN OVER 
" + indexName2 + "("
-          + tableName + ")" + " [2,*] - [2,9,000]\n"
-          + "    SERVER FILTER BY ((\"C1\" >= 10 AND \"C1\" <= 20) AND 
TO_INTEGER(\"C3\") < 5000)\n"
-          + "CLIENT MERGE SORT");
+      assertMutationPlan(conn, query).abstractExplainPlan("DELETE ROWS CLIENT 
SELECT")
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(indexName2 + 
"(" + tableName + ")")
+        .keyRanges(" [2,*] - [2,9,000]")
+        .serverWhereFilter("((\"C1\" >= 10 AND \"C1\" <= 20) AND 
TO_INTEGER(\"C3\") < 5000)")
+        .clientSortAlgo("CLIENT MERGE SORT");
 
       PreparedStatement stmt = conn
         .prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2, c3) 
VALUES (?, ?, ?, ?)");
@@ -302,10 +270,10 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the idx2 plan that scans less data when stats become available.
-      verifyQueryPlan(query,
-        "DELETE ROWS CLIENT SELECT\n" + "CLIENT PARALLEL 1-WAY RANGE SCAN OVER 
" + indexName1 + "("
-          + tableName + ")" + " [1,10] - [1,20]\n"
-          + "    SERVER FILTER BY (\"C2\" < 9000 AND \"C3\" < 5000)\n" + 
"CLIENT MERGE SORT");
+      assertMutationPlan(conn, query).abstractExplainPlan("DELETE ROWS CLIENT 
SELECT")
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(indexName1 + 
"(" + tableName + ")")
+        .keyRanges(" [1,10] - [1,20]").serverWhereFilter("(\"C2\" < 9000 AND 
\"C3\" < 5000)")
+        .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
       conn.close();
     }
@@ -328,12 +296,13 @@ public class CostBasedDecisionIT extends BaseTest {
         + " where rowkey <= 'z' GROUP BY c1 " + "UNION ALL SELECT c1, 
max(rowkey), max(c2) FROM "
         + tableName + " where rowkey >= 'a' GROUP BY c1";
       // Use the default plan when stats are not available.
-      verifyQueryPlan(query,
-        "UNION ALL OVER 2 QUERIES\n" + "    CLIENT PARALLEL 1-WAY RANGE SCAN 
OVER " + tableName
-          + " [*] - ['z']\n" + "        SERVER AGGREGATE INTO DISTINCT ROWS BY 
[C1]\n"
-          + "    CLIENT MERGE SORT\n" + "    CLIENT PARALLEL 1-WAY RANGE SCAN 
OVER " + tableName
-          + " ['a'] - [*]\n" + "        SERVER AGGREGATE INTO DISTINCT ROWS BY 
[C1]\n"
-          + "    CLIENT MERGE SORT");
+      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 QUERIES")
+        .iteratorType("PARALLEL").scanType("RANGE 
SCAN").table(tableName).keyRanges(" [*] - ['z']")
+        .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
+        .clientSortAlgo("CLIENT MERGE 
SORT").rhs().iteratorType("PARALLEL").scanType("RANGE SCAN")
+        .table(tableName).keyRanges(" ['a'] - [*]")
+        .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
+        .clientSortAlgo("CLIENT MERGE SORT");
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) 
VALUES (?, ?, ?)");
@@ -348,18 +317,16 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the optimal plan based on cost when stats become available.
-      verifyQueryPlan(query,
-        "UNION ALL OVER 2 QUERIES\n" + "    CLIENT PARALLEL 1-WAY RANGE SCAN 
OVER " + indexName
-          + "(" + tableName + ") [1]\n" + "        SERVER MERGE [0.C2]\n"
-          + "        SERVER PROJECTION FILTER BY FIRST KEY ONLY\n"
-          + "        SERVER FILTER BY \"ROWKEY\" <= 'z'\n"
-          + "        SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"C1\"]\n"
-          + "    CLIENT MERGE SORT\n" + "    CLIENT PARALLEL 1-WAY RANGE SCAN 
OVER " + indexName
-          + "(" + tableName + ") [1]\n" + "        SERVER MERGE [0.C2]\n"
-          + "        SERVER PROJECTION FILTER BY FIRST KEY ONLY\n"
-          + "        SERVER FILTER BY \"ROWKEY\" >= 'a'\n"
-          + "        SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"C1\"]\n"
-          + "    CLIENT MERGE SORT");
+      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 QUERIES")
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(indexName + "(" 
+ tableName + ")")
+        .keyRanges(" 
[1]").serverMergeColumns("[0.C2]").serverFirstKeyOnlyProjection(true)
+        .serverWhereFilter("\"ROWKEY\" <= 'z'")
+        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]")
+        .clientSortAlgo("CLIENT MERGE 
SORT").rhs().iteratorType("PARALLEL").scanType("RANGE SCAN")
+        .table(indexName + "(" + tableName + ")").keyRanges(" 
[1]").serverMergeColumns("[0.C2]")
+        .serverFirstKeyOnlyProjection(true).serverWhereFilter("\"ROWKEY\" >= 
'a'")
+        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]")
+        .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
       conn.close();
     }
@@ -383,12 +350,13 @@ public class CostBasedDecisionIT extends BaseTest {
         + " where rowkey <= 'z' GROUP BY c1) t2 "
         + "ON t1.rowkey = t2.mrk WHERE t1.c1 LIKE 'X0%' ORDER BY t1.rowkey";
       // Use the default plan when stats are not available.
-      verifyQueryPlan(query,
-        "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + tableName + "\n"
-          + "    SERVER FILTER BY C1 LIKE 'X0%'\n" + "    PARALLEL INNER-JOIN 
TABLE 0\n"
-          + "        CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + tableName + " 
[*] - ['z']\n"
-          + "            SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]\n"
-          + "        CLIENT MERGE SORT\n" + "    DYNAMIC SERVER FILTER BY 
T1.ROWKEY IN (T2.MRK)");
+      assertPlan(conn, query).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(tableName)
+        .serverWhereFilter("C1 LIKE 'X0%'")
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY T1.ROWKEY IN 
(T2.MRK)").subPlanCount(1)
+        .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
0").iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(tableName).keyRanges(" [*] - ['z']")
+        .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
+        .clientSortAlgo("CLIENT MERGE SORT");
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) 
VALUES (?, ?, ?)");
@@ -403,18 +371,17 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the optimal plan based on cost when stats become available.
-      verifyQueryPlan(query,
-        "CLIENT PARALLEL 626-WAY RANGE SCAN OVER " + indexName + "(" + 
tableName
-          + ") [1,'X0'] - [1,'X1']\n" + "    SERVER MERGE [0.C2]\n"
-          + "    SERVER PROJECTION FILTER BY FIRST KEY ONLY\n"
-          + "    SERVER SORTED BY [\"T1.:ROWKEY\"]\n" + "CLIENT MERGE SORT\n"
-          + "    PARALLEL INNER-JOIN TABLE 0\n" + "        CLIENT PARALLEL 
1-WAY RANGE SCAN OVER "
-          + indexName + "(" + tableName + ") [1]\n" + "            SERVER 
MERGE [0.C2]\n"
-          + "            SERVER PROJECTION FILTER BY FIRST KEY ONLY\n"
-          + "            SERVER FILTER BY \"ROWKEY\" <= 'z'\n"
-          + "            SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]\n"
-          + "        CLIENT MERGE SORT\n"
-          + "    DYNAMIC SERVER FILTER BY \"T1.:ROWKEY\" IN (T2.MRK)");
+      assertPlan(conn, query).iteratorType("PARALLEL").scanType("RANGE SCAN")
+        .table(indexName + "(" + tableName + ")").keyRanges(" [1,'X0'] - 
[1,'X1']")
+        .serverMergeColumns("[0.C2]").serverFirstKeyOnlyProjection(true)
+        .serverSortedBy("[\"T1.:ROWKEY\"]").clientSortAlgo("CLIENT MERGE SORT")
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY \"T1.:ROWKEY\" IN 
(T2.MRK)").subPlanCount(1)
+        .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
0").iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(indexName + "(" + tableName + 
")").keyRanges(" [1]")
+        .serverMergeColumns("[0.C2]").serverFirstKeyOnlyProjection(true)
+        .serverWhereFilter("\"ROWKEY\" <= 'z'")
+        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]")
+        .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
       conn.close();
     }
@@ -440,11 +407,7 @@ public class CostBasedDecisionIT extends BaseTest {
       String indexPlan = "(\"ROWKEY\" >= 1 AND \"ROWKEY\" <= 10)";
 
       // Use the index table plan that opts out order-by when stats are not 
available.
-      ExplainPlan plan = 
conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      ExplainPlanAttributes explainPlanAttributes = 
plan.getPlanStepsAsAttributes();
-      assertTrue(explainPlanAttributes.isServerFirstKeyOnlyProjection());
-      assertEquals(indexPlan, explainPlanAttributes.getServerWhereFilter());
+      assertPlan(conn, 
query).serverFirstKeyOnlyProjection(true).serverWhereFilter(indexPlan);
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) 
VALUES (?, ?, ?)");
@@ -459,17 +422,10 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the data table plan that has a lower cost when stats are 
available.
-      plan = 
conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class).optimizeQuery()
-        .getExplainPlan();
-      explainPlanAttributes = plan.getPlanStepsAsAttributes();
-      assertEquals(dataPlan, explainPlanAttributes.getServerSortedBy());
+      assertPlan(conn, query).serverSortedBy(dataPlan);
 
       // Use the index table plan as has been hinted.
-      plan = 
conn.prepareStatement(hintedQuery).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      explainPlanAttributes = plan.getPlanStepsAsAttributes();
-      assertTrue(explainPlanAttributes.isServerFirstKeyOnlyProjection());
-      assertEquals(indexPlan, explainPlanAttributes.getServerWhereFilter());
+      assertPlan(conn, 
hintedQuery).serverFirstKeyOnlyProjection(true).serverWhereFilter(indexPlan);
     } finally {
       conn.close();
     }
@@ -482,17 +438,9 @@ public class CostBasedDecisionIT extends BaseTest {
       + "ON t1.ID = t2.ID";
     Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
     try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
-      ExplainPlan plan = 
conn.prepareStatement(q).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      ExplainPlanAttributes explainPlanAttributes = 
plan.getPlanStepsAsAttributes();
-      assertEquals("SORT-MERGE-JOIN (INNER)", 
explainPlanAttributes.getAbstractExplainPlan());
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(testTable500, explainPlanAttributes.getTableName());
-      ExplainPlanAttributes rhsTable = 
explainPlanAttributes.getRhsJoinQueryExplainPlan();
-      assertEquals("PARALLEL 1-WAY", rhsTable.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", rhsTable.getExplainScanType());
-      assertEquals(testTable1000, rhsTable.getTableName());
+      assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN (INNER)").lhs()
+        .iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable500).end().rhs()
+        .iteratorType("PARALLEL").scanType("FULL SCAN").table(testTable1000);
     }
   }
 
@@ -505,20 +453,11 @@ public class CostBasedDecisionIT extends BaseTest {
       + "ON t1.ID = t2.ID\n" + "WHERE t1.COL1 < 200";
     Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
     try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
-      ExplainPlan plan = 
conn.prepareStatement(q).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      ExplainPlanAttributes explainPlanAttributes = 
plan.getPlanStepsAsAttributes();
-      assertEquals("SORT-MERGE-JOIN (INNER)", 
explainPlanAttributes.getAbstractExplainPlan());
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals("COL1 < 200", explainPlanAttributes.getServerWhereFilter());
-      assertEquals(testTable500, explainPlanAttributes.getTableName());
-      assertEquals("CLIENT AGGREGATE INTO SINGLE ROW", 
explainPlanAttributes.getClientAggregate());
-      ExplainPlanAttributes rhsTable = 
explainPlanAttributes.getRhsJoinQueryExplainPlan();
-      assertEquals("PARALLEL 1-WAY", rhsTable.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", rhsTable.getExplainScanType());
-      assertTrue(rhsTable.isServerFirstKeyOnlyProjection());
-      assertEquals(testTable1000, rhsTable.getTableName());
+      assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN (INNER)")
+        .clientAggregate("CLIENT AGGREGATE INTO SINGLE 
ROW").lhs().iteratorType("PARALLEL")
+        .scanType("FULL SCAN").table(testTable500).serverWhereFilter("COL1 < 
200").end().rhs()
+        .iteratorType("PARALLEL").scanType("FULL 
SCAN").serverFirstKeyOnlyProjection(true)
+        .table(testTable1000);
     }
   }
 
@@ -527,10 +466,13 @@ public class CostBasedDecisionIT extends BaseTest {
   public void testJoinStrategy3() throws Exception {
     String q = "SELECT *\n" + "FROM " + testTable500 + " t1 JOIN " + 
testTable1000 + " t2\n"
       + "ON t1.COL1 = t2.ID\n" + "WHERE t1.ID > 200";
-    String expected = "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + testTable1000 
+ "\n"
-      + "    PARALLEL INNER-JOIN TABLE 0\n" + "        CLIENT PARALLEL 1-WAY 
RANGE SCAN OVER "
-      + testTable500 + " [201] - [*]\n" + "    DYNAMIC SERVER FILTER BY T2.ID 
IN (T1.COL1)";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable1000)
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY T2.ID IN 
(T1.COL1)").subPlanCount(1)
+        .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
0").iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(testTable500).keyRanges(" [201] - [*]");
+    }
   }
 
   /**
@@ -541,10 +483,13 @@ public class CostBasedDecisionIT extends BaseTest {
   public void testJoinStrategy4() throws Exception {
     String q = "SELECT *\n" + "FROM " + testTable990 + " t1 JOIN " + 
testTable1000 + " t2\n"
       + "ON t1.ID = t2.COL1";
-    String expected = "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + testTable990 + 
"\n"
-      + "    PARALLEL INNER-JOIN TABLE 0\n" + "        CLIENT PARALLEL 1-WAY 
FULL SCAN OVER "
-      + testTable1000 + "\n" + "    DYNAMIC SERVER FILTER BY T1.ID IN 
(T2.COL1)";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable990)
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY T1.ID IN 
(T2.COL1)").subPlanCount(1)
+        .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
0").iteratorType("PARALLEL")
+        .scanType("FULL SCAN").table(testTable1000);
+    }
   }
 
   /** Hash-join wins over sort-merge-join w/ smaller side ordered. */
@@ -552,10 +497,13 @@ public class CostBasedDecisionIT extends BaseTest {
   public void testJoinStrategy5() throws Exception {
     String q = "SELECT *\n" + "FROM " + testTable500 + " t1 JOIN " + 
testTable1000 + " t2\n"
       + "ON t1.ID = t2.COL1\n" + "WHERE t1.ID > 200";
-    String expected = "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + testTable1000 
+ "\n"
-      + "    PARALLEL INNER-JOIN TABLE 0\n" + "        CLIENT PARALLEL 1-WAY 
RANGE SCAN OVER "
-      + testTable500 + " [201] - [*]";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable1000)
+        .subPlanCount(1).subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN 
TABLE 0")
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(testTable500)
+        .keyRanges(" [201] - [*]");
+    }
   }
 
   /** Hash-join wins over sort-merge-join w/o any side ordered. */
@@ -563,10 +511,13 @@ public class CostBasedDecisionIT extends BaseTest {
   public void testJoinStrategy6() throws Exception {
     String q = "SELECT *\n" + "FROM " + testTable500 + " t1 JOIN " + 
testTable1000 + " t2\n"
       + "ON t1.COL1 = t2.COL1\n" + "WHERE t1.ID > 200";
-    String expected = "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + testTable1000 
+ "\n"
-      + "    PARALLEL INNER-JOIN TABLE 0\n" + "        CLIENT PARALLEL 1-WAY 
RANGE SCAN OVER "
-      + testTable500 + " [201] - [*]";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable1000)
+        .subPlanCount(1).subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN 
TABLE 0")
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(testTable500)
+        .keyRanges(" [201] - [*]");
+    }
   }
 
   /**
@@ -578,11 +529,14 @@ public class CostBasedDecisionIT extends BaseTest {
   public void testJoinStrategy7() throws Exception {
     String q = "SELECT *\n" + "FROM " + testTable500 + " t1 JOIN " + 
testTable1000 + " t2\n"
       + "ON t1.ID = t2.ID\n" + "ORDER BY t1.COL1";
-    String expected = "CLIENT PARALLEL 1001-WAY FULL SCAN OVER " + 
testTable1000 + "\n"
-      + "    SERVER SORTED BY [T1.COL1]\n" + "CLIENT MERGE SORT\n"
-      + "    PARALLEL INNER-JOIN TABLE 0\n" + "        CLIENT PARALLEL 1-WAY 
FULL SCAN OVER "
-      + testTable500 + "\n" + "    DYNAMIC SERVER FILTER BY T2.ID IN (T1.ID)";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable1000)
+        .serverSortedBy("[T1.COL1]").clientSortAlgo("CLIENT MERGE SORT")
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY T2.ID IN 
(T1.ID)").subPlanCount(1).subPlan(0)
+        .abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
0").iteratorType("PARALLEL")
+        .scanType("FULL SCAN").table(testTable500);
+    }
   }
 
   /**
@@ -596,19 +550,9 @@ public class CostBasedDecisionIT extends BaseTest {
       + "ON t1.ID = t2.ID\n" + "ORDER BY t1.COL1 LIMIT 5";
     Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
     try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
-      ExplainPlan plan = 
conn.prepareStatement(q).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      ExplainPlanAttributes explainPlanAttributes = 
plan.getPlanStepsAsAttributes();
-      assertEquals("SORT-MERGE-JOIN (INNER)", 
explainPlanAttributes.getAbstractExplainPlan());
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(testTable500, explainPlanAttributes.getTableName());
-      assertEquals("[T1.COL1]", explainPlanAttributes.getClientSortedBy());
-      assertEquals(new Integer(5), explainPlanAttributes.getClientRowLimit());
-      ExplainPlanAttributes rhsTable = 
explainPlanAttributes.getRhsJoinQueryExplainPlan();
-      assertEquals("PARALLEL 1-WAY", rhsTable.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", rhsTable.getExplainScanType());
-      assertEquals(testTable1000, rhsTable.getTableName());
+      assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").clientSortedBy("[T1.COL1]")
+        .clientRowLimit(5).lhs().iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable500)
+        .end().rhs().iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable1000);
     }
   }
 
@@ -620,28 +564,36 @@ public class CostBasedDecisionIT extends BaseTest {
     String q = "SELECT *\n" + "FROM " + testTable1000 + " t1 LEFT JOIN " + 
testTable500 + " t2\n"
       + "ON t1.ID = t2.ID AND t2.ID > 200\n" + "LEFT JOIN " + testTable990 + " 
t3\n"
       + "ON t1.ID = t3.ID AND t3.ID < 100";
-    String expected = "SORT-MERGE-JOIN (LEFT) TABLES\n" + "    SORT-MERGE-JOIN 
(LEFT) TABLES\n"
-      + "        CLIENT PARALLEL 1-WAY FULL SCAN OVER " + testTable1000 + "\n" 
+ "    AND\n"
-      + "        CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + testTable500 + " 
[201] - [*]\n" + "AND\n"
-      + "    CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + testTable990 + " [*] - 
[100]";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN (LEFT)").lhs()
+        .abstractExplainPlan("SORT-MERGE-JOIN 
(LEFT)").lhs().iteratorType("PARALLEL")
+        .scanType("FULL 
SCAN").table(testTable1000).end().rhs().iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(testTable500).keyRanges(" [201] - 
[*]").end().end().rhs()
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(testTable990)
+        .keyRanges(" [*] - [100]");
+    }
   }
 
   /**
-   * Multi-table join: a mix of join strategies chosen based on cost.
+   * Multi-table join: a mix of join strategies chosen based on cost. SMJ 
root, lhs is hash-join,
+   * rhs is range scan.
    */
   @Test
   public void testJoinStrategy10() throws Exception {
     String q = "SELECT *\n" + "FROM " + testTable1000 + " t1 JOIN " + 
testTable500 + " t2\n"
       + "ON t1.ID = t2.COL1 AND t2.ID > 200\n" + "JOIN " + testTable990 + " 
t3\n"
       + "ON t1.ID = t3.ID AND t3.ID < 100";
-    String expected =
-      "SORT-MERGE-JOIN (INNER) TABLES\n" + "    CLIENT PARALLEL 1-WAY FULL 
SCAN OVER "
-        + testTable1000 + "\n" + "        PARALLEL INNER-JOIN TABLE 0\n"
-        + "            CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + testTable500 
+ " [201] - [*]\n"
-        + "        DYNAMIC SERVER FILTER BY T1.ID IN (T2.COL1)\n" + "AND\n"
-        + "    CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + testTable990 + " [*] 
- [100]";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN (INNER)").lhs()
+        .iteratorType("PARALLEL").scanType("FULL SCAN").table(testTable1000)
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY T1.ID IN 
(T2.COL1)").subPlanCount(1)
+        .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
0").iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(testTable500).keyRanges(" [201] - 
[*]").end().end().rhs()
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(testTable990)
+        .keyRanges(" [*] - [100]");
+    }
   }
 
   /**
@@ -653,11 +605,15 @@ public class CostBasedDecisionIT extends BaseTest {
     String q = "SELECT *\n" + "FROM " + testTable1000 + " t1 JOIN " + 
testTable500 + " t2\n"
       + "ON t1.COL2 = t2.COL1 AND t2.ID > 200\n" + "JOIN " + testTable990 + " 
t3\n"
       + "ON t1.COL1 = t3.COL2 AND t3.ID < 100";
-    String expected = "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + testTable1000 
+ "\n"
-      + "    PARALLEL INNER-JOIN TABLE 0\n" + "        CLIENT PARALLEL 1-WAY 
RANGE SCAN OVER "
-      + testTable500 + " [201] - [*]\n" + "    PARALLEL INNER-JOIN TABLE 1\n"
-      + "        CLIENT PARALLEL 1-WAY RANGE SCAN OVER " + testTable990 + " 
[*] - [100]";
-    verifyQueryPlan(q, expected);
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(testTable1000)
+        .subPlanCount(2).subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN 
TABLE 0")
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(testTable500)
+        .keyRanges(" [201] - [*]").end().subPlan(1)
+        .abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
1").iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(testTable990).keyRanges(" [*] - [100]");
+    }
   }
 
   /**
@@ -668,29 +624,16 @@ public class CostBasedDecisionIT extends BaseTest {
   public void testJoinStrategy12() throws Exception {
     String q = "SELECT *\n" + "FROM " + testTable1000 + " t1 JOIN " + 
testTable990 + " t2\n"
       + "ON t1.COL2 = t2.COL1\n" + "JOIN " + testTable990 + " t3\n" + "ON 
t1.COL1 = t3.COL2";
-    String expected =
-      "SORT-MERGE-JOIN (INNER) TABLES\n" + "    CLIENT PARALLEL 1001-WAY FULL 
SCAN OVER "
-        + testTable1000 + "\n" + "        SERVER SORTED BY [T1.COL1]\n" + "    
CLIENT MERGE SORT\n"
-        + "        PARALLEL INNER-JOIN TABLE 0\n"
-        + "            CLIENT PARALLEL 1-WAY FULL SCAN OVER " + testTable990 + 
"\n" + "AND\n"
-        + "    CLIENT PARALLEL 991-WAY FULL SCAN OVER " + testTable990 + "\n"
-        + "        SERVER SORTED BY [T3.COL2]\n" + "    CLIENT MERGE SORT";
-    verifyQueryPlan(q, expected);
-  }
-
-  private static void verifyQueryPlan(String query, String expected) throws 
Exception {
     Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
-    Connection conn = DriverManager.getConnection(getUrl(), props);
-    ResultSet rs = conn.createStatement().executeQuery("explain " + query);
-    String plan = QueryUtil.getExplainPlan(rs);
-    // These assertions verify cost-based plan structure (scan type, join 
strategy, ordering). The
-    // per-scan INDEX and REGIONS PLANNED annotations carry guidepost-derived 
region counts that are
-    // covered directly by the attribute-based tests, so strip them here to 
keep these structural
-    // substring checks stable.
-    String normalizedPlan = plan.replaceAll("(?m)^ *INDEX [^\n]*\n", "")
-      .replaceAll("(?m)^ *REGIONS PLANNED [^\n]*\n", "");
-    assertTrue("Expected '" + expected + "' in the plan:\n" + plan + ".",
-      normalizedPlan.contains(expected));
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN (INNER)").lhs()
+        .iteratorType("PARALLEL").scanType("FULL SCAN").table(testTable1000)
+        .serverSortedBy("[T1.COL1]").clientSortAlgo("CLIENT MERGE 
SORT").subPlanCount(1).subPlan(0)
+        .abstractExplainPlan("PARALLEL INNER-JOIN TABLE 
0").iteratorType("PARALLEL")
+        .scanType("FULL 
SCAN").table(testTable990).end().end().rhs().iteratorType("PARALLEL")
+        .scanType("FULL SCAN").table(testTable990).serverSortedBy("[T3.COL2]")
+        .clientSortAlgo("CLIENT MERGE SORT");
+    }
   }
 
   private static String initTestTableValues(int rows) throws Exception {
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/DerivedTableIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/DerivedTableIT.java
index d35960fae9..2e809ae609 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/DerivedTableIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/DerivedTableIT.java
@@ -17,6 +17,7 @@
  */
 package org.apache.phoenix.end2end;
 
+import static org.apache.phoenix.query.explain.ExplainPlanTestUtil.assertPlan;
 import static org.apache.phoenix.util.TestUtil.A_VALUE;
 import static org.apache.phoenix.util.TestUtil.B_VALUE;
 import static org.apache.phoenix.util.TestUtil.C_VALUE;
@@ -43,8 +44,8 @@ import java.sql.ResultSet;
 import java.util.Collection;
 import java.util.List;
 import java.util.Properties;
+import org.apache.phoenix.query.explain.ExplainPlanTestUtil.ExplainPlanAssert;
 import org.apache.phoenix.util.PropertiesUtil;
-import org.apache.phoenix.util.QueryUtil;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -54,8 +55,6 @@ import org.junit.rules.TestName;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 import org.junit.runners.Parameterized.Parameters;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import org.apache.phoenix.thirdparty.com.google.common.collect.Lists;
 
@@ -68,12 +67,31 @@ public class DerivedTableIT extends ParallelStatsDisabledIT 
{
   public TestName name = new TestName();
 
   private String[] indexDDL;
-  private String[] plans;
+  private PlanSpec[] plans;
   private String tableName;
 
-  private static final Logger LOGGER = 
LoggerFactory.getLogger(DerivedTableIT.class);
+  /**
+   * Structured expected plan for one query under {@link 
#testNestedAggregationsWithGroupBy}.
+   * Captures the table the server-side scan targets, the {@code SERVER 
AGGREGATE} string, and the
+   * ordered list of client-side pipeline steps.
+   */
+  private static final class PlanSpec {
+    final String tableSuffix;
+    final String serverAggregate;
+    final String[] clientSteps;
+
+    PlanSpec(String tableSuffix, String serverAggregate, String... 
clientSteps) {
+      this.tableSuffix = tableSuffix;
+      this.serverAggregate = serverAggregate;
+      this.clientSteps = clientSteps;
+    }
+
+    String resolvedTable(String concreteTableName) {
+      return tableSuffix.replace(dynamicTableName, concreteTableName);
+    }
+  }
 
-  public DerivedTableIT(String[] indexDDL, String[] plans) {
+  public DerivedTableIT(String[] indexDDL, PlanSpec[] plans) {
     this.indexDDL = indexDDL;
     this.plans = plans;
   }
@@ -92,13 +110,6 @@ public class DerivedTableIT extends ParallelStatsDisabledIT 
{
         conn.createStatement().execute(ddl);
       }
     }
-    String[] newplan = new String[plans.length];
-    if (plans != null && plans.length > 0) {
-      for (int i = 0; i < plans.length; i++) {
-        newplan[i] = plans[i].replace(dynamicTableName, tableName);
-      }
-      plans = newplan;
-    }
   }
 
   @After
@@ -109,41 +120,36 @@ public class DerivedTableIT extends 
ParallelStatsDisabledIT {
   }
 
   @Parameters(name = "DerivedTableIT_{index}") // name is used by failsafe as 
file name in reports
-  public static synchronized Collection<Object> data() {
-    List<Object> testCases = Lists.newArrayList();
-    testCases.add(new String[][] {
-      { "CREATE INDEX " + dynamicTableName + "_DERIVED_IDX ON " + 
dynamicTableName
+  public static synchronized Collection<Object[]> data() {
+    List<Object[]> testCases = Lists.newArrayList();
+    // Indexed case: server-side aggregate runs over the covered index 
(PARALLEL 1-WAY).
+    testCases.add(new Object[] {
+      new String[] { "CREATE INDEX " + dynamicTableName + "_DERIVED_IDX ON " + 
dynamicTableName
         + " (a_byte) INCLUDE (A_STRING, B_STRING)" },
-      { "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + dynamicTableName + 
"_DERIVED_IDX\n"
-        + "    SERVER AGGREGATE INTO DISTINCT ROWS BY [\"A_STRING\", 
\"B_STRING\"]\n"
-        + "CLIENT MERGE SORT\n" + "CLIENT SORTED BY [\"B_STRING\"]\n" + 
"CLIENT SORTED BY [A]\n"
-        + "CLIENT AGGREGATE INTO DISTINCT ROWS BY [A]\n" + "CLIENT SORTED BY 
[A DESC]",
-
-        "CLIENT PARALLEL 1-WAY FULL SCAN OVER " + dynamicTableName + 
"_DERIVED_IDX\n"
-          + "    SERVER AGGREGATE INTO DISTINCT ROWS BY [\"A_STRING\", 
\"B_STRING\"]\n"
-          + "CLIENT MERGE SORT\n" + "CLIENT AGGREGATE INTO ORDERED DISTINCT 
ROWS BY [A]\n"
-          + "CLIENT DISTINCT ON [COLLECTDISTINCT(B)]\n" + "CLIENT SORTED BY [A 
DESC]" } });
-    testCases.add(new String[][] { {},
-      { "CLIENT PARALLEL 4-WAY FULL SCAN OVER " + dynamicTableName + "\n"
-        + "    SERVER AGGREGATE INTO DISTINCT ROWS BY [A_STRING, B_STRING]\n"
-        + "CLIENT MERGE SORT\n" + "CLIENT SORTED BY [B_STRING]\n" + "CLIENT 
SORTED BY [A]\n"
-        + "CLIENT AGGREGATE INTO DISTINCT ROWS BY [A]\n" + "CLIENT SORTED BY 
[A DESC]",
-
-        "CLIENT PARALLEL 4-WAY FULL SCAN OVER " + dynamicTableName + "\n"
-          + "    SERVER AGGREGATE INTO DISTINCT ROWS BY [A_STRING, B_STRING]\n"
-          + "CLIENT MERGE SORT\n" + "CLIENT AGGREGATE INTO ORDERED DISTINCT 
ROWS BY [A]\n"
-          + "CLIENT DISTINCT ON [COLLECTDISTINCT(B)]\n" + "CLIENT SORTED BY [A 
DESC]" } });
+      new PlanSpec[] {
+        new PlanSpec(dynamicTableName + "_DERIVED_IDX",
+          "SERVER AGGREGATE INTO DISTINCT ROWS BY [\"A_STRING\", 
\"B_STRING\"]",
+          "CLIENT MERGE SORT", "CLIENT SORTED BY [\"B_STRING\"]", "CLIENT 
SORTED BY [A]",
+          "CLIENT AGGREGATE INTO DISTINCT ROWS BY [A]", "CLIENT SORTED BY [A 
DESC]"),
+        new PlanSpec(dynamicTableName + "_DERIVED_IDX",
+          "SERVER AGGREGATE INTO DISTINCT ROWS BY [\"A_STRING\", 
\"B_STRING\"]",
+          "CLIENT MERGE SORT", "CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[A]",
+          "CLIENT DISTINCT ON [COLLECTDISTINCT(B)]", "CLIENT SORTED BY [A 
DESC]") } });
+    // Non-indexed case: server-side aggregate runs over the data table 
(PARALLEL 4-WAY).
+    testCases.add(new Object[] { new String[] {}, new PlanSpec[] {
+      new PlanSpec(dynamicTableName, "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING, B_STRING]",
+        "CLIENT MERGE SORT", "CLIENT SORTED BY [B_STRING]", "CLIENT SORTED BY 
[A]",
+        "CLIENT AGGREGATE INTO DISTINCT ROWS BY [A]", "CLIENT SORTED BY [A 
DESC]"),
+      new PlanSpec(dynamicTableName, "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING, B_STRING]",
+        "CLIENT MERGE SORT", "CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[A]",
+        "CLIENT DISTINCT ON [COLLECTDISTINCT(B)]", "CLIENT SORTED BY [A 
DESC]") } });
     return testCases;
   }
 
-  /**
-   * Removes the per-scan {@code INDEX} and {@code REGIONS PLANNED} annotation 
lines so these
-   * structural plan assertions stay stable; those attributes are covered by 
the attribute-based
-   * tests and {@code REGIONS PLANNED} carries an environment-dependent region 
count.
-   */
-  private static String stripScanAnnotations(String plan) {
-    return plan.replaceAll("(?m)^ *INDEX [^\n]*\n", "")
-      .replaceAll("(?m)^ *REGIONS PLANNED [^\n]*\n", "");
+  private void verifyPlan(Connection conn, String query, PlanSpec spec) throws 
Exception {
+    ExplainPlanAssert assertion = assertPlan(conn, query);
+    assertion.iteratorType("PARALLEL").scanType("FULL 
SCAN").table(spec.resolvedTable(tableName))
+      .serverAggregate(spec.serverAggregate).clientSteps(spec.clientSteps);
   }
 
   @Test
@@ -394,12 +400,7 @@ public class DerivedTableIT extends 
ParallelStatsDisabledIT {
 
       assertFalse(rs.next());
 
-      rs = conn.createStatement().executeQuery("EXPLAIN WITH REGIONS " + 
query);
-      String explainPlanOutput = QueryUtil.getExplainPlan(rs);
-      LOGGER.info("Explain plan output: {}", explainPlanOutput);
-      String[] splitExplainPlan = explainPlanOutput.split("\\n \\(region 
locations = \\[region=");
-      String[] secondSplitExplainPlan = splitExplainPlan[1].split("]\\)");
-      assertEquals(plans[0], stripScanAnnotations(splitExplainPlan[0] + 
secondSplitExplainPlan[1]));
+      verifyPlan(conn, query, plans[0]);
 
       // distinct b (groupby a, b) groupby a orderby a
       query = "SELECT DISTINCT COLLECTDISTINCT(t.b) FROM (SELECT b_string b, 
a_string a FROM "
@@ -421,12 +422,7 @@ public class DerivedTableIT extends 
ParallelStatsDisabledIT {
 
       assertFalse(rs.next());
 
-      rs = conn.createStatement().executeQuery("EXPLAIN WITH REGIONS " + 
query);
-      explainPlanOutput = QueryUtil.getExplainPlan(rs);
-      LOGGER.info("Explain plan output: {}", explainPlanOutput);
-      splitExplainPlan = explainPlanOutput.split("\\n \\(region locations = 
\\[region=");
-      secondSplitExplainPlan = splitExplainPlan[1].split("]\\)");
-      assertEquals(plans[1], stripScanAnnotations(splitExplainPlan[0] + 
secondSplitExplainPlan[1]));
+      verifyPlan(conn, query, plans[1]);
 
       // (orderby) groupby
       query = "SELECT t.a_string, count(*) FROM (SELECT * FROM " + tableName
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java
index 0116d31e1a..4a626c01a7 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java
@@ -427,14 +427,14 @@ public class SortMergeJoinMoreIT extends 
ParallelStatsDisabledIT {
         String rhsClientSortedBy = i == 0 ? "[BUCKET, \"TIMESTAMP\"]" : null;
 
         assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(true)
-          .iteratorType("PARALLEL").scanType("SKIP SCAN ON 2 
RANGES").table(eventCountTableName)
-          .keyRanges(lhsKeyRanges).serverFirstKeyOnlyProjection(true)
+          .lhs().iteratorType("PARALLEL").scanType("SKIP SCAN ON 2 RANGES")
+          
.table(eventCountTableName).keyRanges(lhsKeyRanges).serverFirstKeyOnlyProjection(true)
           .serverDistinctFilter("SERVER DISTINCT PREFIX FILTER OVER [BUCKET, 
TIMESTAMP, LOCATION]")
           .serverAggregate(
             "SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [BUCKET, 
TIMESTAMP, LOCATION]")
           .clientSortAlgo("CLIENT MERGE SORT").clientSortedBy("[BUCKET, 
TIMESTAMP]")
           .clientAggregate("CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[E.BUCKET, E.TIMESTAMP]")
-          .rhs().iteratorType("PARALLEL").scanType("SKIP SCAN ON 2 
RANGES").table(t[i])
+          .end().rhs().iteratorType("PARALLEL").scanType("SKIP SCAN ON 2 
RANGES").table(t[i])
           .keyRanges(rhsKeyRanges).serverFirstKeyOnlyProjection(true)
           .serverWhereFilter("SRC_LOCATION = DST_LOCATION")
           .serverDistinctFilter(
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java
index 0a8ef6a425..dfc9c7a7c9 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java
@@ -17,6 +17,7 @@
  */
 package org.apache.phoenix.end2end;
 
+import static org.apache.phoenix.query.explain.ExplainPlanTestUtil.assertPlan;
 import static org.apache.phoenix.util.MetaDataUtil.getViewIndexSequenceName;
 import static 
org.apache.phoenix.util.MetaDataUtil.getViewIndexSequenceSchemaName;
 import static org.apache.phoenix.util.PhoenixRuntime.TENANT_ID_ATTRIB;
@@ -41,7 +42,6 @@ import org.apache.phoenix.schema.ColumnNotFoundException;
 import org.apache.phoenix.schema.PNameFactory;
 import org.apache.phoenix.util.MetaDataUtil;
 import org.apache.phoenix.util.PhoenixRuntime;
-import org.apache.phoenix.util.QueryUtil;
 import org.apache.phoenix.util.SchemaUtil;
 import org.junit.Test;
 import org.junit.experimental.categories.Category;
@@ -358,40 +358,33 @@ public class TenantSpecificViewIndexIT extends 
BaseTenantSpecificViewIndexIT {
       viewConn.createStatement()
         .execute("CREATE VIEW IF NOT EXISTS " + viewName + " AS SELECT * FROM 
" + tableName);
 
-      String query = "EXPLAIN SELECT PARENT_ID FROM " + viewName + " WHERE 
PARENT_TYPE='001' "
+      String query1 = "SELECT PARENT_ID FROM " + viewName + " WHERE 
PARENT_TYPE='001' "
         + "AND (CREATED_DATE > to_date('2011-01-01') AND CREATED_DATE < 
to_date('2016-10-31'))"
         + "ORDER BY PARENT_TYPE,CREATED_DATE LIMIT 501";
 
-      ResultSet rs = viewConn.createStatement().executeQuery(query);
-      String exptectedIndexName = SchemaUtil.getTableName(SCHEMA1, "IDX");
-      String expectedPlanFormat = "CLIENT SERIAL 1-WAY RANGE SCAN OVER " + 
exptectedIndexName
-        + " ['tenant1        ','001','%s 00:00:00.001'] - ['tenant1        
','001','%s 00:00:00.000']"
-        + "\n" + "    SERVER PROJECTION FILTER BY FIRST KEY ONLY" + "\n"
-        + "    SERVER 501 ROW LIMIT" + "\n" + "CLIENT 501 ROW LIMIT";
-      assertEquals(String.format(expectedPlanFormat, "2011-01-01", 
"2016-10-31"),
-        stripScanAnnotations(QueryUtil.getExplainPlan(rs)));
+      String expectedIndexName = SchemaUtil.getTableName(SCHEMA1, "IDX");
+      String expectedKeyRangesFormat =
+        " ['tenant1        ','001','%s 00:00:00.001'] - ['tenant1        
','001','%s 00:00:00.000']";
 
-      query = "EXPLAIN SELECT PARENT_ID FROM " + viewName + " WHERE 
PARENT_TYPE='001' "
+      assertPlan(viewConn, query1).iteratorType("SERIAL").scanType("RANGE 
SCAN")
+        .indexName(expectedIndexName)
+        .keyRanges(String.format(expectedKeyRangesFormat, "2011-01-01", 
"2016-10-31"))
+        
.serverFirstKeyOnlyProjection(true).serverRowLimit(501L).clientRowLimit(501)
+        .clientSteps("CLIENT 501 ROW LIMIT");
+
+      String query2 = "SELECT PARENT_ID FROM " + viewName + " WHERE 
PARENT_TYPE='001' "
         + " AND (CREATED_DATE >= to_date('2011-01-01') AND CREATED_DATE <= 
to_date('2016-01-01'))"
         + " AND (CREATED_DATE > to_date('2012-10-21') AND CREATED_DATE < 
to_date('2016-10-31')) "
         + "ORDER BY PARENT_TYPE,CREATED_DATE LIMIT 501";
 
-      rs = viewConn.createStatement().executeQuery(query);
-      assertEquals(String.format(expectedPlanFormat, "2012-10-21", 
"2016-01-01"),
-        stripScanAnnotations(QueryUtil.getExplainPlan(rs)));
+      assertPlan(viewConn, query2).iteratorType("SERIAL").scanType("RANGE 
SCAN")
+        .indexName(expectedIndexName)
+        .keyRanges(String.format(expectedKeyRangesFormat, "2012-10-21", 
"2016-01-01"))
+        
.serverFirstKeyOnlyProjection(true).serverRowLimit(501L).clientRowLimit(501)
+        .clientSteps("CLIENT 501 ROW LIMIT");
     }
   }
 
-  /**
-   * Removes the per-scan {@code INDEX} and {@code REGIONS PLANNED} annotation 
lines so this plan
-   * assertion stays stable; those attributes are covered by the 
attribute-based tests and
-   * {@code REGIONS PLANNED} carries an environment-dependent region count.
-   */
-  private static String stripScanAnnotations(String plan) {
-    return plan.replaceAll("(?m)^ *INDEX [^\n]*\n", "")
-      .replaceAll("(?m)^ *REGIONS PLANNED [^\n]*\n", "");
-  }
-
   @Test
   public void testCurrentTimeWithViewIndexes() throws Exception {
 
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java
index e468e6a7ba..7144653792 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java
@@ -17,10 +17,10 @@
  */
 package org.apache.phoenix.end2end;
 
+import static org.apache.phoenix.query.explain.ExplainPlanTestUtil.assertPlan;
 import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
@@ -30,15 +30,10 @@ import java.sql.DriverManager;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
-import java.sql.Statement;
 import java.util.Properties;
-import org.apache.phoenix.compile.ExplainPlan;
-import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.exception.SQLExceptionCode;
-import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.util.PropertiesUtil;
-import org.apache.phoenix.util.QueryUtil;
 import org.apache.phoenix.util.TestUtil;
 import org.junit.Test;
 import org.junit.experimental.categories.Category;
@@ -621,84 +616,34 @@ public class UnionAllIT extends ParallelStatsDisabledIT {
         + "  CONSTRAINT pk PRIMARY KEY (a_string))\n";
       createTestTable(getUrl(), ddl);
 
-      ddl = "select a_string, col1 from " + tableName1 + " union all select 
a_string, col1 from "
-        + tableName2 + " order by col1 limit 1";
-      ExplainPlan plan = 
conn.prepareStatement(ddl).unwrap(PhoenixPreparedStatement.class)
-        .optimizeQuery().getExplainPlan();
-      ExplainPlanAttributes explainPlanAttributes = 
plan.getPlanStepsAsAttributes();
-      assertEquals("UNION ALL OVER 2 QUERIES", 
explainPlanAttributes.getAbstractExplainPlan());
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(tableName1, explainPlanAttributes.getTableName());
-      assertEquals("[COL1]", explainPlanAttributes.getServerSortedBy());
-      assertEquals(1L, explainPlanAttributes.getServerRowLimit().longValue());
-      assertEquals(1, explainPlanAttributes.getClientRowLimit().intValue());
-      assertEquals("CLIENT MERGE SORT", 
explainPlanAttributes.getClientSortAlgo());
-      ExplainPlanAttributes rhsPlanAttributes = 
explainPlanAttributes.getRhsJoinQueryExplainPlan();
-      assertEquals("PARALLEL 1-WAY", 
rhsPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", rhsPlanAttributes.getExplainScanType());
-      assertEquals(tableName2, rhsPlanAttributes.getTableName());
-      assertEquals("[COL1]", rhsPlanAttributes.getServerSortedBy());
-      assertEquals(1L, rhsPlanAttributes.getServerRowLimit().longValue());
-      assertEquals(1, rhsPlanAttributes.getClientRowLimit().intValue());
-      assertEquals("CLIENT MERGE SORT", rhsPlanAttributes.getClientSortAlgo());
-
-      String limitPlan = "UNION ALL OVER 2 QUERIES\n" + "    CLIENT SERIAL 
1-WAY FULL SCAN OVER "
-        + tableName1 + "\n" + "        SERVER 2 ROW LIMIT\n" + "    CLIENT 2 
ROW LIMIT\n"
-        + "    CLIENT SERIAL 1-WAY FULL SCAN OVER " + tableName2 + "\n"
-        + "        SERVER 2 ROW LIMIT\n" + "    CLIENT 2 ROW LIMIT\n" + 
"CLIENT 2 ROW LIMIT";
-
-      ddl = "select a_string, col1 from " + tableName1 + " union all select 
a_string, col1 from "
-        + tableName2 + " limit 2";
-      plan = 
conn.prepareStatement(ddl).unwrap(PhoenixPreparedStatement.class).optimizeQuery()
-        .getExplainPlan();
-      explainPlanAttributes = plan.getPlanStepsAsAttributes();
-      assertEquals("UNION ALL OVER 2 QUERIES", 
explainPlanAttributes.getAbstractExplainPlan());
-      assertEquals("SERIAL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(tableName1, explainPlanAttributes.getTableName());
-      assertNull(explainPlanAttributes.getServerSortedBy());
-      assertEquals(2L, explainPlanAttributes.getServerRowLimit().longValue());
-      assertEquals(2, explainPlanAttributes.getClientRowLimit().intValue());
-      rhsPlanAttributes = explainPlanAttributes.getRhsJoinQueryExplainPlan();
-      assertEquals("SERIAL 1-WAY", 
rhsPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", rhsPlanAttributes.getExplainScanType());
-      assertEquals(tableName2, rhsPlanAttributes.getTableName());
-      assertNull(rhsPlanAttributes.getServerSortedBy());
-      assertEquals(2L, rhsPlanAttributes.getServerRowLimit().longValue());
-      assertEquals(2, rhsPlanAttributes.getClientRowLimit().intValue());
-
-      Statement stmt = conn.createStatement();
-      stmt.setMaxRows(2);
-      ResultSet rs = stmt.executeQuery("explain " + ddl);
-      assertEquals(limitPlan, 
stripScanAnnotations(QueryUtil.getExplainPlan(rs)));
-
-      ddl = "select a_string, col1 from " + tableName1 + " union all select 
a_string, col1 from "
-        + tableName2;
-      plan = 
conn.prepareStatement(ddl).unwrap(PhoenixPreparedStatement.class).optimizeQuery()
-        .getExplainPlan();
-      explainPlanAttributes = plan.getPlanStepsAsAttributes();
-      assertEquals("UNION ALL OVER 2 QUERIES", 
explainPlanAttributes.getAbstractExplainPlan());
-      assertEquals("PARALLEL 1-WAY", 
explainPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", explainPlanAttributes.getExplainScanType());
-      assertEquals(tableName1, explainPlanAttributes.getTableName());
-      rhsPlanAttributes = explainPlanAttributes.getRhsJoinQueryExplainPlan();
-      assertEquals("PARALLEL 1-WAY", 
rhsPlanAttributes.getIteratorTypeAndScanSize());
-      assertEquals("FULL SCAN ", rhsPlanAttributes.getExplainScanType());
-      assertEquals(tableName2, rhsPlanAttributes.getTableName());
+      String orderLimit = "select a_string, col1 from " + tableName1
+        + " union all select a_string, col1 from " + tableName2 + " order by 
col1 limit 1";
+      assertPlan(conn, orderLimit).abstractExplainPlan("UNION ALL OVER 2 
QUERIES")
+        .iteratorType("PARALLEL").scanType("FULL 
SCAN").table(tableName1).serverSortedBy("[COL1]")
+        .serverRowLimit(1L).clientRowLimit(1).clientSortAlgo("CLIENT MERGE 
SORT").rhs()
+        .iteratorType("PARALLEL").scanType("FULL 
SCAN").table(tableName2).serverSortedBy("[COL1]")
+        .serverRowLimit(1L).clientRowLimit(1).clientSortAlgo("CLIENT MERGE 
SORT");
+
+      String limitOnly = "select a_string, col1 from " + tableName1
+        + " union all select a_string, col1 from " + tableName2 + " limit 2";
+      // The LHS branch's builder is shared with the union plan's root 
builder, so the LHS
+      // root carries both the inner branch's CLIENT 2 ROW LIMIT and the 
union's outer
+      // CLIENT 2 ROW LIMIT in its clientSteps. The RHS branch has its own 
separate builder.
+      assertPlan(conn, limitOnly).abstractExplainPlan("UNION ALL OVER 2 
QUERIES")
+        .iteratorType("SERIAL").scanType("FULL 
SCAN").table(tableName1).serverSortedBy(null)
+        .serverRowLimit(2L).clientRowLimit(2)
+        .clientSteps("CLIENT 2 ROW LIMIT", "CLIENT 2 ROW 
LIMIT").rhs().iteratorType("SERIAL")
+        .scanType("FULL 
SCAN").table(tableName2).serverSortedBy(null).serverRowLimit(2L)
+        .clientRowLimit(2).clientSteps("CLIENT 2 ROW LIMIT");
+
+      String noLimit = "select a_string, col1 from " + tableName1
+        + " union all select a_string, col1 from " + tableName2;
+      assertPlan(conn, noLimit).abstractExplainPlan("UNION ALL OVER 2 QUERIES")
+        .iteratorType("PARALLEL").scanType("FULL SCAN").table(tableName1).rhs()
+        .iteratorType("PARALLEL").scanType("FULL SCAN").table(tableName2);
     }
   }
 
-  /**
-   * Removes the per-scan {@code INDEX} and {@code REGIONS PLANNED} annotation 
lines so this
-   * structural plan assertion stays stable; those attributes are covered by 
the attribute-based
-   * assertions above and {@code REGIONS PLANNED} carries an 
environment-dependent region count.
-   */
-  private static String stripScanAnnotations(String plan) {
-    return plan.replaceAll("(?m)^ *INDEX [^\n]*\n", "")
-      .replaceAll("(?m)^ *REGIONS PLANNED [^\n]*\n", "");
-  }
-
   @Test
   public void testBug2295() throws Exception {
     String tableName1 = generateUniqueName();
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinGlobalIndexIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinGlobalIndexIT.java
index 108be15272..170b295a7a 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinGlobalIndexIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinGlobalIndexIT.java
@@ -60,25 +60,24 @@ public class SortMergeJoinGlobalIndexIT extends 
SortMergeJoinIT {
     String order = getTableName(conn, JOIN_ORDER_TABLE_FULL_NAME);
     String itemIndex = getSchemaName() + ".idx_item";
     String supplierIndex = getSchemaName() + ".idx_supplier";
-    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(LEFT)").scanType("FULL SCAN")
-      .table(supplierIndex).serverFirstKeyOnlyProjection(true)
-      .serverSortedBy("[\"S.:supplier_id\"]").clientSortAlgo("CLIENT MERGE 
SORT")
-      .sortMergeSkipMerge(false).rhs().abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)")
-      .scanType("FULL 
SCAN").table(itemIndex).serverSortedBy("[\"I.:item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").sortMergeSkipMerge(true)
-      .clientSortedBy("[\"I.0:supplier_id\"]").rhs().scanType("FULL 
SCAN").table(order)
-      .serverWhereFilter("QUANTITY < 5000").serverSortedBy("[\"O.item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end().end();
+    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(LEFT)").sortMergeSkipMerge(false)
+      .lhs().scanType("FULL 
SCAN").table(supplierIndex).serverFirstKeyOnlyProjection(true)
+      .serverSortedBy("[\"S.:supplier_id\"]").clientSortAlgo("CLIENT MERGE 
SORT").end().rhs()
+      .abstractExplainPlan("SORT-MERGE-JOIN (INNER)").sortMergeSkipMerge(true)
+      .clientSortedBy("[\"I.0:supplier_id\"]").lhs().scanType("FULL 
SCAN").table(itemIndex)
+      .serverSortedBy("[\"I.:item_id\"]").clientSortAlgo("CLIENT MERGE 
SORT").end().rhs()
+      .scanType("FULL SCAN").table(order).serverWhereFilter("QUANTITY < 5000")
+      .serverSortedBy("[\"O.item_id\"]").clientSortAlgo("CLIENT MERGE 
SORT").end().end();
   }
 
   @Override
   protected void assertSelfJoinPlan(Connection conn, String query) throws 
Exception {
     String itemIndex = getSchemaName() + ".idx_item";
-    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").scanType("FULL SCAN")
-      
.table(itemIndex).serverFirstKeyOnlyProjection(true).serverSortedBy("[\"I1.:item_id\"]")
-      .clientSortAlgo("CLIENT MERGE 
SORT").sortMergeSkipMerge(false).rhs().scanType("FULL SCAN")
-      
.table(itemIndex).serverFirstKeyOnlyProjection(true).serverSortedBy("[\"I2.:item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end();
+    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(false)
+      .lhs().scanType("FULL 
SCAN").table(itemIndex).serverFirstKeyOnlyProjection(true)
+      .serverSortedBy("[\"I1.:item_id\"]").clientSortAlgo("CLIENT MERGE 
SORT").end().rhs()
+      .scanType("FULL 
SCAN").table(itemIndex).serverFirstKeyOnlyProjection(true)
+      .serverSortedBy("[\"I2.:item_id\"]").clientSortAlgo("CLIENT MERGE 
SORT").end();
   }
 
   @Override
@@ -91,10 +90,10 @@ public class SortMergeJoinGlobalIndexIT extends 
SortMergeJoinIT {
     statement.setMaxRows(4);
     ExplainPlanAttributes attributes =
       statement.optimizeQuery().getExplainPlan().getPlanStepsAsAttributes();
-    assertPlan(attributes).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").scanType("FULL SCAN")
-      
.table(itemIndex).serverFirstKeyOnlyProjection(true).serverSortedBy("[\"I.:item_id\"]")
-      .clientSortAlgo("CLIENT MERGE 
SORT").sortMergeSkipMerge(false).clientRowLimit(4).rhs()
-      .scanType("FULL SCAN").table(order)
+    assertPlan(attributes).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(false)
+      .clientRowLimit(4).lhs().scanType("FULL SCAN").table(itemIndex)
+      .serverFirstKeyOnlyProjection(true).serverSortedBy("[\"I.:item_id\"]")
+      .clientSortAlgo("CLIENT MERGE SORT").end().rhs().scanType("FULL 
SCAN").table(order)
       .serverSortedBy(queryIndex == 0 ? "[\"O.item_id\"]" : "[\"item_id\"]")
       .clientSortAlgo("CLIENT MERGE SORT").end();
   }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinLocalIndexIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinLocalIndexIT.java
index 8b3cd696eb..53abdd2569 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinLocalIndexIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinLocalIndexIT.java
@@ -62,14 +62,14 @@ public class SortMergeJoinLocalIndexIT extends 
SortMergeJoinIT {
     String order = getTableName(conn, JOIN_ORDER_TABLE_FULL_NAME);
     String itemIndex = SchemaUtil.getTableName(getSchemaName(), 
JOIN_ITEM_INDEX);
     String supplierIndex = SchemaUtil.getTableName(getSchemaName(), 
JOIN_SUPPLIER_INDEX);
-    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(LEFT)").scanType("RANGE SCAN")
-      .table(supplierIndex + "(" + supplier + ")").keyRanges(" [1]")
+    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(LEFT)").sortMergeSkipMerge(false)
+      .lhs().scanType("RANGE SCAN").table(supplierIndex + "(" + supplier + 
")").keyRanges(" [1]")
       
.serverFirstKeyOnlyProjection(true).serverSortedBy("[\"S.:supplier_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").sortMergeSkipMerge(false).rhs()
-      .abstractExplainPlan("SORT-MERGE-JOIN (INNER)").scanType("RANGE SCAN")
+      .clientSortAlgo("CLIENT MERGE SORT").end().rhs()
+      .abstractExplainPlan("SORT-MERGE-JOIN (INNER)").sortMergeSkipMerge(true)
+      .clientSortedBy("[\"I.0:supplier_id\"]").lhs().scanType("RANGE SCAN")
       .table(itemIndex + "(" + item + ")").keyRanges(" 
[1]").serverSortedBy("[\"I.:item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").sortMergeSkipMerge(true)
-      .clientSortedBy("[\"I.0:supplier_id\"]").rhs().scanType("FULL 
SCAN").table(order)
+      .clientSortAlgo("CLIENT MERGE SORT").end().rhs().scanType("FULL 
SCAN").table(order)
       .serverWhereFilter("QUANTITY < 5000").serverSortedBy("[\"O.item_id\"]")
       .clientSortAlgo("CLIENT MERGE SORT").end().end();
   }
@@ -78,12 +78,12 @@ public class SortMergeJoinLocalIndexIT extends 
SortMergeJoinIT {
   protected void assertSelfJoinPlan(Connection conn, String query) throws 
Exception {
     String item = getTableName(conn, JOIN_ITEM_TABLE_FULL_NAME);
     String itemIndex = SchemaUtil.getTableName(getSchemaName(), 
JOIN_ITEM_INDEX);
-    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").scanType("RANGE SCAN")
+    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(false)
+      .lhs().scanType("RANGE SCAN").table(itemIndex + "(" + item + 
")").keyRanges(" [1]")
+      .serverFirstKeyOnlyProjection(true).serverSortedBy("[\"I1.:item_id\"]")
+      .clientSortAlgo("CLIENT MERGE SORT").end().rhs().scanType("RANGE SCAN")
       .table(itemIndex + "(" + item + ")").keyRanges(" 
[1]").serverFirstKeyOnlyProjection(true)
-      .serverSortedBy("[\"I1.:item_id\"]").clientSortAlgo("CLIENT MERGE SORT")
-      .sortMergeSkipMerge(false).rhs().scanType("RANGE SCAN").table(itemIndex 
+ "(" + item + ")")
-      .keyRanges(" 
[1]").serverFirstKeyOnlyProjection(true).serverSortedBy("[\"I2.:item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end();
+      .serverSortedBy("[\"I2.:item_id\"]").clientSortAlgo("CLIENT MERGE 
SORT").end();
   }
 
   @Override
@@ -97,10 +97,10 @@ public class SortMergeJoinLocalIndexIT extends 
SortMergeJoinIT {
     statement.setMaxRows(4);
     ExplainPlanAttributes attributes =
       statement.optimizeQuery().getExplainPlan().getPlanStepsAsAttributes();
-    assertPlan(attributes).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").scanType("RANGE SCAN")
-      .table(itemIndex + "(" + item + ")").keyRanges(" 
[1]").serverFirstKeyOnlyProjection(true)
-      .serverSortedBy("[\"I.:item_id\"]").clientSortAlgo("CLIENT MERGE SORT")
-      .sortMergeSkipMerge(false).clientRowLimit(4).rhs().scanType("FULL 
SCAN").table(order)
+    assertPlan(attributes).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(false)
+      .clientRowLimit(4).lhs().scanType("RANGE SCAN").table(itemIndex + "(" + 
item + ")")
+      .keyRanges(" 
[1]").serverFirstKeyOnlyProjection(true).serverSortedBy("[\"I.:item_id\"]")
+      .clientSortAlgo("CLIENT MERGE SORT").end().rhs().scanType("FULL 
SCAN").table(order)
       .serverSortedBy(queryIndex == 0 ? "[\"O.item_id\"]" : "[\"item_id\"]")
       .clientSortAlgo("CLIENT MERGE SORT").end();
   }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinNoIndexIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinNoIndexIT.java
index 3eb29809b5..683030f07d 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinNoIndexIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SortMergeJoinNoIndexIT.java
@@ -60,19 +60,19 @@ public class SortMergeJoinNoIndexIT extends SortMergeJoinIT 
{
     String item = getTableName(conn, JOIN_ITEM_TABLE_FULL_NAME);
     String supplier = getTableName(conn, JOIN_SUPPLIER_TABLE_FULL_NAME);
     String order = getTableName(conn, JOIN_ORDER_TABLE_FULL_NAME);
-    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(LEFT)").scanType("FULL SCAN")
-      .table(supplier).sortMergeSkipMerge(false).rhs()
-      .abstractExplainPlan("SORT-MERGE-JOIN (INNER)").scanType("FULL 
SCAN").table(item)
-      
.sortMergeSkipMerge(true).clientSortedBy("[\"I.supplier_id\"]").rhs().scanType("FULL
 SCAN")
-      .table(order).serverWhereFilter("QUANTITY < 
5000").serverSortedBy("[\"O.item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end().end();
+    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(LEFT)").sortMergeSkipMerge(false)
+      .lhs().scanType("FULL SCAN").table(supplier).end().rhs()
+      .abstractExplainPlan("SORT-MERGE-JOIN (INNER)").sortMergeSkipMerge(true)
+      .clientSortedBy("[\"I.supplier_id\"]").lhs().scanType("FULL 
SCAN").table(item).end().rhs()
+      .scanType("FULL SCAN").table(order).serverWhereFilter("QUANTITY < 5000")
+      .serverSortedBy("[\"O.item_id\"]").clientSortAlgo("CLIENT MERGE 
SORT").end().end();
   }
 
   @Override
   protected void assertSelfJoinPlan(Connection conn, String query) throws 
Exception {
     String item = getTableName(conn, JOIN_ITEM_TABLE_FULL_NAME);
-    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").scanType("FULL SCAN")
-      .table(item).sortMergeSkipMerge(false).rhs().scanType("FULL 
SCAN").table(item)
+    assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(false)
+      .lhs().scanType("FULL SCAN").table(item).end().rhs().scanType("FULL 
SCAN").table(item)
       .serverFirstKeyOnlyProjection(true).end();
   }
 
@@ -86,8 +86,8 @@ public class SortMergeJoinNoIndexIT extends SortMergeJoinIT {
     statement.setMaxRows(4);
     ExplainPlanAttributes attributes =
       statement.optimizeQuery().getExplainPlan().getPlanStepsAsAttributes();
-    assertPlan(attributes).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").scanType("FULL SCAN")
-      
.table(item).sortMergeSkipMerge(false).clientRowLimit(4).rhs().scanType("FULL 
SCAN")
+    assertPlan(attributes).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(false)
+      .clientRowLimit(4).lhs().scanType("FULL 
SCAN").table(item).end().rhs().scanType("FULL SCAN")
       .table(order).serverSortedBy(queryIndex == 0 ? "[\"O.item_id\"]" : 
"[\"item_id\"]")
       .clientSortAlgo("CLIENT MERGE SORT").end();
   }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SubqueryUsingSortMergeJoinIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SubqueryUsingSortMergeJoinIT.java
index 74b95b8029..5f8a68d1bc 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SubqueryUsingSortMergeJoinIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/SubqueryUsingSortMergeJoinIT.java
@@ -184,8 +184,7 @@ public class SubqueryUsingSortMergeJoinIT extends 
BaseJoinIT {
       assertFalse(rs.next());
 
       assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN (LEFT)")
-        .sortMergeSkipMerge(false).scanType("FULL 
SCAN").table(tableName5).iteratorType("PARALLEL")
-        .rhs().subPlan(0).scanType("FULL SCAN").table(tableName4)
+        .sortMergeSkipMerge(false).rhs().subPlan(0).scanType("FULL 
SCAN").table(tableName4)
         .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY 
[\"item_id\"]")
         .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
@@ -241,8 +240,7 @@ public class SubqueryUsingSortMergeJoinIT extends 
BaseJoinIT {
       assertFalse(rs.next());
 
       assertPlan(conn, query).abstractExplainPlan("SORT-MERGE-JOIN (LEFT)")
-        .sortMergeSkipMerge(false).scanType("FULL 
SCAN").table(tableName5).iteratorType("PARALLEL")
-        .rhs().subPlan(0).scanType("FULL SCAN").table(tableName4)
+        .sortMergeSkipMerge(false).rhs().subPlan(0).scanType("FULL 
SCAN").table(tableName4)
         .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY 
[\"item_id\"]")
         .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
index 22fa3e715f..39e18fde96 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
@@ -79,6 +79,11 @@ public final class ExplainJsonNormalizer {
         .replaceAll(DYNAMIC_FILTER_ALIAS_PLACEHOLDER));
     }
 
+    JsonNode lhs = obj.get("lhsJoinQueryExplainPlan");
+    if (lhs != null && lhs.isObject()) {
+      normalize(lhs);
+    }
+
     JsonNode rhs = obj.get("rhsJoinQueryExplainPlan");
     if (rhs != null && rhs.isObject()) {
       normalize(rhs);
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java
index 46facbc4d9..1326750984 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java
@@ -28,6 +28,7 @@ import static org.junit.Assert.fail;
 import com.fasterxml.jackson.annotation.JsonPropertyOrder;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.sql.Connection;
 import java.sql.DriverManager;
@@ -225,7 +226,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "CLIENT MERGE SORT"),
       scanAttrs("FULL SCAN ", "ATABLE", "")
         .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING]")
-        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT")));
   }
 
   @Test
@@ -239,7 +241,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "CLIENT MERGE SORT"),
       scanAttrs("FULL SCAN ", "ATABLE", "").put("serverWhereFilter", 
"A_INTEGER = 1")
         .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[ENTITY_ID, ROUND(A_TIME)]")
-        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT")));
   }
 
   @Test
@@ -250,7 +253,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "CLIENT MERGE SORT", "CLIENT LIMIT 3"),
       scanAttrs("FULL SCAN ", "ATABLE", "").put("serverSortedBy", "[A_STRING 
DESC]")
         .put("serverRowLimit", 3).put("clientRowLimit", 3)
-        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT", "CLIENT LIMIT 
3")));
   }
 
   @Test
@@ -263,7 +267,9 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "CLIENT FILTER BY MAX(A_STRING) = 'a'"),
       scanAttrs("FULL SCAN ", "ATABLE", "")
         .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING, B_STRING]")
-        .put("clientFilterBy", "MAX(A_STRING) = 'a'").put("clientSortAlgo", 
"CLIENT MERGE SORT"));
+        .put("clientFilterBy", "MAX(A_STRING) = 'a'").put("clientSortAlgo", 
"CLIENT MERGE SORT")
+        .set("clientSteps",
+          clientSteps("CLIENT MERGE SORT", "CLIENT FILTER BY MAX(A_STRING) = 
'a'")));
   }
 
   @Test
@@ -279,7 +285,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
       scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']")
         .put("serverWhereFilter",
           "(ENTITY_ID != '00E00000000002' AND X_INTEGER = 2 AND A_INTEGER < 
5)")
-        .put("serverRowLimit", 10).put("clientRowLimit", 10));
+        .put("serverRowLimit", 10).put("clientRowLimit", 10)
+        .set("clientSteps", clientSteps("CLIENT 10 ROW LIMIT")));
   }
 
   @Test
@@ -405,7 +412,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "CLIENT MERGE SORT", "CLIENT 5 ROW LIMIT"),
       scanAttrs("FULL SCAN ", "ATABLE", "")
         .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING]")
-        .put("clientRowLimit", 5).put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientRowLimit", 5).put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT", "CLIENT 5 ROW 
LIMIT")));
   }
 
   @Test
@@ -418,7 +426,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "CLIENT MERGE SORT", "CLIENT LIMIT 10"),
       scanAttrs("RANGE SCAN ", "ATABLE", " 
['000000000000001']").put("serverSortedBy", "[A_STRING]")
         .put("serverRowLimit", 10).put("clientRowLimit", 10)
-        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT", "CLIENT LIMIT 
10")));
   }
 
   @Test
@@ -431,7 +440,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "CLIENT MERGE SORT", "CLIENT LIMIT 10"),
       scanAttrs("RANGE SCAN ", "ATABLE", " ['000000000000001']")
         .put("serverSortedBy", "[A_STRING DESC NULLS 
LAST]").put("serverRowLimit", 10)
-        .put("clientRowLimit", 10).put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientRowLimit", 10).put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT", "CLIENT LIMIT 
10")));
   }
 
   @Test
@@ -448,7 +458,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
       scanAttrs("RANGE SCAN ", "ATABLE", " ['000000000000001']")
         .put("serverAggregate",
           "SERVER AGGREGATE INTO DISTINCT ROWS BY [ORGANIZATION_ID, ENTITY_ID, 
ROUND(A_DATE)]")
-        .put("clientRowLimit", 10).put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientRowLimit", 10).put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT", "CLIENT 10 ROW 
LIMIT")));
   }
 
   @Test
@@ -463,12 +474,19 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
       scanAttrs("FULL SCAN ", "ATABLE", "").put("serverWhereFilter", 
"A_INTEGER = 1")
         .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING, B_STRING]")
         .put("clientFilterBy", "MAX(A_STRING) = 'a'").put("clientSortedBy", 
"[B_STRING]")
-        .put("clientOffset", 0).put("clientSortAlgo", "CLIENT MERGE SORT"));
+        .put("clientOffset", 0).put("clientSortAlgo", "CLIENT MERGE SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT", "CLIENT FILTER BY 
MAX(A_STRING) = 'a'",
+          "CLIENT SORTED BY [B_STRING]")));
   }
 
   @Test
   public void testSortMergeJoin() throws Exception {
+    ObjectNode lhs = scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000001']");
     ObjectNode rhs = scanAttrs("FULL SCAN ", "ATABLE", "");
+    ObjectNode expected = defaultAttrs();
+    expected.put("abstractExplainPlan", "SORT-MERGE-JOIN (INNER)");
+    expected.set("lhsJoinQueryExplainPlan", lhs);
+    expected.set("rhsJoinQueryExplainPlan", rhs);
     verifyQuery("sortMergeJoin",
       "SELECT /*+ USE_SORT_MERGE_JOIN */ a.a_string, b.a_string FROM atable a"
         + " JOIN atable b ON a.organization_id = b.organization_id"
@@ -478,8 +496,7 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "        INDEX ATABLE", "        REGIONS PLANNED <N>", "AND",
         "    CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "        INDEX 
ATABLE",
         "        REGIONS PLANNED <N>"),
-      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']")
-        .put("abstractExplainPlan", "SORT-MERGE-JOIN 
(INNER)").set("rhsJoinQueryExplainPlan", rhs));
+      expected);
   }
 
   @Test
@@ -613,7 +630,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "    REGIONS PLANNED <N>", "    SERVER PROJECTION FILTER BY FIRST KEY 
ONLY",
         "CLIENT RESERVE VALUES FROM 1 SEQUENCE"),
       scanAttrs("FULL SCAN ", "ATABLE", 
"").put("serverFirstKeyOnlyProjection", true)
-        .put("clientSequenceCount", 1));
+        .put("clientSequenceCount", 1)
+        .set("clientSteps", clientSteps("CLIENT RESERVE VALUES FROM 1 
SEQUENCE")));
   }
 
   @Test
@@ -623,7 +641,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "    SALT BUCKETS 4", "    REGIONS PLANNED <N>", "    SERVER FILTER BY 
V = 7",
         "CLIENT MERGE SORT"),
       scanAttrs("FULL SCAN ", "EO_SALTED", "").put("saltBuckets", 4)
-        .put("serverWhereFilter", "V = 7").put("clientSortAlgo", "CLIENT MERGE 
SORT"));
+        .put("serverWhereFilter", "V = 7").put("clientSortAlgo", "CLIENT MERGE 
SORT")
+        .set("clientSteps", clientSteps("CLIENT MERGE SORT")));
   }
 
   @Test
@@ -637,7 +656,7 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
       attrs().put("iteratorTypeAndScanSize", "SERIAL 
<N>-WAY").put("consistency", "STRONG")
         .put("explainScanType", "RANGE SCAN ").put("tableName", "EO_MT_BASE")
         .put("indexName", "EO_MT_VIEW").put("keyRanges", " 
['tenant42']").put("serverRowLimit", 1)
-        .put("clientRowLimit", 1));
+        .put("clientRowLimit", 1).set("clientSteps", clientSteps("CLIENT 1 ROW 
LIMIT")));
   }
 
   @Test
@@ -838,6 +857,27 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     assertTrue(nestedRhs.get("regionLocations").isNull());
   }
 
+  @Test
+  public void testJsonNormalizerRecursesIntoLhsJoinQueryExplainPlan() {
+    ObjectNode root = mapper.createObjectNode();
+    root.put("iteratorTypeAndScanSize", "PARALLEL 5-WAY");
+    root.put("numRegionLocationLookups", 1);
+    ObjectNode lhs = mapper.createObjectNode();
+    lhs.put("iteratorTypeAndScanSize", "PARALLEL 7-WAY");
+    lhs.put("numRegionLocationLookups", 42);
+    lhs.set("regionLocations", mapper.createArrayNode().add(1));
+    root.set("lhsJoinQueryExplainPlan", lhs);
+
+    new ExplainJsonNormalizer().normalize(root);
+
+    assertEquals("PARALLEL <N>-WAY", 
root.get("iteratorTypeAndScanSize").asText());
+    assertEquals(0, root.get("numRegionLocationLookups").asInt());
+    JsonNode nestedLhs = root.get("lhsJoinQueryExplainPlan");
+    assertEquals("PARALLEL <N>-WAY", 
nestedLhs.get("iteratorTypeAndScanSize").asText());
+    assertEquals(0, nestedLhs.get("numRegionLocationLookups").asInt());
+    assertTrue(nestedLhs.get("regionLocations").isNull());
+  }
+
   @Test
   public void testJacksonFieldOrderMatchesPropertyOrderAnnotation() throws 
Exception {
     // The serialized field order must exactly follow the @JsonPropertyOrder 
declaration. Deriving
@@ -1057,6 +1097,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     n.putNull("clientSequenceCount");
     n.putNull("clientCursorName");
     n.putNull("clientSortAlgo");
+    n.putNull("clientSteps");
+    n.putNull("lhsJoinQueryExplainPlan");
     n.putNull("rhsJoinQueryExplainPlan");
     n.putNull("serverMergeColumns");
     n.putNull("regionLocations");
@@ -1096,6 +1138,15 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     return defaultAttrs();
   }
 
+  /** Build a {@code clientSteps} JSON array for embedding into an expected 
attributes object. */
+  private static ArrayNode clientSteps(String... steps) {
+    ArrayNode arr = mapper.createArrayNode();
+    for (String s : steps) {
+      arr.add(s);
+    }
+    return arr;
+  }
+
   private static ExplainPlan samplePlan(String way, String scanType) {
     ExplainPlanAttributes a = new 
ExplainPlanAttributesBuilder().setIteratorTypeAndScanSize(way)
       .setExplainScanType(scanType).setTableName("T")
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java
index 0f79f98d34..8c01511018 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java
@@ -357,6 +357,13 @@ public final class ExplainPlanTestUtil {
       return this;
     }
 
+    /** Navigate to the left hand side plan (e.g. sort-merge join). */
+    public ExplainPlanAssert lhs() {
+      ExplainPlanAttributes lhs = attributes.getLhsJoinQueryExplainPlan();
+      assertNotNull(at("lhsJoinQueryExplainPlan") + " must not be null", lhs);
+      return new ExplainPlanAssert(lhs, this, context + ".lhs");
+    }
+
     /** Navigate to the right-hand side plan (sort-merge join / union all). */
     public ExplainPlanAssert rhs() {
       ExplainPlanAttributes rhs = attributes.getRhsJoinQueryExplainPlan();
@@ -364,6 +371,33 @@ public final class ExplainPlanTestUtil {
       return new ExplainPlanAssert(rhs, this, context + ".rhs");
     }
 
+    /** Assert the number of ordered client-side pipeline steps on this node. 
*/
+    public ExplainPlanAssert clientStepCount(int expected) {
+      List<String> steps = attributes.getClientSteps();
+      int actual = steps == null ? 0 : steps.size();
+      assertEquals(at("clientSteps.size"), expected, actual);
+      return this;
+    }
+
+    /** Assert the i-th ordered client-side pipeline step on this node. */
+    public ExplainPlanAssert clientStep(int i, String expected) {
+      List<String> steps = attributes.getClientSteps();
+      assertNotNull(at("clientSteps") + " must not be null", steps);
+      assertTrue(at("clientSteps") + " has no index " + i + " (size=" + 
steps.size() + ")",
+        i >= 0 && i < steps.size());
+      assertEquals(at("clientSteps[" + i + "]"), expected, steps.get(i));
+      return this;
+    }
+
+    /** Assert the entire ordered client-side pipeline on this node matches 
{@code expected}. */
+    public ExplainPlanAssert clientSteps(String... expected) {
+      List<String> actual = attributes.getClientSteps();
+      List<String> actualOrEmpty =
+        actual == null ? java.util.Collections.<String> emptyList() : actual;
+      assertEquals(at("clientSteps"), java.util.Arrays.asList(expected), 
actualOrEmpty);
+      return this;
+    }
+
     /** Assert the number of hash-join sub-plans (children). */
     public ExplainPlanAssert subPlanCount(int expected) {
       List<ExplainPlanAttributes> subPlans = attributes.getSubPlans();

Reply via email to