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 d18b98c647 PHOENIX-7918 Implement EXPLAIN VERBOSE disclosures (#2526)
d18b98c647 is described below

commit d18b98c6472e987cdf113c9523e8f47906b4d119
Author: Andrew Purtell <[email protected]>
AuthorDate: Fri Jun 12 23:10:16 2026 -0700

    PHOENIX-7918 Implement EXPLAIN VERBOSE disclosures (#2526)
    
    Co-authored-by: Claude Opus 4.8[1m] <[email protected]>
---
 .../phoenix/compile/ExplainPlanAttributes.java     | 168 +++++++++++++++-
 .../org/apache/phoenix/compile/HavingCompiler.java |  13 ++
 .../org/apache/phoenix/compile/JoinCompiler.java   |   6 +
 .../org/apache/phoenix/compile/QueryCompiler.java  |   4 +
 .../apache/phoenix/compile/RVCOffsetCompiler.java  |   3 +
 .../apache/phoenix/compile/StatementContext.java   | 113 +++++++++++
 .../apache/phoenix/compile/SubqueryRewriter.java   |  27 ++-
 .../org/apache/phoenix/compile/WhereCompiler.java  |  21 ++
 .../org/apache/phoenix/execute/AggregatePlan.java  |   2 +-
 .../phoenix/execute/ClientAggregatePlan.java       |  25 ++-
 .../org/apache/phoenix/execute/ClientScanPlan.java |  23 ++-
 .../org/apache/phoenix/execute/HashJoinPlan.java   |   2 +-
 .../phoenix/execute/TupleProjectionPlan.java       |  24 ++-
 .../phoenix/iterate/BaseResultIterators.java       |   5 +
 .../org/apache/phoenix/iterate/ExplainTable.java   | 215 ++++++++++++++++++++-
 .../iterate/FilterAggregatingResultIterator.java   |  28 ++-
 .../phoenix/iterate/FilterResultIterator.java      |  27 ++-
 .../apache/phoenix/optimize/QueryOptimizer.java    |  21 +-
 .../org/apache/phoenix/parse/ExplainOptions.java   |   1 +
 .../java/org/apache/phoenix/schema/PTableImpl.java |   2 +
 .../query/explain/ExplainJsonNormalizer.java       |  28 +++
 .../phoenix/query/explain/ExplainPlanTest.java     | 209 +++++++++++++++++++-
 .../phoenix/query/explain/ExplainPlanTestUtil.java | 174 +++++++++++++++++
 23 files changed, 1088 insertions(+), 53 deletions(-)

diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
index 6a6f7c0ea8..24a74a771b 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
@@ -45,14 +45,14 @@ import org.apache.phoenix.schema.PColumn;
   "saltBuckets", "regionsPlanned", "scanTimeRangeMin", "scanTimeRangeMax", 
"splitsChunk",
   "useRoundRobinIterator", "samplingRate", "hexStringRVCOffset", 
"iteratorTypeAndScanSize",
   "scanEstimatedRows", "scanEstimatedSizeInBytes", "serverWhereFilter", 
"serverDistinctFilter",
-  "serverMergeColumns", "serverParsedProjections", 
"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" })
+  "serverMergeColumns", "serverParsedProjections", "serverProject", 
"serverFilters", "ignoredHints",
+  "serverFirstKeyOnlyProjection", "serverEmptyColumnOnlyProjection", 
"serverAggregate",
+  "serverGroupByLimit", "serverSortedBy", "serverOffset", "serverRowLimit", 
"clientFilterBy",
+  "clientFilters", "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)
@@ -100,6 +100,9 @@ public class ExplainPlanAttributes {
   private final String serverDistinctFilter;
   private final Set<PColumn> serverMergeColumns;
   private final Map<String, List<String>> serverParsedProjections;
+  private final List<String> serverProject;
+  private final List<ExplainFilter> serverFilters;
+  private final Map<String, String> ignoredHints;
   private final boolean serverFirstKeyOnlyProjection;
   private final boolean serverEmptyColumnOnlyProjection;
   private final String serverAggregate;
@@ -110,6 +113,7 @@ public class ExplainPlanAttributes {
 
   // Client-side operations
   private final String clientFilterBy;
+  private final List<ExplainFilter> clientFilters;
   private final String clientAggregate;
   private final String clientDistinctFilter;
   private final String clientAfterAggregate;
@@ -177,6 +181,9 @@ public class ExplainPlanAttributes {
     this.serverDistinctFilter = null;
     this.serverMergeColumns = null;
     this.serverParsedProjections = null;
+    this.serverProject = null;
+    this.serverFilters = null;
+    this.ignoredHints = null;
     this.serverFirstKeyOnlyProjection = false;
     this.serverEmptyColumnOnlyProjection = false;
     this.serverAggregate = null;
@@ -185,6 +192,7 @@ public class ExplainPlanAttributes {
     this.serverOffset = null;
     this.serverRowLimit = null;
     this.clientFilterBy = null;
+    this.clientFilters = null;
     this.clientAggregate = null;
     this.clientDistinctFilter = null;
     this.clientAfterAggregate = null;
@@ -227,7 +235,9 @@ public class ExplainPlanAttributes {
     ExplainPlanAttributes rhsJoinQueryExplainPlan, List<ExplainPlanAttributes> 
subPlans,
     String dynamicServerFilter, String afterJoinFilter, Long joinScannerLimit,
     boolean sortMergeSkipMerge, List<HRegionLocation> regionLocations,
-    Integer regionLocationsTotalSize, int numRegionLocationLookups) {
+    Integer regionLocationsTotalSize, int numRegionLocationLookups, 
List<String> serverProject,
+    List<ExplainFilter> serverFilters, Map<String, String> ignoredHints,
+    List<ExplainFilter> clientFilters) {
     this.tenantId = tenantId;
     this.viewName = viewName;
     this.viewBaseName = viewBaseName;
@@ -279,6 +289,9 @@ public class ExplainPlanAttributes {
     this.serverOffset = serverOffset;
     this.serverRowLimit = serverRowLimit;
     this.clientFilterBy = clientFilterBy;
+    this.clientFilters = (clientFilters == null || clientFilters.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(clientFilters));
     this.clientAggregate = clientAggregate;
     this.clientDistinctFilter = clientDistinctFilter;
     this.clientAfterAggregate = clientAfterAggregate;
@@ -301,6 +314,15 @@ public class ExplainPlanAttributes {
     this.regionLocations = regionLocations;
     this.regionLocationsTotalSize = regionLocationsTotalSize;
     this.numRegionLocationLookups = numRegionLocationLookups;
+    this.serverProject = (serverProject == null || serverProject.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(serverProject));
+    this.serverFilters = (serverFilters == null || serverFilters.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(serverFilters));
+    this.ignoredHints = (ignoredHints == null || ignoredHints.isEmpty())
+      ? null
+      : Collections.unmodifiableMap(new LinkedHashMap<>(ignoredHints));
   }
 
   public String getTenantId() {
@@ -464,6 +486,18 @@ public class ExplainPlanAttributes {
     return Collections.unmodifiableMap(copy);
   }
 
+  public List<String> getServerProject() {
+    return serverProject;
+  }
+
+  public List<ExplainFilter> getServerFilters() {
+    return serverFilters;
+  }
+
+  public Map<String, String> getIgnoredHints() {
+    return ignoredHints;
+  }
+
   public boolean isServerFirstKeyOnlyProjection() {
     return serverFirstKeyOnlyProjection;
   }
@@ -496,6 +530,10 @@ public class ExplainPlanAttributes {
     return clientFilterBy;
   }
 
+  public List<ExplainFilter> getClientFilters() {
+    return clientFilters;
+  }
+
   public String getClientAggregate() {
     return clientAggregate;
   }
@@ -581,6 +619,59 @@ public class ExplainPlanAttributes {
     return EXPLAIN_PLAN_INSTANCE;
   }
 
+  /** A single VERBOSE-mode filter predicate. */
+  @JsonPropertyOrder({ "expr", "origin", "pathTestSubtag" })
+  public static class ExplainFilter {
+    private final String expr;
+    private final List<String> origin;
+    private final String pathTestSubtag;
+
+    public ExplainFilter(String expr, List<String> origin, String 
pathTestSubtag) {
+      this.expr = expr;
+      this.origin = (origin == null || origin.isEmpty())
+        ? null
+        : Collections.unmodifiableList(new ArrayList<>(origin));
+      this.pathTestSubtag = pathTestSubtag;
+    }
+
+    public String getExpr() {
+      return expr;
+    }
+
+    public List<String> getOrigin() {
+      return origin;
+    }
+
+    public String getPathTestSubtag() {
+      return pathTestSubtag;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof ExplainFilter)) {
+        return false;
+      }
+      ExplainFilter that = (ExplainFilter) o;
+      return java.util.Objects.equals(expr, that.expr)
+        && java.util.Objects.equals(origin, that.origin)
+        && java.util.Objects.equals(pathTestSubtag, that.pathTestSubtag);
+    }
+
+    @Override
+    public int hashCode() {
+      return java.util.Objects.hash(expr, origin, pathTestSubtag);
+    }
+
+    @Override
+    public String toString() {
+      return "ExplainFilter{expr=" + expr + ", origin=" + origin + ", 
pathTestSubtag="
+        + pathTestSubtag + "}";
+    }
+  }
+
   public static class ExplainPlanAttributesBuilder {
     private String tenantId;
     private String viewName;
@@ -619,6 +710,9 @@ public class ExplainPlanAttributes {
     private String serverDistinctFilter;
     private Set<PColumn> serverMergeColumns;
     private Map<String, List<String>> serverParsedProjections;
+    private List<String> serverProject;
+    private List<ExplainFilter> serverFilters;
+    private Map<String, String> ignoredHints;
     private boolean serverFirstKeyOnlyProjection;
     private boolean serverEmptyColumnOnlyProjection;
     private String serverAggregate;
@@ -627,6 +721,7 @@ public class ExplainPlanAttributes {
     private Integer serverOffset;
     private Long serverRowLimit;
     private String clientFilterBy;
+    private List<ExplainFilter> clientFilters;
     private String clientAggregate;
     private String clientDistinctFilter;
     private String clientAfterAggregate;
@@ -697,6 +792,12 @@ public class ExplainPlanAttributes {
         explainPlanAttributes.getServerParsedProjections();
       this.serverParsedProjections =
         srcServerParsedProjections == null ? null : new 
LinkedHashMap<>(srcServerParsedProjections);
+      List<String> srcServerProject = explainPlanAttributes.getServerProject();
+      this.serverProject = srcServerProject == null ? null : new 
ArrayList<>(srcServerProject);
+      List<ExplainFilter> srcServerFilters = 
explainPlanAttributes.getServerFilters();
+      this.serverFilters = srcServerFilters == null ? null : new 
ArrayList<>(srcServerFilters);
+      Map<String, String> srcIgnoredHints = 
explainPlanAttributes.getIgnoredHints();
+      this.ignoredHints = srcIgnoredHints == null ? null : new 
LinkedHashMap<>(srcIgnoredHints);
       this.serverFirstKeyOnlyProjection = 
explainPlanAttributes.isServerFirstKeyOnlyProjection();
       this.serverEmptyColumnOnlyProjection =
         explainPlanAttributes.isServerEmptyColumnOnlyProjection();
@@ -706,6 +807,8 @@ public class ExplainPlanAttributes {
       this.serverOffset = explainPlanAttributes.getServerOffset();
       this.serverRowLimit = explainPlanAttributes.getServerRowLimit();
       this.clientFilterBy = explainPlanAttributes.getClientFilterBy();
+      List<ExplainFilter> srcClientFilters = 
explainPlanAttributes.getClientFilters();
+      this.clientFilters = srcClientFilters == null ? null : new 
ArrayList<>(srcClientFilters);
       this.clientAggregate = explainPlanAttributes.getClientAggregate();
       this.clientDistinctFilter = 
explainPlanAttributes.getClientDistinctFilter();
       this.clientAfterAggregate = 
explainPlanAttributes.getClientAfterAggregate();
@@ -935,6 +1038,37 @@ public class ExplainPlanAttributes {
       return this;
     }
 
+    public ExplainPlanAttributesBuilder setServerProject(List<String> 
serverProject) {
+      this.serverProject = serverProject == null ? null : new 
ArrayList<>(serverProject);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setServerFilters(List<ExplainFilter> 
serverFilters) {
+      this.serverFilters = serverFilters == null ? null : new 
ArrayList<>(serverFilters);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder addServerFilter(ExplainFilter 
serverFilter) {
+      if (this.serverFilters == null) {
+        this.serverFilters = new ArrayList<>();
+      }
+      this.serverFilters.add(serverFilter);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setIgnoredHints(Map<String, String> 
ignoredHints) {
+      this.ignoredHints = ignoredHints == null ? null : new 
LinkedHashMap<>(ignoredHints);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder addIgnoredHint(String hint, String 
reason) {
+      if (this.ignoredHints == null) {
+        this.ignoredHints = new LinkedHashMap<>();
+      }
+      this.ignoredHints.put(hint, reason);
+      return this;
+    }
+
     public ExplainPlanAttributesBuilder
       setServerFirstKeyOnlyProjection(boolean serverFirstKeyOnlyProjection) {
       this.serverFirstKeyOnlyProjection = serverFirstKeyOnlyProjection;
@@ -977,6 +1111,19 @@ public class ExplainPlanAttributes {
       return this;
     }
 
+    public ExplainPlanAttributesBuilder setClientFilters(List<ExplainFilter> 
clientFilters) {
+      this.clientFilters = clientFilters == null ? null : new 
ArrayList<>(clientFilters);
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder addClientFilter(ExplainFilter 
clientFilter) {
+      if (this.clientFilters == null) {
+        this.clientFilters = new ArrayList<>();
+      }
+      this.clientFilters.add(clientFilter);
+      return this;
+    }
+
     public ExplainPlanAttributesBuilder setClientAggregate(String 
clientAggregate) {
       this.clientAggregate = clientAggregate;
       return this;
@@ -1102,7 +1249,8 @@ public class ExplainPlanAttributes {
         clientSortedBy, clientOffset, clientRowLimit, clientSequenceCount, 
clientCursorName,
         clientSteps, lhsJoinQueryExplainPlan, rhsJoinQueryExplainPlan, 
subPlans,
         dynamicServerFilter, afterJoinFilter, joinScannerLimit, 
sortMergeSkipMerge, regionLocations,
-        regionLocationsTotalSize, numRegionLocationLookups);
+        regionLocationsTotalSize, numRegionLocationLookups, serverProject, 
serverFilters,
+        ignoredHints, clientFilters);
     }
   }
 }
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 0d989a8957..16f77a8b6c 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
@@ -64,6 +64,14 @@ public class HavingCompiler {
       throw new 
SQLExceptionInfo.Builder(SQLExceptionCode.ONLY_AGGREGATE_IN_HAVING_CLAUSE).build()
         .buildException();
     }
+    // Tag the residual HAVING predicate(s) with their origin for VERBOSE 
attribution.
+    if (expression instanceof org.apache.phoenix.expression.AndExpression) {
+      for (Expression child : expression.getChildren()) {
+        context.tagPredicate(child, "HAVING");
+      }
+    } else {
+      context.tagPredicate(expression, "HAVING");
+    }
     return expression;
   }
 
@@ -77,6 +85,11 @@ public class HavingCompiler {
     having.accept(visitor);
     if (!visitor.getMoveToWhereClauseExpressions().isEmpty()) {
       context.addAppliedRewrite("HAVING PREDICATE AS WHERE");
+      // Record the parse nodes lifted from HAVING into WHERE so VERBOSE 
predicate attribution can
+      // distinguish them.
+      for (ParseNode lifted : visitor.getMoveToWhereClauseExpressions()) {
+        context.addLiftedHavingNode(lifted);
+      }
     }
     statement = SelectStatementRewriter.moveFromHavingToWhereClause(statement,
       visitor.getMoveToWhereClauseExpressions());
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 c408d37259..5beab671c8 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
@@ -675,6 +675,12 @@ public class JoinCompiler {
         if (right.getDataType() != toType || right.getSortOrder() != 
toSortOrder) {
           right = CoerceExpression.create(right, toType, toSortOrder, 
right.getMaxLength());
         }
+        // Tag the compiled ON predicates with their origin for VERBOSE 
attribution.
+        String decorrelatedAlias = 
lhsCtx.getDecorrelatedSubqueryAlias(condition);
+        String onOrigin =
+          decorrelatedAlias == null ? "JOIN ON" : "decorrelated from " + 
decorrelatedAlias;
+        lhsCtx.tagPredicate(left, onOrigin);
+        rhsCtx.tagPredicate(right, onOrigin);
         compiled.add(new Pair<Expression, Expression>(left, right));
       }
       // TODO PHOENIX-4618:
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 4bdbd9dbab..5ba9ec29f1 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
@@ -320,6 +320,10 @@ public class QueryCompiler {
       JoinTable joinTable = JoinCompiler.compile(statement, select, 
context.getResolver(), context);
       return compileJoinQuery(context, joinTable, false, false, null);
     } else {
+      // A USE_SORT_MERGE_JOIN hint on a query without any join is ignored.
+      if (select.getHint().hasHint(Hint.USE_SORT_MERGE_JOIN)) {
+        context.recordIgnoredHint(Hint.USE_SORT_MERGE_JOIN, "no join in 
query");
+      }
       return compileSingleQuery(context, select, false, true);
     }
   }
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 a7c124f256..abb3270017 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
@@ -180,6 +180,9 @@ public class RVCOffsetCompiler {
       throw new RowValueConstructorOffsetInternalErrorException("RVC Offset 
unexpected failure.");
     }
 
+    // Tag the RVC offset predicate with its origin for VERBOSE attribution.
+    context.tagPredicate(whereExpression, "RVC OFFSET");
+
     Expression expression;
     try {
       expression = WhereOptimizer.pushKeyExpressionsToScan(context, 
originalHints, whereExpression,
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 0c89d1c82c..17cd40305a 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
@@ -24,7 +24,10 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Deque;
+import java.util.EnumMap;
+import java.util.IdentityHashMap;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -42,6 +45,7 @@ import org.apache.phoenix.monitoring.ScanMetricsHolder;
 import org.apache.phoenix.monitoring.SlowestScanMetricsQueue;
 import org.apache.phoenix.monitoring.TopNTreeMultiMap;
 import org.apache.phoenix.parse.ExplainOptions;
+import org.apache.phoenix.parse.HintNode.Hint;
 import org.apache.phoenix.parse.ParseNode;
 import org.apache.phoenix.parse.SelectStatement;
 import org.apache.phoenix.query.QueryConstants;
@@ -114,6 +118,10 @@ public class StatementContext {
   private Map<String, List<Expression>> serverParsedProjections;
   private StatementContext parentContext;
   private ExplainOptions explainOptions = ExplainOptions.DEFAULT;
+  private Map<Expression, Set<String>> predicateOrigins;
+  private Set<ParseNode> liftedHavingNodes;
+  private Map<ParseNode, String> decorrelatedSubqueryAlias;
+  private Map<Hint, String> ignoredHints;
 
   public StatementContext(PhoenixStatement statement) {
     this(statement, new Scan());
@@ -159,6 +167,10 @@ public class StatementContext {
     this.serverParsedProjections = context.serverParsedProjections;
     this.parentContext = context.parentContext;
     this.explainOptions = context.explainOptions;
+    this.predicateOrigins = context.predicateOrigins;
+    this.liftedHavingNodes = context.liftedHavingNodes;
+    this.decorrelatedSubqueryAlias = context.decorrelatedSubqueryAlias;
+    this.ignoredHints = context.ignoredHints;
   }
 
   /**
@@ -229,6 +241,10 @@ public class StatementContext {
     this.partialIndexCheckedSet = Sets.newHashSet();
     this.serverParsedProjections = null;
     this.parentContext = null;
+    this.predicateOrigins = new IdentityHashMap<>();
+    this.liftedHavingNodes = Collections.newSetFromMap(new 
IdentityHashMap<>());
+    this.decorrelatedSubqueryAlias = new IdentityHashMap<>();
+    this.ignoredHints = new EnumMap<>(Hint.class);
   }
 
   /**
@@ -523,6 +539,10 @@ public class StatementContext {
     this.functionalIndexNames = source.functionalIndexNames;
     this.partialIndexCheckedSet = source.partialIndexCheckedSet;
     this.serverParsedProjections = source.serverParsedProjections;
+    this.predicateOrigins = source.predicateOrigins;
+    this.liftedHavingNodes = source.liftedHavingNodes;
+    this.decorrelatedSubqueryAlias = source.decorrelatedSubqueryAlias;
+    this.ignoredHints = source.ignoredHints;
   }
 
   public void incrementDerivedTableFlattenCount() {
@@ -622,6 +642,99 @@ public class StatementContext {
     this.explainOptions = explainOptions == null ? ExplainOptions.DEFAULT : 
explainOptions;
   }
 
+  /** Returns true if the {@code EXPLAIN} statement requested {@code VERBOSE} 
output. */
+  public boolean isVerbose() {
+    return explainOptions != null && explainOptions.isVerbose();
+  }
+
+  /**
+   * Tag a predicate {@link Expression} with the given origin (e.g. {@code 
"WHERE"},
+   * {@code "JOIN ON"}). Tags accumulate as a set so a predicate fused from 
multiple sources carries
+   * every contributing origin. Keyed by object identity, since Phoenix 
rewrites and re-instantiates
+   * expressions during compilation. Diagnostic only; never affects the 
compiled plan.
+   */
+  public void tagPredicate(Expression expression, String origin) {
+    if (expression == null || origin == null) {
+      return;
+    }
+    predicateOrigins.computeIfAbsent(expression, k -> new 
LinkedHashSet<>()).add(origin);
+  }
+
+  /**
+   * Propagate the accumulated origin tags of each source expression onto a 
freshly minted
+   * destination expression. Used by expression rewriters/clone visitors so 
identity-keyed tags
+   * survive node replacement.
+   */
+  public void unionTags(Expression dst, Iterable<? extends Expression> srcs) {
+    if (dst == null || srcs == null) {
+      return;
+    }
+    for (Expression src : srcs) {
+      Set<String> tags = predicateOrigins.get(src);
+      if (tags != null && !tags.isEmpty()) {
+        predicateOrigins.computeIfAbsent(dst, k -> new 
LinkedHashSet<>()).addAll(tags);
+      }
+    }
+  }
+
+  /** Returns the predicate origin tags accumulated during compilation. 
Identity keyed. */
+  public Map<Expression, Set<String>> getPredicateOrigins() {
+    return predicateOrigins;
+  }
+
+  /** Returns the origin tags recorded for {@code expression}, or an empty set 
if none. */
+  public Set<String> getPredicateOrigins(Expression expression) {
+    Set<String> tags = predicateOrigins.get(expression);
+    return tags == null ? Collections.emptySet() : tags;
+  }
+
+  /** Records a parse node lifted from HAVING into the WHERE clause (identity 
keyed). */
+  public void addLiftedHavingNode(ParseNode node) {
+    if (node != null) {
+      liftedHavingNodes.add(node);
+    }
+  }
+
+  /** Returns true if {@code node} was lifted from HAVING into the WHERE 
clause. */
+  public boolean isLiftedHavingNode(ParseNode node) {
+    return liftedHavingNodes.contains(node);
+  }
+
+  /**
+   * Records that the given decorrelated join-condition parse node originated 
from the subquery with
+   * the given temp alias.
+   */
+  public void putDecorrelatedSubqueryAlias(ParseNode joinConditionNode, String 
subqueryAlias) {
+    if (joinConditionNode != null && subqueryAlias != null) {
+      decorrelatedSubqueryAlias.put(joinConditionNode, subqueryAlias);
+    }
+  }
+
+  /**
+   * Returns the subquery temp alias the given join-condition parse node was 
decorrelated from, or
+   * {@code null} if the node is not a decorrelated predicate.
+   */
+  public String getDecorrelatedSubqueryAlias(ParseNode joinConditionNode) {
+    return decorrelatedSubqueryAlias.get(joinConditionNode);
+  }
+
+  /**
+   * Records that the planner intentionally ignored {@code hint} for the given 
{@code reason}.
+   * Surfaced under {@code EXPLAIN (VERBOSE)} as a {@code /*- HINT(args) -- 
reason *}{@code /}
+   * comment. The first reason recorded for a hint wins. Diagnostic only.
+   */
+  public void recordIgnoredHint(Hint hint, String reason) {
+    if (hint == null || reason == null) {
+      return;
+    }
+    ignoredHints.putIfAbsent(hint, reason);
+  }
+
+  /** Returns the hints the planner intentionally ignored, mapped to the 
reason. */
+  public Map<Hint, String> getIgnoredHints() {
+    return ignoredHints;
+  }
+
   /** Returns true if this is the top-level (root) statement context, i.e. it 
has no parent. */
   public boolean isRoot() {
     return parentContext == null;
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 19f2ddcc76..b5fd3bcde4 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
@@ -233,7 +233,7 @@ public class SubqueryRewriter extends ParseNodeRewriter {
     String subqueryTableTempAlias = ParseNodeFactory.createTempAlias();
 
     JoinConditionExtractor joinConditionExtractor = new JoinConditionExtractor(
-      subquerySelectStatementToUse, columnResolver, connection, 
subqueryTableTempAlias);
+      subquerySelectStatementToUse, columnResolver, connection, 
subqueryTableTempAlias, context);
 
     List<AliasedNode> newSubquerySelectAliasedNodes = null;
     ParseNode extractedJoinConditionParseNode = null;
@@ -380,7 +380,7 @@ public class SubqueryRewriter extends ParseNodeRewriter {
       fixSubqueryStatement(subqueryParseNode.getSelectNode());
     String subqueryTableTempAlias = ParseNodeFactory.createTempAlias();
     JoinConditionExtractor joinConditionExtractor = new JoinConditionExtractor(
-      subquerySelectStatementToUse, columnResolver, connection, 
subqueryTableTempAlias);
+      subquerySelectStatementToUse, columnResolver, connection, 
subqueryTableTempAlias, context);
     ParseNode whereParseNodeAfterExtract = 
subquerySelectStatementToUse.getWhere() == null
       ? null
       : subquerySelectStatementToUse.getWhere().accept(joinConditionExtractor);
@@ -453,7 +453,7 @@ public class SubqueryRewriter extends ParseNodeRewriter {
     SelectStatement subquery = 
fixSubqueryStatement(subqueryNode.getSelectNode());
     String rhsTableAlias = ParseNodeFactory.createTempAlias();
     JoinConditionExtractor conditionExtractor =
-      new JoinConditionExtractor(subquery, columnResolver, connection, 
rhsTableAlias);
+      new JoinConditionExtractor(subquery, columnResolver, connection, 
rhsTableAlias, context);
     ParseNode where =
       subquery.getWhere() == null ? null : 
subquery.getWhere().accept(conditionExtractor);
     if (where == subquery.getWhere()) { // non-correlated comparison subquery, 
add LIMIT 2,
@@ -540,7 +540,7 @@ public class SubqueryRewriter extends ParseNodeRewriter {
     SelectStatement subquery = 
fixSubqueryStatement(subqueryNode.getSelectNode());
     String rhsTableAlias = ParseNodeFactory.createTempAlias();
     JoinConditionExtractor conditionExtractor =
-      new JoinConditionExtractor(subquery, columnResolver, connection, 
rhsTableAlias);
+      new JoinConditionExtractor(subquery, columnResolver, connection, 
rhsTableAlias, context);
     ParseNode where =
       subquery.getWhere() == null ? null : 
subquery.getWhere().accept(conditionExtractor);
     if (where == subquery.getWhere()) { // non-correlated any/all comparison 
subquery
@@ -737,14 +737,19 @@ public class SubqueryRewriter extends ParseNodeRewriter {
 
   private static class JoinConditionExtractor extends 
AndRewriterBooleanParseNodeVisitor {
     private final TableName tableName;
+    private final String tableAlias;
+    private final StatementContext context;
     private ColumnResolveVisitor columnResolveVisitor;
     private List<AliasedNode> additionalSubselectSelectAliasedNodes;
     private List<ParseNode> joinConditionParseNodes;
 
     public JoinConditionExtractor(SelectStatement subquery, ColumnResolver 
outerResolver,
-      PhoenixConnection connection, String tableAlias) throws SQLException {
+      PhoenixConnection connection, String tableAlias, StatementContext 
context)
+      throws SQLException {
       super(NODE_FACTORY);
       this.tableName = NODE_FACTORY.table(null, tableAlias);
+      this.tableAlias = tableAlias;
+      this.context = context;
       ColumnResolver localResolver = 
FromCompiler.getResolverForQuery(subquery, connection);
       this.columnResolveVisitor = new ColumnResolveVisitor(localResolver, 
outerResolver);
       this.additionalSubselectSelectAliasedNodes = Lists.<AliasedNode> 
newArrayList();
@@ -807,7 +812,11 @@ public class SubqueryRewriter extends ParseNodeRewriter {
         this.additionalSubselectSelectAliasedNodes
           .add(NODE_FACTORY.aliasedNode(alias, node.getLHS()));
         ParseNode lhsNode = NODE_FACTORY.column(tableName, alias, null);
-        this.joinConditionParseNodes.add(NODE_FACTORY.equal(lhsNode, 
node.getRHS()));
+        ParseNode joinCondition = NODE_FACTORY.equal(lhsNode, node.getRHS());
+        if (context != null) {
+          context.putDecorrelatedSubqueryAlias(joinCondition, tableAlias);
+        }
+        this.joinConditionParseNodes.add(joinCondition);
         return null;
       }
       if (
@@ -818,7 +827,11 @@ public class SubqueryRewriter extends ParseNodeRewriter {
         this.additionalSubselectSelectAliasedNodes
           .add(NODE_FACTORY.aliasedNode(alias, node.getRHS()));
         ParseNode rhsNode = NODE_FACTORY.column(tableName, alias, null);
-        this.joinConditionParseNodes.add(NODE_FACTORY.equal(node.getLHS(), 
rhsNode));
+        ParseNode joinCondition = NODE_FACTORY.equal(node.getLHS(), rhsNode);
+        if (context != null) {
+          context.putDecorrelatedSubqueryAlias(joinCondition, tableAlias);
+        }
+        this.joinConditionParseNodes.add(joinCondition);
         return null;
       }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereCompiler.java
index 6dee0c2f51..4d4ac7717f 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/WhereCompiler.java
@@ -254,11 +254,32 @@ public class WhereCompiler {
         scan.withStopRow(whereCompiler.getScanEndKey());
       }
     }
+    // Tag the residual predicate(s) that become the server-side filter with 
their "WHERE" origin
+    // so EXPLAIN can attribute each SERVER FILTER BY line.
+    tagWhereOrigins(context, expression);
+
     setScanFilter(context, statement, expression, 
whereCompiler.disambiguateWithFamily);
 
     return expression;
   }
 
+  /**
+   * Tag the top-level conjuncts of the residual WHERE expression with their 
origin for VERBOSE
+   * predicate source attribution.
+   */
+  private static void tagWhereOrigins(StatementContext context, Expression 
expression) {
+    if (expression == null) {
+      return;
+    }
+    if (expression instanceof AndExpression) {
+      for (Expression child : expression.getChildren()) {
+        context.tagPredicate(child, "WHERE");
+      }
+    } else {
+      context.tagPredicate(expression, "WHERE");
+    }
+  }
+
   public static class WhereExpressionCompiler extends ExpressionCompiler {
     protected boolean disambiguateWithFamily;
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/AggregatePlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/AggregatePlan.java
index 2c5d62b2a5..8a8ad87f8a 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/AggregatePlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/AggregatePlan.java
@@ -330,7 +330,7 @@ public class AggregatePlan extends BaseQueryPlan {
     }
 
     if (having != null) {
-      aggResultIterator = new 
FilterAggregatingResultIterator(aggResultIterator, having);
+      aggResultIterator = new 
FilterAggregatingResultIterator(aggResultIterator, having, context);
     }
 
     if (statement.isDistinct() && statement.isAggregate()) { // Dedup on 
client if select distinct
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 4ecdb69117..b6b3e69219 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
@@ -22,6 +22,7 @@ import static 
org.apache.phoenix.query.QueryConstants.UNGROUPED_AGG_ROW_KEY;
 
 import java.io.IOException;
 import java.sql.SQLException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import org.apache.hadoop.hbase.Cell;
@@ -29,6 +30,7 @@ import org.apache.hadoop.hbase.client.Scan;
 import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
+import org.apache.phoenix.compile.ExplainPlanAttributes.ExplainFilter;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
 import org.apache.phoenix.compile.GroupByCompiler.GroupBy;
 import org.apache.phoenix.compile.OrderByCompiler.OrderBy;
@@ -136,7 +138,7 @@ public class ClientAggregatePlan extends 
ClientProcessingPlan {
   public ResultIterator iterator(ParallelScanGrouper scanGrouper, Scan scan) 
throws SQLException {
     ResultIterator iterator = delegate.iterator(scanGrouper, scan);
     if (where != null) {
-      iterator = new FilterResultIterator(iterator, where);
+      iterator = new FilterResultIterator(iterator, where, context);
     }
 
     AggregatingResultIterator aggResultIterator;
@@ -186,7 +188,7 @@ public class ClientAggregatePlan extends 
ClientProcessingPlan {
     }
 
     if (having != null) {
-      aggResultIterator = new 
FilterAggregatingResultIterator(aggResultIterator, having);
+      aggResultIterator = new 
FilterAggregatingResultIterator(aggResultIterator, having, context);
     }
 
     if (statement.isDistinct() && statement.isAggregate()) { // Dedup on 
client if select distinct
@@ -227,10 +229,21 @@ public class ClientAggregatePlan extends 
ClientProcessingPlan {
     ExplainPlanAttributesBuilder newBuilder =
       new ExplainPlanAttributesBuilder(explainPlanAttributes);
     if (where != null) {
-      String step = "CLIENT FILTER BY " + where.toString();
-      planSteps.add(step);
-      newBuilder.setClientFilterBy(where.toString());
-      newBuilder.addClientStep(step);
+      if (context.isVerbose()) {
+        List<String> filterLines = new ArrayList<>();
+        List<ExplainFilter> clientFilters = 
ExplainTable.renderVerboseFilters(context, where,
+          where.toString(), "CLIENT FILTER BY", filterLines);
+        for (String filterLine : filterLines) {
+          planSteps.add(filterLine);
+          newBuilder.addClientStep(filterLine);
+        }
+        newBuilder.setClientFilters(clientFilters);
+      } else {
+        String step = "CLIENT FILTER BY " + where.toString();
+        planSteps.add(step);
+        newBuilder.setClientFilterBy(where.toString());
+        newBuilder.addClientStep(step);
+      }
     }
     if (groupBy.isEmpty()) {
       String step = "CLIENT AGGREGATE INTO SINGLE ROW";
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 ec9f15d801..895853ee6a 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
@@ -18,11 +18,13 @@
 package org.apache.phoenix.execute;
 
 import java.sql.SQLException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import org.apache.hadoop.hbase.client.Scan;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
+import org.apache.phoenix.compile.ExplainPlanAttributes.ExplainFilter;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
 import org.apache.phoenix.compile.OrderByCompiler.OrderBy;
 import org.apache.phoenix.compile.QueryPlan;
@@ -87,7 +89,7 @@ public class ClientScanPlan extends ClientProcessingPlan {
   public ResultIterator iterator(ParallelScanGrouper scanGrouper, Scan scan) 
throws SQLException {
     ResultIterator iterator = delegate.iterator(scanGrouper, scan);
     if (where != null) {
-      iterator = new FilterResultIterator(iterator, where);
+      iterator = new FilterResultIterator(iterator, where, context);
     }
 
     if (!orderBy.getOrderByExpressions().isEmpty()) { // TopN
@@ -124,10 +126,21 @@ public class ClientScanPlan extends ClientProcessingPlan {
     ExplainPlanAttributesBuilder newBuilder =
       new ExplainPlanAttributesBuilder(explainPlanAttributes);
     if (where != null) {
-      String step = "CLIENT FILTER BY " + where.toString();
-      planSteps.add(step);
-      newBuilder.setClientFilterBy(where.toString());
-      newBuilder.addClientStep(step);
+      if (context.isVerbose()) {
+        List<String> filterLines = new ArrayList<>();
+        List<ExplainFilter> clientFilters = 
ExplainTable.renderVerboseFilters(context, where,
+          where.toString(), "CLIENT FILTER BY", filterLines);
+        for (String filterLine : filterLines) {
+          planSteps.add(filterLine);
+          newBuilder.addClientStep(filterLine);
+        }
+        newBuilder.setClientFilters(clientFilters);
+      } else {
+        String step = "CLIENT FILTER BY " + where.toString();
+        planSteps.add(step);
+        newBuilder.setClientFilterBy(where.toString());
+        newBuilder.addClientStep(step);
+      }
     }
     if (!orderBy.getOrderByExpressions().isEmpty()) {
       if (offset != null) {
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 9995c2f06c..7732d4d6e7 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
@@ -273,7 +273,7 @@ public class HashJoinPlan extends DelegateQueryPlan {
       ? delegate.iterator(scanGrouper, scan)
       : ((BaseQueryPlan) delegate).iterator(dependencies, scanGrouper, scan);
     if (statement.getInnerSelectStatement() != null && postFilter != null) {
-      iterator = new FilterResultIterator(iterator, postFilter);
+      iterator = new FilterResultIterator(iterator, postFilter, 
delegate.getContext());
     }
 
     if (hasSubPlansWithPersistentCache) {
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
index 413f046d22..bc2e1b200f 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/TupleProjectionPlan.java
@@ -27,6 +27,7 @@ import org.apache.hadoop.hbase.client.Scan;
 import org.apache.phoenix.compile.ColumnResolver;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
+import org.apache.phoenix.compile.ExplainPlanAttributes.ExplainFilter;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
 import org.apache.phoenix.compile.GroupByCompiler.GroupBy;
 import org.apache.phoenix.compile.OrderByCompiler.OrderBy;
@@ -40,6 +41,7 @@ import org.apache.phoenix.expression.Expression;
 import org.apache.phoenix.expression.OrderByExpression;
 import org.apache.phoenix.expression.ProjectedColumnExpression;
 import org.apache.phoenix.iterate.DelegateResultIterator;
+import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.iterate.FilterResultIterator;
 import org.apache.phoenix.iterate.ParallelScanGrouper;
 import org.apache.phoenix.iterate.ResultIterator;
@@ -53,6 +55,7 @@ public class TupleProjectionPlan extends DelegateQueryPlan {
   private final TupleProjector tupleProjector;
   private final Expression postFilter;
   private final ColumnResolver columnResolver;
+  private final StatementContext statementContext;
   private final List<OrderBy> actualOutputOrderBys;
 
   public TupleProjectionPlan(QueryPlan plan, TupleProjector tupleProjector,
@@ -63,6 +66,7 @@ public class TupleProjectionPlan extends DelegateQueryPlan {
     }
     this.tupleProjector = tupleProjector;
     this.postFilter = postFilter;
+    this.statementContext = statementContext;
     if (statementContext != null) {
       this.columnResolver = statementContext.getResolver();
       this.actualOutputOrderBys = this.convertInputOrderBys(plan);
@@ -147,12 +151,22 @@ public class TupleProjectionPlan extends 
DelegateQueryPlan {
     List<String> planSteps = Lists.newArrayList(explainPlan.getPlanSteps());
     ExplainPlanAttributes explainPlanAttributes = 
explainPlan.getPlanStepsAsAttributes();
     if (postFilter != null) {
-      String step = "CLIENT FILTER BY " + postFilter.toString();
-      planSteps.add(step);
       ExplainPlanAttributesBuilder newBuilder =
         new ExplainPlanAttributesBuilder(explainPlanAttributes);
-      newBuilder.setClientFilterBy(postFilter.toString());
-      newBuilder.addClientStep(step);
+      if (statementContext != null && statementContext.isVerbose()) {
+        int from = planSteps.size();
+        List<ExplainFilter> filters = 
ExplainTable.renderVerboseFilters(statementContext,
+          postFilter, postFilter.toString(), "CLIENT FILTER BY", planSteps);
+        for (int i = from; i < planSteps.size(); i++) {
+          newBuilder.addClientStep(planSteps.get(i));
+        }
+        newBuilder.setClientFilters(filters);
+      } else {
+        String step = "CLIENT FILTER BY " + postFilter.toString();
+        planSteps.add(step);
+        newBuilder.setClientFilterBy(postFilter.toString());
+        newBuilder.addClientStep(step);
+      }
       explainPlanAttributes = newBuilder.build();
     }
 
@@ -178,7 +192,7 @@ public class TupleProjectionPlan extends DelegateQueryPlan {
     };
 
     if (postFilter != null) {
-      iterator = new FilterResultIterator(iterator, postFilter);
+      iterator = new FilterResultIterator(iterator, postFilter, 
statementContext);
     }
 
     return iterator;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java
index 87956b1141..64ce3cb73e 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java
@@ -651,6 +651,11 @@ public abstract class BaseResultIterators extends 
ExplainTable implements Result
     return plan.getOptimizerDecision();
   }
 
+  @Override
+  protected org.apache.phoenix.compile.RowProjector getProjector() {
+    return plan.getProjector();
+  }
+
   @Override
   public List<List<Scan>> getScans() {
     if (scans == null) return Collections.emptyList();
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 a096fd574c..dff466b3c8 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
@@ -37,15 +37,21 @@ 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.ColumnProjector;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
+import org.apache.phoenix.compile.ExplainPlanAttributes.ExplainFilter;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
 import org.apache.phoenix.compile.GroupByCompiler.GroupBy;
 import org.apache.phoenix.compile.OrderByCompiler.OrderBy;
+import org.apache.phoenix.compile.RowProjector;
 import org.apache.phoenix.compile.ScanRanges;
 import org.apache.phoenix.compile.StatementContext;
 import org.apache.phoenix.compile.StatementPlan;
 import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants;
+import org.apache.phoenix.expression.AndExpression;
 import org.apache.phoenix.expression.Expression;
+import org.apache.phoenix.expression.function.BsonConditionExpressionFunction;
+import org.apache.phoenix.expression.function.JsonExistsFunction;
 import org.apache.phoenix.filter.BooleanExpressionFilter;
 import org.apache.phoenix.filter.DistinctPrefixFilter;
 import org.apache.phoenix.filter.EmptyColumnOnlyFilter;
@@ -153,6 +159,14 @@ public abstract class ExplainTable {
     return null;
   }
 
+  /**
+   * The post-compile row projector for the plan this scan belongs to. Returns 
{@code null} when the
+   * plan has no projector.
+   */
+  protected RowProjector getProjector() {
+    return null;
+  }
+
   /**
    * Whether {@code rule} is a default rule whose {@code INDEX} comment is 
suppressed. The default
    * rules are {@link OptimizerReasons#RULE_DATA_TABLE} (no candidate indexes 
considered) and
@@ -297,6 +311,7 @@ public abstract class ExplainTable {
     StringBuilder buf = new StringBuilder(prefix);
     ScanRanges scanRanges = context.getScanRanges();
     Scan scan = context.getScan();
+    boolean verbose = context.isVerbose();
 
     if (scan.getConsistency() != Consistency.STRONG) {
       buf.append("TIMELINE-CONSISTENCY ");
@@ -340,6 +355,8 @@ public abstract class ExplainTable {
       }
     }
 
+    emitProject(planSteps, explainPlanAttributesBuilder, verbose);
+
     PTable.IndexType indexType = tableRef.getTable().getIndexType();
     String explainIndexName = getExplainIndexName(tableRef.getTable());
     String indexKind = null;
@@ -367,7 +384,7 @@ public abstract class ExplainTable {
       indexLine.append("  /* ").append(decision.getRule()).append(" */");
     }
     planSteps.add(indexLine.toString());
-    if (decision != null) {
+    if (verbose && decision != null) {
       for (RejectedIndexEntry rejected : decision.getRejectedIndexes()) {
         planSteps
           .add("    /* !INDEX " + rejected.getName() + " -- " + 
rejected.getReason() + " */");
@@ -455,11 +472,17 @@ public abstract class ExplainTable {
         explainPlanAttributesBuilder.setServerEmptyColumnOnlyProjection(true);
       }
     }
+    emitIgnoredHints(planSteps, explainPlanAttributesBuilder, verbose);
     if (whereFilterStr != null) {
-      String serverWhereFilter = "SERVER FILTER BY " + whereFilterStr;
-      planSteps.add("    " + serverWhereFilter);
-      if (explainPlanAttributesBuilder != null) {
-        explainPlanAttributesBuilder.setServerWhereFilter(serverWhereFilter);
+      if (verbose) {
+        emitServerFilters(planSteps, explainPlanAttributesBuilder,
+          whereFilter == null ? null : whereFilter.getExpression(), 
whereFilterStr);
+      } else {
+        String serverWhereFilter = "SERVER FILTER BY " + whereFilterStr;
+        planSteps.add("    " + serverWhereFilter);
+        if (explainPlanAttributesBuilder != null) {
+          explainPlanAttributesBuilder.setServerWhereFilter(serverWhereFilter);
+        }
       }
     }
     if (distinctFilter != null) {
@@ -562,6 +585,188 @@ public abstract class ExplainTable {
     }
   }
 
+  /** Emit the VERBOSE-only {@code PROJECT <cols>} line and populate {@code 
serverProject}. */
+  private void emitProject(List<String> planSteps,
+    ExplainPlanAttributesBuilder explainPlanAttributesBuilder, boolean 
verbose) {
+    if (!verbose) {
+      return;
+    }
+    RowProjector projector = getProjector();
+    if (projector == null) {
+      return;
+    }
+    List<? extends ColumnProjector> columnProjectors = 
projector.getColumnProjectors();
+    if (columnProjectors == null || columnProjectors.isEmpty()) {
+      return;
+    }
+    List<String> columns = new ArrayList<>(columnProjectors.size());
+    for (ColumnProjector columnProjector : columnProjectors) {
+      String name = columnProjector.getName();
+      if (name == null || name.isEmpty()) {
+        name = String.valueOf(columnProjector.getExpression());
+      }
+      columns.add(name);
+    }
+    planSteps.add("    PROJECT " + String.join(", ", columns));
+    if (explainPlanAttributesBuilder != null) {
+      explainPlanAttributesBuilder.setServerProject(columns);
+    }
+  }
+
+  /**
+   * Emit the VERBOSE-only ignored-hint comments, one {@code /*- HINT(args) -- 
reason *}{@code /}
+   * line per hint the planner intentionally ignored.
+   */
+  private void emitIgnoredHints(List<String> planSteps,
+    ExplainPlanAttributesBuilder explainPlanAttributesBuilder, boolean 
verbose) {
+    if (!verbose) {
+      return;
+    }
+    Map<Hint, String> ignoredHints = context.getIgnoredHints();
+    if (ignoredHints == null || ignoredHints.isEmpty()) {
+      return;
+    }
+    for (Map.Entry<Hint, String> entry : ignoredHints.entrySet()) {
+      Hint ignoredHint = entry.getKey();
+      String args = hint == null ? null : hint.getHint(ignoredHint);
+      String rendered = ignoredHint.name() + (args == null ? "" : args);
+      planSteps.add("    /*- " + rendered + " -- " + entry.getValue() + " */");
+      if (explainPlanAttributesBuilder != null) {
+        explainPlanAttributesBuilder.addIgnoredHint(ignoredHint.name(), 
entry.getValue());
+      }
+    }
+  }
+
+  /**
+   * Emit the VERBOSE-only per-predicate {@code SERVER FILTER BY <expr>  -- 
<origin>} lines and
+   * populate {@code serverFilters}. When the top-level filter is an {@link 
AndExpression} whose
+   * children all carry origin tags, one line is emitted per child. Otherwise 
a single line carries
+   * the comma-separated union of origins.
+   */
+  private void emitServerFilters(List<String> planSteps,
+    ExplainPlanAttributesBuilder explainPlanAttributesBuilder, Expression 
whereExpression,
+    String whereFilterStr) {
+    List<ExplainFilter> filters = renderVerboseFilters(context, 
whereExpression, whereFilterStr,
+      "    SERVER FILTER BY", planSteps);
+    if (explainPlanAttributesBuilder != null) {
+      explainPlanAttributesBuilder.setServerFilters(filters);
+    }
+  }
+
+  /**
+   * Render the VERBOSE per-predicate filter breakdown. Each emitted line is 
appended to
+   * {@code planSteps}.
+   * @param context           the statement context carrying the 
predicate-origin tags.
+   * @param filterExpression  the residual filter expression (may be {@code 
null} when only the
+   *                          rendered string is available, e.g. an 
index-serialized filter).
+   * @param combinedFilterStr the combined filter string used for the 
single-line fallback.
+   * @param linePrefix        the full leading token including any 
indentation, e.g.
+   *                          {@code "    SERVER FILTER BY"} or {@code "CLIENT 
FILTER BY"}.
+   * @param planSteps         the plan-steps list to append rendered lines to.
+   * @return the structured filters in render order (never {@code null}; never 
empty).
+   */
+  public static List<ExplainFilter> renderVerboseFilters(StatementContext 
context,
+    Expression filterExpression, String combinedFilterStr, String linePrefix,
+    List<String> planSteps) {
+    List<ExplainFilter> filters = new ArrayList<>();
+    if (filterExpression instanceof AndExpression) {
+      List<Expression> children = filterExpression.getChildren();
+      boolean allTagged = !children.isEmpty();
+      for (Expression child : children) {
+        if (context.getPredicateOrigins(child).isEmpty()) {
+          allTagged = false;
+          break;
+        }
+      }
+      if (allTagged) {
+        for (Expression child : children) {
+          filters.add(buildFilter(context, "(" + child + ")", child));
+        }
+      }
+    }
+    if (filters.isEmpty()) {
+      filters.add(buildCombinedFilter(context, combinedFilterStr, 
filterExpression));
+    }
+    for (ExplainFilter filter : filters) {
+      StringBuilder line = new StringBuilder(linePrefix).append(" 
").append(filter.getExpr());
+      String comment = renderOriginComment(filter);
+      if (comment != null) {
+        line.append("  -- ").append(comment);
+      }
+      planSteps.add(line.toString());
+    }
+    return filters;
+  }
+
+  private static ExplainFilter buildFilter(StatementContext context, String 
exprStr,
+    Expression expression) {
+    List<String> origins =
+      expression == null ? null : new 
ArrayList<>(context.getPredicateOrigins(expression));
+    String pathTestSubtag = detectPathTestSubtag(expression);
+    return new ExplainFilter(exprStr, origins, pathTestSubtag);
+  }
+
+  /**
+   * Build the {@link ExplainFilter} for a single combined line. Origin tags 
can live on the parent
+   * expression or its conjunct children, so when we collapse to one line 
union both sets to avoid
+   * dropping the attribution.
+   */
+  private static ExplainFilter buildCombinedFilter(StatementContext context, 
String exprStr,
+    Expression expression) {
+    List<String> origins = expression == null ? null : 
combinedOrigins(context, expression);
+    String pathTestSubtag = detectPathTestSubtag(expression);
+    return new ExplainFilter(exprStr, origins, pathTestSubtag);
+  }
+
+  /** Union of origin tags on {@code expression} and, when it is an AND, its 
conjunct children. */
+  private static List<String> combinedOrigins(StatementContext context, 
Expression expression) {
+    Set<String> origins = new 
LinkedHashSet<>(context.getPredicateOrigins(expression));
+    if (expression instanceof AndExpression) {
+      for (Expression child : expression.getChildren()) {
+        origins.addAll(context.getPredicateOrigins(child));
+      }
+    }
+    return new ArrayList<>(origins);
+  }
+
+  /** Render the trailing origin comment for a server filter. */
+  private static String renderOriginComment(ExplainFilter filter) {
+    StringBuilder comment = new StringBuilder();
+    List<String> origins = filter.getOrigin();
+    if (origins != null && !origins.isEmpty()) {
+      comment.append(String.join(", ", origins));
+    }
+    if (filter.getPathTestSubtag() != null) {
+      if (comment.length() > 0) {
+        comment.append(" ");
+      }
+      comment.append("(").append(filter.getPathTestSubtag()).append(")");
+    }
+    return comment.length() == 0 ? null : comment.toString();
+  }
+
+  /** Detect a path test function anywhere in the expression tree and return 
its sub tag. */
+  private static String detectPathTestSubtag(Expression expression) {
+    if (expression == null) {
+      return null;
+    }
+    if (expression instanceof JsonExistsFunction) {
+      return "JSON EXISTS";
+    }
+    if (expression instanceof BsonConditionExpressionFunction) {
+      return "BSON CONDITION";
+    }
+    if (expression.getChildren() != null) {
+      for (Expression child : expression.getChildren()) {
+        String subtag = detectPathTestSubtag(child);
+        if (subtag != null) {
+          return subtag;
+        }
+      }
+    }
+    return null;
+  }
+
   /**
    * Retrieve region locations and set the values in the explain plan output.
    * @param planSteps                    list of plan steps to add explain 
plan output to.
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
index 82d6ba6824..436e7dc435 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterAggregatingResultIterator.java
@@ -20,7 +20,9 @@ package org.apache.phoenix.iterate;
 import java.sql.SQLException;
 import java.util.List;
 import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
+import org.apache.phoenix.compile.ExplainPlanAttributes.ExplainFilter;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
+import org.apache.phoenix.compile.StatementContext;
 import org.apache.phoenix.expression.Expression;
 import org.apache.phoenix.expression.aggregator.Aggregator;
 import org.apache.phoenix.schema.tuple.Tuple;
@@ -37,12 +39,14 @@ import org.apache.phoenix.schema.types.PBoolean;
 public class FilterAggregatingResultIterator implements 
AggregatingResultIterator {
   private final AggregatingResultIterator delegate;
   private final Expression expression;
+  private final StatementContext context;
   private final ImmutableBytesWritable ptr = new ImmutableBytesWritable();
 
-  public FilterAggregatingResultIterator(AggregatingResultIterator delegate,
-    Expression expression) {
+  public FilterAggregatingResultIterator(AggregatingResultIterator delegate, 
Expression expression,
+    StatementContext context) {
     this.delegate = delegate;
     this.expression = expression;
+    this.context = context;
     if (expression.getDataType() != PBoolean.INSTANCE) {
       throw new IllegalArgumentException(
         "FilterResultIterator requires a boolean expression, but got " + 
expression);
@@ -81,10 +85,22 @@ public class FilterAggregatingResultIterator implements 
AggregatingResultIterato
   public void explain(List<String> planSteps,
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     delegate.explain(planSteps, explainPlanAttributesBuilder);
-    explainPlanAttributesBuilder.setClientFilterBy(expression.toString());
-    String step = "CLIENT FILTER BY " + expression.toString();
-    planSteps.add(step);
-    explainPlanAttributesBuilder.addClientStep(step);
+    if (context != null && context.isVerbose() && explainPlanAttributesBuilder 
!= null) {
+      int from = planSteps.size();
+      List<ExplainFilter> filters = ExplainTable.renderVerboseFilters(context, 
expression,
+        expression.toString(), "CLIENT FILTER BY", planSteps);
+      for (int i = from; i < planSteps.size(); i++) {
+        explainPlanAttributesBuilder.addClientStep(planSteps.get(i));
+      }
+      explainPlanAttributesBuilder.setClientFilters(filters);
+    } else {
+      String step = "CLIENT FILTER BY " + expression.toString();
+      planSteps.add(step);
+      if (explainPlanAttributesBuilder != null) {
+        explainPlanAttributesBuilder.setClientFilterBy(expression.toString());
+        explainPlanAttributesBuilder.addClientStep(step);
+      }
+    }
   }
 
   @Override
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
index 9435f20464..680fdad71e 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/FilterResultIterator.java
@@ -20,7 +20,9 @@ package org.apache.phoenix.iterate;
 import java.sql.SQLException;
 import java.util.List;
 import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
+import org.apache.phoenix.compile.ExplainPlanAttributes.ExplainFilter;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
+import org.apache.phoenix.compile.StatementContext;
 import org.apache.phoenix.expression.Expression;
 import org.apache.phoenix.schema.tuple.Tuple;
 import org.apache.phoenix.schema.types.PBoolean;
@@ -36,15 +38,18 @@ import org.apache.phoenix.schema.types.PBoolean;
 public class FilterResultIterator extends LookAheadResultIterator {
   private final ResultIterator delegate;
   private final Expression expression;
+  private final StatementContext context;
   private final ImmutableBytesWritable ptr = new ImmutableBytesWritable();
 
-  public FilterResultIterator(ResultIterator delegate, Expression expression) {
+  public FilterResultIterator(ResultIterator delegate, Expression expression,
+    StatementContext context) {
     if (delegate instanceof AggregatingResultIterator) {
       throw new IllegalArgumentException(
         "FilterResultScanner may not be used with an aggregate delegate. Use 
phoenix.iterate.FilterAggregateResultScanner instead");
     }
     this.delegate = delegate;
     this.expression = expression;
+    this.context = context;
     if (expression.getDataType() != PBoolean.INSTANCE) {
       throw new IllegalArgumentException(
         "FilterResultIterator requires a boolean expression, but got " + 
expression);
@@ -79,10 +84,22 @@ public class FilterResultIterator extends 
LookAheadResultIterator {
   public void explain(List<String> planSteps,
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
     delegate.explain(planSteps, explainPlanAttributesBuilder);
-    explainPlanAttributesBuilder.setClientFilterBy(expression.toString());
-    String step = "CLIENT FILTER BY " + expression.toString();
-    planSteps.add(step);
-    explainPlanAttributesBuilder.addClientStep(step);
+    if (context != null && context.isVerbose() && explainPlanAttributesBuilder 
!= null) {
+      int from = planSteps.size();
+      List<ExplainFilter> filters = ExplainTable.renderVerboseFilters(context, 
expression,
+        expression.toString(), "CLIENT FILTER BY", planSteps);
+      for (int i = from; i < planSteps.size(); i++) {
+        explainPlanAttributesBuilder.addClientStep(planSteps.get(i));
+      }
+      explainPlanAttributesBuilder.setClientFilters(filters);
+    } else {
+      String step = "CLIENT FILTER BY " + expression.toString();
+      planSteps.add(step);
+      if (explainPlanAttributesBuilder != null) {
+        explainPlanAttributesBuilder.setClientFilterBy(expression.toString());
+        explainPlanAttributesBuilder.addClientStep(step);
+      }
+    }
   }
 
   @Override
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 b16a0ed22e..63fe8e5a8c 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
@@ -250,6 +250,9 @@ public class QueryOptimizer {
       indexHint == null && 
dataPlan.getContext().getScanRanges().isPointLookup() && stopAtBestPlan
         && dataPlan.isApplicable()
     ) {
+      if (select.getHint().hasHint(Hint.NO_INDEX)) {
+        dataPlan.getContext().recordIgnoredHint(Hint.NO_INDEX, "point lookup 
short-circuit");
+      }
       return Collections.<QueryPlan> singletonList(
         recordDecision(dataPlan, OptimizerReasons.RULE_POINT_LOOKUP, state));
     }
@@ -283,6 +286,9 @@ public class QueryOptimizer {
       table = cdcBuilder.build();
       dataPlan.getTableRef().setTable(table);
       forCDC = true;
+      if (select.getHint().hasHint(Hint.NO_INDEX)) {
+        dataPlan.getContext().recordIgnoredHint(Hint.NO_INDEX, "CDC table");
+      }
     }
 
     List<PTable> indexes = 
Lists.newArrayList(dataPlan.getTableRef().getTable().getIndexes());
@@ -292,6 +298,10 @@ public class QueryOptimizer {
     ) {
       if (select.getHint().hasHint(Hint.NO_INDEX)) {
         state.rejectAll(indexes, 
OptimizerReasons.REASON_EXCLUDED_BY_NO_INDEX_HINT);
+        if (indexes.isEmpty()) {
+          // NO_INDEX had no effect: the table has no indexes to exclude.
+          dataPlan.getContext().recordIgnoredHint(Hint.NO_INDEX, "no indexes 
on table");
+        }
       }
       return Collections.<
         QueryPlan> singletonList(recordDecision(dataPlan, 
OptimizerReasons.RULE_DATA_TABLE, state));
@@ -334,6 +344,9 @@ public class QueryOptimizer {
           }
           plans.add(0, hintedPlan);
         }
+      } else if (indexHint != null) {
+        // An INDEX(...) hint was supplied but no hinted index could be built 
into a usable plan.
+        dataPlan.getContext().recordIgnoredHint(Hint.INDEX, "no matching index 
applicable");
       }
     }
 
@@ -395,7 +408,8 @@ public class QueryOptimizer {
    */
   private static void carryForwardRewrites(StatementContext from, 
List<QueryPlan> plans) {
     List<String> rewrites = from.getAppliedRewrites();
-    if (rewrites.isEmpty()) {
+    Map<Hint, String> ignoredHints = from.getIgnoredHints();
+    if (rewrites.isEmpty() && (ignoredHints == null || 
ignoredHints.isEmpty())) {
       return;
     }
     for (QueryPlan plan : plans) {
@@ -408,6 +422,11 @@ public class QueryOptimizer {
           to.addAppliedRewrite(rewrite);
         }
       }
+      if (ignoredHints != null) {
+        for (Map.Entry<Hint, String> entry : ignoredHints.entrySet()) {
+          to.recordIgnoredHint(entry.getKey(), entry.getValue());
+        }
+      }
     }
   }
 
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ExplainOptions.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ExplainOptions.java
index ca8f4bc4c3..776f4a7059 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ExplainOptions.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/ExplainOptions.java
@@ -33,6 +33,7 @@ public final class ExplainOptions {
 
   public static final ExplainOptions DEFAULT = new ExplainOptions(false, 
false, Format.TEXT);
   public static final ExplainOptions WITH_REGIONS = new ExplainOptions(true, 
false, Format.TEXT);
+  public static final ExplainOptions VERBOSE = new ExplainOptions(false, true, 
Format.TEXT);
 
   private final boolean regions;
   private final boolean verbose;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTableImpl.java 
b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTableImpl.java
index dee96ceafc..89f6fc7ad7 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTableImpl.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTableImpl.java
@@ -2537,6 +2537,8 @@ public class PTableImpl implements PTable {
     ParseNode where = plan.getStatement().getWhere();
     
plan.getContext().setResolver(FromCompiler.getResolver(plan.getTableRef()));
     indexWhereExpression = transformDNF(where, plan.getContext());
+    // Tag the partial-index WHERE predicate with its origin for VERBOSE 
attribution.
+    plan.getContext().tagPredicate(indexWhereExpression, "INDEX WHERE");
     indexWhereColumns =
       
Sets.newHashSetWithExpectedSize(plan.getContext().getWhereConditionColumns().size());
     for (Pair<byte[], byte[]> column : 
plan.getContext().getWhereConditionColumns()) {
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
index e07eb68261..6739e09469 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
@@ -125,6 +125,34 @@ public final class ExplainJsonNormalizer {
       }
     }
 
+    // The VERBOSE serverFilters and clientFilters breakdowns carry a rendered 
predicate string per
+    // element. Rewrite any temp aliases inside each element's "expr" so the 
comparison is
+    // invariant under environment differences.
+    rewriteFilterExprs(obj.get("serverFilters"), aliases);
+    rewriteFilterExprs(obj.get("clientFilters"), aliases);
+
     return obj;
   }
+
+  /**
+   * Rewrite temp aliases inside the {@code expr} string of each element of a 
filter-breakdown array
+   * (e.g. {@code serverFilters} or {@code clientFilters}).
+   */
+  private static void rewriteFilterExprs(JsonNode node, TempAliasRenumberer 
aliases) {
+    if (node == null || !node.isArray()) {
+      return;
+    }
+    for (JsonNode element : (ArrayNode) node) {
+      if (element != null && element.isObject()) {
+        JsonNode expr = element.get("expr");
+        if (expr != null && expr.isTextual()) {
+          String original = expr.asText();
+          String rewritten = aliases.rewrite(original);
+          if (!rewritten.equals(original)) {
+            ((ObjectNode) element).put("expr", rewritten);
+          }
+        }
+      }
+    }
+  }
 }
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 9e510fd9e9..03aa689c7b 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
@@ -34,6 +34,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.SQLException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
@@ -46,9 +47,17 @@ import org.apache.hadoop.hbase.TableName;
 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.ExplainFilter;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
+import org.apache.phoenix.compile.QueryPlan;
+import org.apache.phoenix.compile.StatementContext;
+import org.apache.phoenix.expression.AndExpression;
+import org.apache.phoenix.expression.Expression;
+import org.apache.phoenix.expression.LiteralExpression;
 import org.apache.phoenix.iterate.ExplainTable;
+import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
 import org.apache.phoenix.optimize.OptimizerReasons;
+import org.apache.phoenix.parse.ExplainOptions;
 import org.apache.phoenix.query.BaseConnectionlessQueryTest;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.schema.PColumn;
@@ -973,11 +982,205 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
       stmt.execute("CREATE LOCAL INDEX " + idx + " ON " + base + " (c1)");
       String query =
         "SELECT c1, max(rowkey), max(c2) FROM " + base + " WHERE rowkey <= 'z' 
GROUP BY c1";
+      // The structured indexRejected attribute is populated regardless of 
EXPLAIN mode.
       ExplainPlanTestUtil.assertPlan(conn, query).indexName(base)
         
.indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedCount(1)
         .indexRejected(0, idx, OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
       assertPlanContainsLine(conn, query, "    INDEX " + base + "  /* more 
bound PK columns */");
-      assertPlanContainsLine(conn, query, "    /* !INDEX " + idx + " -- no PK 
prefix bound */");
+      // The !INDEX rejection comment text is VERBOSE-only.
+      assertNoPlanLineContains(conn, query, "!INDEX");
+      List<String> verboseSteps =
+        ExplainPlanTestUtil.getPlanSteps(conn, query, ExplainOptions.VERBOSE);
+      assertTrue(
+        "expected VERBOSE plan to contain the !INDEX rejection comment but was 
" + verboseSteps,
+        verboseSteps.contains("    /* !INDEX " + idx + " -- no PK prefix bound 
*/"));
+    }
+  }
+
+  @Test
+  public void testVerboseProjectLine() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      stmt
+        .execute("CREATE TABLE " + t + " (k VARCHAR PRIMARY KEY, a VARCHAR, b 
VARCHAR, c VARCHAR)");
+      String query = "SELECT a, b FROM " + t;
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).serverProject("A", "B");
+      List<String> verboseSteps =
+        ExplainPlanTestUtil.getPlanSteps(conn, query, ExplainOptions.VERBOSE);
+      assertTrue("expected VERBOSE plan to contain the PROJECT line but was " 
+ verboseSteps,
+        verboseSteps.contains("    PROJECT A, B"));
+      // Plain EXPLAIN carries no PROJECT line and no serverProject attribute.
+      ExplainPlanTestUtil.assertPlan(conn, query).serverProjectNone();
+      assertNoPlanLineContains(conn, query, "PROJECT ");
+    }
+  }
+
+  @Test
+  public void testVerboseServerFilterWhereFanout() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      stmt
+        .execute("CREATE TABLE " + t + " (k VARCHAR PRIMARY KEY, a VARCHAR, b 
VARCHAR, c VARCHAR)");
+      String query = "SELECT a FROM " + t + " WHERE b = 'x' AND c = 'y'";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).serverFilterCount(2)
+        .serverFilterOrigin(0, "WHERE").serverFilterPathTest(0, 
null).serverFilterOrigin(1, "WHERE")
+        .serverFilterPathTest(1, null);
+      // Plain EXPLAIN keeps the combined single serverWhereFilter line, no 
per-predicate breakdown.
+      ExplainPlanTestUtil.assertPlan(conn, query).serverFiltersNone();
+    }
+  }
+
+  @Test
+  public void testVerboseServerFilterSinglePredicate() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      stmt.execute("CREATE TABLE " + t + " (k VARCHAR PRIMARY KEY, a VARCHAR, 
b VARCHAR)");
+      String query = "SELECT a FROM " + t + " WHERE b = 'x'";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).serverFilterCount(1)
+        .serverFilterOrigin(0, "WHERE").serverFilterPathTest(0, null);
+    }
+  }
+
+  @Test
+  public void testVerboseServerFilterJsonExistsSubtag() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      stmt.execute("CREATE TABLE " + t + " (pk VARCHAR PRIMARY KEY, jsoncol 
JSON)");
+      String query = "SELECT pk FROM " + t + " WHERE JSON_EXISTS(jsoncol, 
'$.info.address.town')";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).serverFilterCount(1)
+        .serverFilterOrigin(0, "WHERE").serverFilterPathTest(0, "JSON EXISTS");
+    }
+  }
+
+  @Test
+  public void testVerboseServerFilterBsonConditionSubtag() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      stmt.execute("CREATE TABLE " + t + " (pk VARCHAR PRIMARY KEY, payload 
BSON)");
+      String query = "SELECT pk FROM " + t
+        + " WHERE BSON_CONDITION_EXPRESSION(payload, '{\"$EXPR\": 
\"field_exists(Id)\"}')";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).serverFilterCount(1)
+        .serverFilterOrigin(0, "WHERE").serverFilterPathTest(0, "BSON 
CONDITION");
+    }
+  }
+
+  @Test
+  public void testVerboseClientFilterFanout() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      String query = "SELECT a_string FROM (SELECT a_string, a_integer FROM 
atable LIMIT 5)"
+        + " WHERE a_integer > 2";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).clientFilterCount(1)
+        .clientFilter(0, "A_INTEGER > 2").clientFilterOrigin(0, "WHERE")
+        .clientFilterPathTest(0, null);
+      List<String> verboseSteps =
+        ExplainPlanTestUtil.getPlanSteps(conn, query, ExplainOptions.VERBOSE);
+      assertTrue("expected VERBOSE plan to contain a CLIENT FILTER BY line but 
was " + verboseSteps,
+        verboseSteps.stream().anyMatch(s -> s.startsWith("CLIENT FILTER BY 
A_INTEGER > 2")));
+      // Plain EXPLAIN keeps the combined clientFilterBy string and no 
structured breakdown.
+      ExplainPlanTestUtil.assertPlan(conn, query).clientFiltersNone()
+        .clientFilterBy("A_INTEGER > 2");
+    }
+  }
+
+  @Test
+  public void testVerboseClientFilterHavingFanout() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      String query =
+        "SELECT count(1) FROM atable GROUP BY a_string, b_string HAVING 
max(a_string) = 'a'";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).clientFilterCount(1)
+        .clientFilter(0, "MAX(A_STRING) = 'a'").clientFilterOrigin(0, "HAVING")
+        .clientFilterPathTest(0, null);
+      // Plain EXPLAIN keeps the combined string and no structured breakdown.
+      ExplainPlanTestUtil.assertPlan(conn, query).clientFiltersNone()
+        .clientFilterBy("MAX(A_STRING) = 'a'");
+    }
+  }
+
+  /**
+   * When a top-level AND is only partially tagged, renderVerboseFilters 
collapses to a single
+   * combined line. That line must still union the origins recorded on the 
tagged conjunct(s) rather
+   * than reading only the parent expression. Normal WHERE/HAVING compilation 
always tags every
+   * conjunct, so this partial-tag state is reachable only when identity is 
lost during expression
+   * rewriting.
+   */
+  @Test
+  public void testVerboseCombinedFilterUnionsConjunctOrigins() throws 
Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      QueryPlan plan = conn.prepareStatement("SELECT * FROM atable")
+        .unwrap(PhoenixPreparedStatement.class).optimizeQuery();
+      StatementContext context = plan.getContext();
+      Expression tagged = LiteralExpression.newConstant(true);
+      Expression untagged = LiteralExpression.newConstant(false);
+      Expression and = new AndExpression(Arrays.asList(tagged, untagged));
+      context.tagPredicate(tagged, "WHERE");
+      List<String> planSteps = new ArrayList<>();
+      List<ExplainFilter> filters = ExplainTable.renderVerboseFilters(context, 
and, and.toString(),
+        "    SERVER FILTER BY", planSteps);
+      assertEquals("partial tagging must collapse to one combined line", 1, 
filters.size());
+      assertEquals("combined line must union origins from tagged conjuncts",
+        Collections.singletonList("WHERE"), filters.get(0).getOrigin());
+      assertEquals(1, planSteps.size());
+      assertTrue("combined line should carry the unioned origin comment but 
was " + planSteps,
+        planSteps.get(0).endsWith("-- WHERE"));
+    }
+  }
+
+  @Test
+  public void testVerboseIgnoredHintNoIndexNoIndexes() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      stmt.execute("CREATE TABLE " + t + " (k VARCHAR PRIMARY KEY, a VARCHAR, 
b VARCHAR)");
+      String query = "SELECT /*+ NO_INDEX */ a FROM " + t + " WHERE b = 'x'";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).ignoredHint("NO_INDEX",
+        "no indexes on table");
+      List<String> verboseSteps =
+        ExplainPlanTestUtil.getPlanSteps(conn, query, ExplainOptions.VERBOSE);
+      assertTrue(
+        "expected VERBOSE plan to disclose the ignored NO_INDEX hint but was " 
+ verboseSteps,
+        verboseSteps.contains("    /*- NO_INDEX -- no indexes on table */"));
+      // Plain EXPLAIN does not disclose ignored hints.
+      ExplainPlanTestUtil.assertPlan(conn, query).ignoredHintsNone();
+      assertNoPlanLineContains(conn, query, "/*- NO_INDEX");
+    }
+  }
+
+  @Test
+  public void testVerboseIgnoredHintIndexNoMatch() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      String idx = generateUniqueName();
+      stmt.execute("CREATE TABLE " + t + " (k VARCHAR PRIMARY KEY, v1 VARCHAR, 
v2 VARCHAR)");
+      stmt.execute("CREATE INDEX " + idx + " ON " + t + " (v1) INCLUDE (v2)");
+      // Hint references an index name that does not exist on the table.
+      String query =
+        "SELECT /*+ INDEX(" + t + " NONEXISTENT_IDX) */ k, v2 FROM " + t + " 
WHERE v1 = 'x'";
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).ignoredHint("INDEX",
+        "no matching index applicable");
+    }
+  }
+
+  @Test
+  public void testVerboseIgnoredHintSortMergeNoJoin() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String t = generateUniqueName();
+      stmt.execute("CREATE TABLE " + t + " (k VARCHAR PRIMARY KEY, a 
VARCHAR)");
+      String query = "SELECT /*+ USE_SORT_MERGE_JOIN */ a FROM " + t;
+      ExplainPlanTestUtil.assertPlanWithVerbose(conn, 
query).ignoredHint("USE_SORT_MERGE_JOIN",
+        "no join in query");
+      List<String> verboseSteps =
+        ExplainPlanTestUtil.getPlanSteps(conn, query, ExplainOptions.VERBOSE);
+      assertTrue(
+        "expected VERBOSE plan to disclose the ignored USE_SORT_MERGE_JOIN 
hint but was "
+          + verboseSteps,
+        verboseSteps.contains("    /*- USE_SORT_MERGE_JOIN -- no join in query 
*/"));
     }
   }
 
@@ -1545,10 +1748,14 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     n.putNull("serverOffset");
     n.putNull("serverRowLimit");
     n.putNull("serverParsedProjections");
+    n.putNull("serverProject");
+    n.putNull("serverFilters");
+    n.putNull("ignoredHints");
     n.put("serverFirstKeyOnlyProjection", false);
     n.put("serverEmptyColumnOnlyProjection", false);
     n.putNull("serverAggregate");
     n.putNull("clientFilterBy");
+    n.putNull("clientFilters");
     n.putNull("clientAggregate");
     n.putNull("clientSortedBy");
     n.putNull("clientAfterAggregate");
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 16f1f6c360..b239b9af50 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
@@ -81,6 +81,20 @@ public final class ExplainPlanTestUtil {
     return getExplainPlan(conn, query).getPlanSteps();
   }
 
+  /**
+   * Optimize {@code query} with the given {@link ExplainOptions} applied to 
the plan's
+   * {@code StatementContext} and return its plan-steps text.
+   */
+  public static List<String> getPlanSteps(Connection conn, String query, 
ExplainOptions options)
+    throws SQLException {
+    try (PhoenixPreparedStatement statement =
+      conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class)) {
+      QueryPlan plan = statement.optimizeQuery();
+      plan.getContext().setExplainOptions(options);
+      return plan.getExplainPlan().getPlanSteps();
+    }
+  }
+
   /** Compile a mutation (UPSERT/DELETE) and return its {@link ExplainPlan}. */
   public static ExplainPlan getMutationExplainPlan(Connection conn, String 
query)
     throws SQLException {
@@ -118,6 +132,16 @@ public final class ExplainPlanTestUtil {
     return assertPlan(getExplainAttributes(conn, query, 
ExplainOptions.WITH_REGIONS));
   }
 
+  /**
+   * Optimize {@code query} on {@code conn} with the {@code VERBOSE} option 
enabled and begin
+   * assertions on its plan attributes. Use this when asserting on 
VERBOSE-only attributes such as
+   * {@code serverProject}, {@code serverFilters}, and {@code ignoredHints}.
+   */
+  public static ExplainPlanAssert assertPlanWithVerbose(Connection conn, 
String query)
+    throws SQLException {
+    return assertPlan(getExplainAttributes(conn, query, 
ExplainOptions.VERBOSE));
+  }
+
   /**
    * Optimize an already-prepared and, if needed, parameter-bound {@link 
PhoenixPreparedStatement}
    * and begin assertions on its plan attributes.
@@ -507,6 +531,156 @@ public final class ExplainPlanTestUtil {
       return this;
     }
 
+    /** Assert the entire VERBOSE {@code PROJECT} column list matches {@code 
expected}, in order. */
+    public ExplainPlanAssert serverProject(String... expected) {
+      List<String> actual = attributes.getServerProject();
+      List<String> actualOrEmpty = actual == null ? Collections.<String> 
emptyList() : actual;
+      assertEquals(at("serverProject"), Arrays.asList(expected), 
actualOrEmpty);
+      return this;
+    }
+
+    /** Assert the number of VERBOSE {@code PROJECT} columns. */
+    public ExplainPlanAssert serverProjectCount(int expected) {
+      List<String> actual = attributes.getServerProject();
+      int actualCount = actual == null ? 0 : actual.size();
+      assertEquals(at("serverProject.size"), expected, actualCount);
+      return this;
+    }
+
+    /** Assert that no VERBOSE {@code PROJECT} disclosure was emitted (null or 
empty). */
+    public ExplainPlanAssert serverProjectNone() {
+      List<String> actual = attributes.getServerProject();
+      assertTrue(at("serverProject") + " expected none but was " + actual,
+        actual == null || actual.isEmpty());
+      return this;
+    }
+
+    /** Assert the number of VERBOSE server filter predicates. */
+    public ExplainPlanAssert serverFilterCount(int expected) {
+      List<ExplainPlanAttributes.ExplainFilter> actual = 
attributes.getServerFilters();
+      int actualCount = actual == null ? 0 : actual.size();
+      assertEquals(at("serverFilters.size"), expected, actualCount);
+      return this;
+    }
+
+    /** Assert that no VERBOSE server filter breakdown was emitted (null or 
empty). */
+    public ExplainPlanAssert serverFiltersNone() {
+      List<ExplainPlanAttributes.ExplainFilter> actual = 
attributes.getServerFilters();
+      assertTrue(at("serverFilters") + " expected none but was " + actual,
+        actual == null || actual.isEmpty());
+      return this;
+    }
+
+    /** Assert the i-th VERBOSE server filter's rendered expression. */
+    public ExplainPlanAssert serverFilter(int i, String expectedExpr) {
+      ExplainPlanAttributes.ExplainFilter filter = serverFilterAt(i);
+      assertEquals(at("serverFilters[" + i + "].expr"), expectedExpr, 
filter.getExpr());
+      return this;
+    }
+
+    /** Assert the i-th VERBOSE server filter's origin attribution, in order. 
*/
+    public ExplainPlanAssert serverFilterOrigin(int i, String... 
expectedOrigin) {
+      ExplainPlanAttributes.ExplainFilter filter = serverFilterAt(i);
+      List<String> actual =
+        filter.getOrigin() == null ? Collections.<String> emptyList() : 
filter.getOrigin();
+      assertEquals(at("serverFilters[" + i + "].origin"), 
Arrays.asList(expectedOrigin), actual);
+      return this;
+    }
+
+    /** Assert the i-th VERBOSE server filter's path-test sub-tag (or {@code 
null}). */
+    public ExplainPlanAssert serverFilterPathTest(int i, String 
expectedSubtag) {
+      ExplainPlanAttributes.ExplainFilter filter = serverFilterAt(i);
+      assertEquals(at("serverFilters[" + i + "].pathTestSubtag"), 
expectedSubtag,
+        filter.getPathTestSubtag());
+      return this;
+    }
+
+    private ExplainPlanAttributes.ExplainFilter serverFilterAt(int i) {
+      List<ExplainPlanAttributes.ExplainFilter> filters = 
attributes.getServerFilters();
+      assertNotNull(at("serverFilters") + " must not be null", filters);
+      assertTrue(at("serverFilters") + " has no index " + i + " (size=" + 
filters.size() + ")",
+        i >= 0 && i < filters.size());
+      return filters.get(i);
+    }
+
+    /** Assert the number of VERBOSE client filter predicates. */
+    public ExplainPlanAssert clientFilterCount(int expected) {
+      List<ExplainPlanAttributes.ExplainFilter> actual = 
attributes.getClientFilters();
+      int actualCount = actual == null ? 0 : actual.size();
+      assertEquals(at("clientFilters.size"), expected, actualCount);
+      return this;
+    }
+
+    /** Assert that no VERBOSE client filter breakdown was emitted (null or 
empty). */
+    public ExplainPlanAssert clientFiltersNone() {
+      List<ExplainPlanAttributes.ExplainFilter> actual = 
attributes.getClientFilters();
+      assertTrue(at("clientFilters") + " expected none but was " + actual,
+        actual == null || actual.isEmpty());
+      return this;
+    }
+
+    /** Assert the i-th VERBOSE client filter's rendered expression. */
+    public ExplainPlanAssert clientFilter(int i, String expectedExpr) {
+      ExplainPlanAttributes.ExplainFilter filter = clientFilterAt(i);
+      assertEquals(at("clientFilters[" + i + "].expr"), expectedExpr, 
filter.getExpr());
+      return this;
+    }
+
+    /** Assert the i-th VERBOSE client filter's origin attribution, in order. 
*/
+    public ExplainPlanAssert clientFilterOrigin(int i, String... 
expectedOrigin) {
+      ExplainPlanAttributes.ExplainFilter filter = clientFilterAt(i);
+      List<String> actual =
+        filter.getOrigin() == null ? Collections.<String> emptyList() : 
filter.getOrigin();
+      assertEquals(at("clientFilters[" + i + "].origin"), 
Arrays.asList(expectedOrigin), actual);
+      return this;
+    }
+
+    /** Assert the i-th VERBOSE client filter's path-test sub-tag (or {@code 
null}). */
+    public ExplainPlanAssert clientFilterPathTest(int i, String 
expectedSubtag) {
+      ExplainPlanAttributes.ExplainFilter filter = clientFilterAt(i);
+      assertEquals(at("clientFilters[" + i + "].pathTestSubtag"), 
expectedSubtag,
+        filter.getPathTestSubtag());
+      return this;
+    }
+
+    private ExplainPlanAttributes.ExplainFilter clientFilterAt(int i) {
+      List<ExplainPlanAttributes.ExplainFilter> filters = 
attributes.getClientFilters();
+      assertNotNull(at("clientFilters") + " must not be null", filters);
+      assertTrue(at("clientFilters") + " has no index " + i + " (size=" + 
filters.size() + ")",
+        i >= 0 && i < filters.size());
+      return filters.get(i);
+    }
+
+    /** Assert the entire VERBOSE ignored-hint map matches {@code expected}. */
+    public ExplainPlanAssert ignoredHints(Map<String, String> expected) {
+      assertEquals(at("ignoredHints"), expected, attributes.getIgnoredHints());
+      return this;
+    }
+
+    /** Assert the ignored-hint map carries {@code hint} mapped to {@code 
reason}. */
+    public ExplainPlanAssert ignoredHint(String hint, String reason) {
+      Map<String, String> actual = attributes.getIgnoredHints();
+      assertNotNull(at("ignoredHints") + " must not be null", actual);
+      assertEquals(at("ignoredHints[" + hint + "]"), reason, actual.get(hint));
+      return this;
+    }
+
+    /** Assert that the ignored-hint map contains an entry for {@code hint}. */
+    public ExplainPlanAssert hasIgnoredHint(String hint) {
+      Map<String, String> actual = attributes.getIgnoredHints();
+      assertTrue(at("ignoredHints") + " expected to contain '" + hint + "' but 
was " + actual,
+        actual != null && actual.containsKey(hint));
+      return this;
+    }
+
+    /** Assert that no ignored-hint disclosure was emitted (null or empty). */
+    public ExplainPlanAssert ignoredHintsNone() {
+      Map<String, String> actual = attributes.getIgnoredHints();
+      assertTrue(at("ignoredHints") + " expected none but was " + actual,
+        actual == null || actual.isEmpty());
+      return this;
+    }
+
     public ExplainPlanAssert serverFirstKeyOnlyProjection(boolean expected) {
       assertEquals(at("serverFirstKeyOnlyProjection"), expected,
         attributes.isServerFirstKeyOnlyProjection());


Reply via email to