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 0c721c87ca PHOENIX-7930: Address review feedback for EXPLAIN 
improvements (#2537)
0c721c87ca is described below

commit 0c721c87ca40b225bd4384d96ed37980c0e8dac7
Author: Andrew Purtell <[email protected]>
AuthorDate: Wed Jun 17 16:11:51 2026 -0700

    PHOENIX-7930: Address review feedback for EXPLAIN improvements (#2537)
    
    Co-authored-by: Claude Opus 4.8[1m] <[email protected]>
---
 .../phoenix/compile/ExplainPlanAttributes.java     | 15 +++--
 .../org/apache/phoenix/compile/QueryCompiler.java  |  2 +-
 .../apache/phoenix/compile/StatementContext.java   | 62 ++++++++++++-------
 .../org/apache/phoenix/compile/UnionCompiler.java  | 10 +--
 .../org/apache/phoenix/iterate/ExplainTable.java   | 71 ++++++++++------------
 .../apache/phoenix/optimize/QueryOptimizer.java    |  1 -
 .../apache/phoenix/end2end/index/IndexUsageIT.java | 30 ++++-----
 .../phoenix/end2end/json/JsonFunctionsIT.java      |  2 +-
 .../apache/phoenix/compile/QueryOptimizerTest.java | 43 +++++++++++++
 .../phoenix/query/explain/ExplainPlanTest.java     |  2 +-
 .../phoenix/query/explain/ExplainPlanTestUtil.java | 14 ++---
 11 files changed, 153 insertions(+), 99 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 29be54c6e0..a068ec5fb4 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
@@ -22,6 +22,7 @@ import 
com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -186,7 +187,9 @@ public class ExplainPlanAttributes {
     this.scanEstimatedSizeInBytes = b.scanEstimatedSizeInBytes;
     this.serverWhereFilter = b.serverWhereFilter;
     this.serverDistinctFilter = b.serverDistinctFilter;
-    this.serverMergeColumns = b.serverMergeColumns;
+    this.serverMergeColumns = (b.serverMergeColumns == null || 
b.serverMergeColumns.isEmpty())
+      ? null
+      : Collections.unmodifiableSet(new LinkedHashSet<>(b.serverMergeColumns));
     this.serverParsedProjections = 
copyServerParsedProjections(b.serverParsedProjections);
     this.serverProject = (b.serverProject == null || b.serverProject.isEmpty())
       ? null
@@ -222,12 +225,16 @@ public class ExplainPlanAttributes {
       : Collections.unmodifiableList(new ArrayList<>(b.clientSteps));
     this.lhsJoinQueryExplainPlan = b.lhsJoinQueryExplainPlan;
     this.rhsJoinQueryExplainPlan = b.rhsJoinQueryExplainPlan;
-    this.subPlans = b.subPlans;
+    this.subPlans = (b.subPlans == null || b.subPlans.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(b.subPlans));
     this.dynamicServerFilter = b.dynamicServerFilter;
     this.afterJoinFilter = b.afterJoinFilter;
     this.joinScannerLimit = b.joinScannerLimit;
     this.sortMergeSkipMerge = b.sortMergeSkipMerge;
-    this.regionLocations = b.regionLocations;
+    this.regionLocations = (b.regionLocations == null || 
b.regionLocations.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(b.regionLocations));
     this.regionLocationsTotalSize = b.regionLocationsTotalSize;
     this.numRegionLocationLookups = b.numRegionLocationLookups;
   }
@@ -924,7 +931,7 @@ public class ExplainPlanAttributes {
     }
 
     public ExplainPlanAttributesBuilder setServerMergeColumns(Set<PColumn> 
columns) {
-      this.serverMergeColumns = columns;
+      this.serverMergeColumns = columns == null ? null : new 
LinkedHashSet<>(columns);
       return this;
     }
 
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 6d38fb51bb..8b187042aa 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
@@ -762,7 +762,7 @@ public class QueryCompiler {
     QueryPlan innerPlan = compileSubquery(innerSelect, false, context);
     if (innerPlan instanceof UnionPlan) {
       UnionCompiler.optimizeUnionOrderByIfPossible((UnionPlan) innerPlan, 
select,
-        this::createStatementContext);
+        this::createStatementContext, context);
     }
     RowProjector innerQueryPlanRowProjector = innerPlan.getProjector();
     TupleProjector tupleProjector = new 
TupleProjector(innerQueryPlanRowProjector);
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 4ce383aed9..6767035b3c 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
@@ -175,18 +175,9 @@ public class StatementContext {
     this.totalSegmentsValue = context.totalSegmentsValue;
     this.hasRowSizeFunction = context.hasRowSizeFunction;
     this.hasRawRowSizeFunction = context.hasRawRowSizeFunction;
-    this.appliedRewrites = context.appliedRewrites;
-    this.derivedTableFlattenCount = context.derivedTableFlattenCount;
-    this.appliedIndexExpressionMatches = context.appliedIndexExpressionMatches;
-    this.appliedIndexExpressionPairs = context.appliedIndexExpressionPairs;
-    this.functionalIndexNames = context.functionalIndexNames;
-    this.partialIndexCheckedSet = context.partialIndexCheckedSet;
-    this.serverParsedProjections = context.serverParsedProjections;
     this.parentContext = context.parentContext;
     this.explainOptions = context.explainOptions;
-    this.predicateOrigins = context.predicateOrigins;
-    this.decorrelatedSubqueryAlias = context.decorrelatedSubqueryAlias;
-    this.ignoredHints = context.ignoredHints;
+    copyRewriteStateFrom(context);
   }
 
   /**
@@ -253,8 +244,8 @@ public class StatementContext {
     this.derivedTableFlattenCount = 0;
     this.appliedIndexExpressionMatches = Maps.newLinkedHashMap();
     this.appliedIndexExpressionPairs = Maps.newLinkedHashMap();
-    this.functionalIndexNames = Sets.newHashSet();
-    this.partialIndexCheckedSet = Sets.newHashSet();
+    this.functionalIndexNames = Sets.newLinkedHashSet();
+    this.partialIndexCheckedSet = Sets.newLinkedHashSet();
     this.serverParsedProjections = null;
     this.parentContext = null;
     this.predicateOrigins = new IdentityHashMap<>();
@@ -541,22 +532,47 @@ public class StatementContext {
   }
 
   /**
-   * Adopt the rewrite breadcrumb accumulator (and related diagnostic state) 
of another context by
-   * sharing its references. Used to carry breadcrumbs recorded by the early
+   * Adopt the rewrite breadcrumb accumulator (and related diagnostic state) 
of another context.
+   * Used to carry breadcrumbs recorded by the early
    * {@link SubselectRewriter}/{@link SubqueryRewriter} pass on a pre-built 
context across the
-   * rewrite boundary onto the actual compilation context.
+   * rewrite boundary onto the actual compilation context. Fresh collections 
are allocated (see
+   * {@link #copyRewriteStateFrom}) so this context and {@code source} 
continue to mutate
+   * independently.
    */
   public void adoptRewriteState(StatementContext source) {
-    this.appliedRewrites = source.appliedRewrites;
+    copyRewriteStateFrom(source);
+  }
+
+  /**
+   * Copy the rewrite breadcrumb accumulators and related diagnostic state 
from {@code source} into
+   * this context, allocating fresh collections so the two contexts never 
share mutable backing
+   * collections. Shared by the copy constructor and {@link 
#adoptRewriteState}, both of which hand
+   * the resulting context to a compile pass that continues to mutate this 
state.
+   */
+  private void copyRewriteStateFrom(StatementContext source) {
+    this.appliedRewrites =
+      source.appliedRewrites == null ? new ArrayList<>() : new 
ArrayList<>(source.appliedRewrites);
     this.derivedTableFlattenCount = source.derivedTableFlattenCount;
-    this.appliedIndexExpressionMatches = source.appliedIndexExpressionMatches;
-    this.appliedIndexExpressionPairs = source.appliedIndexExpressionPairs;
-    this.functionalIndexNames = source.functionalIndexNames;
-    this.partialIndexCheckedSet = source.partialIndexCheckedSet;
+    this.appliedIndexExpressionMatches = Maps.newLinkedHashMap();
+    for (Map.Entry<String, List<String>> entry : 
source.appliedIndexExpressionMatches.entrySet()) {
+      this.appliedIndexExpressionMatches.put(entry.getKey(), new 
ArrayList<>(entry.getValue()));
+    }
+    this.appliedIndexExpressionPairs = Maps.newLinkedHashMap();
+    for (Map.Entry<String, Map<String, String>> entry : 
source.appliedIndexExpressionPairs
+      .entrySet()) {
+      this.appliedIndexExpressionPairs.put(entry.getKey(), new 
LinkedHashMap<>(entry.getValue()));
+    }
+    this.functionalIndexNames = 
Sets.newLinkedHashSet(source.functionalIndexNames);
+    this.partialIndexCheckedSet = 
Sets.newLinkedHashSet(source.partialIndexCheckedSet);
+    // serverParsedProjections is nullable and only ever replaced wholesale 
via its setter (never
+    // mutated in place), so the reference can be carried over directly.
     this.serverParsedProjections = source.serverParsedProjections;
-    this.predicateOrigins = source.predicateOrigins;
-    this.decorrelatedSubqueryAlias = source.decorrelatedSubqueryAlias;
-    this.ignoredHints = source.ignoredHints;
+    this.predicateOrigins = new IdentityHashMap<>();
+    for (Map.Entry<Expression, Set<String>> entry : 
source.predicateOrigins.entrySet()) {
+      this.predicateOrigins.put(entry.getKey(), new 
LinkedHashSet<>(entry.getValue()));
+    }
+    this.decorrelatedSubqueryAlias = new 
IdentityHashMap<>(source.decorrelatedSubqueryAlias);
+    this.ignoredHints = new EnumMap<>(source.ignoredHints);
   }
 
   public void incrementDerivedTableFlattenCount() {
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
index f99c9346d6..646d1cd587 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/UnionCompiler.java
@@ -235,8 +235,8 @@ public class UnionCompiler {
    * perform any special processing on the output of the subqueries.
    */
   static void optimizeUnionOrderByIfPossible(UnionPlan innerUnionPlan,
-    SelectStatement outerSelectStatement, Supplier<StatementContext> 
statementContextCreator)
-    throws SQLException {
+    SelectStatement outerSelectStatement, Supplier<StatementContext> 
statementContextCreator,
+    StatementContext breadcrumbContext) throws SQLException {
     innerUnionPlan.enableCheckSupportOrderByOptimize();
     if (!innerUnionPlan.isSupportOrderByOptimize()) {
       return;
@@ -250,8 +250,10 @@ public class UnionCompiler {
       // not perform any special processing on the output of the subqueries.
       innerUnionPlan.disableSupportOrderByOptimize();
     } else {
-      // The order-by merge optimization is preserved. Record a top-of-plan 
breadcrumb.
-      statementContextCreator.get().addAppliedRewrite("UNION ORDER BY MERGE");
+      // The order-by merge optimization is preserved. Record a top-of-plan 
breadcrumb on the outer
+      // query's compilation context (the throwaway contexts produced by 
statementContextCreator are
+      // only used for trial group-by/order-by compilation and are discarded).
+      breadcrumbContext.addAppliedRewrite("UNION ORDER BY MERGE");
     }
   }
 
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 1045d492fc..115e88d329 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
@@ -75,12 +75,9 @@ import org.apache.phoenix.util.CDCUtil;
 import org.apache.phoenix.util.MetaDataUtil;
 import org.apache.phoenix.util.ScanUtil;
 import org.apache.phoenix.util.StringUtil;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public abstract class ExplainTable {
 
-  private static final Logger LOGGER = 
LoggerFactory.getLogger(ExplainTable.class);
   private static final List<KeyRange> EVERYTHING =
     Collections.singletonList(KeyRange.EVERYTHING_RANGE);
   public static final String POINT_LOOKUP_ON_STRING = "POINT LOOKUP ON ";
@@ -801,46 +798,40 @@ public abstract class ExplainTable {
     if (regionLocationsFromResultIterator == null) {
       return "";
     }
-    try {
-      StringBuilder buf = new StringBuilder().append(REGION_LOCATIONS);
-      Set<String> regionBoundaries = new LinkedHashSet<>();
-      List<HRegionLocation> regionLocations = new ArrayList<>();
-      for (HRegionLocation regionLocation : regionLocationsFromResultIterator) 
{
-        String regionBoundary = 
Bytes.toStringBinary(regionLocation.getRegion().getStartKey()) + ":"
-          + Bytes.toStringBinary(regionLocation.getRegion().getEndKey());
-        if (regionBoundaries.add(regionBoundary)) {
-          regionLocations.add(regionLocation);
-        }
+    StringBuilder buf = new StringBuilder().append(REGION_LOCATIONS);
+    Set<String> regionBoundaries = new LinkedHashSet<>();
+    List<HRegionLocation> regionLocations = new ArrayList<>();
+    for (HRegionLocation regionLocation : regionLocationsFromResultIterator) {
+      String regionBoundary = 
Bytes.toStringBinary(regionLocation.getRegion().getStartKey()) + ":"
+        + Bytes.toStringBinary(regionLocation.getRegion().getEndKey());
+      if (regionBoundaries.add(regionBoundary)) {
+        regionLocations.add(regionLocation);
+      }
+    }
+    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);
       }
-      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);
-        buf.append("...total size = ");
-        buf.append(originalSize);
-      } else {
-        buf.append(regionLocations);
-        if (explainPlanAttributesBuilder != null) {
-          explainPlanAttributesBuilder
-            .setRegionLocations(Collections.unmodifiableList(regionLocations))
-            .setRegionLocationsTotalSize(regionLocations.size());
-        }
+      buf.append(trimmedRegionLocations);
+      buf.append("...total size = ");
+      buf.append(originalSize);
+    } else {
+      buf.append(regionLocations);
+      if (explainPlanAttributesBuilder != null) {
+        explainPlanAttributesBuilder
+          .setRegionLocations(Collections.unmodifiableList(regionLocations))
+          .setRegionLocationsTotalSize(regionLocations.size());
       }
-      buf.append(") ");
-      return buf.toString();
-    } catch (Exception e) {
-      LOGGER.error("Explain table unable to add region locations.", e);
-      return "";
     }
+    buf.append(") ");
+    return buf.toString();
   }
 
   @SuppressWarnings("rawtypes")
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 fd5bfaa498..20711d5412 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
@@ -177,7 +177,6 @@ public class QueryOptimizer {
         if (stmt.getWhere() != null && stmt.getWhere().hasSubquery()) {
           StatementContext context =
             new StatementContext(statement, resolver, new Scan(), new 
SequenceManager(statement));
-          ;
           ParseNode dummyWhere =
             
GenSubqueryParamValuesRewriter.replaceWithDummyValues(stmt.getWhere(), context);
           stmt = FACTORY.select(stmt, dummyWhere);
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 de405ef9f5..2ca74c462f 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)\")]")
-        .indexRuleStartsWith("matches").indexRejectedNone();
+        .indexRuleMatches("(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)\")]")
-        .indexRuleStartsWith("matches").indexRejectedNone();
+        .indexRuleMatches("(A.INT_COL1 + 1)").indexRejectedNone();
       if (localIndex) {
         basePlan.table("INDEX_TEST." + indexName + "(" + fullDataTableName + 
")")
           .keyRanges(" [1,0] - [1,*]").clientSortAlgo("CLIENT MERGE SORT");
@@ -245,9 +245,9 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       conn.createStatement().execute(ddl);
       String sql = "SELECT int_col1+1 FROM " + fullDataTableName + " where 
int_col1+1 IN (2)";
 
-      ExplainPlanTestUtil.ExplainPlanAssert basePlan =
-        assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN")
-          
.serverFirstKeyOnlyProjection(true).indexRuleStartsWith("matches").indexRejectedNone();
+      ExplainPlanTestUtil.ExplainPlanAssert basePlan = assertPlan(conn, sql)
+        .iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN").serverFirstKeyOnlyProjection(true)
+        .indexRuleMatches("(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)
-          .indexRuleStartsWith("matches").indexRejectedNone();
+          .indexRuleMatches("(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")
-          .indexRuleStartsWith("matches").indexRejectedNone();
+          .indexRuleMatches("(\"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")
-        .indexRuleStartsWith("matches").indexRejectedNone();
+        .indexRuleMatches("(\"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)
-          .indexRuleStartsWith("matches").indexRejectedNone();
+          .indexRuleMatches("(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")
-          .indexRuleStartsWith("matches").indexRejectedNone();
+          .indexRuleMatches("(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).indexRuleStartsWith("matches")
+        .serverFirstKeyOnlyProjection(true).indexRuleMatches("(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)
-        .indexRuleStartsWith("matches").indexRejectedCount(1);
+        .indexRuleMatches("(S2 || '_' || S3)").indexRejectedCount(1);
 
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -722,9 +722,9 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       query = "SELECT k FROM " + dataTableName + " WHERE 
REGEXP_SUBSTR(v,'id:\\\\w+') = 'id:id1'";
 
-      ExplainPlanTestUtil.ExplainPlanAssert basePlan =
-        assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN")
-          
.serverFirstKeyOnlyProjection(true).indexRuleStartsWith("matches").indexRejectedNone();
+      ExplainPlanTestUtil.ExplainPlanAssert basePlan = assertPlan(conn, query)
+        .iteratorType("PARALLEL 1-WAY").scanType("RANGE 
SCAN").serverFirstKeyOnlyProjection(true)
+        .indexRuleMatches("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/json/JsonFunctionsIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java
index e527e76d12..d6eddcda1e 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)
-        .indexRuleStartsWith("matches").indexRejectedNone();
+        
.indexRuleMatches("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 abd027ca1a..29d609ba1c 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
@@ -36,12 +36,15 @@ import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Properties;
 import org.apache.hadoop.hbase.client.Scan;
 import org.apache.hadoop.hbase.util.Bytes;
 import org.apache.hadoop.hbase.util.Pair;
 import org.apache.phoenix.compile.OrderByCompiler.OrderBy;
+import org.apache.phoenix.expression.Expression;
+import org.apache.phoenix.expression.LiteralExpression;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
 import org.apache.phoenix.jdbc.PhoenixResultSet;
 import org.apache.phoenix.jdbc.PhoenixStatement;
@@ -956,4 +959,44 @@ public class QueryOptimizerTest extends 
BaseConnectionlessQueryTest {
     assertNull(scan.getAttribute(MIN_QUALIFIER));
     assertNull(scan.getAttribute(MAX_QUALIFIER));
   }
+
+  /**
+   * Test the deep copy contract of the {@link StatementContext} copy 
constructor and
+   * {@link StatementContext#adoptRewriteState}.
+   */
+  @Test
+  public void testAdoptRewriteStateDoesNotBleedSourceMutations() throws 
Exception {
+    Connection conn = DriverManager.getConnection(getUrl());
+    PhoenixStatement stmt = 
conn.createStatement().unwrap(PhoenixStatement.class);
+
+    StatementContext source = new StatementContext(stmt);
+    source.addAppliedRewrite("X");
+    source.recordAppliedIndexExpressionMatches("IDX", 
Collections.singletonList("A + B"));
+    Expression predicate = LiteralExpression.newConstant(1);
+    source.tagPredicate(predicate, "WHERE");
+
+    // Copy via the copy constructor, which shares copyRewriteStateFrom() with 
adoptRewriteState().
+    StatementContext copy = new StatementContext(source);
+
+    // The copy observed the breadcrumbs recorded before the copy.
+    assertTrue(copy.getAppliedRewrites().contains("X"));
+    assertEquals(Collections.singletonList("A + B"), 
copy.getAppliedIndexExpressionMatches("IDX"));
+    assertTrue(copy.getPredicateOrigins(predicate).contains("WHERE"));
+
+    // Mutating the source after the copy must not leak into the destination.
+    source.addAppliedRewrite("foo");
+    source.recordAppliedIndexExpressionMatches("IDX", 
Collections.singletonList("C + D"));
+    source.tagPredicate(predicate, "JOIN ON");
+
+    assertFalse(copy.getAppliedRewrites().contains("foo"));
+    assertFalse(copy.getAppliedIndexExpressionMatches("IDX").contains("C + 
D"));
+    assertFalse(copy.getPredicateOrigins(predicate).contains("JOIN ON"));
+
+    // adoptRewriteState() onto a fresh destination has the same isolation 
guarantee.
+    StatementContext adopted = new StatementContext(stmt);
+    adopted.adoptRewriteState(source);
+    assertTrue(adopted.getAppliedRewrites().contains("foo"));
+    source.addAppliedRewrite("bar");
+    assertFalse(adopted.getAppliedRewrites().contains("bar"));
+  }
 }
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 12fa1bc43b..72fa018b75 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
@@ -1020,7 +1020,7 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
 
       // The functional index is chosen and the rule comment names the matched 
expression.
       ExplainPlanTestUtil.assertPlan(conn, query).indexName(idx)
-        .indexRuleStartsWith("matches BSON_VALUE(PAYLOAD,'k','VARCHAR')")
+        .indexRuleMatches("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')\"");
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 b239b9af50..711f5dcd53 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
@@ -32,6 +32,7 @@ import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.compile.QueryPlan;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.optimize.RejectedIndexEntry;
 import org.apache.phoenix.parse.ExplainOptions;
 import org.apache.phoenix.parse.UpsertStatement.OnDuplicateKeyType;
@@ -255,16 +256,11 @@ public final class ExplainPlanTestUtil {
     }
 
     /**
-     * Assert the index-selection rule label starts with {@code prefix}. 
Useful for the functional
-     * index {@code "matches <expr>"} rule whose suffix is expression 
dependent.
+     * Assert the optimizer chose a functional index whose rule label is 
exactly
+     * {@code "matches <expression>"} (see {@link 
OptimizerReasons#matches(String)}).
      */
-    public ExplainPlanAssert indexRuleStartsWith(String prefix) {
-      String actual = attributes.getIndexRule();
-      assertNotNull(at("indexRule") + " must not be null", actual);
-      assertTrue(
-        at("indexRule") + " expected to start with '" + prefix + "' but was '" 
+ actual + "'",
-        actual.startsWith(prefix));
-      return this;
+    public ExplainPlanAssert indexRuleMatches(String expression) {
+      return indexRule(OptimizerReasons.matches(expression));
     }
 
     /** Assert the number of rejected index candidates recorded for this plan. 
*/

Reply via email to