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

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


The following commit(s) were added to refs/heads/PHOENIX-7876-feature by this 
push:
     new 31e7f60089 PHOENIX-7896 EXPLAIN top-of-plan disclosures (#2517)
31e7f60089 is described below

commit 31e7f60089259033a84f134b57f0e176475481d3
Author: Andrew Purtell <[email protected]>
AuthorDate: Thu Jun 11 10:38:12 2026 -0700

    PHOENIX-7896 EXPLAIN top-of-plan disclosures (#2517)
    
    Co-authored-by: Claude Opus 4.8[1m] <[email protected]>
---
 .../org/apache/phoenix/compile/DeleteCompiler.java |  27 +-
 .../phoenix/compile/ExplainPlanAttributes.java     | 177 ++++++++---
 .../org/apache/phoenix/compile/HavingCompiler.java |   3 +
 .../org/apache/phoenix/compile/JoinCompiler.java   |  32 +-
 .../apache/phoenix/compile/OrderByCompiler.java    |   1 +
 .../org/apache/phoenix/compile/QueryCompiler.java  |  45 ++-
 .../apache/phoenix/compile/RVCOffsetCompiler.java  |   3 +
 .../apache/phoenix/compile/StatementContext.java   |  87 +++++-
 .../apache/phoenix/compile/SubqueryRewriter.java   |  45 ++-
 .../apache/phoenix/compile/SubselectRewriter.java  |   8 +
 .../org/apache/phoenix/compile/UnionCompiler.java  |   5 +
 .../org/apache/phoenix/compile/UpsertCompiler.java |  26 +-
 .../org/apache/phoenix/execute/BaseQueryPlan.java  |   4 +
 .../phoenix/execute/ClientAggregatePlan.java       |   4 +
 .../org/apache/phoenix/execute/ClientScanPlan.java |   4 +
 .../org/apache/phoenix/execute/HashJoinPlan.java   |   3 +
 .../apache/phoenix/execute/SortMergeJoinPlan.java  |   4 +
 .../java/org/apache/phoenix/execute/UnionPlan.java |   4 +
 .../org/apache/phoenix/iterate/ExplainTable.java   | 100 ++++++-
 .../org/apache/phoenix/jdbc/PhoenixStatement.java  |  40 ++-
 .../apache/phoenix/optimize/QueryOptimizer.java    |  87 ++++--
 .../parse/IndexExpressionParseNodeRewriter.java    |  10 +
 .../org/apache/phoenix/util/ParseNodeUtil.java     |  13 +-
 .../index/ChildViewsUseParentViewIndexIT.java      |   1 -
 .../phoenix/end2end/index/PartialIndexIT.java      | 102 ++++---
 .../TenantSpecificViewIndexCompileTest.java        |   1 +
 .../phoenix/query/explain/ExplainPlanTest.java     | 326 ++++++++++++++++++---
 .../phoenix/query/explain/ExplainPlanTestUtil.java | 110 ++++++-
 28 files changed, 1078 insertions(+), 194 deletions(-)

diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
index 05316748b9..dc78cae33b 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/DeleteCompiler.java
@@ -59,6 +59,7 @@ import 
org.apache.phoenix.hbase.index.covered.update.ColumnReference;
 import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr;
 import org.apache.phoenix.index.IndexMaintainer;
 import org.apache.phoenix.index.PhoenixIndexCodec;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.ResultIterator;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixResultSet;
@@ -595,9 +596,13 @@ public class DeleteCompiler {
       delete.getLimit(), null, delete.getBindCount(), false, false,
       Collections.<SelectStatement> emptyList(), delete.getUdfParseNodes());
     select = StatementNormalizer.normalize(select, resolverToBe);
-
+    // Pre-build a context so the early rewrite pass records top of plan 
breadcrumbs that are
+    // adopted by the DELETE data query plan's compilation context.
+    StatementContext rewriteContext =
+      new StatementContext(statement, FromCompiler.EMPTY_TABLE_RESOLVER,
+        new BindManager(statement.getParameters()), new Scan(), new 
SequenceManager(statement));
     SelectStatement transformedSelect =
-      SubqueryRewriter.transform(select, resolverToBe, connection);
+      SubqueryRewriter.transform(select, resolverToBe, connection, 
rewriteContext);
     boolean hasPreProcessing = transformedSelect != select;
     if (transformedSelect != select) {
       resolverToBe = FromCompiler.getResolverForQuery(transformedSelect, 
connection, false,
@@ -624,7 +629,8 @@ public class DeleteCompiler {
     QueryOptimizer optimizer = new QueryOptimizer(services);
     QueryCompiler compiler =
       new QueryCompiler(statement, select, resolverToBe, Collections.<PColumn> 
emptyList(),
-        parallelIteratorFactoryToBe, new SequenceManager(statement));
+        parallelIteratorFactoryToBe, new SequenceManager(statement))
+          .withRewriteContext(rewriteContext);
     final QueryPlan dataPlan = compiler.compile();
     // TODO: the select clause should know that there's a sub query, but 
doesn't seem to currently
     queryPlans = Lists.newArrayList(!clientSideIndexes.isEmpty()
@@ -793,9 +799,12 @@ public class DeleteCompiler {
 
     @Override
     public ExplainPlan getExplainPlan() throws SQLException {
-      ExplainPlanAttributes attributes =
-        new ExplainPlanAttributesBuilder().setAbstractExplainPlan("DELETE 
SINGLE ROW").build();
-      return new ExplainPlan(Collections.singletonList("DELETE SINGLE ROW"), 
attributes);
+      ExplainPlanAttributesBuilder builder =
+        new ExplainPlanAttributesBuilder().setAbstractExplainPlan("DELETE 
SINGLE ROW");
+      if (getContext().isRoot()) {
+        ExplainTable.populateTopOfPlanAttributes(builder, getContext(), 
getTargetRef());
+      }
+      return new ExplainPlan(Collections.singletonList("DELETE SINGLE ROW"), 
builder.build());
     }
 
     @Override
@@ -978,6 +987,9 @@ public class DeleteCompiler {
       newBuilder.setAbstractExplainPlan("DELETE ROWS SERVER SELECT");
       planSteps.add("DELETE ROWS SERVER SELECT");
       planSteps.addAll(queryPlanSteps);
+      if (getContext().isRoot()) {
+        ExplainTable.populateTopOfPlanAttributes(newBuilder, getContext(), 
getTargetRef());
+      }
       return new ExplainPlan(planSteps, newBuilder.build());
     }
 
@@ -1113,6 +1125,9 @@ public class DeleteCompiler {
       newBuilder.setAbstractExplainPlan("DELETE ROWS CLIENT SELECT");
       planSteps.add("DELETE ROWS CLIENT SELECT");
       planSteps.addAll(queryPlanSteps);
+      if (getContext().isRoot()) {
+        ExplainTable.populateTopOfPlanAttributes(newBuilder, getContext(), 
getTargetRef());
+      }
       return new ExplainPlan(planSteps, newBuilder.build());
     }
 
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 84012ddfa1..db729b014a 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
@@ -35,20 +35,29 @@ import org.apache.phoenix.schema.PColumn;
  * 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", "indexName", "indexKind", "indexRule", "indexRejected", 
"saltBuckets",
-  "regionsPlanned", "scanTimeRangeMin", "scanTimeRangeMax", "splitsChunk", 
"useRoundRobinIterator",
-  "samplingRate", "hexStringRVCOffset", "iteratorTypeAndScanSize", 
"estimatedRows",
-  "estimatedSizeInBytes", "serverWhereFilter", "serverDistinctFilter", 
"serverMergeColumns",
-  "serverArrayElementProjection", "serverFirstKeyOnlyProjection", 
"serverEmptyColumnOnlyProjection",
-  "serverAggregate", "serverGroupByLimit", "serverSortedBy", "serverOffset", 
"serverRowLimit",
-  "clientFilterBy", "clientAggregate", "clientDistinctFilter", 
"clientAfterAggregate",
-  "clientSortAlgo", "clientSortedBy", "clientOffset", "clientRowLimit", 
"clientSequenceCount",
-  "clientCursorName", "clientSteps", "lhsJoinQueryExplainPlan", 
"rhsJoinQueryExplainPlan",
-  "subPlans", "dynamicServerFilter", "afterJoinFilter", "joinScannerLimit", 
"sortMergeSkipMerge",
+@JsonPropertyOrder({ "tenantId", "viewName", "viewBaseName", "cdcScopes", 
"txnProvider", "rewrites",
+  "abstractExplainPlan", "hint", "explainScanType", "consistency", 
"tableName", "keyRanges",
+  "indexName", "indexKind", "indexRule", "indexRejected", "saltBuckets", 
"regionsPlanned",
+  "scanTimeRangeMin", "scanTimeRangeMax", "splitsChunk", 
"useRoundRobinIterator", "samplingRate",
+  "hexStringRVCOffset", "iteratorTypeAndScanSize", "estimatedRows", 
"estimatedSizeInBytes",
+  "serverWhereFilter", "serverDistinctFilter", "serverMergeColumns", 
"serverArrayElementProjection",
+  "serverFirstKeyOnlyProjection", "serverEmptyColumnOnlyProjection", 
"serverAggregate",
+  "serverGroupByLimit", "serverSortedBy", "serverOffset", "serverRowLimit", 
"clientFilterBy",
+  "clientAggregate", "clientDistinctFilter", "clientAfterAggregate", 
"clientSortAlgo",
+  "clientSortedBy", "clientOffset", "clientRowLimit", "clientSequenceCount", 
"clientCursorName",
+  "clientSteps", "lhsJoinQueryExplainPlan", "rhsJoinQueryExplainPlan", 
"subPlans",
+  "dynamicServerFilter", "afterJoinFilter", "joinScannerLimit", 
"sortMergeSkipMerge",
   "regionLocations", "regionLocationsTotalSize", "numRegionLocationLookups" })
 public class ExplainPlanAttributes {
 
+  // Top-of-plan disclosures (populated only on the root plan)
+  private final String tenantId;
+  private final String viewName;
+  private final String viewBaseName;
+  private final String cdcScopes;
+  private final String txnProvider;
+  private final List<String> rewrites;
+
   // Plan identity and scan-level metadata
   private final String abstractExplainPlan;
   private final Hint hint;
@@ -117,6 +126,12 @@ public class ExplainPlanAttributes {
   private static final ExplainPlanAttributes EXPLAIN_PLAN_INSTANCE = new 
ExplainPlanAttributes();
 
   private ExplainPlanAttributes() {
+    this.tenantId = null;
+    this.viewName = null;
+    this.viewBaseName = null;
+    this.cdcScopes = null;
+    this.txnProvider = null;
+    this.rewrites = null;
     this.abstractExplainPlan = null;
     this.hint = null;
     this.explainScanType = null;
@@ -172,23 +187,33 @@ public class ExplainPlanAttributes {
     this.numRegionLocationLookups = 0;
   }
 
-  public ExplainPlanAttributes(String abstractExplainPlan, Hint hint, String 
explainScanType,
-    Consistency consistency, String tableName, String keyRanges, String 
indexName, String indexKind,
-    String indexRule, List<RejectedIndexEntry> indexRejected, Integer 
saltBuckets,
-    Integer regionsPlanned, Long scanTimeRangeMin, Long scanTimeRangeMax, 
Integer splitsChunk,
-    boolean useRoundRobinIterator, Double samplingRate, String 
hexStringRVCOffset,
-    String iteratorTypeAndScanSize, Long estimatedRows, Long 
estimatedSizeInBytes,
-    String serverWhereFilter, String serverDistinctFilter, Set<PColumn> 
serverMergeColumns,
-    boolean serverArrayElementProjection, boolean serverFirstKeyOnlyProjection,
-    boolean serverEmptyColumnOnlyProjection, String serverAggregate, Integer 
serverGroupByLimit,
-    String serverSortedBy, Integer serverOffset, Long serverRowLimit, 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,
+  public ExplainPlanAttributes(String tenantId, String viewName, String 
viewBaseName,
+    String cdcScopes, String txnProvider, List<String> rewrites, String 
abstractExplainPlan,
+    Hint hint, String explainScanType, Consistency consistency, String 
tableName, String keyRanges,
+    String indexName, String indexKind, String indexRule, 
List<RejectedIndexEntry> indexRejected,
+    Integer saltBuckets, Integer regionsPlanned, Long scanTimeRangeMin, Long 
scanTimeRangeMax,
+    Integer splitsChunk, boolean useRoundRobinIterator, Double samplingRate,
+    String hexStringRVCOffset, String iteratorTypeAndScanSize, Long 
estimatedRows,
+    Long estimatedSizeInBytes, String serverWhereFilter, String 
serverDistinctFilter,
+    Set<PColumn> serverMergeColumns, boolean serverArrayElementProjection,
+    boolean serverFirstKeyOnlyProjection, boolean 
serverEmptyColumnOnlyProjection,
+    String serverAggregate, Integer serverGroupByLimit, String serverSortedBy, 
Integer serverOffset,
+    Long serverRowLimit, 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,
     Integer regionLocationsTotalSize, int numRegionLocationLookups) {
+    this.tenantId = tenantId;
+    this.viewName = viewName;
+    this.viewBaseName = viewBaseName;
+    this.cdcScopes = cdcScopes;
+    this.txnProvider = txnProvider;
+    this.rewrites = (rewrites == null || rewrites.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(rewrites));
     this.abstractExplainPlan = abstractExplainPlan;
     this.hint = hint;
     this.explainScanType = explainScanType;
@@ -248,6 +273,30 @@ public class ExplainPlanAttributes {
     this.numRegionLocationLookups = numRegionLocationLookups;
   }
 
+  public String getTenantId() {
+    return tenantId;
+  }
+
+  public String getViewName() {
+    return viewName;
+  }
+
+  public String getViewBaseName() {
+    return viewBaseName;
+  }
+
+  public String getCdcScopes() {
+    return cdcScopes;
+  }
+
+  public String getTxnProvider() {
+    return txnProvider;
+  }
+
+  public List<String> getRewrites() {
+    return rewrites;
+  }
+
   public String getAbstractExplainPlan() {
     return abstractExplainPlan;
   }
@@ -467,6 +516,12 @@ public class ExplainPlanAttributes {
   }
 
   public static class ExplainPlanAttributesBuilder {
+    private String tenantId;
+    private String viewName;
+    private String viewBaseName;
+    private String cdcScopes;
+    private String txnProvider;
+    private List<String> rewrites;
     private String abstractExplainPlan;
     private HintNode.Hint hint;
     private String explainScanType;
@@ -526,6 +581,13 @@ public class ExplainPlanAttributes {
     }
 
     public ExplainPlanAttributesBuilder(ExplainPlanAttributes 
explainPlanAttributes) {
+      this.tenantId = explainPlanAttributes.getTenantId();
+      this.viewName = explainPlanAttributes.getViewName();
+      this.viewBaseName = explainPlanAttributes.getViewBaseName();
+      this.cdcScopes = explainPlanAttributes.getCdcScopes();
+      this.txnProvider = explainPlanAttributes.getTxnProvider();
+      List<String> srcRewrites = explainPlanAttributes.getRewrites();
+      this.rewrites = srcRewrites == null ? null : new 
ArrayList<>(srcRewrites);
       this.abstractExplainPlan = 
explainPlanAttributes.getAbstractExplainPlan();
       this.hint = explainPlanAttributes.getHint();
       this.explainScanType = explainPlanAttributes.getExplainScanType();
@@ -584,6 +646,44 @@ public class ExplainPlanAttributes {
       this.numRegionLocationLookups = 
explainPlanAttributes.getNumRegionLocationLookups();
     }
 
+    public ExplainPlanAttributesBuilder setTenantId(String tenantId) {
+      this.tenantId = tenantId;
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setViewName(String viewName) {
+      this.viewName = viewName;
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setViewBaseName(String viewBaseName) {
+      this.viewBaseName = viewBaseName;
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setCdcScopes(String cdcScopes) {
+      this.cdcScopes = cdcScopes;
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setTxnProvider(String txnProvider) {
+      this.txnProvider = txnProvider;
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setRewrites(List<String> rewrites) {
+      this.rewrites = rewrites == null ? null : new ArrayList<>(rewrites);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder addRewrite(String rewrite) {
+      if (this.rewrites == null) {
+        this.rewrites = new ArrayList<>();
+      }
+      this.rewrites.add(rewrite);
+      return this;
+    }
+
     public ExplainPlanAttributesBuilder setAbstractExplainPlan(String 
abstractExplainPlan) {
       this.abstractExplainPlan = abstractExplainPlan;
       return this;
@@ -864,17 +964,18 @@ public class ExplainPlanAttributes {
     }
 
     public ExplainPlanAttributes build() {
-      return new ExplainPlanAttributes(abstractExplainPlan, hint, 
explainScanType, consistency,
-        tableName, keyRanges, indexName, indexKind, indexRule, indexRejected, 
saltBuckets,
-        regionsPlanned, scanTimeRangeMin, scanTimeRangeMax, splitsChunk, 
useRoundRobinIterator,
-        samplingRate, hexStringRVCOffset, iteratorTypeAndScanSize, 
estimatedRows,
-        estimatedSizeInBytes, serverWhereFilter, serverDistinctFilter, 
serverMergeColumns,
-        serverArrayElementProjection, serverFirstKeyOnlyProjection, 
serverEmptyColumnOnlyProjection,
-        serverAggregate, serverGroupByLimit, serverSortedBy, serverOffset, 
serverRowLimit,
-        clientFilterBy, clientAggregate, clientDistinctFilter, 
clientAfterAggregate, clientSortAlgo,
-        clientSortedBy, clientOffset, clientRowLimit, clientSequenceCount, 
clientCursorName,
-        clientSteps, lhsJoinQueryExplainPlan, rhsJoinQueryExplainPlan, 
subPlans,
-        dynamicServerFilter, afterJoinFilter, joinScannerLimit, 
sortMergeSkipMerge, regionLocations,
+      return new ExplainPlanAttributes(tenantId, viewName, viewBaseName, 
cdcScopes, txnProvider,
+        rewrites, abstractExplainPlan, hint, explainScanType, consistency, 
tableName, keyRanges,
+        indexName, indexKind, indexRule, indexRejected, saltBuckets, 
regionsPlanned,
+        scanTimeRangeMin, scanTimeRangeMax, splitsChunk, 
useRoundRobinIterator, samplingRate,
+        hexStringRVCOffset, iteratorTypeAndScanSize, estimatedRows, 
estimatedSizeInBytes,
+        serverWhereFilter, serverDistinctFilter, serverMergeColumns, 
serverArrayElementProjection,
+        serverFirstKeyOnlyProjection, serverEmptyColumnOnlyProjection, 
serverAggregate,
+        serverGroupByLimit, serverSortedBy, serverOffset, serverRowLimit, 
clientFilterBy,
+        clientAggregate, clientDistinctFilter, clientAfterAggregate, 
clientSortAlgo, clientSortedBy,
+        clientOffset, clientRowLimit, clientSequenceCount, clientCursorName, 
clientSteps,
+        lhsJoinQueryExplainPlan, rhsJoinQueryExplainPlan, subPlans, 
dynamicServerFilter,
+        afterJoinFilter, joinScannerLimit, sortMergeSkipMerge, regionLocations,
         regionLocationsTotalSize, numRegionLocationLookups);
     }
   }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/HavingCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/HavingCompiler.java
index f57a126f74..0d989a8957 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/HavingCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/HavingCompiler.java
@@ -75,6 +75,9 @@ public class HavingCompiler {
     }
     HavingClauseVisitor visitor = new HavingClauseVisitor(context, groupBy);
     having.accept(visitor);
+    if (!visitor.getMoveToWhereClauseExpressions().isEmpty()) {
+      context.addAppliedRewrite("HAVING PREDICATE AS WHERE");
+    }
     statement = SelectStatementRewriter.moveFromHavingToWhereClause(statement,
       visitor.getMoveToWhereClauseExpressions());
     return statement;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/JoinCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/JoinCompiler.java
index 8a091fbdc0..c408d37259 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/JoinCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/JoinCompiler.java
@@ -128,9 +128,10 @@ public class JoinCompiler {
   private final Map<ColumnRef, ColumnRefType> columnRefs;
   private final Map<ColumnRef, ColumnParseNode> columnNodes;
   private final boolean useSortMergeJoin;
+  private final StatementContext context;
 
-  private JoinCompiler(PhoenixStatement statement, SelectStatement select,
-    ColumnResolver resolver) {
+  private JoinCompiler(PhoenixStatement statement, SelectStatement select, 
ColumnResolver resolver,
+    StatementContext context) {
     this.phoenixStatement = statement;
     this.originalJoinSelectStatement = select;
     this.origResolver = resolver;
@@ -138,16 +139,25 @@ public class JoinCompiler {
     this.columnRefs = new HashMap<ColumnRef, ColumnRefType>();
     this.columnNodes = new HashMap<ColumnRef, ColumnParseNode>();
     this.useSortMergeJoin = select.getHint().hasHint(Hint.USE_SORT_MERGE_JOIN);
+    this.context = context;
+  }
+
+  public static JoinTable compile(PhoenixStatement statement, SelectStatement 
select,
+    ColumnResolver resolver) throws SQLException {
+    return compile(statement, select, resolver, null);
   }
 
   /**
    * After this method is called, the inner state of the parameter resolver 
may be changed by
    * {@link FromCompiler#refreshDerivedTableNode} because of some sql 
optimization, see also
    * {@link Table#pruneSubselectAliasedNodes()}.
+   * <p>
+   * The supplied {@link StatementContext} receives top of plan rewrite 
breadcrumbs recorded during
+   * join compilation. May be null.
    */
   public static JoinTable compile(PhoenixStatement statement, SelectStatement 
select,
-    ColumnResolver resolver) throws SQLException {
-    JoinCompiler compiler = new JoinCompiler(statement, select, resolver);
+    ColumnResolver resolver, StatementContext context) throws SQLException {
+    JoinCompiler compiler = new JoinCompiler(statement, select, resolver, 
context);
     JoinTableConstructor constructor = compiler.new JoinTableConstructor();
     Pair<Table, List<JoinSpec>> res = select.getFrom().accept(constructor);
     JoinTable joinTable = res.getSecond() == null
@@ -253,7 +263,7 @@ public class JoinCompiler {
     private JoinTable(Table table) {
       this.leftTable = table;
       this.joinSpecs = Collections.<JoinSpec> emptyList();
-      this.postFilters = Collections.EMPTY_LIST;
+      this.postFilters = Collections.emptyList();
       this.allTables = Collections.<Table> singletonList(table);
       this.allTableRefs = Collections.<TableRef> 
singletonList(table.getTableRef());
       this.allLeftJoin = false;
@@ -479,6 +489,10 @@ public class JoinCompiler {
         }
       }
 
+      if (context != null && vector.length >= 2) {
+        context.addAppliedRewrite("STAR JOIN ON " + vector.length + " RIGHT 
LEGS");
+      }
+
       return vector;
     }
 
@@ -633,6 +647,7 @@ public class JoinCompiler {
       return dependentTableRefs;
     }
 
+    @SuppressWarnings("rawtypes")
     public Pair<List<Expression>, List<Expression>> 
compileJoinConditions(StatementContext lhsCtx,
       StatementContext rhsCtx, Strategy strategy) throws SQLException {
       if (onConditions.isEmpty()) {
@@ -704,6 +719,7 @@ public class JoinCompiler {
       return new Pair<List<Expression>, List<Expression>>(lConditions, 
rConditions);
     }
 
+    @SuppressWarnings("rawtypes")
     private PDataType getCommonType(PDataType lType, PDataType rType) throws 
SQLException {
       if (lType == rType) return lType;
 
@@ -823,7 +839,7 @@ public class JoinCompiler {
       this.dynamicColumns = Collections.<ColumnDef> emptyList();
       this.tableSamplingRate = ConcreteTableNode.DEFAULT_TABLE_SAMPLING_RATE;
       this.subselectStatement =
-        SubselectRewriter.flatten(tableNode.getSelect(), 
phoenixStatement.getConnection());
+        SubselectRewriter.flatten(tableNode.getSelect(), 
phoenixStatement.getConnection(), context);
       this.tableRef = tableRef;
       this.preFilterParseNodes = new ArrayList<ParseNode>();
       this.postFilterParseNodes = new ArrayList<ParseNode>();
@@ -1056,8 +1072,10 @@ public class JoinCompiler {
       }
 
       SelectStatement selectStatementToUse = this.getAsSubquery(null);
+      // The rewrite result is only used to test a predicate, so any rewrite 
breadcrumbs are
+      // recorded on a throwaway context and discarded.
       RewriteResult rewriteResult =
-        ParseNodeUtil.rewrite(selectStatementToUse, 
phoenixStatement.getConnection());
+        ParseNodeUtil.rewrite(selectStatementToUse, new 
StatementContext(phoenixStatement));
       return JoinCompiler
         
.isCouldPushToServerAsHashJoinProbeSide(rewriteResult.getRewrittenSelectStatement());
     }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/OrderByCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/OrderByCompiler.java
index 5630d22ec6..101eb03510 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/OrderByCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/OrderByCompiler.java
@@ -224,6 +224,7 @@ public class OrderByCompiler {
           if (offset.getByteOffset().isPresent()) {
             throw new SQLException("Do not allow non-pk ORDER BY with RVC 
OFFSET");
           }
+          context.addAppliedRewrite("REVERSE SCAN SUBSTITUTION");
           return OrderBy.REV_ROW_KEY_ORDER_BY;
         }
       } else {
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryCompiler.java
index 86f8b500c0..4bdbd9dbab 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryCompiler.java
@@ -121,6 +121,7 @@ public class QueryCompiler {
   private final Map<TableRef, QueryPlan> dataPlans;
   private final boolean costBased;
   private final StatementContext parentContext;
+  private StatementContext prebuiltContext;
 
   public QueryCompiler(PhoenixStatement statement, SelectStatement select, 
ColumnResolver resolver,
     boolean projectTuples, boolean optimizeSubquery, Map<TableRef, QueryPlan> 
dataPlans)
@@ -268,6 +269,9 @@ public class QueryCompiler {
     ColumnResolver resolver = FromCompiler.getResolver(tableRef);
     StatementContext context =
       new StatementContext(statement, resolver, bindManager, scan, 
sequenceManager);
+    if (prebuiltContext != null) {
+      context.adoptRewriteState(prebuiltContext);
+    }
     plans = UnionCompiler.convertToTupleProjectionPlan(plans, tableRef, 
context);
     QueryPlan plan = compileSingleFlatQuery(context, select, false, false, 
null, false, true);
     plan = new UnionPlan(context, select, tableRef, plan.getProjector(), 
plan.getLimit(),
@@ -290,7 +294,7 @@ public class QueryCompiler {
   public QueryPlan compileSelect(SelectStatement select) throws SQLException {
     StatementContext context = createStatementContext();
     if (parentContext != null) {
-      parentContext.addSubStatementContext(context);
+      parentContext.addSubStatementContext(context, false);
     }
     QueryPlan dataPlanForCDC = getExistingDataPlanForCDC();
     if (dataPlanForCDC != null) {
@@ -313,15 +317,30 @@ public class QueryCompiler {
       context.setCDCIncludeScopes(cdcIncludeScopes);
     }
     if (select.isJoin()) {
-      JoinTable joinTable = JoinCompiler.compile(statement, select, 
context.getResolver());
+      JoinTable joinTable = JoinCompiler.compile(statement, select, 
context.getResolver(), context);
       return compileJoinQuery(context, joinTable, false, false, null);
     } else {
       return compileSingleQuery(context, select, false, true);
     }
   }
 
+  /**
+   * Supplies the pre-built top level {@link StatementContext} used by
+   * {@link org.apache.phoenix.util.ParseNodeUtil#rewrite} so the breadcrumbs 
recorded by the early
+   * rewrite pass are carried onto the compilation context.
+   */
+  public QueryCompiler withRewriteContext(StatementContext prebuiltContext) {
+    this.prebuiltContext = prebuiltContext;
+    return this;
+  }
+
   private StatementContext createStatementContext() {
-    return new StatementContext(statement, resolver, bindManager, scan, 
sequenceManager);
+    StatementContext context =
+      new StatementContext(statement, resolver, bindManager, scan, 
sequenceManager);
+    if (prebuiltContext != null) {
+      context.adoptRewriteState(prebuiltContext);
+    }
+    return context;
   }
 
   /**
@@ -384,6 +403,7 @@ public class QueryCompiler {
     return bestPlan;
   }
 
+  @SuppressWarnings("unchecked")
   protected QueryPlan compileJoinQuery(JoinCompiler.Strategy strategy, 
StatementContext context,
     JoinTable joinTable, boolean asSubquery, boolean projectPKColumns, 
List<OrderByNode> orderBy)
     throws SQLException {
@@ -491,6 +511,9 @@ public class QueryCompiler {
       case HASH_BUILD_LEFT: {
         JoinSpec lastJoinSpec = joinSpecs.get(joinSpecs.size() - 1);
         JoinType type = lastJoinSpec.getType();
+        if (type == JoinType.Right) {
+          context.addAppliedRewrite("RIGHT JOIN AS LEFT JOIN");
+        }
         JoinTable rhsJoinTable = lastJoinSpec.getRhsJoinTable();
         Table rhsTable = rhsJoinTable.getLeftTable();
         JoinTable lhsJoin = 
joinTable.createSubJoinTable(statement.getConnection());
@@ -571,6 +594,7 @@ public class QueryCompiler {
         JoinType type = lastJoinSpec.getType();
         JoinTable rhsJoin = lastJoinSpec.getRhsJoinTable();
         if (type == JoinType.Right) {
+          context.addAppliedRewrite("RIGHT JOIN AS LEFT JOIN");
           JoinTable temp = lhsJoin;
           lhsJoin = rhsJoin;
           rhsJoin = temp;
@@ -696,14 +720,19 @@ public class QueryCompiler {
 
   protected QueryPlan compileSubquery(SelectStatement subquerySelectStatement,
     boolean pushDownMaxRows, StatementContext parentContext) throws 
SQLException {
-    PhoenixConnection phoenixConnection = this.statement.getConnection();
-    RewriteResult rewriteResult = 
ParseNodeUtil.rewrite(subquerySelectStatement, phoenixConnection);
+    // Pre-build a context so the subquery's early rewrite pass records 
breadcrumbs that are then
+    // adopted by the subquery's compilation context.
+    StatementContext rewriteContext =
+      new StatementContext(this.statement, FromCompiler.EMPTY_TABLE_RESOLVER, 
bindManager,
+        new Scan(), new SequenceManager(this.statement));
+    RewriteResult rewriteResult = 
ParseNodeUtil.rewrite(subquerySelectStatement, rewriteContext);
     int maxRows = this.statement.getMaxRows();
-    this.statement.setMaxRows(pushDownMaxRows ? maxRows : 0); // overwrite 
maxRows to avoid its
-                                                              // impact on 
inner queries.
+    // overwrite maxRows to avoid its impact on inner queries.
+    this.statement.setMaxRows(pushDownMaxRows ? maxRows : 0);
     QueryPlan queryPlan =
       new QueryCompiler(this.statement, 
rewriteResult.getRewrittenSelectStatement(),
-        rewriteResult.getColumnResolver(), bindManager, false, 
optimizeSubquery, null).compile();
+        rewriteResult.getColumnResolver(), bindManager, false, 
optimizeSubquery, null)
+          .withRewriteContext(rewriteContext).compile();
     if (optimizeSubquery) {
       queryPlan =
         
statement.getConnection().getQueryServices().getOptimizer().optimize(statement, 
queryPlan);
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/RVCOffsetCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/RVCOffsetCompiler.java
index 739f7f019a..a7c124f256 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/RVCOffsetCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/RVCOffsetCompiler.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.hbase.util.Bytes;
 import org.apache.phoenix.expression.AndExpression;
 import org.apache.phoenix.expression.CoerceExpression;
 import org.apache.phoenix.expression.ComparisonExpression;
@@ -289,6 +290,8 @@ public class RVCOffsetCompiler {
     CompiledOffset compiledOffset =
       new CompiledOffset(Optional.<Integer> absent(), Optional.of(key));
 
+    context.addAppliedRewrite("RVC OFFSET 0x" + Bytes.toHex(key));
+
     return compiledOffset;
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
index 1053dac1ea..e329b4ee86 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
@@ -39,6 +39,7 @@ import org.apache.phoenix.monitoring.ReadMetricQueue;
 import org.apache.phoenix.monitoring.ScanMetricsHolder;
 import org.apache.phoenix.monitoring.SlowestScanMetricsQueue;
 import org.apache.phoenix.monitoring.TopNTreeMultiMap;
+import org.apache.phoenix.parse.ParseNode;
 import org.apache.phoenix.parse.SelectStatement;
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.query.QueryServices;
@@ -101,6 +102,11 @@ public class StatementContext {
   private boolean hasRawRowSizeFunction = false;
   private final SlowestScanMetricsQueue slowestScanMetricsQueue;
   private final int slowestScanMetricsCount;
+  private List<String> appliedRewrites;
+  private int derivedTableFlattenCount;
+  private List<Pair<ParseNode, String>> indexExpressionSubstitutions;
+  private Set<Pair<String, String>> partialIndexCheckedSet;
+  private StatementContext parentContext;
 
   public StatementContext(PhoenixStatement statement) {
     this(statement, new Scan());
@@ -132,11 +138,16 @@ public class StatementContext {
     this.isClientSideUpsertSelect = context.isClientSideUpsertSelect;
     this.isUncoveredIndex = context.isUncoveredIndex;
     this.hasFirstValidResult = new 
AtomicBoolean(context.getHasFirstValidResult());
-    this.subStatementContexts = Sets.newHashSet();
+    this.subStatementContexts = Sets.newLinkedHashSet();
     this.totalSegmentsFunction = context.totalSegmentsFunction;
     this.totalSegmentsValue = context.totalSegmentsValue;
     this.hasRowSizeFunction = context.hasRowSizeFunction;
     this.hasRawRowSizeFunction = context.hasRawRowSizeFunction;
+    this.appliedRewrites = context.appliedRewrites;
+    this.derivedTableFlattenCount = context.derivedTableFlattenCount;
+    this.indexExpressionSubstitutions = context.indexExpressionSubstitutions;
+    this.partialIndexCheckedSet = context.partialIndexCheckedSet;
+    this.parentContext = context.parentContext;
   }
 
   /**
@@ -198,7 +209,12 @@ public class StatementContext {
       : SlowestScanMetricsQueue.NOOP_SLOWEST_SCAN_METRICS_QUEUE;
     this.retryingPersistentCache = Maps.<Long, Boolean> newHashMap();
     this.hasFirstValidResult = new AtomicBoolean(false);
-    this.subStatementContexts = Sets.newHashSet();
+    this.subStatementContexts = Sets.newLinkedHashSet();
+    this.appliedRewrites = new ArrayList<>();
+    this.derivedTableFlattenCount = 0;
+    this.indexExpressionSubstitutions = new ArrayList<>();
+    this.partialIndexCheckedSet = Sets.newHashSet();
+    this.parentContext = null;
   }
 
   /**
@@ -453,13 +469,80 @@ public class StatementContext {
   }
 
   public void addSubStatementContext(StatementContext sub) {
+    addSubStatementContext(sub, true);
+  }
+
+  public void addSubStatementContext(StatementContext sub, boolean 
linkParentForDisclosure) {
     subStatementContexts.add(sub);
+    if (linkParentForDisclosure) {
+      sub.parentContext = this;
+    }
   }
 
   public Set<StatementContext> getSubStatementContexts() {
     return subStatementContexts;
   }
 
+  /**
+   * Records a top-of-plan rewrite breadcrumb (e.g. "STAR JOIN ON 2 RIGHT 
LEGS"). Breadcrumbs are
+   * diagnostic only and never affect the compiled plan.
+   */
+  public void addAppliedRewrite(String rewrite) {
+    appliedRewrites.add(rewrite);
+  }
+
+  public List<String> getAppliedRewrites() {
+    return appliedRewrites;
+  }
+
+  /**
+   * Adopt the rewrite breadcrumb accumulator (and related diagnostic state) 
of another context by
+   * sharing its references. Used to carry breadcrumbs recorded by the early
+   * {@link SubselectRewriter}/{@link SubqueryRewriter} pass on a pre-built 
context across the
+   * rewrite boundary onto the actual compilation context.
+   */
+  public void adoptRewriteState(StatementContext source) {
+    this.appliedRewrites = source.appliedRewrites;
+    this.derivedTableFlattenCount = source.derivedTableFlattenCount;
+    this.indexExpressionSubstitutions = source.indexExpressionSubstitutions;
+    this.partialIndexCheckedSet = source.partialIndexCheckedSet;
+  }
+
+  public void incrementDerivedTableFlattenCount() {
+    derivedTableFlattenCount++;
+  }
+
+  public int getDerivedTableFlattenCount() {
+    return derivedTableFlattenCount;
+  }
+
+  /** Structured pairs recorded when a functional index expression is 
substituted. */
+  public List<Pair<ParseNode, String>> getIndexExpressionSubstitutions() {
+    return indexExpressionSubstitutions;
+  }
+
+  public void addIndexExpressionSubstitution(ParseNode source, String 
indexColumnName) {
+    indexExpressionSubstitutions.add(new Pair<>(source, indexColumnName));
+  }
+
+  /**
+   * Dedup set used by the optimizer so a partial index applicability 
breadcrumb is recorded only
+   * once per (table, index) pair even when the same index is scored across 
multiple paths.
+   * @return true if the pair had not been recorded before
+   */
+  public boolean markPartialIndexChecked(String tableName, String indexName) {
+    return partialIndexCheckedSet.add(new Pair<>(tableName, indexName));
+  }
+
+  public StatementContext getParentContext() {
+    return parentContext;
+  }
+
+  /** Returns true if this is the top-level (root) statement context, i.e. it 
has no parent. */
+  public boolean isRoot() {
+    return parentContext == null;
+  }
+
   public boolean hasTotalSegmentsFunction() {
     return totalSegmentsFunction;
   }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubqueryRewriter.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubqueryRewriter.java
index 3f7d3d22f8..19f2ddcc76 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubqueryRewriter.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubqueryRewriter.java
@@ -69,15 +69,21 @@ public class SubqueryRewriter extends ParseNodeRewriter {
 
   private final ColumnResolver columnResolver;
   private final PhoenixConnection connection;
+  private final StatementContext context;
   private TableNode tableNode;
   private ParseNode topNode;
 
   public static SelectStatement transform(SelectStatement select, 
ColumnResolver resolver,
     PhoenixConnection connection) throws SQLException {
+    return transform(select, resolver, connection, null);
+  }
+
+  public static SelectStatement transform(SelectStatement select, 
ColumnResolver resolver,
+    PhoenixConnection connection, StatementContext context) throws 
SQLException {
     ParseNode where = select.getWhere();
     if (where == null) return select;
 
-    SubqueryRewriter rewriter = new SubqueryRewriter(select, resolver, 
connection);
+    SubqueryRewriter rewriter = new SubqueryRewriter(select, resolver, 
connection, context);
     ParseNode normWhere = rewrite(where, rewriter);
     if (normWhere == where) return select;
 
@@ -86,12 +92,46 @@ public class SubqueryRewriter extends ParseNodeRewriter {
 
   protected SubqueryRewriter(SelectStatement select, ColumnResolver resolver,
     PhoenixConnection connection) {
+    this(select, resolver, connection, null);
+  }
+
+  protected SubqueryRewriter(SelectStatement select, ColumnResolver resolver,
+    PhoenixConnection connection, StatementContext context) {
     this.columnResolver = resolver;
     this.connection = connection;
+    this.context = context;
     this.tableNode = select.getFrom();
     this.topNode = null;
   }
 
+  /**
+   * Records a subquery to join rewrite breadcrumb on the active context. The 
wording is derived
+   * from the originating subquery kind and the chosen {@link JoinType}.
+   */
+  private void recordSubqueryRewrite(String subqueryKind, JoinType joinType) {
+    if (context == null) {
+      return;
+    }
+    final String joinName;
+    switch (joinType) {
+      case Semi:
+        joinName = "SEMI";
+        break;
+      case Anti:
+        joinName = "ANTI";
+        break;
+      case Inner:
+        joinName = "INNER";
+        break;
+      case Left:
+        joinName = "LEFT";
+        break;
+      default:
+        joinName = joinType.name().toUpperCase();
+    }
+    context.addAppliedRewrite(subqueryKind + " SUBQUERY AS " + joinName + " 
JOIN");
+  }
+
   @Override
   protected void enterParseNode(ParseNode node) {
     if (topNode == null) {
@@ -263,6 +303,7 @@ public class SubqueryRewriter extends ParseNodeRewriter {
         newSubquerySelectAliasedNodes.get(0).getAlias(), null), 
!inParseNode.isNegate());
     tableNode = NODE_FACTORY.join(joinType, tableNode, 
subqueryDerivedTableNode,
       joinOnConditionParseNode, false);
+    recordSubqueryRewrite(inParseNode.isNegate() ? "NOT IN" : "IN", joinType);
 
     return resultWhereParseNode;
   }
@@ -392,6 +433,7 @@ public class SubqueryRewriter extends ParseNodeRewriter {
         newSubquerySelectAliasedNodes.get(0).getAlias(), null), 
!existsParseNode.isNegate());
     tableNode = NODE_FACTORY.join(joinType, tableNode, 
subqueryDerivedTableNode,
       joinOnConditionParseNode, false);
+    recordSubqueryRewrite(existsParseNode.isNegate() ? "NOT EXISTS" : 
"EXISTS", joinType);
     return resultWhereParseNode;
   }
 
@@ -459,6 +501,7 @@ public class SubqueryRewriter extends ParseNodeRewriter {
     ParseNode ret = NODE_FACTORY.comparison(node.getFilterOp(), l.get(0), 
NODE_FACTORY
       .column(NODE_FACTORY.table(null, rhsTableAlias), 
selectNodes.get(0).getAlias(), null));
     tableNode = NODE_FACTORY.join(joinType, tableNode, rhsTable, onNode, 
!isAggregate || isGroupby);
+    recordSubqueryRewrite("SCALAR", joinType);
 
     return ret;
   }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubselectRewriter.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubselectRewriter.java
index 2282e2fd41..032370d2ad 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubselectRewriter.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/SubselectRewriter.java
@@ -300,6 +300,11 @@ public class SubselectRewriter extends ParseNodeRewriter {
 
   public static SelectStatement flatten(SelectStatement select, 
PhoenixConnection connection)
     throws SQLException {
+    return flatten(select, connection, null);
+  }
+
+  public static SelectStatement flatten(SelectStatement select, 
PhoenixConnection connection,
+    StatementContext context) throws SQLException {
     TableNode from = select.getFrom();
     while (from != null && from instanceof DerivedTableNode) {
       DerivedTableNode derivedTable = (DerivedTableNode) from;
@@ -314,6 +319,9 @@ public class SubselectRewriter extends ParseNodeRewriter {
       if (ret == select) {
         break;
       }
+      if (context != null) {
+        context.incrementDerivedTableFlattenCount();
+      }
       select = ret;
       from = select.getFrom();
     }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
index b40de288b3..f99c9346d6 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
@@ -118,6 +118,7 @@ public class UnionCompiler {
     return new TableRef(null, tempTable, 0, false);
   }
 
+  @SuppressWarnings("rawtypes")
   private static void compareExperssions(int i, Expression expression,
     List<TargetDataExpression> targetTypes) throws SQLException {
     PDataType type = expression.getDataType();
@@ -164,6 +165,7 @@ public class UnionCompiler {
     return new TupleProjector(exprs);
   }
 
+  @SuppressWarnings("rawtypes")
   private static class TargetDataExpression {
     private PDataType type;
     private Integer maxLength;
@@ -247,6 +249,9 @@ public class UnionCompiler {
       // subquery) could not compile out the outer query's group by or order 
by, we would
       // not perform any special processing on the output of the subqueries.
       innerUnionPlan.disableSupportOrderByOptimize();
+    } else {
+      // The order-by merge optimization is preserved. Record a top-of-plan 
breadcrumb.
+      statementContextCreator.get().addAppliedRewrite("UNION ORDER BY MERGE");
     }
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
index b9dfbc33e5..8a26cb8c8c 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UpsertCompiler.java
@@ -62,6 +62,7 @@ import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr;
 import org.apache.phoenix.index.IndexMaintainer;
 import org.apache.phoenix.index.PhoenixIndexBuilderHelper;
 import org.apache.phoenix.index.PhoenixIndexCodec;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.ResultIterator;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixResultSet;
@@ -352,6 +353,7 @@ public class UpsertCompiler {
     this.operation = operation;
   }
 
+  @SuppressWarnings("rawtypes")
   private static LiteralParseNode getNodeForRowTimestampColumn(PColumn col) {
     PDataType type = col.getDataType();
     long dummyValue = 0L;
@@ -579,14 +581,19 @@ public class UpsertCompiler {
     if (valueNodesList.isEmpty()) {
       SelectStatement select = upsert.getSelect();
       assert (select != null);
-      select = SubselectRewriter.flatten(select, connection);
+      // Pre-build a context so the early rewrite pass records top-of-plan 
breadcrumbs that are
+      // adopted by the UPSERT SELECT query plan's compilation context.
+      StatementContext rewriteContext =
+        new StatementContext(statement, FromCompiler.EMPTY_TABLE_RESOLVER,
+          new BindManager(statement.getParameters()), new Scan(), new 
SequenceManager(statement));
+      select = SubselectRewriter.flatten(select, connection, rewriteContext);
       ColumnResolver selectResolver =
         FromCompiler.getResolverForQuery(select, connection, false, 
upsert.getTable().getName());
       select = StatementNormalizer.normalize(select, selectResolver);
       select = prependTenantAndViewConstants(table, select, tenantIdStr, 
addViewColumnsToBe,
         useServerTimestampToBe);
       SelectStatement transformedSelect =
-        SubqueryRewriter.transform(select, selectResolver, connection);
+        SubqueryRewriter.transform(select, selectResolver, connection, 
rewriteContext);
       if (transformedSelect != select) {
         selectResolver = FromCompiler.getResolverForQuery(transformedSelect, 
connection, false,
           upsert.getTable().getName());
@@ -649,7 +656,8 @@ public class UpsertCompiler {
       // correctly
       // Use optimizer to choose the best plan
       QueryCompiler compiler = new QueryCompiler(statement, select, 
selectResolver, targetColumns,
-        parallelIteratorFactoryToBe, new SequenceManager(statement), true, 
false, null);
+        parallelIteratorFactoryToBe, new SequenceManager(statement), true, 
false, null)
+          .withRewriteContext(rewriteContext);
       queryPlanToBe = compiler.compile();
 
       if (sameTable) {
@@ -1246,6 +1254,9 @@ public class UpsertCompiler {
       newBuilder.setAbstractExplainPlan("UPSERT ROWS");
       planSteps.add("UPSERT ROWS");
       planSteps.addAll(queryPlanSteps);
+      if (getContext().isRoot()) {
+        ExplainTable.populateTopOfPlanAttributes(newBuilder, getContext(), 
getTargetRef());
+      }
       return new ExplainPlan(planSteps, newBuilder.build());
     }
 
@@ -1430,6 +1441,12 @@ public class UpsertCompiler {
           .add("CLIENT RESERVE " + 
context.getSequenceManager().getSequenceCount() + " SEQUENCES");
       }
       planSteps.add("PUT SINGLE ROW");
+      if (getContext().isRoot()) {
+        ExplainPlanAttributesBuilder builder =
+          new 
ExplainPlanAttributesBuilder(ExplainPlanAttributes.getDefaultExplainPlan());
+        ExplainTable.populateTopOfPlanAttributes(builder, getContext(), 
getTargetRef());
+        return new ExplainPlan(planSteps, builder.build());
+      }
       return new ExplainPlan(planSteps);
     }
 
@@ -1554,6 +1571,9 @@ public class UpsertCompiler {
       newBuilder.setAbstractExplainPlan("UPSERT SELECT");
       planSteps.add("UPSERT SELECT");
       planSteps.addAll(queryPlanSteps);
+      if (getContext().isRoot()) {
+        ExplainTable.populateTopOfPlanAttributes(newBuilder, getContext(), 
getTargetRef());
+      }
       return new ExplainPlan(planSteps, newBuilder.build());
     }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
index e7ca83c84d..d6839d6fa9 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
@@ -52,6 +52,7 @@ import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr;
 import org.apache.phoenix.index.IndexMaintainer;
 import org.apache.phoenix.iterate.DefaultParallelScanGrouper;
 import org.apache.phoenix.iterate.DelegateResultIterator;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.ParallelIteratorFactory;
 import org.apache.phoenix.iterate.ParallelScanGrouper;
 import org.apache.phoenix.iterate.ResultIterator;
@@ -550,6 +551,9 @@ public abstract class BaseQueryPlan implements QueryPlan {
       builder.setIndexRule(decision.getRule());
       builder.setIndexRejected(decision.getRejectedIndexes());
     }
+    if (context.isRoot()) {
+      ExplainTable.populateTopOfPlanAttributes(builder, context, 
getTableRef());
+    }
     return Pair.of(planSteps, builder.build());
   }
 
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 4a3d1b5d28..c0dc5ffe68 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
@@ -48,6 +48,7 @@ import org.apache.phoenix.iterate.AggregatingResultIterator;
 import org.apache.phoenix.iterate.BaseGroupedAggregatingResultIterator;
 import org.apache.phoenix.iterate.ClientHashAggregatingResultIterator;
 import org.apache.phoenix.iterate.DistinctAggregatingResultIterator;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.FilterAggregatingResultIterator;
 import org.apache.phoenix.iterate.FilterResultIterator;
 import org.apache.phoenix.iterate.GroupedAggregatingResultIterator;
@@ -308,6 +309,9 @@ public class ClientAggregatePlan extends 
ClientProcessingPlan {
       newBuilder.addClientStep(step);
     }
 
+    if (context.isRoot()) {
+      ExplainTable.populateTopOfPlanAttributes(newBuilder, context, 
getTableRef());
+    }
     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 9807ba8ef9..ae155b3a12 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
@@ -31,6 +31,7 @@ import org.apache.phoenix.compile.StatementContext;
 import org.apache.phoenix.execute.visitor.ByteCountVisitor;
 import org.apache.phoenix.execute.visitor.QueryPlanVisitor;
 import org.apache.phoenix.expression.Expression;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.FilterResultIterator;
 import org.apache.phoenix.iterate.LimitingResultIterator;
 import org.apache.phoenix.iterate.OffsetResultIterator;
@@ -165,6 +166,9 @@ public class ClientScanPlan extends ClientProcessingPlan {
       newBuilder.addClientStep(step);
     }
 
+    if (context.isRoot()) {
+      ExplainTable.populateTopOfPlanAttributes(newBuilder, context, 
getTableRef());
+    }
     return new ExplainPlan(planSteps, newBuilder.build());
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/HashJoinPlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/HashJoinPlan.java
index a558c1832a..5a92f4adca 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/HashJoinPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/HashJoinPlan.java
@@ -361,6 +361,9 @@ public class HashJoinPlan extends DelegateQueryPlan {
     if (!subPlanAttributes.isEmpty()) {
       builder.setSubPlans(subPlanAttributes);
     }
+    if (getContext().isRoot()) {
+      ExplainTable.populateTopOfPlanAttributes(builder, getContext(), 
getTableRef());
+    }
     return new ExplainPlan(planSteps, builder.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 bf36c03a58..e3ebae1779 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
@@ -57,6 +57,7 @@ import org.apache.phoenix.execute.visitor.QueryPlanVisitor;
 import org.apache.phoenix.expression.Expression;
 import org.apache.phoenix.expression.OrderByExpression;
 import org.apache.phoenix.iterate.DefaultParallelScanGrouper;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.ParallelScanGrouper;
 import org.apache.phoenix.iterate.PhoenixQueues;
 import org.apache.phoenix.iterate.ResultIterator;
@@ -217,6 +218,9 @@ public class SortMergeJoinPlan implements QueryPlan {
     rootBuilder.setSortMergeSkipMerge(rhsSchema.getFieldCount() == 0);
     rootBuilder.setLhsJoinQueryExplainPlan(lhsPlanAttributes);
     rootBuilder.setRhsJoinQueryExplainPlan(rhsPlanAttributes);
+    if (getContext().isRoot()) {
+      ExplainTable.populateTopOfPlanAttributes(rootBuilder, getContext(), 
getTableRef());
+    }
     return new ExplainPlan(steps, rootBuilder.build());
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java
index 8d15872e7b..a704750319 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java
@@ -40,6 +40,7 @@ import org.apache.phoenix.execute.visitor.QueryPlanVisitor;
 import org.apache.phoenix.expression.OrderByExpression;
 import org.apache.phoenix.iterate.ConcatResultIterator;
 import org.apache.phoenix.iterate.DefaultParallelScanGrouper;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.LimitingResultIterator;
 import org.apache.phoenix.iterate.MergeSortTopNResultIterator;
 import org.apache.phoenix.iterate.OffsetResultIterator;
@@ -242,6 +243,9 @@ public class UnionPlan implements QueryPlan {
     // branch is preserved and explaining the union does not trigger sub-plan 
execution.
     UnionResultIterators.explainBranches(plans, steps, builder);
     addUnionTailLines(steps, builder);
+    if (getContext().isRoot()) {
+      ExplainTable.populateTopOfPlanAttributes(builder, getContext(), 
getTableRef());
+    }
     return new ExplainPlan(steps, builder.build());
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java
index ce20eb4571..ca08b984fd 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java
@@ -23,6 +23,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
 import org.apache.hadoop.hbase.HRegionLocation;
@@ -34,6 +35,7 @@ import org.apache.hadoop.hbase.filter.PageFilter;
 import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
 import org.apache.hadoop.hbase.io.TimeRange;
 import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.phoenix.compile.ExplainPlanAttributes;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
 import org.apache.phoenix.compile.GroupByCompiler.GroupBy;
 import org.apache.phoenix.compile.OrderByCompiler.OrderBy;
@@ -55,10 +57,12 @@ import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.query.QueryServicesOptions;
 import org.apache.phoenix.schema.PColumn;
 import org.apache.phoenix.schema.PTable;
+import org.apache.phoenix.schema.PTableType;
 import org.apache.phoenix.schema.SortOrder;
 import org.apache.phoenix.schema.TableRef;
 import org.apache.phoenix.schema.types.PDataType;
 import org.apache.phoenix.schema.types.PInteger;
+import org.apache.phoenix.util.CDCUtil;
 import org.apache.phoenix.util.MetaDataUtil;
 import org.apache.phoenix.util.ScanUtil;
 import org.apache.phoenix.util.StringUtil;
@@ -173,6 +177,100 @@ public abstract class ExplainTable {
     return indexName;
   }
 
+  /**
+   * Populate the top-of-plan disclosure attributes on the supplied builder. 
Should be invoked only
+   * for a root plan (i.e. when {@code context.isRoot()}).
+   * @param builder  the attributes builder to populate (no-op when null)
+   * @param context  the root statement context
+   * @param tableRef the plan's primary table reference (may be null)
+   */
+  public static void populateTopOfPlanAttributes(ExplainPlanAttributesBuilder 
builder,
+    StatementContext context, TableRef tableRef) {
+    if (builder == null || context == null) {
+      return;
+    }
+    if (context.getConnection() != null && 
context.getConnection().getTenantId() != null) {
+      builder.setTenantId(context.getConnection().getTenantId().getString());
+    }
+    PTable table = tableRef == null ? null : tableRef.getTable();
+    if (table != null) {
+      if (table.getType() == PTableType.VIEW) {
+        builder.setViewName(table.getName().getString());
+        if (table.getBaseTableLogicalName() != null) {
+          builder.setViewBaseName(table.getBaseTableLogicalName().getString());
+        }
+      }
+      if (table.isTransactional() && table.getTransactionProvider() != null) {
+        builder.setTxnProvider(table.getTransactionProvider().name());
+      }
+    }
+    String cdcScopes = context.getEncodedCdcIncludeScopes();
+    if (cdcScopes == null && table != null && table.getType() == 
PTableType.CDC) {
+      Set<PTable.CDCChangeScope> scopes = table.getCDCIncludeScopes();
+      if (scopes != null && !scopes.isEmpty()) {
+        cdcScopes = CDCUtil.makeChangeScopeStringFromEnums(scopes);
+      }
+    }
+    if (cdcScopes != null) {
+      builder.setCdcScopes(cdcScopes);
+    }
+    // Aggregate rewrite breadcrumbs across the context and its sub-contexts, 
deduping while
+    // preserving first occurrence order. The derived table flatten count is 
rendered as a
+    // trailing breadcrumb when non-zero.
+    Set<String> rewrites = new LinkedHashSet<>();
+    int flattenCount = collectAppliedRewrites(context, rewrites);
+    if (flattenCount > 0) {
+      rewrites.add("DERIVED TABLE FLATTENED " + flattenCount);
+    }
+    if (!rewrites.isEmpty()) {
+      builder.setRewrites(new ArrayList<>(rewrites));
+    }
+  }
+
+  private static int collectAppliedRewrites(StatementContext context, 
Set<String> out) {
+    int flattenCount = context.getDerivedTableFlattenCount();
+    out.addAll(context.getAppliedRewrites());
+    for (StatementContext sub : context.getSubStatementContexts()) {
+      flattenCount += collectAppliedRewrites(sub, out);
+    }
+    return flattenCount;
+  }
+
+  /**
+   * Render the top-of-plan disclosure lines from the populated attributes and 
insert them at the
+   * head of {@code planSteps}.
+   * @param planSteps the rendered plan step lines to prepend onto (no-op when 
null)
+   * @param attrs     the already-populated root plan attributes (no-op when 
null)
+   */
+  public static void renderTopOfPlanText(List<String> planSteps, 
ExplainPlanAttributes attrs) {
+    if (planSteps == null || attrs == null) {
+      return;
+    }
+    List<String> lines = new ArrayList<>();
+    if (attrs.getTenantId() != null) {
+      lines.add("TENANT '" + attrs.getTenantId() + "'");
+    }
+    if (attrs.getViewName() != null) {
+      StringBuilder viewLine = new StringBuilder("VIEW 
").append(attrs.getViewName());
+      if (attrs.getViewBaseName() != null) {
+        viewLine.append(" OVER ").append(attrs.getViewBaseName());
+      }
+      lines.add(viewLine.toString());
+    }
+    if (attrs.getCdcScopes() != null) {
+      lines.add("CDC SCOPE " + attrs.getCdcScopes());
+    }
+    if (attrs.getTxnProvider() != null) {
+      lines.add("TXN " + attrs.getTxnProvider());
+    }
+    if (attrs.getRewrites() != null) {
+      for (String rewrite : attrs.getRewrites()) {
+        lines.add("REWRITE " + rewrite);
+      }
+    }
+    planSteps.addAll(0, lines);
+  }
+
   protected void explain(String prefix, List<String> planSteps,
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder,
     List<HRegionLocation> regionLocations) {
@@ -384,7 +482,7 @@ public abstract class ExplainTable {
         explainPlanAttributesBuilder.setServerOffset(offset);
         // Populate the attribute whenever a "SERVER n ROW LIMIT" step is 
emitted, including the
         // uncovered-index/server-merge path where the limit originates from 
the INDEX_LIMIT scan
-        // attribute rather than a PageFilter.
+        // attribute.
         if (limit != null) {
           explainPlanAttributesBuilder.setServerRowLimit(limit);
         }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
index 2a76e8a6fa..173d8ef29b 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
@@ -80,6 +80,7 @@ import org.apache.hadoop.hbase.client.Scan;
 import org.apache.hadoop.hbase.util.Pair;
 import org.apache.phoenix.call.CallRunner;
 import org.apache.phoenix.compile.BaseMutationPlan;
+import org.apache.phoenix.compile.BindManager;
 import org.apache.phoenix.compile.CloseStatementCompiler;
 import org.apache.phoenix.compile.ColumnProjector;
 import org.apache.phoenix.compile.CreateFunctionCompiler;
@@ -93,6 +94,7 @@ import org.apache.phoenix.compile.DropSequenceCompiler;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.compile.ExpressionProjector;
+import org.apache.phoenix.compile.FromCompiler;
 import org.apache.phoenix.compile.GroupByCompiler.GroupBy;
 import org.apache.phoenix.compile.ListJarsQueryPlan;
 import org.apache.phoenix.compile.MutationPlan;
@@ -865,12 +867,18 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       if (!getUdfParseNodes().isEmpty()) {
         
phoenixStatement.throwIfUnallowedUserDefinedFunctions(getUdfParseNodes());
       }
-
-      RewriteResult rewriteResult = ParseNodeUtil.rewrite(this, 
phoenixStatement.getConnection());
+      // Pre-build the top-level StatementContext so the early 
SubselectRewriter/SubqueryRewriter
+      // pass can record top of plan rewrite breadcrumbs onto it. The same 
accumulator is then
+      // adopted by the compilation context via 
QueryCompiler.withRewriteContext.
+      StatementContext rewriteContext = new StatementContext(phoenixStatement,
+        FromCompiler.EMPTY_TABLE_RESOLVER, new 
BindManager(phoenixStatement.getParameters()),
+        new Scan(), new SequenceManager(phoenixStatement));
+      RewriteResult rewriteResult = ParseNodeUtil.rewrite(this, 
rewriteContext);
       QueryPlan queryPlan = new QueryCompiler(phoenixStatement,
         rewriteResult.getRewrittenSelectStatement(), 
rewriteResult.getColumnResolver(),
         Collections.<PDatum> emptyList(), 
phoenixStatement.getConnection().getIteratorFactory(),
-        new SequenceManager(phoenixStatement), true, false, null).compile();
+        new SequenceManager(phoenixStatement), true, false, 
null).withRewriteContext(rewriteContext)
+          .compile();
       queryPlan.getContext().getSequenceManager().validateSequences(seqAction);
       return queryPlan;
     }
@@ -887,6 +895,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       return true;
     }
 
+    @SuppressWarnings("rawtypes")
     @Override
     public PDataType getDataType() {
       return PVarchar.INSTANCE;
@@ -994,14 +1003,17 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
           
stmt.getConnection().getQueryServices().getOptimizer().optimize(stmt, dataPlan);
       }
       final StatementPlan plan = compilePlan;
-      List<String> planSteps = plan.getExplainPlan().getPlanSteps();
+      ExplainPlan explainPlan = plan.getExplainPlan();
+      // Prepend the top-of-plan disclosure blocks. This is the only place the 
disclosure text is
+      // emitted.
+      List<String> planSteps = new ArrayList<>(explainPlan.getPlanSteps());
+      ExplainTable.renderTopOfPlanText(planSteps, 
explainPlan.getPlanStepsAsAttributes());
       ExplainType explainType = getExplainType();
       if (explainType == ExplainType.DEFAULT) {
-        List<String> updatedExplainPlanSteps = new ArrayList<>(planSteps);
-        updatedExplainPlanSteps.removeIf(
+        planSteps.removeIf(
           planStep -> planStep != null && 
planStep.contains(ExplainTable.REGION_LOCATIONS));
-        planSteps = Collections.unmodifiableList(updatedExplainPlanSteps);
       }
+      planSteps = Collections.unmodifiableList(planSteps);
       List<Tuple> tuples = 
Lists.newArrayListWithExpectedSize(planSteps.size());
       Long estimatedBytesToScan = plan.getEstimatedBytesToScan();
       Long estimatedRowsToScan = plan.getEstimatedRowsToScan();
@@ -1303,6 +1315,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(cdcObjName, dataTable, includeScopes, props, ifNotExists, 
bindCount);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public MutationPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1464,6 +1477,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(cursor, select);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public MutationPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1481,6 +1495,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(cursor);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public MutationPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1495,6 +1510,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(cursor);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public MutationPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1509,6 +1525,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(cursor, isNext, fetchLimit);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public QueryPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1604,6 +1621,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(schema, pattern);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public QueryPlan compilePlan(final PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1621,6 +1639,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(pattern);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public QueryPlan compilePlan(final PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1637,6 +1656,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
       super(tableName);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public QueryPlan compilePlan(final PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -1801,6 +1821,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
         isGrantStatement);
     }
 
+    @SuppressWarnings("unchecked")
     @Override
     public MutationPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp 
seqAction)
       throws SQLException {
@@ -2428,6 +2449,7 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
     }
   }
 
+  @SuppressWarnings("rawtypes")
   public Format getFormatter(PDataType type) {
     return connection.getFormatter(type);
   }
@@ -3002,10 +3024,6 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
     this.lastUpdateCount = lastUpdateCount;
   }
 
-  private String getLastUpdateTable() {
-    return lastUpdateTable;
-  }
-
   private void setLastUpdateTable(String lastUpdateTable) {
     if (!Strings.isNullOrEmpty(lastUpdateTable)) {
       this.lastUpdateTable = lastUpdateTable;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
index 28372949d1..b2cea9c761 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
@@ -201,16 +201,18 @@ public class QueryOptimizer {
 
       if (replacement != null) {
         select = rewriteQueryWithIndexReplacement(statement.getConnection(), 
resolver, select,
-          replacement);
+          replacement, dataPlan.getContext());
       }
     }
 
     // Re-compile the plan with option "optimizeSubquery" turned on, so that 
enclosed
-    // sub-queries can be optimized recursively.
+    // sub-queries can be optimized recursively. Carry forward the top-of-plan 
rewrite breadcrumbs
+    // recorded on the data plan's context so they survive the recompile onto 
the optimized plan's
+    // context.
     QueryCompiler compiler = new QueryCompiler(statement, select,
       FromCompiler.getResolverForQuery(select, statement.getConnection()), 
targetColumns,
       parallelIteratorFactory, dataPlan.getContext().getSequenceManager(), 
true, true, dataPlans,
-      dataPlan.getContext());
+      dataPlan.getContext()).withRewriteContext(dataPlan.getContext());
     return Collections.singletonList(compiler.compile());
   }
 
@@ -218,9 +220,23 @@ public class QueryOptimizer {
     PTable index) throws SQLException {
     StatementContext context = new StatementContext(dataPlan.getContext());
     context.setResolver(FromCompiler.getResolver(dataPlan.getTableRef()));
-    return WhereCompiler.contains(
-      index.getIndexWhereExpression(dataPlan.getContext().getConnection()),
-      WhereCompiler.transformDNF(select.getWhere(), context));
+    boolean usable =
+      
WhereCompiler.contains(index.getIndexWhereExpression(dataPlan.getContext().getConnection()),
+        WhereCompiler.transformDNF(select.getWhere(), context));
+    StatementContext rootContext = dataPlan.getContext();
+    String tableName = dataPlan.getTableRef().getTable().getName().getString();
+    String indexName = index.getName().getString();
+    // Dedupe so the breadcrumb is recorded once per table and index pair even 
when the optimizer
+    // scores the same index across multiple candidate paths.
+    if (rootContext.markPartialIndexChecked(tableName, indexName)) {
+      // Name the index in the breadcrumb so multiple partial indexes stay 
distinct after the
+      // rewrite list is deduped by string. Otherwise they collapse into a 
single ambiguous line.
+      rootContext.addAppliedRewrite(usable
+        ? "PARTIAL INDEX " + indexName + " APPLICABLE"
+        : "PARTIAL INDEX " + indexName
+          + " NOT APPLICABLE -- index WHERE not implied by query WHERE");
+    }
+    return usable;
   }
 
   private List<QueryPlan> getApplicablePlansForSingleFlatQuery(QueryPlan 
dataPlan,
@@ -281,12 +297,9 @@ public class QueryOptimizer {
         QueryPlan> singletonList(recordDecision(dataPlan, 
OptimizerReasons.RULE_DATA_TABLE, state));
     }
     // The targetColumns is set for UPSERT SELECT to ensure that the proper 
type conversion takes
-    // place.
-    // For a SELECT, it is empty. In this case, we want to set the 
targetColumns to match the
-    // projection
-    // from the dataPlan to ensure that the metadata for when an index is used 
matches the metadata
-    // for
-    // when the data table is used.
+    // place. For a SELECT, it is empty. In this case, we want to set the 
targetColumns to match
+    // the projection from the dataPlan to ensure that the metadata for when 
an index is used
+    // matches the metadata for when the data table is used.
     if (targetColumns.isEmpty()) {
       List<? extends ColumnProjector> projectors = 
dataPlan.getProjector().getColumnProjectors();
       List<PDatum> targetDatums = 
Lists.newArrayListWithExpectedSize(projectors.size());
@@ -314,8 +327,10 @@ public class QueryOptimizer {
             stopAtBestPlan && hintedPlan.isApplicable()
               && (index.getIndexWhere() == null || 
isPartialIndexUsable(select, dataPlan, index))
           ) {
-            return Collections
+            List<QueryPlan> hinted = Collections
               .singletonList(recordDecision(hintedPlan, 
OptimizerReasons.RULE_HINT, state));
+            carryForwardRewrites(dataPlan.getContext(), hinted);
+            return hinted;
           }
           plans.add(0, hintedPlan);
         }
@@ -338,7 +353,9 @@ public class QueryOptimizer {
       ) {
         // Query can't possibly return anything so just return this plan.
         if (plan.isDegenerate()) {
-          return Collections.singletonList(plan);
+          List<QueryPlan> degenerate = Collections.singletonList(plan);
+          carryForwardRewrites(dataPlan.getContext(), degenerate);
+          return degenerate;
         }
         plans.add(plan);
       } else if (plan == null) {
@@ -364,9 +381,34 @@ public class QueryOptimizer {
     }
 
     // OrderPlans
-    return hintedPlan == null
+    List<QueryPlan> orderedPlans = hintedPlan == null
       ? orderPlansBestToWorst(select, applicablePlans, stopAtBestPlan, state, 
forCDC)
       : applicablePlans;
+    carryForwardRewrites(dataPlan.getContext(), orderedPlans);
+    return orderedPlans;
+  }
+
+  /**
+   * Carry the rewrite breadcrumbs recorded on the data plan's context onto 
the candidate plans'
+   * contexts. The partial index applicability decisions are recorded once per 
index on the shared
+   * root context while scoring plans, so that all of them stay visible even 
though only one wins.
+   */
+  private static void carryForwardRewrites(StatementContext from, 
List<QueryPlan> plans) {
+    List<String> rewrites = from.getAppliedRewrites();
+    if (rewrites.isEmpty()) {
+      return;
+    }
+    for (QueryPlan plan : plans) {
+      StatementContext to = plan.getContext();
+      if (to == from) {
+        continue;
+      }
+      for (String rewrite : rewrites) {
+        if (!to.getAppliedRewrites().contains(rewrite)) {
+          to.addAppliedRewrite(rewrite);
+        }
+      }
+    }
   }
 
   private QueryPlan getHintedQueryPlan(PhoenixStatement statement, 
SelectStatement select,
@@ -631,11 +673,12 @@ public class QueryOptimizer {
                 new Hint[] { Hint.INDEX, 
Hint.NO_CHILD_PARENT_JOIN_OPTIMIZATION }),
               FACTORY.hint("NO_INDEX"));
             SelectStatement query = FACTORY.select(dataSelect, hint, 
outerWhere);
-            RewriteResult rewriteResult = ParseNodeUtil.rewrite(query, 
statement.getConnection());
+            RewriteResult rewriteResult = ParseNodeUtil.rewrite(query, 
dataPlan.getContext());
             QueryPlan plan =
               new QueryCompiler(statement, 
rewriteResult.getRewrittenSelectStatement(),
                 rewriteResult.getColumnResolver(), targetColumns, 
parallelIteratorFactory,
-                dataPlan.getContext().getSequenceManager(), isProjected, true, 
dataPlans).compile();
+                dataPlan.getContext().getSequenceManager(), isProjected, true, 
dataPlans)
+                  .withRewriteContext(dataPlan.getContext()).compile();
             return AddPlanResult.success(plan);
           }
         }
@@ -908,9 +951,7 @@ public class QueryOptimizer {
     return null;
   }
 
-  /**
-   * The bound-PK-column count for {@code plan}.
-   */
+  /** The bound-PK-column count for {@code plan}. */
   private static int adjustedBoundCount(QueryPlan plan, int boundRanges) {
     PTable table = plan.getTableRef().getTable();
     int boundCount = plan.getContext().getScanRanges().getBoundPkColumnCount();
@@ -1002,7 +1043,8 @@ public class QueryOptimizer {
 
   private static SelectStatement rewriteQueryWithIndexReplacement(
     final PhoenixConnection connection, final ColumnResolver resolver, final 
SelectStatement select,
-    final Map<TableRef, TableRef> replacement) throws SQLException {
+    final Map<TableRef, TableRef> replacement, final StatementContext 
breadcrumbContext)
+    throws SQLException {
     TableNode from = select.getFrom();
     TableNode newFrom = from.accept(new QueryOptimizerTableNode(resolver, 
replacement));
     if (from == newFrom) {
@@ -1015,7 +1057,8 @@ public class QueryOptimizer {
       // replace expressions with corresponding matching columns for 
functional indexes
       indexSelect = ParseNodeRewriter.rewrite(indexSelect,
         new IndexExpressionParseNodeRewriter(indexTableRef.getTable(),
-          indexTableRef.getTableAlias(), connection, 
indexSelect.getUdfParseNodes()));
+          indexTableRef.getTableAlias(), connection, 
indexSelect.getUdfParseNodes(),
+          breadcrumbContext));
     }
 
     return indexSelect;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
index 67085741d5..8d93f6415f 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
@@ -46,6 +46,12 @@ public class IndexExpressionParseNodeRewriter extends 
ParseNodeRewriter {
 
   public IndexExpressionParseNodeRewriter(PTable index, String alias, 
PhoenixConnection connection,
     Map<String, UDFParseNode> udfParseNodes) throws SQLException {
+    this(index, alias, connection, udfParseNodes, null);
+  }
+
+  public IndexExpressionParseNodeRewriter(PTable index, String alias, 
PhoenixConnection connection,
+    Map<String, UDFParseNode> udfParseNodes, StatementContext 
breadcrumbContext)
+    throws SQLException {
     indexedParseNodeToColumnParseNodeMap =
       Maps.newHashMapWithExpectedSize(index.getColumns().size());
     NamedTableNode tableNode =
@@ -74,6 +80,10 @@ public class IndexExpressionParseNodeRewriter extends 
ParseNodeRewriter {
         columnParseNode = NODE_FACTORY.cast(columnParseNode, 
expressionDataType, null, null);
       }
       indexedParseNodeToColumnParseNodeMap.put(indexedParseNode, 
columnParseNode);
+      if (breadcrumbContext != null) {
+        breadcrumbContext.addAppliedRewrite("INDEX EXPRESSION " + 
expressionStr + " AS " + colName);
+        breadcrumbContext.addIndexExpressionSubstitution(indexedParseNode, 
colName);
+      }
     }
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/util/ParseNodeUtil.java 
b/phoenix-core-client/src/main/java/org/apache/phoenix/util/ParseNodeUtil.java
index 4d96f1bf9c..e3c1b83aa6 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/util/ParseNodeUtil.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/util/ParseNodeUtil.java
@@ -23,6 +23,7 @@ import java.util.Set;
 import org.apache.phoenix.compile.ColumnResolver;
 import org.apache.phoenix.compile.FromCompiler;
 import org.apache.phoenix.compile.QueryCompiler;
+import org.apache.phoenix.compile.StatementContext;
 import org.apache.phoenix.compile.StatementNormalizer;
 import org.apache.phoenix.compile.SubqueryRewriter;
 import org.apache.phoenix.compile.SubselectRewriter;
@@ -159,16 +160,20 @@ public class ParseNodeUtil {
   /**
    * Optimize rewriting {@link SelectStatement} by {@link SubselectRewriter} 
and
    * {@link SubqueryRewriter} before {@link QueryCompiler#compile}.
+   * <p>
+   * The supplied {@link StatementContext} is the top level context that 
carries top-of-plan rewrite
+   * breadcrumb.
    */
-  public static RewriteResult rewrite(SelectStatement selectStatement,
-    PhoenixConnection phoenixConnection) throws SQLException {
+  public static RewriteResult rewrite(SelectStatement selectStatement, 
StatementContext context)
+    throws SQLException {
+    PhoenixConnection phoenixConnection = context.getConnection();
     SelectStatement selectStatementToUse =
-      SubselectRewriter.flatten(selectStatement, phoenixConnection);
+      SubselectRewriter.flatten(selectStatement, phoenixConnection, context);
     ColumnResolver columnResolver =
       FromCompiler.getResolverForQuery(selectStatementToUse, 
phoenixConnection);
     selectStatementToUse = StatementNormalizer.normalize(selectStatementToUse, 
columnResolver);
     SelectStatement transformedSubquery =
-      SubqueryRewriter.transform(selectStatementToUse, columnResolver, 
phoenixConnection);
+      SubqueryRewriter.transform(selectStatementToUse, columnResolver, 
phoenixConnection, context);
     if (transformedSubquery != selectStatementToUse) {
       columnResolver = FromCompiler.getResolverForQuery(transformedSubquery, 
phoenixConnection);
       transformedSubquery = StatementNormalizer.normalize(transformedSubquery, 
columnResolver);
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java
index 08471a9e5b..5162930c3f 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java
@@ -269,7 +269,6 @@ public class ChildViewsUseParentViewIndexIT extends 
ParallelStatsDisabledIT {
       .keyRanges(" [" + Short.MIN_VALUE + 
",'00Dxxxxxxxxxxx1','003xxxxxxxxxxx1',*] - ["
         + Short.MIN_VALUE + ",'00Dxxxxxxxxxxx1','003xxxxxxxxxxx5',~'2016-01-01 
06:00:00.000']")
       
.indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
-
     ResultSet rs = conn.createStatement().executeQuery(sql);
     for (int i = 0; i < expectedRows; ++i) {
       assertTrue(rs.next());
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java
index eeeb02bdf0..f59c9b74e2 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java
@@ -243,18 +243,16 @@ public class PartialIndexIT extends BaseTest {
       assertEquals("b", rs.getString(1));
       assertFalse(rs.next());
       // explain plan verify to check if partial index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(indexTableName));
+      assertTrue(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).toString().contains(indexTableName));
 
       selectSql = "SELECT  D from " + dataTableName + " WHERE A = 50";
       rs = conn.createStatement().executeQuery(selectSql);
       // Verify that the index table is not used
       assertCurrentTable((PhoenixResultSet) rs, "", dataTableName);
       // explain plan verify to check if partial index is not used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertFalse(rs.getString(1).contains(indexTableName));
+      assertFalse(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).get(0).contains(indexTableName));
 
       // Add more rows to test the index write path
       conn.createStatement()
@@ -292,9 +290,8 @@ public class PartialIndexIT extends BaseTest {
       assertTrue(rs.next());
       assertEquals("id2", rs.getString(1));
       // explain plan verify to check if partial index is not used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertFalse(rs.getString(1).contains(indexTableName));
+      assertFalse(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).get(0).contains(indexTableName));
 
       // Test index verification and repair by IndexTool
       verifyIndex(dataTableName, indexTableName);
@@ -306,6 +303,36 @@ public class PartialIndexIT extends BaseTest {
     }
   }
 
+  @Test
+  public void testPartialIndexBreadcrumbsAreDistinctPerIndex() throws 
Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl())) {
+      String dataTableName = generateUniqueName();
+      conn.createStatement()
+        .execute("create table " + dataTableName + " (id varchar not null 
primary key, "
+          + "A integer, B integer, C double, D varchar) COLUMN_ENCODED_BYTES=0"
+          + (salted ? ", SALT_BUCKETS=4" : ""));
+      // Two partial indexes on the same table with different WHERE 
predicates. The index is
+      // created on an empty table so it is active immediately without an 
async rebuild.
+      String indexName1 = generateUniqueName();
+      String indexName2 = generateUniqueName();
+      conn.createStatement()
+        .execute("CREATE " + (uncovered ? "UNCOVERED " : " ") + (local ? 
"LOCAL " : " ") + "INDEX "
+          + indexName1 + " on " + dataTableName + " (A) " + (uncovered ? "" : 
"INCLUDE (B, C, D)")
+          + " WHERE A > 50");
+      conn.createStatement()
+        .execute("CREATE " + (uncovered ? "UNCOVERED " : " ") + (local ? 
"LOCAL " : " ") + "INDEX "
+          + indexName2 + " on " + dataTableName + " (B) " + (uncovered ? "" : 
"INCLUDE (A, C, D)")
+          + " WHERE B > 50");
+      // A query whose WHERE implies both index WHERE clauses makes the 
optimizer evaluate both
+      // partial indexes, so both applicability decisions are recorded. Each 
breadcrumb names its
+      // index.
+      String selectSql = "SELECT D from " + dataTableName + " WHERE A > 60 AND 
B > 60";
+      ExplainPlanTestUtil.assertPlan(conn, selectSql)
+        .rewriteContains("PARTIAL INDEX " + indexName1 + " APPLICABLE")
+        .rewriteContains("PARTIAL INDEX " + indexName2 + " APPLICABLE");
+    }
+  }
+
   @Test
   public void testComparisonOfColumns() throws Exception {
     try (Connection conn = DriverManager.getConnection(getUrl())) {
@@ -340,18 +367,16 @@ public class PartialIndexIT extends BaseTest {
       assertEquals("a", rs.getString(1));
       assertFalse(rs.next());
       // explain plan verify to check if partial index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(indexTableName));
+      assertTrue(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).toString().contains(indexTableName));
 
       selectSql = "SELECT  D from " + dataTableName + " WHERE A > 100";
       rs = conn.createStatement().executeQuery(selectSql);
       // Verify that the index table is not used
       assertCurrentTable((PhoenixResultSet) rs, "", dataTableName);
       // explain plan verify to check if partial index is not used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertFalse(rs.getString(1).contains(indexTableName));
+      assertFalse(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).get(0).contains(indexTableName));
 
       // Add more rows to test the index write path
       conn.createStatement()
@@ -429,9 +454,8 @@ public class PartialIndexIT extends BaseTest {
       assertEquals("a", rs.getString(2));
       assertFalse(rs.next());
       // explain plan verify to check if partial index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(indexTableName));
+      assertTrue(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).toString().contains(indexTableName));
 
       // Add more rows to test the index write path
       conn.createStatement()
@@ -460,10 +484,8 @@ public class PartialIndexIT extends BaseTest {
       assertTrue(rs.next());
       assertEquals(5, rs.getInt(1));
       // explain plan verify to check if partial index is not used
-      rs =
-        conn.createStatement().executeQuery("EXPLAIN " + "SELECT Count(*) from 
" + dataTableName);
-      assertTrue(rs.next());
-      assertFalse(rs.getString(1).contains(indexTableName));
+      assertFalse(ExplainPlanTestUtil.getPlanSteps(conn, "SELECT Count(*) from 
" + dataTableName)
+        .get(0).contains(indexTableName));
 
       // Overwrite an existing row that satisfies the index WHERE clause such 
that
       // the new version of the row does not satisfy the index where clause 
anymore. This
@@ -519,9 +541,8 @@ public class PartialIndexIT extends BaseTest {
       assertEquals("abcdef", rs.getString(1));
       assertFalse(rs.next());
       // explain plan verify to check if partial index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(indexTableName));
+      assertTrue(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).toString().contains(indexTableName));
 
       // Add more rows to test the index write path
       conn.createStatement()
@@ -548,9 +569,8 @@ public class PartialIndexIT extends BaseTest {
       assertTrue(rs.next());
       assertEquals(5, rs.getInt(1));
       // explain plan verify to check if partial index is not used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertFalse(rs.getString(1).contains(indexTableName));
+      assertFalse(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).get(0).contains(indexTableName));
 
       // Overwrite an existing row that satisfies the index WHERE clause such 
that
       // the new version of the row does not satisfy the index where clause 
anymore. This
@@ -611,9 +631,8 @@ public class PartialIndexIT extends BaseTest {
       assertEquals(70, rs.getInt(1));
       assertFalse(rs.next());
       // explain plan verify to check if partial index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(indexTableName));
+      assertTrue(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).toString().contains(indexTableName));
 
       // Add more rows to test the index write path
       conn.createStatement().execute("upsert into " + dataTableName + " values 
('id2', 20, 3)");
@@ -627,10 +646,8 @@ public class PartialIndexIT extends BaseTest {
       assertTrue(rs.next());
       assertEquals(4, rs.getInt(1));
       // explain plan verify to check if partial index is not used
-      rs =
-        conn.createStatement().executeQuery("EXPLAIN " + "SELECT Count(*) from 
" + dataTableName);
-      assertTrue(rs.next());
-      assertFalse(rs.getString(1).contains(indexTableName));
+      assertFalse(ExplainPlanTestUtil.getPlanSteps(conn, "SELECT Count(*) from 
" + dataTableName)
+        .get(0).contains(indexTableName));
 
       rs = conn.createStatement().executeQuery("SELECT Count(*) from " + 
indexTableName);
       assertTrue(rs.next());
@@ -649,9 +666,8 @@ public class PartialIndexIT extends BaseTest {
       assertEquals(0, rs.getInt(1));
       assertFalse(rs.next());
       // explain plan verify to check if partial index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(indexTableName));
+      assertTrue(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).toString().contains(indexTableName));
 
       // Test index verification and repair by IndexTool
       verifyIndex(dataTableName, indexTableName);
@@ -805,9 +821,8 @@ public class PartialIndexIT extends BaseTest {
       assertEquals("b", rs.getString(1));
       assertFalse(rs.next());
       // explain plan verify to check if partial index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(partialIndexTableName));
+      assertTrue(ExplainPlanTestUtil.getPlanSteps(conn, selectSql).toString()
+        .contains(partialIndexTableName));
 
       selectSql = "SELECT  D from " + dataTableName + " WHERE A < 50";
       // Verify that the full index table is used
@@ -817,9 +832,8 @@ public class PartialIndexIT extends BaseTest {
       assertEquals("a", rs.getString(1));
       assertFalse(rs.next());
       // explain plan verify to check if full index is used
-      rs = conn.createStatement().executeQuery("EXPLAIN " + selectSql);
-      assertTrue(rs.next());
-      assertTrue(rs.getString(1).contains(fullIndexTableName));
+      assertTrue(
+        ExplainPlanTestUtil.getPlanSteps(conn, 
selectSql).toString().contains(fullIndexTableName));
     }
   }
 
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java
index 6665d18d88..6760e5b0f7 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java
@@ -245,6 +245,7 @@ public class TenantSpecificViewIndexCompileTest extends 
BaseConnectionlessQueryT
   // -----------------------------------------------------------------
   // Private Helper Methods
   // -----------------------------------------------------------------
+
   private Connection createTenantSpecificConnection() throws SQLException {
     Connection conn;
     Properties props = new Properties();
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 bb7f8133c4..09ece953c6 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
@@ -47,6 +47,7 @@ import org.apache.hadoop.hbase.client.RegionInfoBuilder;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseConnectionlessQueryTest;
 import org.apache.phoenix.query.QueryServices;
@@ -183,7 +184,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
       text("CLIENT PARALLEL <N>-WAY REVERSE RANGE SCAN OVER PTSDB2 ['na1']", " 
   INDEX PTSDB2",
         "    REGIONS PLANNED <N>", "    SERVER PROJECTION FILTER BY FIRST KEY 
ONLY"),
       scanAttrs("RANGE SCAN ", "PTSDB2", " 
['na1']").put("serverFirstKeyOnlyProjection", true)
-        .put("clientSortedBy", "REVERSE"));
+        .put("clientSortedBy", "REVERSE")
+        .set("rewrites", rewriteList("REVERSE SCAN SUBSTITUTION")));
   }
 
   @Test
@@ -474,10 +476,9 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
 
   @Test
   public void testSortMergeJoin() throws Exception {
-    // After the SMJ reshape the root is a synthetic node carrying just the 
join header and the
-    // two operand plans as separate lhs / rhs children.
-    // Join operand leaves are compiled through the join path, not optimizer 
index selection, so
-    // they carry no decision rule.
+    // In a SMJ the root is a synthetic node carrying the join header and the 
two operand plans as
+    // lhs and rhs. Join operand leaves are compiled through the join path, 
not optimizer index
+    // selection, so they carry no decision rule.
     ObjectNode lhs =
       scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000001']").putNull("indexRule");
     ObjectNode rhs = scanAttrs("FULL SCAN ", "ATABLE", 
"").putNull("indexRule");
@@ -501,8 +502,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
   public void testHashJoinInner() throws Exception {
     // HashJoinPlan root attributes come from the delegate scan. Each 
hash/skip-scan child
     // is recorded under subPlans with its join header on abstractExplainPlan.
-    // Hash join nodes (the delegate root and the build-side child) are 
compiled through the join
-    // path, not optimizer index selection, so they carry no decision rule.
+    // Hash joins are compiled through the join path, not optimizer index 
selection, so they carry
+    // no decision rule.
     ObjectNode child = scanAttrs("FULL SCAN ", "ATABLE", "")
       .put("abstractExplainPlan", "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD 
RIGHT */")
       .putNull("indexRule");
@@ -525,35 +526,46 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
 
   @Test
   public void testHashJoinSemiInSubquery() throws Exception {
-    // Phoenix temp aliases are renamed by first appearance so this case 
asserts on the canonical
-    // "$1.$2" form.
-    ObjectNode child = scanAttrs("FULL SCAN ", "ATABLE", "")
-      .put("abstractExplainPlan", "SKIP-SCAN-JOIN TABLE 0  /* HASH BUILD RIGHT 
*/")
-      .put("serverWhereFilter", "SERVER FILTER BY A_INTEGER = 1")
-      .put("serverAggregate", "SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[ORGANIZATION_ID]");
-    // The outer hash-join scan is compiled through the subquery/join path and 
carries no decision
-    // rule; the aggregate subquery build side is optimized separately and 
keeps its data-table
-    // rule.
-    ObjectNode expected = scanAttrs("FULL SCAN ", "ATABLE", 
"").putNull("indexRule");
-    expected.set("subPlans", mapper.createArrayNode().add(child));
-    expected.put("dynamicServerFilter",
-      "DYNAMIC SERVER FILTER BY ATABLE.ORGANIZATION_ID IN ($1.$2)");
-    verifyQuery("hashJoinSemiInSubquery",
-      "SELECT a_string FROM atable"
-        + " WHERE organization_id IN (SELECT organization_id FROM atable WHERE 
a_integer = 1)",
-      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "    INDEX ATABLE",
-        "    REGIONS PLANNED <N>", "    SKIP-SCAN-JOIN TABLE 0  /* HASH BUILD 
RIGHT */",
-        "        CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "            
INDEX ATABLE",
-        "            REGIONS PLANNED <N>", "            SERVER FILTER BY 
A_INTEGER = 1",
-        "            SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[ORGANIZATION_ID]",
-        "    DYNAMIC SERVER FILTER BY ATABLE.ORGANIZATION_ID IN ($1.$2)"),
-      expected);
+    // The behavior under test is the IN-subquery -> semi-join rewrite 
breadcrumb, asserted via
+    // attributes below. The optimizer's behavior is nondeterministic.
+    // WhereOptimizer.getKeyExpressionCombination probes the row key using
+    // PDataType.getSampleValue, which for CHAR/VARCHAR row keys returns a 
value from an
+    // unseeded ThreadLocal Random. The choice flips between SKIP-SCAN-JOIN 
and PARALLEL
+    // SEMI-JOIN ... SKIP MERGE from run to run. Every other rewrite 
breadcrumb is stable and
+    // asserted exactly. Phoenix temp aliases are renamed by first appearance 
so the dynamic
+    // server filter asserts on the canonical "$1.$2" form.
+    String query = "SELECT a_string FROM atable"
+      + " WHERE organization_id IN (SELECT organization_id FROM atable WHERE 
a_integer = 1)";
+    String skipScanJoin = "    SKIP-SCAN-JOIN TABLE 0  /* HASH BUILD RIGHT */";
+    String parallelSemiJoin = "    PARALLEL SEMI-JOIN TABLE 0  /* HASH BUILD 
RIGHT, SKIP MERGE */";
+    // Index 3 is the unstable join-operator line. null marks it as 
"tolerated".
+    List<String> expectedStable = text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER 
ATABLE",
+      "    INDEX ATABLE", "    REGIONS PLANNED <N>", null,
+      "        CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "            
INDEX ATABLE",
+      "            REGIONS PLANNED <N>", "            SERVER FILTER BY 
A_INTEGER = 1",
+      "            SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[ORGANIZATION_ID]",
+      "    DYNAMIC SERVER FILTER BY ATABLE.ORGANIZATION_ID IN ($1.$2)");
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlan plan = ExplainPlanTestUtil.getExplainPlan(conn, query);
+      List<String> actual = oracle.normalizeText(plan.getPlanSteps());
+      assertEquals("plan line count, was " + actual, expectedStable.size(), 
actual.size());
+      for (int i = 0; i < expectedStable.size(); i++) {
+        if (expectedStable.get(i) == null) {
+          assertTrue("unexpected join-strategy line: " + actual.get(i),
+            actual.get(i).equals(skipScanJoin) || 
actual.get(i).equals(parallelSemiJoin));
+        } else {
+          assertEquals("@" + i, expectedStable.get(i), actual.get(i));
+        }
+      }
+      // The dynamic server filter line is normalized above.
+      ExplainPlanTestUtil.assertPlan(plan.getPlanStepsAsAttributes())
+        .rewriteContains("IN SUBQUERY AS SEMI JOIN");
+    }
   }
 
   @Test
   public void testUnionAll() throws Exception {
-    // The union composes each branch recursively from its own 
getExplainPlan() into the subPlans
-    // list.
+    // The union composes each branch recursively from its own explain plan 
into subPlans.
     ObjectNode lhs = scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000001']");
     ObjectNode rhs = scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000002']");
     ObjectNode expected = defaultAttrs();
@@ -573,9 +585,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
   @Test
   public void testUnionAllOfHashJoins() throws Exception {
     // A UNION ALL whose branches are each hash joins. Exercises recursive 
explain composition end
-    // to end and
-    // confirms each branch carries its own subPlans and dynamicServerFilter.
-    // Every node in a union-of-hash-joins is compiled through the join path, 
so none carry a
+    // to end and confirms each branch carries its own subPlans and 
dynamicServerFilter.
+    // Every node in a union of hash joins is compiled through the join path, 
so none carry a
     // decision rule.
     ObjectNode joinChild1 = scanAttrs("FULL SCAN ", "ATABLE", "")
       .put("abstractExplainPlan", "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD 
RIGHT */")
@@ -715,7 +726,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     verifyQuery("multiTenantView", "SELECT * FROM " + MT_VIEW + " LIMIT 1", 
tenantProps,
       text("CLIENT SERIAL <N>-WAY RANGE SCAN OVER EO_MT_BASE ['tenant42']", "  
  INDEX EO_MT_VIEW",
         "    REGIONS PLANNED <N>", "    SERVER 1 ROW LIMIT", "CLIENT 1 ROW 
LIMIT"),
-      attrs().put("iteratorTypeAndScanSize", "SERIAL 
<N>-WAY").put("consistency", "STRONG")
+      attrs().put("tenantId", TENANT_ID).put("viewName", 
MT_VIEW).put("viewBaseName", MT_BASE)
+        .put("iteratorTypeAndScanSize", "SERIAL <N>-WAY").put("consistency", 
"STRONG")
         .put("explainScanType", "RANGE SCAN ").put("tableName", "EO_MT_BASE")
         .put("indexName", "EO_MT_VIEW").put("indexRule", "data table")
         .put("keyRanges", " ['tenant42']").put("serverRowLimit", 
1).put("clientRowLimit", 1)
@@ -834,6 +846,224 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     }
   }
 
+  @Test
+  public void testRewriteReverseScanSubstitution() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil.assertPlan(conn, "SELECT inst,\"DATE\" FROM ptsdb2 
WHERE inst = 'na1'"
+        + " ORDER BY inst DESC, \"DATE\" DESC").rewriteContains("REVERSE SCAN 
SUBSTITUTION");
+    }
+  }
+
+  @Test
+  public void testRewriteInSubqueryAsSemiJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil
+        .assertPlan(conn,
+          "SELECT a_string FROM atable"
+            + " WHERE organization_id IN (SELECT organization_id FROM atable 
WHERE a_integer = 1)")
+        .rewriteContains("IN SUBQUERY AS SEMI JOIN");
+    }
+  }
+
+  @Test
+  public void testRewriteNotInSubqueryAsAntiJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil.assertPlan(conn, "SELECT a_string FROM atable"
+        + " WHERE organization_id NOT IN (SELECT organization_id FROM atable 
WHERE a_integer = 1)")
+        .rewriteContains("NOT IN SUBQUERY AS ANTI JOIN");
+    }
+  }
+
+  @Test
+  public void testRewriteExistsSubqueryAsSemiJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil.assertPlan(conn,
+        "SELECT a_string FROM atable a WHERE EXISTS"
+          + " (SELECT 1 FROM atable b WHERE b.organization_id = 
a.organization_id"
+          + " AND b.a_integer = 1)")
+        .rewriteContains("EXISTS SUBQUERY AS SEMI JOIN");
+    }
+  }
+
+  @Test
+  public void testRewriteNotExistsSubqueryAsAntiJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil.assertPlan(conn,
+        "SELECT a_string FROM atable a WHERE NOT EXISTS"
+          + " (SELECT 1 FROM atable b WHERE b.organization_id = 
a.organization_id"
+          + " AND b.a_integer = 1)")
+        .rewriteContains("NOT EXISTS SUBQUERY AS ANTI JOIN");
+    }
+  }
+
+  @Test
+  public void testRewriteScalarSubqueryAsInnerJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil
+        .assertPlan(conn,
+          "SELECT a_string FROM atable a WHERE a_integer ="
+            + " (SELECT max(a_integer) FROM atable b WHERE b.organization_id = 
a.organization_id)")
+        .rewriteContains("SCALAR SUBQUERY AS INNER JOIN");
+    }
+  }
+
+  @Test
+  public void testRewriteHavingPredicateAsWhere() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil
+        .assertPlan(conn, "SELECT count(1) FROM atable GROUP BY a_string 
HAVING a_string = 'a'")
+        .rewriteContains("HAVING PREDICATE AS WHERE");
+    }
+  }
+
+  @Test
+  public void testRewriteDerivedTableFlattened() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil
+        .assertPlan(conn, "SELECT a_string FROM (SELECT a_string FROM atable) 
WHERE a_string = 'a'")
+        .rewriteContains("DERIVED TABLE FLATTENED 1");
+    }
+  }
+
+  @Test
+  public void testRewriteUnionOrderByMerge() throws Exception {
+    // The UNION ORDER BY merge optimization only fires when a union is the 
inner select of an
+    // outer query whose GROUP BY / ORDER BY is order-preserving over the 
union output.
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    props.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
+    props.setProperty(QueryServices.FORCE_ROW_KEY_ORDER_ATTRIB, 
Boolean.toString(false));
+    try (Connection conn = DriverManager.getConnection(getUrl(), props);
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t1 = generateUniqueName();
+      String t2 = generateUniqueName();
+      stmt.execute("CREATE TABLE " + t1 + " (fuid UNSIGNED_LONG NOT NULL,"
+        + " fstatsdate UNSIGNED_LONG NOT NULL, faid_1 UNSIGNED_LONG NOT NULL,"
+        + " clk_pv_1 UNSIGNED_LONG CONSTRAINT pk PRIMARY KEY (fuid, 
fstatsdate, faid_1))");
+      stmt.execute("CREATE TABLE " + t2 + " (fuid UNSIGNED_LONG NOT NULL,"
+        + " fstatsdate UNSIGNED_LONG NOT NULL, faid_2 UNSIGNED_LONG NOT NULL,"
+        + " clk_pv_2 UNSIGNED_LONG CONSTRAINT pk PRIMARY KEY (fuid, 
fstatsdate, faid_2))");
+      String unionSql = "(SELECT fuid AS advertiser_id, faid_1 AS adgroup_id,"
+        + " fstatsdate AS date, SUM(clk_pv_1) AS clicks FROM " + t1
+        + " GROUP BY fuid, faid_1, fstatsdate UNION ALL"
+        + " SELECT fuid AS advertiser_id, faid_2 AS adgroup_id,"
+        + " fstatsdate AS date, SUM(clk_pv_2) AS clicks FROM " + t2
+        + " GROUP BY fuid, faid_2, fstatsdate)";
+      String sql = "SELECT advertiser_id, adgroup_id, date, SUM(clicks) AS 
clicks FROM " + unionSql
+        + " GROUP BY advertiser_id, adgroup_id, date ORDER BY advertiser_id, 
adgroup_id, date";
+      ExplainPlanTestUtil.assertPlan(conn, sql).rewriteContains("UNION ORDER 
BY MERGE");
+    }
+  }
+
+  @Test
+  public void testRewriteStarJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil
+        .assertPlan(conn,
+          "SELECT a.a_string, b.a_string, c.a_string FROM atable a"
+            + " JOIN atable b ON a.organization_id = b.organization_id"
+            + " JOIN atable c ON a.organization_id = c.organization_id"
+            + " WHERE a.organization_id = '00D000000000001'")
+        .rewriteContains("STAR JOIN ON 2 RIGHT LEGS");
+    }
+  }
+
+  @Test
+  public void testRewriteRightJoinAsLeftJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil
+        .assertPlan(conn,
+          "SELECT a.a_string, b.a_string FROM atable a"
+            + " RIGHT JOIN atable b ON a.organization_id = b.organization_id"
+            + " WHERE b.organization_id = '00D000000000001'")
+        .rewriteContains("RIGHT JOIN AS LEFT JOIN");
+    }
+  }
+
+  @Test
+  public void testDisclosureTenantAndView() throws Exception {
+    Properties tenantProps = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    tenantProps.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
+    tenantProps.setProperty(TENANT_ID_ATTRIB, TENANT_ID);
+    try (Connection conn = DriverManager.getConnection(getUrl(), tenantProps)) 
{
+      ExplainPlanTestUtil.assertPlan(conn, "SELECT * FROM " + MT_VIEW + " 
LIMIT 1")
+        .tenant(TENANT_ID).view(MT_VIEW, MT_BASE);
+    }
+  }
+
+  @Test
+  public void testDisclosureNoneOnPlainScan() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      ExplainPlanTestUtil.assertPlan(conn, "SELECT * FROM 
atable").tenantNone().viewNone()
+        .cdcScopesNone().txnProviderNone().rewritesNone();
+    }
+  }
+
+  /**
+   * End-to-end check that the disclosure block is prepended to the real JDBC 
{@code EXPLAIN} result
+   * set (the {@link org.apache.phoenix.jdbc.PhoenixStatement} path that 
invokes
+   * {@link ExplainTable#renderTopOfPlanText}), with {@code TENANT} then 
{@code VIEW} ahead of the
+   * first operator line.
+   */
+  @Test
+  public void testTopOfPlanTextTenantAndView() throws Exception {
+    Properties tenantProps = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    tenantProps.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
+    tenantProps.setProperty(TENANT_ID_ATTRIB, TENANT_ID);
+    try (Connection conn = DriverManager.getConnection(getUrl(), tenantProps)) 
{
+      List<String> rows = explainViaJdbc(conn, "SELECT * FROM " + MT_VIEW + " 
LIMIT 1");
+      assertEquals("TENANT '" + TENANT_ID + "'", rows.get(0));
+      assertEquals("VIEW " + MT_VIEW + " OVER " + MT_BASE, rows.get(1));
+      assertTrue("operator should follow the disclosure block: " + rows.get(2),
+        rows.get(2).startsWith("CLIENT"));
+    }
+  }
+
+  /**
+   * End-to-end check that a {@code REWRITE} breadcrumb is prepended ahead of 
the first operator.
+   */
+  @Test
+  public void testTopOfPlanTextRewriteAtTop() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      List<String> rows =
+        explainViaJdbc(conn, "SELECT a.a_string, b.a_string FROM atable a 
RIGHT JOIN atable b"
+          + " ON a.organization_id = b.organization_id WHERE b.organization_id 
= '00D000000000001'");
+      assertEquals("REWRITE RIGHT JOIN AS LEFT JOIN", rows.get(0));
+      assertTrue("operator should follow the REWRITE line: " + rows.get(1),
+        rows.get(1).startsWith("CLIENT"));
+    }
+  }
+
+  /** A view with no resolvable base table renders {@code VIEW <name>} without 
{@code OVER}. */
+  @Test
+  public void testRenderTopOfPlanTextViewWithoutBase() {
+    ExplainPlanAttributes attrs = new 
ExplainPlanAttributesBuilder().setViewName("V").build();
+    List<String> steps = new java.util.ArrayList<>(Arrays.asList("CLIENT 
FOO"));
+    ExplainTable.renderTopOfPlanText(steps, attrs);
+    assertEquals(Arrays.asList("VIEW V", "CLIENT FOO"), steps);
+  }
+
+  /** {@link ExplainTable#renderTopOfPlanText} leaves the steps untouched. */
+  @Test
+  public void testRenderTopOfPlanTextNoopWhenNoDisclosure() {
+    ExplainPlanAttributes attrs = new ExplainPlanAttributesBuilder().build();
+    List<String> steps = new java.util.ArrayList<>(
+      Arrays.asList("CLIENT PARALLEL 1-WAY FULL SCAN OVER T", "    SERVER 
FILTER BY X"));
+    ExplainTable.renderTopOfPlanText(steps, attrs);
+    assertEquals(Arrays.asList("CLIENT PARALLEL 1-WAY FULL SCAN OVER T", "    
SERVER FILTER BY X"),
+      steps);
+  }
+
+  private static List<String> explainViaJdbc(Connection conn, String query) 
throws SQLException {
+    List<String> rows = new java.util.ArrayList<>();
+    try (java.sql.Statement stmt = conn.createStatement();
+      java.sql.ResultSet rs = stmt.executeQuery("EXPLAIN " + query)) {
+      while (rs.next()) {
+        rows.add(rs.getString(1));
+      }
+    }
+    return rows;
+  }
+
   @Test
   public void testTextNormalizerCollapsesWayCount() {
     assertEquals(Collections.singletonList("CLIENT PARALLEL <N>-WAY FULL SCAN 
OVER ATABLE"),
@@ -1135,6 +1365,12 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
    */
   private static ObjectNode defaultAttrs() {
     ObjectNode n = mapper.createObjectNode();
+    n.putNull("tenantId");
+    n.putNull("viewName");
+    n.putNull("viewBaseName");
+    n.putNull("cdcScopes");
+    n.putNull("txnProvider");
+    n.putNull("rewrites");
     n.putNull("abstractExplainPlan");
     n.putNull("splitsChunk");
     n.putNull("estimatedRows");
@@ -1192,9 +1428,7 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
   }
 
   /**
-   * Convenience wrapper that builds {@link #defaultAttrs()} and sets the five 
fields every
-   * connection backed scan emits via {@code ExplainTable.explain}: {@code 
iteratorTypeAndScanSize},
-   * {@code consistency}, {@code explainScanType}, {@code tableName}, and 
{@code keyRanges}.
+   * Convenience wrapper that builds {@link #defaultAttrs()} for scans.
    * @param scanType the {@code explainScanType} string (with its trailing 
space, e.g.
    *                 {@code "FULL SCAN "})
    * @param table    the {@code tableName} value
@@ -1209,9 +1443,8 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     // For a data table scan the per scan INDEX line names the same entity as 
tableName. View and
     // index scans that diverge override indexName on the returned node.
     n.put("indexName", table);
-    // A data-table scan that participated in optimizer index selection 
records its decision rule.
-    // A point lookup short-circuits to the point-lookup rule; any other 
data-table target lands on
-    // the data-table default. Index targets set their own rule on the 
returned node.
+    // A data table scan that participated in optimizer index selection 
records its decision rule.
+    // A point lookup short-circuits to the point lookup rule. Index targets 
set their own rule.
     n.put("indexRule", scanType.trim().startsWith("POINT LOOKUP") ? "point 
lookup" : "data table");
     if (keys != null) {
       n.put("keyRanges", keys);
@@ -1233,6 +1466,15 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     return arr;
   }
 
+  /** Build a {@code rewrites} JSON array for embedding into an expected 
attributes object. */
+  private static ArrayNode rewriteList(String... rewrites) {
+    ArrayNode arr = mapper.createArrayNode();
+    for (String s : rewrites) {
+      arr.add(s);
+    }
+    return arr;
+  }
+
   private static ExplainPlan samplePlan(String way, String scanType) {
     ExplainPlanAttributes a = new 
ExplainPlanAttributesBuilder().setIteratorTypeAndScanSize(way)
       
.setExplainScanType(scanType).setTableName("T").setServerFirstKeyOnlyProjection(true).build();
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 e01fa9d693..cac67da165 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
@@ -19,6 +19,7 @@ package org.apache.phoenix.query.explain;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 import java.sql.Connection;
@@ -184,7 +185,7 @@ public final class ExplainPlanTestUtil {
 
     /**
      * Assert the per-scan index kind token: {@code "LOCAL"}, {@code 
"GLOBAL"}, or
-     * {@code "UNCOVERED GLOBAL"} (null for a data-table target).
+     * {@code "UNCOVERED GLOBAL"}.
      */
     public ExplainPlanAssert indexKind(String expected) {
       assertEquals(at("indexKind"), expected, attributes.getIndexKind());
@@ -193,8 +194,7 @@ public final class ExplainPlanTestUtil {
 
     /**
      * Assert the optimizer's index-selection rule label, e.g. one of the
-     * {@code OptimizerReasons.RULE_*} constants (null when the plan did not 
participate in
-     * optimizer index selection).
+     * {@code OptimizerReasons.RULE_*} constants.
      */
     public ExplainPlanAssert indexRule(String expected) {
       assertEquals(at("indexRule"), expected, attributes.getIndexRule());
@@ -203,7 +203,7 @@ public final class ExplainPlanTestUtil {
 
     /**
      * Assert the index-selection rule label starts with {@code prefix}. 
Useful for the functional
-     * index {@code "matches <expr>"} rule whose suffix is 
expression-dependent.
+     * index {@code "matches <expr>"} rule whose suffix is expression 
dependent.
      */
     public ExplainPlanAssert indexRuleStartsWith(String prefix) {
       String actual = attributes.getIndexRule();
@@ -586,6 +586,108 @@ public final class ExplainPlanTestUtil {
       return new ExplainPlanAssert(subPlans.get(i), this, context + 
".subPlan[" + i + "]");
     }
 
+    /** Assert the tenant id disclosed at the top of the plan. */
+    public ExplainPlanAssert tenant(String expected) {
+      assertEquals(at("tenantId"), expected, attributes.getTenantId());
+      return this;
+    }
+
+    /** Assert that no tenant id disclosure was emitted. */
+    public ExplainPlanAssert tenantNone() {
+      assertNull(at("tenantId") + " expected none but was " + 
attributes.getTenantId(),
+        attributes.getTenantId());
+      return this;
+    }
+
+    /** Assert the view name and its base (physical) table name disclosed at 
the top of the plan. */
+    public ExplainPlanAssert view(String expectedName, String 
expectedBaseName) {
+      assertEquals(at("viewName"), expectedName, attributes.getViewName());
+      assertEquals(at("viewBaseName"), expectedBaseName, 
attributes.getViewBaseName());
+      return this;
+    }
+
+    /** Assert the view name disclosed at the top of the plan. */
+    public ExplainPlanAssert viewName(String expected) {
+      assertEquals(at("viewName"), expected, attributes.getViewName());
+      return this;
+    }
+
+    /** Assert that no view disclosure was emitted. */
+    public ExplainPlanAssert viewNone() {
+      assertNull(at("viewName") + " expected none but was " + 
attributes.getViewName(),
+        attributes.getViewName());
+      return this;
+    }
+
+    /** Assert the CDC change scopes disclosed at the top of the plan. */
+    public ExplainPlanAssert cdcScopes(String expected) {
+      assertEquals(at("cdcScopes"), expected, attributes.getCdcScopes());
+      return this;
+    }
+
+    /** Assert that no CDC scope disclosure was emitted. */
+    public ExplainPlanAssert cdcScopesNone() {
+      assertNull(at("cdcScopes") + " expected none but was " + 
attributes.getCdcScopes(),
+        attributes.getCdcScopes());
+      return this;
+    }
+
+    /** Assert the transaction provider disclosed at the top of the plan. */
+    public ExplainPlanAssert txnProvider(String expected) {
+      assertEquals(at("txnProvider"), expected, attributes.getTxnProvider());
+      return this;
+    }
+
+    /** Assert that no transaction provider disclosure was emitted. */
+    public ExplainPlanAssert txnProviderNone() {
+      assertNull(at("txnProvider") + " expected none but was " + 
attributes.getTxnProvider(),
+        attributes.getTxnProvider());
+      return this;
+    }
+
+    /** Assert the number of distinct rewrite breadcrumbs disclosed at the top 
of the plan. */
+    public ExplainPlanAssert rewriteCount(int expected) {
+      List<String> rewrites = attributes.getRewrites();
+      int actual = rewrites == null ? 0 : rewrites.size();
+      assertEquals(at("rewrites.size"), expected, actual);
+      return this;
+    }
+
+    /** Assert the i-th rewrite breadcrumb. */
+    public ExplainPlanAssert rewrite(int i, String expected) {
+      List<String> rewrites = attributes.getRewrites();
+      assertNotNull(at("rewrites") + " must not be null", rewrites);
+      assertTrue(at("rewrites") + " has no index " + i + " (size=" + 
rewrites.size() + ")",
+        i >= 0 && i < rewrites.size());
+      assertEquals(at("rewrites[" + i + "]"), expected, rewrites.get(i));
+      return this;
+    }
+
+    /** Assert the entire ordered rewrite breadcrumb list matches {@code 
expected}. */
+    public ExplainPlanAssert rewrites(String... expected) {
+      List<String> actual = attributes.getRewrites();
+      List<String> actualOrEmpty = actual == null ? Collections.<String> 
emptyList() : actual;
+      assertEquals(at("rewrites"), Arrays.asList(expected), actualOrEmpty);
+      return this;
+    }
+
+    /** Assert the rewrite breadcrumb list contains {@code expected}. */
+    public ExplainPlanAssert rewriteContains(String expected) {
+      List<String> rewrites = attributes.getRewrites();
+      assertNotNull(at("rewrites") + " must not be null", rewrites);
+      assertTrue(at("rewrites") + " expected to contain '" + expected + "' but 
was " + rewrites,
+        rewrites.contains(expected));
+      return this;
+    }
+
+    /** Assert that no rewrite breadcrumbs were disclosed (null or empty). */
+    public ExplainPlanAssert rewritesNone() {
+      List<String> rewrites = attributes.getRewrites();
+      assertTrue(at("rewrites") + " expected none but was " + rewrites,
+        rewrites == null || rewrites.isEmpty());
+      return this;
+    }
+
     /**
      * Return to the parent assertion after navigating into {@link #rhs()} or 
{@link #subPlan(int)}.
      */

Reply via email to