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 408d4dfd35 PHOENIX-7932 Sweep tests for remaining EXPLAIN improvement 
issues (#2538)
408d4dfd35 is described below

commit 408d4dfd3533caa0e758b2260fcec40365a88143
Author: Andrew Purtell <[email protected]>
AuthorDate: Thu Jun 18 18:15:48 2026 -0700

    PHOENIX-7932 Sweep tests for remaining EXPLAIN improvement issues (#2538)
    
    Co-authored-by: Claude Opus 4.8[1m] <[email protected]>
---
 .../phoenix/compile/ExplainPlanAttributes.java     |  38 ++++---
 .../org/apache/phoenix/execute/BaseQueryPlan.java  |   1 +
 .../org/apache/phoenix/iterate/ExplainTable.java   | 117 +++++++++++++++------
 .../apache/phoenix/optimize/OptimizerDecision.java |  25 +++--
 .../apache/phoenix/optimize/QueryOptimizer.java    |  10 +-
 .../GlobalIndexCheckerEventualGenerateIT.java      |   5 +
 .../index/GlobalIndexCheckerEventualIT.java        |   5 +
 .../end2end/index/GlobalIndexCheckerIT.java        |  57 ++++++++--
 .../apache/phoenix/end2end/index/IndexUsageIT.java |  22 ++--
 .../UncoveredGlobalIndexRegionScanner2IT.java      |  56 ++++++----
 .../index/UncoveredGlobalIndexRegionScannerIT.java |  56 ++++++----
 .../phoenix/end2end/json/JsonFunctionsIT.java      |   2 +-
 .../apache/phoenix/compile/QueryOptimizerTest.java |   7 +-
 .../phoenix/query/explain/ExplainPlanTest.java     |  15 +--
 .../phoenix/query/explain/ExplainPlanTestUtil.java |  17 ++-
 15 files changed, 301 insertions(+), 132 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 a068ec5fb4..e233931f71 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
@@ -42,18 +42,19 @@ import org.apache.phoenix.schema.PColumn;
 @JsonPropertyOrder({ "tenantId", "viewName", "viewBaseName", "cdcScopes", 
"txnProvider", "rewrites",
   "estimatedRows", "estimatedSizeInBytes", "estimateInfoTs", 
"abstractExplainPlan",
   "onDuplicateKeyAction", "serverUpdateSet", "returningRow", "hint", 
"explainScanType",
-  "consistency", "tableName", "keyRanges", "indexName", "indexKind", 
"indexRule", "indexRejected",
-  "saltBuckets", "regionsPlanned", "scanTimeRangeMin", "scanTimeRangeMax", 
"splitsChunk",
-  "useRoundRobinIterator", "samplingRate", "hexStringRVCOffset", 
"iteratorTypeAndScanSize",
-  "scanEstimatedRows", "scanEstimatedSizeInBytes", "serverWhereFilter", 
"serverDistinctFilter",
-  "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" })
+  "consistency", "tableName", "keyRanges", "indexName", "indexKind", 
"indexRule", "functionalMatch",
+  "indexRejected", "saltBuckets", "regionsPlanned", "scanTimeRangeMin", 
"scanTimeRangeMax",
+  "splitsChunk", "useRoundRobinIterator", "samplingRate", "hexStringRVCOffset",
+  "iteratorTypeAndScanSize", "scanEstimatedRows", "scanEstimatedSizeInBytes", 
"serverWhereFilter",
+  "serverDistinctFilter", "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)
@@ -82,6 +83,7 @@ public class ExplainPlanAttributes {
   private final String indexName;
   private final String indexKind;
   private final String indexRule;
+  private final String functionalMatch;
   private final List<RejectedIndexEntry> indexRejected;
   private final Integer saltBuckets;
   private final Integer regionsPlanned;
@@ -171,6 +173,7 @@ public class ExplainPlanAttributes {
     this.indexName = b.indexName;
     this.indexKind = b.indexKind;
     this.indexRule = b.indexRule;
+    this.functionalMatch = b.functionalMatch;
     this.indexRejected = (b.indexRejected == null || b.indexRejected.isEmpty())
       ? null
       : Collections.unmodifiableList(new ArrayList<>(b.indexRejected));
@@ -311,6 +314,10 @@ public class ExplainPlanAttributes {
     return indexRule;
   }
 
+  public String getFunctionalMatch() {
+    return functionalMatch;
+  }
+
   public List<RejectedIndexEntry> getIndexRejected() {
     return indexRejected;
   }
@@ -608,6 +615,7 @@ public class ExplainPlanAttributes {
     private String indexName;
     private String indexKind;
     private String indexRule;
+    private String functionalMatch;
     private List<RejectedIndexEntry> indexRejected;
     private Integer saltBuckets;
     private Integer regionsPlanned;
@@ -686,6 +694,7 @@ public class ExplainPlanAttributes {
       this.indexName = explainPlanAttributes.getIndexName();
       this.indexKind = explainPlanAttributes.getIndexKind();
       this.indexRule = explainPlanAttributes.getIndexRule();
+      this.functionalMatch = explainPlanAttributes.getFunctionalMatch();
       List<RejectedIndexEntry> srcIndexRejected = 
explainPlanAttributes.getIndexRejected();
       this.indexRejected = srcIndexRejected == null ? null : new 
ArrayList<>(srcIndexRejected);
       this.saltBuckets = explainPlanAttributes.getSaltBuckets();
@@ -855,6 +864,11 @@ public class ExplainPlanAttributes {
       return this;
     }
 
+    public ExplainPlanAttributesBuilder setFunctionalMatch(String 
functionalMatch) {
+      this.functionalMatch = functionalMatch;
+      return this;
+    }
+
     public ExplainPlanAttributesBuilder setIndexRule(String indexRule) {
       this.indexRule = indexRule;
       return this;
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
index b955437b83..08076542c4 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
@@ -550,6 +550,7 @@ public abstract class BaseQueryPlan implements QueryPlan {
     OptimizerDecision decision = getOptimizerDecision();
     if (decision != null) {
       builder.setIndexRule(decision.getRule());
+      builder.setFunctionalMatch(decision.getFunctionalMatch());
       builder.setIndexRejected(decision.getRejectedIndexes());
     }
     if (context.isRoot()) {
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 115e88d329..00e2564ab0 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
@@ -376,8 +376,25 @@ public abstract class ExplainTable {
     if (indexKind != null) {
       indexLine.append(" ").append(indexKind);
     }
-    if (decision != null && !isDefaultRule(decision.getRule())) {
-      indexLine.append("  /* ").append(decision.getRule()).append(" */");
+    if (decision != null) {
+      // Disclose the selection rule (unless it is a suppressed default) and, 
when the chosen plan
+      // is a functional index that matched a query expression, the separate 
"matches <expr>"
+      // disclosure. Both may appear together as "/* <rule>, matches <expr> 
*/".
+      boolean showRule = !isDefaultRule(decision.getRule());
+      String functionalMatch = decision.getFunctionalMatch();
+      if (showRule || functionalMatch != null) {
+        indexLine.append("  /* ");
+        if (showRule) {
+          indexLine.append(decision.getRule());
+        }
+        if (functionalMatch != null) {
+          if (showRule) {
+            indexLine.append(", ");
+          }
+          indexLine.append(functionalMatch);
+        }
+        indexLine.append(" */");
+      }
     }
     planSteps.add(indexLine.toString());
     if (verbose && decision != null) {
@@ -772,33 +789,38 @@ public abstract class ExplainTable {
   private void getRegionLocations(List<String> planSteps,
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder,
     List<HRegionLocation> regionLocations) {
-    // Region locations are emitted as text and in the structured attributes 
only when the
-    // EXPLAIN statement requested them via the REGIONS option (or the legacy 
WITH REGIONS alias).
-    if (!context.getExplainOptions().isRegions()) {
+    // Region locations are computed during scan planning, so always record 
them in the structured
+    // attributes. Consumers that read the attributes directly (e.g. the 
connection activity logger,
+    // and JSON output) rely on them being present. Only build and append the 
(potentially large)
+    // text representation to the plan steps when the EXPLAIN statement 
requested them via the
+    // REGIONS option (or the legacy WITH REGIONS alias).
+    RegionLocationsExplainInfo regionLocationsInfo =
+      populateRegionLocationAttributes(explainPlanAttributesBuilder, 
regionLocations);
+    if (regionLocationsInfo == null || 
!context.getExplainOptions().isRegions()) {
       return;
     }
-    String regionLocationPlan =
-      getRegionLocationsForExplainPlan(explainPlanAttributesBuilder, 
regionLocations);
+    String regionLocationPlan = 
renderRegionLocationsForExplainPlan(regionLocationsInfo);
     if (regionLocationPlan.length() > 0) {
       planSteps.add(regionLocationPlan);
     }
   }
 
   /**
-   * Retrieve region locations from hbase client and set the values for the 
explain plan output. If
-   * the list of region locations exceed max limit, print only list with the 
max limit and print num
-   * of total list size.
+   * Deduplicate the region locations by region boundary, trim to the 
configured max size, and
+   * record the result (and the total size) in the structured explain plan 
attributes. This is
+   * always done so that consumers reading the attributes directly have the 
values available,
+   * regardless of whether the text representation is requested.
    * @param explainPlanAttributesBuilder      explain plan v2 attributes 
builder instance.
    * @param regionLocationsFromResultIterator region locations.
-   * @return region locations to be added to the explain plan output.
+   * @return the deduplicated (and possibly trimmed) region locations along 
with the total
+   *         deduplicated size, or {@code null} when no region locations were 
available.
    */
-  private String getRegionLocationsForExplainPlan(
+  private RegionLocationsExplainInfo populateRegionLocationAttributes(
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder,
     List<HRegionLocation> regionLocationsFromResultIterator) {
     if (regionLocationsFromResultIterator == null) {
-      return "";
+      return null;
     }
-    StringBuilder buf = new StringBuilder().append(REGION_LOCATIONS);
     Set<String> regionBoundaries = new LinkedHashSet<>();
     List<HRegionLocation> regionLocations = new ArrayList<>();
     for (HRegionLocation regionLocation : regionLocationsFromResultIterator) {
@@ -811,29 +833,62 @@ public abstract class ExplainTable {
     int maxLimitRegionLoc = 
context.getConnection().getQueryServices().getConfiguration().getInt(
       QueryServices.MAX_REGION_LOCATIONS_SIZE_EXPLAIN_PLAN,
       QueryServicesOptions.DEFAULT_MAX_REGION_LOCATIONS_SIZE_EXPLAIN_PLAN);
-    if (regionLocations.size() > maxLimitRegionLoc) {
-      int originalSize = regionLocations.size();
-      List<HRegionLocation> trimmedRegionLocations = 
regionLocations.subList(0, maxLimitRegionLoc);
-      if (explainPlanAttributesBuilder != null) {
-        explainPlanAttributesBuilder
-          
.setRegionLocations(Collections.unmodifiableList(trimmedRegionLocations))
-          .setRegionLocationsTotalSize(originalSize);
-      }
-      buf.append(trimmedRegionLocations);
+    int totalSize = regionLocations.size();
+    List<HRegionLocation> trimmedRegionLocations = totalSize > 
maxLimitRegionLoc
+      ? regionLocations.subList(0, maxLimitRegionLoc)
+      : regionLocations;
+    if (explainPlanAttributesBuilder != null) {
+      explainPlanAttributesBuilder
+        
.setRegionLocations(Collections.unmodifiableList(trimmedRegionLocations))
+        .setRegionLocationsTotalSize(totalSize);
+    }
+    return new RegionLocationsExplainInfo(trimmedRegionLocations, totalSize);
+  }
+
+  /**
+   * Render the region locations text for the explain plan output. If the 
region locations were
+   * trimmed (i.e. the deduplicated total exceeded the configured max limit), 
the trimmed list is
+   * printed followed by the total deduplicated size.
+   * @param regionLocationsInfo the deduplicated (and possibly trimmed) region 
locations along with
+   *                            the total deduplicated size.
+   * @return region locations to be added to the explain plan output.
+   */
+  private String
+    renderRegionLocationsForExplainPlan(RegionLocationsExplainInfo 
regionLocationsInfo) {
+    List<HRegionLocation> trimmedRegionLocations = 
regionLocationsInfo.getTrimmedRegionLocations();
+    StringBuilder buf = new StringBuilder().append(REGION_LOCATIONS);
+    buf.append(trimmedRegionLocations);
+    if (trimmedRegionLocations.size() < regionLocationsInfo.getTotalSize()) {
       buf.append("...total size = ");
-      buf.append(originalSize);
-    } else {
-      buf.append(regionLocations);
-      if (explainPlanAttributesBuilder != null) {
-        explainPlanAttributesBuilder
-          .setRegionLocations(Collections.unmodifiableList(regionLocations))
-          .setRegionLocationsTotalSize(regionLocations.size());
-      }
+      buf.append(regionLocationsInfo.getTotalSize());
     }
     buf.append(") ");
     return buf.toString();
   }
 
+  /**
+   * Holder for the deduplicated (and possibly trimmed) region locations plus 
the total deduplicated
+   * size, used to keep attribute population separate from text rendering.
+   */
+  private static final class RegionLocationsExplainInfo {
+    private final List<HRegionLocation> trimmedRegionLocations;
+    private final int totalSize;
+
+    private RegionLocationsExplainInfo(List<HRegionLocation> 
trimmedRegionLocations,
+      int totalSize) {
+      this.trimmedRegionLocations = trimmedRegionLocations;
+      this.totalSize = totalSize;
+    }
+
+    private List<HRegionLocation> getTrimmedRegionLocations() {
+      return trimmedRegionLocations;
+    }
+
+    private int getTotalSize() {
+      return totalSize;
+    }
+  }
+
   @SuppressWarnings("rawtypes")
   private void appendPKColumnValue(StringBuilder buf, byte[] range, Boolean 
isNull, int slotIndex,
     boolean changeViewIndexId) {
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerDecision.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerDecision.java
index be137a0bca..ef9af32eb7 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerDecision.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerDecision.java
@@ -22,21 +22,18 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 
-/**
- * Records the optimizer's index selection rationale: the chosen index (or 
data table), the rule
- * that selected it, and the indexes that were considered but rejected. The 
{@code rule} is one of
- * the closed-set {@code RULE_*} labels in {@link OptimizerReasons}. The entry 
for each rejected
- * index carries a {@code REASON_*} label.
- */
+/** Records the optimizer's index selection rationale. */
 public final class OptimizerDecision {
   private final String chosenIndex;
   private final String rule;
+  private final String functionalMatch;
   private final List<RejectedIndexEntry> rejectedIndexes;
 
-  public OptimizerDecision(String chosenIndex, String rule,
+  public OptimizerDecision(String chosenIndex, String rule, String 
functionalMatch,
     List<RejectedIndexEntry> rejectedIndexes) {
     this.chosenIndex = chosenIndex;
     this.rule = rule;
+    this.functionalMatch = functionalMatch;
     this.rejectedIndexes = rejectedIndexes == null
       ? Collections.emptyList()
       : Collections.unmodifiableList(new ArrayList<>(rejectedIndexes));
@@ -50,6 +47,13 @@ public final class OptimizerDecision {
     return rule;
   }
 
+  /**
+   * The functional-index match disclosure of the form {@code "matches 
<expr>"}, or {@code null}.
+   */
+  public String getFunctionalMatch() {
+    return functionalMatch;
+  }
+
   /** Never null; unmodifiable; possibly empty. */
   public List<RejectedIndexEntry> getRejectedIndexes() {
     return rejectedIndexes;
@@ -65,17 +69,18 @@ public final class OptimizerDecision {
     }
     OptimizerDecision that = (OptimizerDecision) o;
     return Objects.equals(chosenIndex, that.chosenIndex) && 
Objects.equals(rule, that.rule)
+      && Objects.equals(functionalMatch, that.functionalMatch)
       && rejectedIndexes.equals(that.rejectedIndexes);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(chosenIndex, rule, rejectedIndexes);
+    return Objects.hash(chosenIndex, rule, functionalMatch, rejectedIndexes);
   }
 
   @Override
   public String toString() {
-    return "OptimizerDecision{chosenIndex=" + chosenIndex + ", rule=" + rule + 
", rejectedIndexes="
-      + rejectedIndexes + "}";
+    return "OptimizerDecision{chosenIndex=" + chosenIndex + ", rule=" + rule + 
", functionalMatch="
+      + functionalMatch + ", rejectedIndexes=" + rejectedIndexes + "}";
   }
 }
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 20711d5412..04d25dfd13 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
@@ -964,13 +964,13 @@ public class QueryOptimizer {
    * can use this inline at a {@code return} site.
    */
   private static QueryPlan recordDecision(QueryPlan winner, String rule, 
DecisionState state) {
-    String functionalRule = functionalIndexRule(winner);
-    if (functionalRule != null) {
-      rule = functionalRule;
-    }
+    // The rule names the selection reason (e.g. hint, more bound PK columns). 
When the winner is a
+    // functional index that matched a query expression, the "matches <expr>" 
disclosure is
+    // recorded separately so both the selection reason and the functional 
match are surfaced.
+    String functionalMatch = functionalIndexRule(winner);
     winner.setOptimizerDecision(
       new 
OptimizerDecision(winner.getTableRef().getTable().getTableName().getString(), 
rule,
-        state == null ? null : state.getRejections()));
+        functionalMatch, state == null ? null : state.getRejections()));
     recordFunctionalIndexExpressionBreadcrumbs(winner);
     return winner;
   }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualGenerateIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualGenerateIT.java
index a862df07ad..2d5de2dbed 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualGenerateIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualGenerateIT.java
@@ -74,6 +74,11 @@ public class GlobalIndexCheckerEventualGenerateIT extends 
GlobalIndexCheckerIT {
     Thread.sleep(18000);
   }
 
+  @Override
+  protected boolean isEventualConsistency() {
+    return true;
+  }
+
   @Parameterized.Parameters(name = "async={0},encoded={1}")
   public static synchronized Collection<Object[]> data() {
     List<Object[]> list = Lists.newArrayListWithExpectedSize(4);
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualIT.java
index d7b5a35e1c..a54d5903be 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerEventualIT.java
@@ -74,6 +74,11 @@ public class GlobalIndexCheckerEventualIT extends 
GlobalIndexCheckerIT {
     Thread.sleep(15000);
   }
 
+  @Override
+  protected boolean isEventualConsistency() {
+    return true;
+  }
+
   @Parameterized.Parameters(name = "async={0},encoded={1}")
   public static synchronized Collection<Object[]> data() {
     List<Object[]> list = Lists.newArrayListWithExpectedSize(4);
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
index f0ffe1fd9f..1c6384f22c 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
@@ -153,6 +153,24 @@ public class GlobalIndexCheckerIT extends BaseTest {
     // Verify the query is served by a RANGE SCAN over the index table not the 
data table.
     ExplainPlanAttributes attributes = getExplainAttributes(conn, selectSql);
     assertPlan(attributes).scanType("RANGE SCAN").indexRule(expectedRule);
+    assertScannedTableIsIndex(attributes, indexTableFullName);
+  }
+
+  /**
+   * Asserts the selection {@code expectedRule} and the separate
+   * {@code "matches <expectedFunctionalMatchExpr>"} functional index match.
+   */
+  public static void assertExplainPlan(Connection conn, String selectSql, 
String dataTableFullName,
+    String indexTableFullName, String expectedRule, String 
expectedFunctionalMatchExpr)
+    throws SQLException {
+    ExplainPlanAttributes attributes = getExplainAttributes(conn, selectSql);
+    assertPlan(attributes).scanType("RANGE SCAN").indexRule(expectedRule)
+      .functionalMatch(expectedFunctionalMatchExpr);
+    assertScannedTableIsIndex(attributes, indexTableFullName);
+  }
+
+  private static void assertScannedTableIsIndex(ExplainPlanAttributes 
attributes,
+    String indexTableFullName) {
     String actualTable =
       attributes.getTableName() == null ? null : 
attributes.getTableName().replaceAll(":", ".");
     String expectedTable = SchemaUtil.normalizeIdentifier(indexTableFullName);
@@ -249,7 +267,8 @@ public class GlobalIndexCheckerIT extends BaseTest {
         + "PHOENIX_ROW_TIMESTAMP() < TO_DATE('" + after.toString()
         + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertExplainPlan(conn, query, dataTableName, indexTableName,
+        OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS, 
"PHOENIX_ROW_TIMESTAMP()");
       ResultSet rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -268,7 +287,8 @@ public class GlobalIndexCheckerIT extends BaseTest {
       conn.createStatement()
         .execute("upsert into " + dataTableName + " values ('c', 'bc', 'ccc', 
'cccc')");
       conn.commit();
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertExplainPlan(conn, query, dataTableName, indexTableName,
+        OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -281,7 +301,8 @@ public class GlobalIndexCheckerIT extends BaseTest {
         + " WHERE val1 = 'bc' AND " + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + 
after.toString()
         + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertExplainPlan(conn, query, dataTableName, indexTableName,
+        OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS, 
"PHOENIX_ROW_TIMESTAMP()");
       waitForEventualConsistency();
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -293,10 +314,15 @@ public class GlobalIndexCheckerIT extends BaseTest {
       String noIndexQuery = "SELECT /*+ NO_INDEX */ val1, val2, 
PHOENIX_ROW_TIMESTAMP() from "
         + dataTableName + " WHERE val1 = 'bc' AND " + "PHOENIX_ROW_TIMESTAMP() 
> TO_DATE('"
         + after.toString() + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + 
"')";
-      // Verify that we will read from the data table
+      // Verify that we will read from the data table. The NO_INDEX hint 
rejects every
+      // secondary index candidate. Under STRONG consistency only the user 
index exists.
+      // Under EVENTUAL consistency the user index is paired with an 
auto-created CDC index,
+      // so two candidates are rejected. Match the user index by name rather 
than position
+      // since the rejection order is not guaranteed.
+      int expectedRejected = isEventualConsistency() ? 2 : 1;
       assertPlan(conn, noIndexQuery).scanType("FULL SCAN").table(dataTableName)
-        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedCount(1)
-        .indexRejected(0, indexTableName, 
OptimizerReasons.REASON_EXCLUDED_BY_NO_INDEX_HINT);
+        
.indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedCount(expectedRejected)
+        .indexRejectedContains(indexTableName, 
OptimizerReasons.REASON_EXCLUDED_BY_NO_INDEX_HINT);
       rs = conn.createStatement().executeQuery(noIndexQuery);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -316,7 +342,8 @@ public class GlobalIndexCheckerIT extends BaseTest {
       query =
         "SELECT  val1, val2, PHOENIX_ROW_TIMESTAMP()  from " + dataTableName + 
" WHERE val1 = 'de'";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertExplainPlan(conn, query, dataTableName, indexTableName,
+        OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS, 
"PHOENIX_ROW_TIMESTAMP()");
       waitForEventualConsistency();
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -346,7 +373,8 @@ public class GlobalIndexCheckerIT extends BaseTest {
         + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + initial.toString()
         + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertExplainPlan(conn, query, dataTableName, indexTableName,
+        OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("ab", rs.getString(1));
@@ -383,7 +411,10 @@ public class GlobalIndexCheckerIT extends BaseTest {
         + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + initial.toString()
         + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + "')";
 
-      assertExplainPlan(conn, query, dataTableName, indexTableName, 
OptimizerReasons.RULE_HINT);
+      // This query carries an explicit INDEX hint, so the selection rule is 
"hint". The functional
+      // index match over PHOENIX_ROW_TIMESTAMP() is disclosed separately.
+      assertExplainPlan(conn, query, dataTableName, indexTableName, 
OptimizerReasons.RULE_HINT,
+        "PHOENIX_ROW_TIMESTAMP()");
       waitForEventualConsistency();
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -1538,7 +1569,7 @@ public class GlobalIndexCheckerIT extends BaseTest {
       assertPlan(attributes).table(indexName);
       assertNotNull("expected a server distinct prefix filter, plan=" + 
attributes,
         attributes.getServerDistinctFilter());
-      List actualValues = Lists.newArrayList();
+      List<String> actualValues = Lists.newArrayList();
       while (rs.next()) {
         actualValues.add(rs.getString(1));
       }
@@ -1877,7 +1908,7 @@ public class GlobalIndexCheckerIT extends BaseTest {
         assertPlan(attributes).table(indexTableName);
         assertNotNull("expected a server distinct prefix filter, plan=" + 
attributes,
           attributes.getServerDistinctFilter());
-        List actualValues = Lists.newArrayList();
+        List<String> actualValues = Lists.newArrayList();
         while (rs.next()) {
           actualValues.add(rs.getString(1));
         }
@@ -1901,6 +1932,10 @@ public class GlobalIndexCheckerIT extends BaseTest {
   protected void waitForEventualConsistency() throws Exception {
   }
 
+  protected boolean isEventualConsistency() {
+    return false;
+  }
+
   protected void verifyTableHealth(Connection conn, String dataTableName, 
String indexTableName)
     throws Exception {
     // Add two rows and check everything is still okay
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java
index 2ca74c462f..9b0d4c20f5 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java
@@ -135,7 +135,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
         .iteratorType("PARALLEL 1-WAY").serverFirstKeyOnlyProjection(true)
         .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY "
           + "[TO_BIGINT(\"(A.INT_COL1 + B.INT_COL2)\")]")
-        .indexRuleMatches("(A.INT_COL1 + B.INT_COL2)").indexRejectedNone();
+        .functionalMatch("(A.INT_COL1 + B.INT_COL2)").indexRejectedNone();
       if (localIndex) {
         basePlan.scanType("RANGE SCAN")
           .table("INDEX_TEST." + indexName + "(" + fullDataTableName + ")")
@@ -192,7 +192,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
           "SERVER DISTINCT PREFIX FILTER OVER " + "[TO_BIGINT(\"(A.INT_COL1 + 
1)\")]")
         .serverAggregate(
           "SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY " + 
"[TO_BIGINT(\"(A.INT_COL1 + 1)\")]")
-        .indexRuleMatches("(A.INT_COL1 + 1)").indexRejectedNone();
+        .functionalMatch("(A.INT_COL1 + 1)").indexRejectedNone();
       if (localIndex) {
         basePlan.table("INDEX_TEST." + indexName + "(" + fullDataTableName + 
")")
           .keyRanges(" [1,0] - [1,*]").clientSortAlgo("CLIENT MERGE SORT");
@@ -247,7 +247,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       ExplainPlanTestUtil.ExplainPlanAssert basePlan = assertPlan(conn, sql)
         .iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN").serverFirstKeyOnlyProjection(true)
-        .indexRuleMatches("(A.INT_COL1 + 1)").indexRejectedNone();
+        .functionalMatch("(A.INT_COL1 + 1)").indexRejectedNone();
       if (localIndex) {
         basePlan.table("INDEX_TEST." + indexName + "(" + fullDataTableName + 
")")
           .keyRanges(" [1,2]").clientSortAlgo("CLIENT MERGE SORT");
@@ -301,7 +301,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       ExplainPlanTestUtil.ExplainPlanAssert basePlan =
         assertPlan(conn, sql).iteratorType("PARALLEL 
1-WAY").serverFirstKeyOnlyProjection(true)
-          .indexRuleMatches("(A.INT_COL1 + 1)").indexRejectedNone();
+          .functionalMatch("(A.INT_COL1 + 1)").indexRejectedNone();
       if (localIndex) {
         basePlan.scanType("RANGE SCAN")
           .table("INDEX_TEST." + indexName + "(" + fullDataTableName + 
")").keyRanges(" [1]")
@@ -376,7 +376,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
         + " WHERE (\"V1\" || '_' || \"v2\") = 'x_1'";
       ExplainPlanTestUtil.ExplainPlanAssert basePlan =
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN")
-          .indexRuleMatches("(\"cf1\".\"V1\" || '_' || 
\"CF2\".\"v2\")").indexRejectedNone();
+          .functionalMatch("(\"cf1\".\"V1\" || '_' || 
\"CF2\".\"v2\")").indexRejectedNone();
       if (localIndex) {
         basePlan.table(indexName + "(" + dataTableName + ")").keyRanges(" 
[1,'x_1']")
           .clientSortAlgo("CLIENT MERGE SORT");
@@ -401,7 +401,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
         "SELECT \"V1\", \"V1\" as foo1, (\"V1\" || '_' || \"v2\") as foo, 
(\"V1\" || '_' || \"v2\") as \"Foo1\", (\"V1\" || '_' || \"v2\") FROM "
           + dataTableName + " ORDER BY foo";
       basePlan = assertPlan(conn, query).iteratorType("PARALLEL 1-WAY")
-        .indexRuleMatches("(\"cf1\".\"V1\" || '_' || 
\"CF2\".\"v2\")").indexRejectedNone();
+        .functionalMatch("(\"cf1\".\"V1\" || '_' || 
\"CF2\".\"v2\")").indexRejectedNone();
       if (localIndex) {
         basePlan.scanType("RANGE SCAN").table(indexName + "(" + dataTableName 
+ ")")
           .keyRanges(" [1]").clientSortAlgo("CLIENT MERGE SORT");
@@ -482,7 +482,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
         basePlan.scanType("RANGE SCAN")
           .table("INDEX_TEST." + indexName + "(" + fullDataTableName + 
")").keyRanges(" [1,2]")
           .clientSortAlgo("CLIENT MERGE 
SORT").serverFirstKeyOnlyProjection(true)
-          .indexRuleMatches("(A.INT_COL1 + 1)").indexRejectedNone();
+          .functionalMatch("(A.INT_COL1 + 1)").indexRejectedNone();
       } else {
         basePlan.scanType("FULL 
SCAN").table(fullDataTableName).clientSortAlgo(null)
           .serverWhereFilter("SERVER FILTER BY (A.INT_COL1 + 1) = 2")
@@ -537,7 +537,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       String query = "SELECT k1, k2, k3, s1, s2 FROM " + viewName + " WHERE 
k1+k2+k3 = 173.0";
       ExplainPlanTestUtil.ExplainPlanAssert basePlan =
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN")
-          .indexRuleMatches("(K1 + K2 + K3)").indexRejectedNone();
+          .functionalMatch("(K1 + K2 + K3)").indexRejectedNone();
       if (local) {
         basePlan.table(indexName1 + "(" + dataTableName + ")").keyRanges(" 
[1,173]")
           .clientSortAlgo("CLIENT MERGE SORT");
@@ -560,7 +560,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       query = "SELECT k1, k2, s1||'_'||s2 FROM " + viewName + " WHERE 
(s1||'_'||s2)='foo2_bar2'";
       basePlan = assertPlan(conn, query).iteratorType("PARALLEL 
1-WAY").scanType("RANGE SCAN")
-        .serverFirstKeyOnlyProjection(true).indexRuleMatches("(S1 || '_' || 
S2)")
+        .serverFirstKeyOnlyProjection(true).functionalMatch("(S1 || '_' || 
S2)")
         .indexRejectedCount(1);
       if (local) {
         basePlan.table(indexName2 + "(" + dataTableName + ")")
@@ -625,7 +625,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN")
         .table(indexName2).keyRanges(" 
[1,'abc_cab','foo']").serverFirstKeyOnlyProjection(true)
-        .indexRuleMatches("(S2 || '_' || S3)").indexRejectedCount(1);
+        .functionalMatch("(S2 || '_' || S3)").indexRejectedCount(1);
 
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -724,7 +724,7 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       ExplainPlanTestUtil.ExplainPlanAssert basePlan = assertPlan(conn, query)
         .iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN").serverFirstKeyOnlyProjection(true)
-        .indexRuleMatches("REGEXP_SUBSTR(V,'id:\\\\w+')").indexRejectedNone();
+        .functionalMatch("REGEXP_SUBSTR(V,'id:\\\\w+')").indexRejectedNone();
       if (localIndex) {
         basePlan.table(indexName + "(" + dataTableName + ")").keyRanges(" 
[1,'id:id1']")
           .clientSortAlgo("CLIENT MERGE SORT");
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScanner2IT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScanner2IT.java
index 5be82ccdd9..d6c716e438 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScanner2IT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScanner2IT.java
@@ -30,6 +30,7 @@ import java.io.IOException;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.ResultSet;
+import java.sql.SQLException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -63,6 +64,7 @@ import org.apache.phoenix.exception.PhoenixParserException;
 import org.apache.phoenix.filter.SkipScanFilter;
 import org.apache.phoenix.hbase.index.IndexRegionObserver;
 import org.apache.phoenix.iterate.ScanningResultPostDummyResultCaller;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.KeyRange;
 import org.apache.phoenix.query.QueryServices;
@@ -325,7 +327,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() < TO_DATE('" + after + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       ResultSet rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -346,7 +348,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
       conn.createStatement()
         .execute("upsert into " + dataTableName + " values ('c', 'bc', 'ccc', 
'cccc')");
       conn.commit();
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -363,7 +365,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + after + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -395,7 +397,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + " val1, val2, PHOENIX_ROW_TIMESTAMP()  from " + dataTableName + " 
WHERE val1 = 'de'";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -424,7 +426,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + initial + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
 
@@ -537,7 +539,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() < TO_DATE('" + after + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       ResultSet rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -558,7 +560,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
       conn.createStatement()
         .execute("upsert into " + dataTableName + " values ('c', 'bc', 'ccc', 
'cccc')");
       conn.commit();
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -573,7 +575,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + after + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -604,7 +606,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + " val1, val2, PHOENIX_ROW_TIMESTAMP()  from " + dataTableName + " 
WHERE val1 = 'de'";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("de", rs.getString(1));
@@ -631,7 +633,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + initial + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -709,6 +711,22 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
     }
   }
 
+  /**
+   * Asserts the query is served by the index, disclosing the optimizer's 
selection rule (and, for a
+   * functional index, the separate {@code matches <expr>}).
+   */
+  private void assertIndexPlan(Connection conn, String sql, String 
dataTableName,
+    String indexTableName, String functionalExpr) throws SQLException {
+    String rule = sql.contains("/*+ INDEX(")
+      ? OptimizerReasons.RULE_HINT
+      : OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS;
+    if (functionalExpr == null) {
+      assertExplainPlan(conn, sql, dataTableName, indexTableName, rule);
+    } else {
+      assertExplainPlan(conn, sql, dataTableName, indexTableName, rule, 
functionalExpr);
+    }
+  }
+
   @Test
   public void testUncoveredQuery() throws Exception {
     String dataTableName = generateUniqueName();
@@ -764,7 +782,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
           + " WHERE val1 = 'bc' AND (val2 = 'bcd' OR val3 ='bcde')";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals("b", rs.getString(1));
@@ -784,7 +802,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         selectSql = "SELECT count(val3) from " + dataTableName + " where val1 
> '0' GROUP BY val1";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -802,7 +820,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         selectSql = "SELECT count(val3) from " + dataTableName + " where val1 
> '0'";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals(3, rs.getInt(1));
@@ -817,7 +835,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         selectSql = "SELECT val3 from " + dataTableName + " where val1 > '0' 
ORDER BY val1";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -862,11 +880,11 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         // is not included by the index table
         selectSql = "SELECT /*+ INDEX(" + dataTableName + " " + indexTableName 
+ ")*/ val4 from "
           + dataTableName + " WHERE val1 = 'bc' AND val2 = 'bcdd'";
-        assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+        assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       } else {
         // Verify that an index hint is not necessary for an uncovered index
         selectSql = "SELECT  val4 from " + dataTableName + " WHERE val1 = 'bc' 
AND val2 = 'bcdd'";
-        assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+        assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       }
 
       ResultSet rs = conn.createStatement().executeQuery(selectSql);
@@ -958,7 +976,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + "Count(v3) from " + dataTableName + " where v1 = 5";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals(count, rs.getInt(1));
@@ -996,7 +1014,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + "val2, val3 from " + dataTableName + " WHERE val1  = 'ab'";
       // Verify that we will read from the first index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       ResultSet rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       moveRegionsOfTable(dataTableName);
@@ -1021,7 +1039,7 @@ public class UncoveredGlobalIndexRegionScanner2IT extends 
BaseTest {
       String selectSql = "SELECT id from " + dataTableName + " WHERE val1  = 
'ab'";
 
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       ResultSet rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals("a", rs.getString(1));
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
index 79772ff1f0..642b25c6c2 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/UncoveredGlobalIndexRegionScannerIT.java
@@ -28,6 +28,7 @@ import static org.junit.Assert.fail;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.ResultSet;
+import java.sql.SQLException;
 import java.sql.Statement;
 import java.sql.Timestamp;
 import java.util.Arrays;
@@ -45,6 +46,7 @@ import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
 import org.apache.phoenix.exception.PhoenixParserException;
 import org.apache.phoenix.filter.SkipScanFilter;
 import org.apache.phoenix.hbase.index.IndexRegionObserver;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.KeyRange;
 import org.apache.phoenix.query.QueryServices;
@@ -192,7 +194,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() < TO_DATE('" + after + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       ResultSet rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -212,7 +214,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
       conn.createStatement()
         .execute("upsert into " + dataTableName + " values ('c', 'bc', 'ccc', 
'cccc')");
       conn.commit();
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -228,7 +230,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
           + " WHERE val1 = 'bc' AND " + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" 
+ after
           + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -259,7 +261,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + " val1, val2, PHOENIX_ROW_TIMESTAMP()  from " + dataTableName + " 
WHERE val1 = 'de'";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("de", rs.getString(1));
@@ -286,7 +288,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + initial + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("a", rs.getString(1));
@@ -373,7 +375,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() < TO_DATE('" + after + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       ResultSet rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -392,7 +394,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
       conn.createStatement()
         .execute("upsert into " + dataTableName + " values ('c', 'bc', 'ccc', 
'cccc')");
       conn.commit();
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -407,7 +409,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + after + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -438,7 +440,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + " val1, val2, PHOENIX_ROW_TIMESTAMP()  from " + dataTableName + " 
WHERE val1 = 'de'";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("de", rs.getString(1));
@@ -465,7 +467,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
           + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + initial + "','yyyy-MM-dd 
HH:mm:ss.SSS', '"
           + timeZoneID + "')";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertIndexPlan(conn, query, dataTableName, indexTableName, 
"PHOENIX_ROW_TIMESTAMP()");
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("a", rs.getString(1));
@@ -529,6 +531,22 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
     }
   }
 
+  /**
+   * Asserts the query is served by the index, disclosing the optimizer's 
selection rule (and, for a
+   * functional index, the separate {@code matches <expr>}).
+   */
+  private void assertIndexPlan(Connection conn, String sql, String 
dataTableName,
+    String indexTableName, String functionalExpr) throws SQLException {
+    String rule = sql.contains("/*+ INDEX(")
+      ? OptimizerReasons.RULE_HINT
+      : OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS;
+    if (functionalExpr == null) {
+      assertExplainPlan(conn, sql, dataTableName, indexTableName, rule);
+    } else {
+      assertExplainPlan(conn, sql, dataTableName, indexTableName, rule, 
functionalExpr);
+    }
+  }
+
   @Test
   public void testUncoveredQuery() throws Exception {
     String dataTableName = generateUniqueName();
@@ -582,7 +600,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
           + " WHERE val1 = 'bc' AND (val2 = 'bcd' OR val3 ='bcde')";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals("b", rs.getString(1));
@@ -602,7 +620,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         selectSql = "SELECT count(val3) from " + dataTableName + " where val1 
> '0' GROUP BY val1";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals(2, rs.getInt(1));
@@ -616,7 +634,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         selectSql = "SELECT count(val3) from " + dataTableName + " where val1 
> '0'";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals(3, rs.getInt(1));
@@ -631,7 +649,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         selectSql = "SELECT val3 from " + dataTableName + " where val1 > '0' 
ORDER BY val1";
       }
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals("abcd", rs.getString(1));
@@ -670,11 +688,11 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         // is not included by the index table
         selectSql = "SELECT /*+ INDEX(" + dataTableName + " " + indexTableName 
+ ")*/ val4 from "
           + dataTableName + " WHERE val1 = 'bc' AND val2 = 'bcdd'";
-        assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+        assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       } else {
         // Verify that an index hint is not necessary for an uncovered index
         selectSql = "SELECT  val4 from " + dataTableName + " WHERE val1 = 'bc' 
AND val2 = 'bcdd'";
-        assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+        assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       }
 
       ResultSet rs = conn.createStatement().executeQuery(selectSql);
@@ -756,7 +774,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + "Count(v3) from " + dataTableName + " where v1 = 5";
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals(count, rs.getInt(1));
@@ -792,7 +810,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
         "SELECT" + (uncovered ? " " : "/*+ INDEX(" + dataTableName + " " + 
indexTableName + ")*/ ")
           + "val2, val3 from " + dataTableName + " WHERE val1  = 'ab'";
       // Verify that we will read from the first index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       ResultSet rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals("abc", rs.getString(1));
@@ -813,7 +831,7 @@ public class UncoveredGlobalIndexRegionScannerIT extends 
BaseTest {
       String selectSql = "SELECT id from " + dataTableName + " WHERE val1  = 
'ab'";
 
       // Verify that we will read from the index table
-      assertExplainPlan(conn, selectSql, dataTableName, indexTableName);
+      assertIndexPlan(conn, selectSql, dataTableName, indexTableName, null);
       ResultSet rs = conn.createStatement().executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals("a", rs.getString(1));
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java
index d6eddcda1e..ce5bdaf950 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java
@@ -369,7 +369,7 @@ public class JsonFunctionsIT extends 
ParallelStatsDisabledIT {
         "SELECT JSON_VALUE(JSONCOL,'$.type'), " + 
"JSON_VALUE(JSONCOL,'$.info.address.town') FROM "
           + tableName + " WHERE JSON_VALUE(JSONCOL,'$.type') = 'Basic'";
       assertPlan(conn, selectSql).scanType("RANGE SCAN").table(indexName)
-        
.indexRuleMatches("JSON_VALUE(JSONCOL.JSONCOL,'$.type')").indexRejectedNone();
+        
.functionalMatch("JSON_VALUE(JSONCOL.JSONCOL,'$.type')").indexRejectedNone();
       // Validate the total count of rows
       String countSql = "SELECT COUNT(1) FROM " + tableName;
       ResultSet rs = conn.createStatement().executeQuery(countSql);
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java 
b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
index 29d609ba1c..e178d4eef6 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
@@ -521,10 +521,11 @@ public class QueryOptimizerTest extends 
BaseConnectionlessQueryTest {
       "create table fe_match (id varchar not null primary key, name varchar, 
val varchar)");
     conn.createStatement().execute("create index fe_match_upper_idx on 
fe_match (UPPER(name))");
     // The functional index's indexed expression matches the query's 
UPPER(NAME) path expression,
-    // so the chosen index's rule is overridden to the "matches <expr>" form. 
Assert on the rule
-    // prefix.
+    // so the chosen index carries a separate "matches <expr>" functional 
match disclosure with
+    // its selection rule.
     assertPlan(conn, "select id from fe_match where UPPER(name) = 'ABC'")
-      
.table("FE_MATCH_UPPER_IDX").indexRule(OptimizerReasons.matches("UPPER(NAME)"));
+      
.table("FE_MATCH_UPPER_IDX").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+      .functionalMatch("UPPER(NAME)");
   }
 
   @Test
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 72fa018b75..50764d2793 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
@@ -1001,10 +1001,11 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
 
   /**
    * A query whose path expression matches a covered functional index's 
indexed expression must
-   * choose that index, label the disclosed rule {@code "matches <expr>"}, and 
emit exactly one
-   * {@code INDEX EXPRESSION <expr> AS <col>} rewrite breadcrumb on the chosen 
plan's context. The
-   * breadcrumb is rendered as a {@code REWRITE INDEX EXPRESSION ...} 
top-of-plan line in plain
-   * EXPLAIN text and as a single entry in the structured {@code rewrites} 
attribute.
+   * choose that index, disclose the separate {@code "matches <expr>"} 
functional match label, and
+   * emit exactly one {@code INDEX EXPRESSION <expr> AS <col>} rewrite 
breadcrumb on the chosen
+   * plan's context. The breadcrumb is rendered as a {@code REWRITE INDEX 
EXPRESSION ...}
+   * top-of-plan line in plain EXPLAIN text and as a single entry in the 
structured {@code rewrites}
+   * attribute.
    */
   @Test
   public void testIndexExpressionRewriteEmittedForChosenFunctionalIndex() 
throws Exception {
@@ -1018,9 +1019,10 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
       String query = "SELECT BSON_VALUE(payload, 'k', 'VARCHAR') FROM " + base
         + " WHERE BSON_VALUE(payload, 'k', 'VARCHAR') = 'x'";
 
-      // The functional index is chosen and the rule comment names the matched 
expression.
+      // The functional index is chosen and the separate functional match 
disclosure names the
+      // matched expression.
       ExplainPlanTestUtil.assertPlan(conn, query).indexName(idx)
-        .indexRuleMatches("BSON_VALUE(PAYLOAD,'k','VARCHAR')")
+        .functionalMatch("BSON_VALUE(PAYLOAD,'k','VARCHAR')")
         // Exactly one breadcrumb (one applied substitution; no eager 
per-PK-column emissions).
         .rewriteCount(1).rewrite(0,
           "INDEX EXPRESSION BSON_VALUE(PAYLOAD,'k','VARCHAR') AS 
\":BSON_VALUE(PAYLOAD,'k','VARCHAR')\"");
@@ -1837,6 +1839,7 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
     n.putNull("indexName");
     n.putNull("indexKind");
     n.putNull("indexRule");
+    n.putNull("functionalMatch");
     n.putNull("indexRejected");
     n.putNull("saltBuckets");
     n.putNull("regionsPlanned");
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 711f5dcd53..135180db83 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
@@ -256,11 +256,20 @@ public final class ExplainPlanTestUtil {
     }
 
     /**
-     * Assert the optimizer chose a functional index whose rule label is 
exactly
-     * {@code "matches <expression>"} (see {@link 
OptimizerReasons#matches(String)}).
+     * Assert the optimizer's separate functional-index match disclosure equals
+     * {@code "matches <expression>"} (see {@link 
OptimizerReasons#matches(String)}). The selection
+     * {@code indexRule} is disclosed independently and is asserted with 
{@link #indexRule(String)}.
      */
-    public ExplainPlanAssert indexRuleMatches(String expression) {
-      return indexRule(OptimizerReasons.matches(expression));
+    public ExplainPlanAssert functionalMatch(String expression) {
+      assertEquals(at("functionalMatch"), OptimizerReasons.matches(expression),
+        attributes.getFunctionalMatch());
+      return this;
+    }
+
+    /** Assert no functional-index match disclosure was recorded for this 
plan. */
+    public ExplainPlanAssert functionalMatchNone() {
+      assertEquals(at("functionalMatch"), null, 
attributes.getFunctionalMatch());
+      return this;
     }
 
     /** Assert the number of rejected index candidates recorded for this plan. 
*/

Reply via email to