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

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


The following commit(s) were added to refs/heads/5.3 by this push:
     new cec6a64228 PHOENIX-7879 Tests for EXPLAIN text and 
ExplainPlanAttributes serialization compatibility (#2495)
cec6a64228 is described below

commit cec6a64228a3d794834ecdd8ec93a0dcd3a2227e
Author: Andrew Purtell <[email protected]>
AuthorDate: Thu Jun 4 16:55:52 2026 -0700

    PHOENIX-7879 Tests for EXPLAIN text and ExplainPlanAttributes serialization 
compatibility (#2495)
---
 .../phoenix/compile/ExplainPlanAttributes.java     |  13 +
 .../compile/RegionLocationsListSerializer.java     |  61 ++
 .../compile/ServerMergeColumnsSerializer.java      |  59 ++
 .../org/apache/phoenix/schema/MetaDataClient.java  |   6 +-
 .../phoenix/query/explain/ExplainChangeRule.java   |  54 ++
 .../query/explain/ExplainCompatibilityTest.java    | 769 +++++++++++++++++++++
 .../query/explain/ExplainJsonNormalizer.java       |  71 ++
 .../phoenix/query/explain/ExplainOracle.java       | 207 ++++++
 .../query/explain/ExplainTextNormalizer.java       |  68 ++
 9 files changed, 1305 insertions(+), 3 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 07f00f2b67..f4ed63f245 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
@@ -17,6 +17,8 @@
  */
 package org.apache.phoenix.compile;
 
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import java.util.List;
 import java.util.Set;
 import org.apache.hadoop.hbase.HRegionLocation;
@@ -30,6 +32,15 @@ import org.apache.phoenix.schema.PColumn;
  * against. This also makes attribute retrieval easier as an API rather than 
retrieving list of
  * Strings containing entire plan.
  */
+@JsonPropertyOrder({ "abstractExplainPlan", "splitsChunk", "estimatedRows", 
"estimatedSizeInBytes",
+  "iteratorTypeAndScanSize", "samplingRate", "useRoundRobinIterator", 
"hexStringRVCOffset",
+  "consistency", "hint", "serverSortedBy", "explainScanType", "tableName", 
"keyRanges",
+  "scanTimeRangeMin", "scanTimeRangeMax", "serverWhereFilter", 
"serverDistinctFilter",
+  "serverOffset", "serverRowLimit", "serverArrayElementProjection", 
"serverAggregate",
+  "clientFilterBy", "clientAggregate", "clientSortedBy", 
"clientAfterAggregate",
+  "clientDistinctFilter", "clientOffset", "clientRowLimit", 
"clientSequenceCount",
+  "clientCursorName", "clientSortAlgo", "rhsJoinQueryExplainPlan", 
"serverMergeColumns",
+  "regionLocations", "numRegionLocationLookups" })
 public class ExplainPlanAttributes {
 
   private final String abstractExplainPlan;
@@ -299,10 +310,12 @@ public class ExplainPlanAttributes {
     return rhsJoinQueryExplainPlan;
   }
 
+  @JsonSerialize(using = ServerMergeColumnsSerializer.class)
   public Set<PColumn> getServerMergeColumns() {
     return serverMergeColumns;
   }
 
+  @JsonSerialize(using = RegionLocationsListSerializer.class)
   public List<HRegionLocation> getRegionLocations() {
     return regionLocations;
   }
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/RegionLocationsListSerializer.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/RegionLocationsListSerializer.java
new file mode 100644
index 0000000000..1ae1b77a53
--- /dev/null
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/RegionLocationsListSerializer.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.compile;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import java.io.IOException;
+import java.util.List;
+import org.apache.hadoop.hbase.HRegionLocation;
+import org.apache.hadoop.hbase.ServerName;
+import org.apache.hadoop.hbase.client.RegionInfo;
+import org.apache.hadoop.hbase.util.Bytes;
+
+/**
+ * Jackson serializer for {@code List<HRegionLocation>} as it appears on
+ * {@link ExplainPlanAttributes#getRegionLocations()}. The HBase {@code 
HRegionLocation} bean is not
+ * cleanly serializable by Jackson's default introspection.
+ */
+public class RegionLocationsListSerializer extends 
StdSerializer<List<HRegionLocation>> {
+
+  private static final long serialVersionUID = 1L;
+
+  @SuppressWarnings("unchecked")
+  public RegionLocationsListSerializer() {
+    super((Class<List<HRegionLocation>>) (Class<?>) List.class);
+  }
+
+  @Override
+  public void serialize(List<HRegionLocation> value, JsonGenerator gen, 
SerializerProvider provider)
+    throws IOException {
+    gen.writeStartArray();
+    for (HRegionLocation loc : value) {
+      gen.writeStartObject();
+      RegionInfo region = loc == null ? null : loc.getRegion();
+      gen.writeStringField("startKey",
+        region == null ? null : Bytes.toStringBinary(region.getStartKey()));
+      gen.writeStringField("endKey",
+        region == null ? null : Bytes.toStringBinary(region.getEndKey()));
+      ServerName sn = loc == null ? null : loc.getServerName();
+      gen.writeStringField("server", sn == null ? null : sn.toString());
+      gen.writeEndObject();
+    }
+    gen.writeEndArray();
+  }
+}
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ServerMergeColumnsSerializer.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ServerMergeColumnsSerializer.java
new file mode 100644
index 0000000000..988c319bb8
--- /dev/null
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ServerMergeColumnsSerializer.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.compile;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import org.apache.phoenix.schema.PColumn;
+
+/**
+ * Jackson serializer for {@code Set<PColumn>} as it appears on
+ * {@link ExplainPlanAttributes#getServerMergeColumns()}. {@code PColumn} is 
an interface backed by
+ * implementations that are not Jackson-friendly.
+ */
+public class ServerMergeColumnsSerializer extends StdSerializer<Set<PColumn>> {
+
+  private static final long serialVersionUID = 1L;
+
+  @SuppressWarnings("unchecked")
+  public ServerMergeColumnsSerializer() {
+    super((Class<Set<PColumn>>) (Class<?>) Set.class);
+  }
+
+  @Override
+  public void serialize(Set<PColumn> value, JsonGenerator gen, 
SerializerProvider provider)
+    throws IOException {
+    List<String> names = new ArrayList<>(value.size());
+    for (PColumn column : value) {
+      names.add(column == null ? null : column.toString());
+    }
+    Collections.sort(names, Comparator.nullsFirst(Comparator.naturalOrder()));
+    gen.writeStartArray();
+    for (String name : names) {
+      gen.writeString(name);
+    }
+    gen.writeEndArray();
+  }
+}
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java
index 4fff6826c6..a2e719c3f0 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/MetaDataClient.java
@@ -4786,9 +4786,9 @@ public class MetaDataClient {
           /**
            * To check if TTL is defined at any of the child below we are 
checking it at
            * {@link 
org.apache.phoenix.coprocessor.MetaDataEndpointImpl#mutateColumn(List, 
ColumnMutator, int, PTable, PTable, boolean)}
-           * level where in function
-           * {@link org.apache.phoenix.coprocessor.MetaDataEndpointImpl# 
validateIfMutationAllowedOnParent(PTable, List, PTableType, long, byte[], 
byte[], byte[], List, int)}
-           * we are already traversing through allDescendantViews.
+           * level where in function {@link 
org.apache.phoenix.coprocessor.MetaDataEndpointImpl#
+           * validateIfMutationAllowedOnParent(PTable, List, PTableType, long, 
byte[], byte[],
+           * byte[], List, int)} we are already traversing through 
allDescendantViews.
            */
         }
 
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainChangeRule.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainChangeRule.java
new file mode 100644
index 0000000000..a661bdca15
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainChangeRule.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.query.explain;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.List;
+
+/**
+ * The {@link ExplainOracle} freezes today's EXPLAIN output as a set of golden 
fixtures. Future
+ * EXPLAIN-design PRs that intentionally change the output register an {@code 
ExplainChangeRule}
+ * that rewrites the (frozen) form into the new expected form, so every change 
to the grammar is
+ * explicit and reviewable.
+ */
+public interface ExplainChangeRule {
+
+  /**
+   * Transform the golden plan steps text for the named case. The default 
implementation returns
+   * {@code goldenText} unchanged.
+   * @param caseId     the corpus case identifier (e.g. {@code "pointLookup"})
+   * @param goldenText the line-oriented golden as currently expected (already 
transformed by any
+   *                   earlier rule in the chain)
+   * @return the next-expected golden text (may be a new list or the same 
instance)
+   */
+  default List<String> applyText(String caseId, List<String> goldenText) {
+    return goldenText;
+  }
+
+  /**
+   * Transform the golden JSON attributes tree for the named case. The default 
implementation
+   * returns {@code goldenJson} unchanged.
+   * @param caseId     the corpus case identifier
+   * @param goldenJson the JSON attributes tree as currently expected (already 
transformed by any
+   *                   earlier rule in the chain)
+   * @return the next-expected JSON tree (may be a mutated input or a new node)
+   */
+  default JsonNode applyJson(String caseId, JsonNode goldenJson) {
+    return goldenJson;
+  }
+}
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainCompatibilityTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainCompatibilityTest.java
new file mode 100644
index 0000000000..cd17176286
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainCompatibilityTest.java
@@ -0,0 +1,769 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.query.explain;
+
+import static org.apache.phoenix.query.QueryServices.AUTO_COMMIT_ATTRIB;
+import static org.apache.phoenix.query.QueryServices.DATE_FORMAT_ATTRIB;
+import static org.apache.phoenix.util.PhoenixRuntime.TENANT_ID_ATTRIB;
+import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import org.apache.hadoop.hbase.HRegionLocation;
+import org.apache.hadoop.hbase.ServerName;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.client.RegionInfoBuilder;
+import org.apache.phoenix.compile.ExplainPlan;
+import org.apache.phoenix.compile.ExplainPlanAttributes;
+import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
+import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.query.BaseConnectionlessQueryTest;
+import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.schema.PColumn;
+import org.apache.phoenix.schema.PColumnImpl;
+import org.apache.phoenix.schema.PName;
+import org.apache.phoenix.schema.PNameFactory;
+import org.apache.phoenix.schema.SortOrder;
+import org.apache.phoenix.schema.types.PInteger;
+import org.apache.phoenix.util.PropertiesUtil;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Backward compatibility tests for Phoenix EXPLAIN output.
+ * <p>
+ * Each corpus {@code @Test} method compiles a representative query against a 
connectionless Phoenix
+ * driver, builds the expected normalized plan-steps text and JSON attributes 
inline, and hands both
+ * to the {@link ExplainOracle} for a tolerant comparison. The corpus covers 
every EXPLAIN grammar
+ * branch reachable without a connection.
+ */
+public class ExplainCompatibilityTest extends BaseConnectionlessQueryTest {
+
+  private static final String SALTED = "EO_SALTED";
+  private static final String SEQ = "EO_SEQ";
+  private static final String MT_BASE = "EO_MT_BASE";
+  private static final String MT_VIEW = "EO_MT_VIEW";
+  private static final String TENANT_ID = "tenant42";
+
+  private static ExplainOracle oracle;
+  private static ObjectMapper mapper;
+
+  @BeforeClass
+  public static synchronized void setUp() throws Exception {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    props.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
+    props.setProperty(QueryServices.FORCE_ROW_KEY_ORDER_ATTRIB, 
Boolean.toString(false));
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      conn.createStatement().execute("CREATE TABLE IF NOT EXISTS " + SALTED
+        + " (k VARCHAR NOT NULL PRIMARY KEY, v INTEGER) SALT_BUCKETS=4");
+      conn.createStatement().execute("CREATE SEQUENCE IF NOT EXISTS " + SEQ);
+      conn.createStatement()
+        .execute("CREATE TABLE IF NOT EXISTS " + MT_BASE + " (" + "  tenant_id 
VARCHAR(8) NOT NULL,"
+          + "  userid INTEGER NOT NULL," + "  username VARCHAR NOT NULL," + "  
col VARCHAR"
+          + "  CONSTRAINT pk PRIMARY KEY (tenant_id, userid, username)) 
MULTI_TENANT=true");
+    }
+    Properties tenantProps = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    tenantProps.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
+    tenantProps.setProperty(TENANT_ID_ATTRIB, TENANT_ID);
+    try (Connection conn = DriverManager.getConnection(getUrl(), tenantProps)) 
{
+      conn.createStatement()
+        .execute("CREATE VIEW IF NOT EXISTS " + MT_VIEW + " AS SELECT * FROM " 
+ MT_BASE);
+    }
+
+    oracle = new ExplainOracle();
+    mapper = oracle.mapper();
+  }
+
+  @Test
+  public void testPointLookup() throws Exception {
+    verifyQuery("pointLookup",
+      "SELECT a_string, b_string FROM atable"
+        + " WHERE organization_id = '00D000000000001' AND entity_id = 
'00E00000000001'"
+        + " AND x_integer = 2 AND a_integer < 5",
+      text("CLIENT PARALLEL <N>-WAY POINT LOOKUP ON 1 KEY OVER ATABLE",
+        "    SERVER FILTER BY (X_INTEGER = 2 AND A_INTEGER < 5)"),
+      scanAttrs("POINT LOOKUP ON 1 KEY ", "ATABLE", 
null).put("serverWhereFilter",
+        "SERVER FILTER BY (X_INTEGER = 2 AND A_INTEGER < 5)"));
+  }
+
+  @Test
+  public void testPointLookupMultiKey() throws Exception {
+    verifyQuery("pointLookupMultiKey",
+      "SELECT a_string, b_string FROM atable"
+        + " WHERE organization_id IN ('00D000000000001', '00D000000000005')"
+        + " AND entity_id IN ('00E00000000000X','00E00000000000Z')",
+      text("CLIENT PARALLEL <N>-WAY POINT LOOKUP ON 4 KEYS OVER ATABLE"),
+      scanAttrs("POINT LOOKUP ON 4 KEYS ", "ATABLE", null));
+  }
+
+  @Test
+  public void testRangeScan() throws Exception {
+    verifyQuery("rangeScan",
+      "SELECT a_string FROM atable WHERE organization_id = '00D000000000001'"
+        + " AND entity_id > '00E00000000002' AND entity_id < '00E00000000008'",
+      text("CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE"
+        + " ['00D000000000001','00E00000000002!'] - 
['00D000000000001','00E00000000008 ']"),
+      scanAttrs("RANGE SCAN ", "ATABLE",
+        " ['00D000000000001','00E00000000002!'] - 
['00D000000000001','00E00000000008 ']"));
+  }
+
+  @Test
+  public void testSkipScanKeys() throws Exception {
+    verifyQuery("skipScanKeys", "SELECT host FROM ptsdb3 WHERE host IN 
('na1','na2','na3')",
+      text("CLIENT PARALLEL <N>-WAY SKIP SCAN ON 3 KEYS OVER PTSDB3 [~'na3'] - 
[~'na1']",
+        "    SERVER FILTER BY FIRST KEY ONLY"),
+      scanAttrs("SKIP SCAN ON 3 KEYS ", "PTSDB3", " [~'na3'] - 
[~'na1']").put("serverWhereFilter",
+        "SERVER FILTER BY FIRST KEY ONLY"));
+  }
+
+  @Test
+  public void testSkipScanRanges() throws Exception {
+    verifyQuery("skipScanRanges",
+      "SELECT inst,host FROM ptsdb WHERE inst IN ('na1','na2','na3')"
+        + " AND host IN ('a','b') AND \"DATE\" >= to_date('2013-01-01')"
+        + " AND \"DATE\" < to_date('2013-01-02')",
+      text(
+        "CLIENT PARALLEL <N>-WAY SKIP SCAN ON 6 RANGES OVER PTSDB"
+          + " ['na1','a','2013-01-01'] - ['na3','b','2013-01-02']",
+        "    SERVER FILTER BY FIRST KEY ONLY"),
+      scanAttrs("SKIP SCAN ON 6 RANGES ", "PTSDB",
+        " ['na1','a','2013-01-01'] - 
['na3','b','2013-01-02']").put("serverWhereFilter",
+          "SERVER FILTER BY FIRST KEY ONLY"));
+  }
+
+  @Test
+  public void testFullScan() throws Exception {
+    verifyQuery("fullScan", "SELECT * FROM atable",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE"), scanAttrs("FULL 
SCAN ", "ATABLE", ""));
+  }
+
+  @Test
+  public void testReverseScan() throws Exception {
+    verifyQuery("reverseScan",
+      "SELECT inst,\"DATE\" FROM ptsdb2 WHERE inst = 'na1' ORDER BY inst DESC, 
\"DATE\" DESC",
+      text("CLIENT PARALLEL <N>-WAY REVERSE RANGE SCAN OVER PTSDB2 ['na1']",
+        "    SERVER FILTER BY FIRST KEY ONLY"),
+      scanAttrs("RANGE SCAN ", "PTSDB2", " ['na1']")
+        .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY ONLY")
+        .put("clientSortedBy", "REVERSE"));
+  }
+
+  @Test
+  public void testSmallHint() throws Exception {
+    verifyQuery("smallHint",
+      "SELECT /*+ SMALL */ host FROM ptsdb3 WHERE host IN ('na1','na2','na3')",
+      text("CLIENT PARALLEL <N>-WAY SMALL SKIP SCAN ON 3 KEYS OVER PTSDB3 
[~'na3'] - [~'na1']",
+        "    SERVER FILTER BY FIRST KEY ONLY"),
+      scanAttrs("SKIP SCAN ON 3 KEYS ", "PTSDB3", " [~'na3'] - 
[~'na1']").put("hint", "SMALL")
+        .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY ONLY"));
+  }
+
+  @Test
+  public void testAggregateSingleRow() throws Exception {
+    verifyQuery("aggregateSingleRow", "SELECT count(*) FROM atable",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "    SERVER FILTER 
BY FIRST KEY ONLY",
+        "    SERVER AGGREGATE INTO SINGLE ROW"),
+      scanAttrs("FULL SCAN ", "ATABLE", "")
+        .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY ONLY")
+        .put("serverAggregate", "SERVER AGGREGATE INTO SINGLE ROW"));
+  }
+
+  @Test
+  public void testAggregateOrderedDistinct() throws Exception {
+    verifyQuery("aggregateOrderedDistinct", "SELECT count(1) FROM atable GROUP 
BY a_string",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE",
+        "    SERVER AGGREGATE INTO DISTINCT ROWS BY [A_STRING]", "CLIENT MERGE 
SORT"),
+      scanAttrs("FULL SCAN ", "ATABLE", "")
+        .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING]")
+        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+  }
+
+  @Test
+  public void testAggregateHashDistinct() throws Exception {
+    verifyQuery("aggregateHashDistinct",
+      "SELECT count(1) FROM atable WHERE a_integer = 1"
+        + " GROUP BY ROUND(a_time,'HOUR',2), entity_id",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "    SERVER FILTER 
BY A_INTEGER = 1",
+        "    SERVER AGGREGATE INTO DISTINCT ROWS BY [ENTITY_ID, 
ROUND(A_TIME)]",
+        "CLIENT MERGE SORT"),
+      scanAttrs("FULL SCAN ", "ATABLE", "")
+        .put("serverWhereFilter", "SERVER FILTER BY A_INTEGER = 1")
+        .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[ENTITY_ID, ROUND(A_TIME)]")
+        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+  }
+
+  @Test
+  public void testTopNSortedBy() throws Exception {
+    verifyQuery("topNSortedBy", "SELECT a_string FROM atable ORDER BY a_string 
DESC LIMIT 3",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE",
+        "    SERVER TOP 3 ROWS SORTED BY [A_STRING DESC]", "CLIENT MERGE 
SORT", "CLIENT LIMIT 3"),
+      scanAttrs("FULL SCAN ", "ATABLE", "").put("serverSortedBy", "[A_STRING 
DESC]")
+        .put("serverRowLimit", 3).put("clientRowLimit", 3)
+        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+  }
+
+  @Test
+  public void testClientFilterByMax() throws Exception {
+    verifyQuery("clientFilterByMax",
+      "SELECT count(1) FROM atable GROUP BY a_string, b_string HAVING 
max(a_string) = 'a'",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE",
+        "    SERVER AGGREGATE INTO DISTINCT ROWS BY [A_STRING, B_STRING]", 
"CLIENT MERGE SORT",
+        "CLIENT FILTER BY MAX(A_STRING) = 'a'"),
+      scanAttrs("FULL SCAN ", "ATABLE", "")
+        .put("serverAggregate", "SERVER AGGREGATE INTO DISTINCT ROWS BY 
[A_STRING, B_STRING]")
+        .put("clientFilterBy", "MAX(A_STRING) = 'a'").put("clientSortAlgo", 
"CLIENT MERGE SORT"));
+  }
+
+  @Test
+  public void testClientLimit() throws Exception {
+    verifyQuery("clientLimit",
+      "SELECT a_string, b_string FROM atable"
+        + " WHERE organization_id = '00D000000000001' AND entity_id != 
'00E00000000002'"
+        + " AND x_integer = 2 AND a_integer < 5 LIMIT 10",
+      text("CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000001']",
+        "    SERVER FILTER BY (ENTITY_ID != '00E00000000002' AND X_INTEGER = 2 
AND A_INTEGER < 5)",
+        "    SERVER 10 ROW LIMIT", "CLIENT 10 ROW LIMIT"),
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']")
+        .put("serverWhereFilter",
+          "SERVER FILTER BY (ENTITY_ID != '00E00000000002' AND X_INTEGER = 2 
AND A_INTEGER < 5)")
+        .put("serverRowLimit", 10).put("clientRowLimit", 10));
+  }
+
+  @Test
+  public void testArrayElementProjection() throws Exception {
+    verifyQuery("arrayElementProjection", "SELECT a_string_array[1] FROM 
table_with_array",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER TABLE_WITH_ARRAY",
+        "    SERVER ARRAY ELEMENT PROJECTION"),
+      scanAttrs("FULL SCAN ", "TABLE_WITH_ARRAY", 
"").put("serverArrayElementProjection", true));
+  }
+
+  @Test
+  public void testSortMergeJoin() throws Exception {
+    ObjectNode rhs = scanAttrs("FULL SCAN ", "ATABLE", "");
+    verifyQuery("sortMergeJoin",
+      "SELECT /*+ USE_SORT_MERGE_JOIN */ a.a_string, b.a_string FROM atable a"
+        + " JOIN atable b ON a.organization_id = b.organization_id"
+        + " WHERE a.organization_id = '00D000000000001'",
+      text("SORT-MERGE-JOIN (INNER) TABLES",
+        "    CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000001']", "AND",
+        "    CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE"),
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']")
+        .put("abstractExplainPlan", "SORT-MERGE-JOIN 
(INNER)").set("rhsJoinQueryExplainPlan", rhs));
+  }
+
+  @Test
+  public void testHashJoinInner() throws Exception {
+    verifyQuery("hashJoinInner",
+      "SELECT a.a_string, b.a_string FROM atable a"
+        + " JOIN atable b ON a.organization_id = b.organization_id"
+        + " WHERE a.organization_id = '00D000000000001'",
+      text("CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000001']",
+        "    PARALLEL INNER-JOIN TABLE 0", "        CLIENT PARALLEL <N>-WAY 
FULL SCAN OVER ATABLE",
+        "    DYNAMIC SERVER FILTER BY A.ORGANIZATION_ID IN 
(B.ORGANIZATION_ID)"),
+      // HashJoinPlan uses the List<String>-only ExplainPlan constructor, 
which installs the
+      // default attributes (all-null/empty). Freeze that baseline.
+      defaultAttrs());
+  }
+
+  @Test
+  public void testHashJoinSemiInSubquery() throws Exception {
+    verifyQuery("hashJoinSemiInSubquery",
+      "SELECT a_string FROM atable"
+        + " WHERE organization_id IN (SELECT organization_id FROM atable WHERE 
a_integer = 1)",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "    
SKIP-SCAN-JOIN TABLE 0",
+        "        CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE",
+        "            SERVER FILTER BY A_INTEGER = 1",
+        "            SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[ORGANIZATION_ID]",
+        "    DYNAMIC SERVER FILTER BY ATABLE.ORGANIZATION_ID IN ($1.$3)"),
+      defaultAttrs());
+  }
+
+  @Test
+  public void testUnionAll() throws Exception {
+    ObjectNode rhs = scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000002']");
+    verifyQuery("unionAll",
+      "SELECT a_string FROM atable WHERE organization_id = '00D000000000001'" 
+ " UNION ALL"
+        + " SELECT a_string FROM atable WHERE organization_id = 
'00D000000000002'",
+      text("UNION ALL OVER 2 QUERIES",
+        "    CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000001']",
+        "    CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000002']"),
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']")
+        .put("abstractExplainPlan", "UNION ALL OVER 2 QUERIES")
+        .set("rhsJoinQueryExplainPlan", rhs));
+  }
+
+  @Test
+  public void testPutSingleRow() throws Exception {
+    verifyMutation("putSingleRow",
+      "UPSERT INTO atable (organization_id, entity_id, a_string)"
+        + " VALUES ('00D000000000001','00E00000000001','x')",
+      false, text("PUT SINGLE ROW"), defaultAttrs());
+  }
+
+  @Test
+  public void testUpsertSelectClient() throws Exception {
+    verifyMutation("upsertSelectClient",
+      "UPSERT INTO atable (organization_id, entity_id, a_string)"
+        + " SELECT organization_id, entity_id, a_string FROM atable"
+        + " WHERE organization_id = '00D000000000001'",
+      false,
+      text("UPSERT SELECT", "CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000001']"),
+      scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000001']").put("abstractExplainPlan",
+        "UPSERT SELECT"));
+  }
+
+  @Test
+  public void testUpsertSelectServer() throws Exception {
+    verifyMutation("upsertSelectServer",
+      "UPSERT INTO atable (organization_id, entity_id, a_string)"
+        + " SELECT organization_id, entity_id, a_string FROM atable"
+        + " WHERE organization_id = '00D000000000001'",
+      true,
+      text("UPSERT ROWS", "CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000001']"),
+      scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000001']").put("abstractExplainPlan",
+        "UPSERT ROWS"));
+  }
+
+  @Test
+  public void testDeleteSingleRow() throws Exception {
+    verifyMutation("deleteSingleRow", "DELETE FROM atable WHERE 
organization_id = '00D000000000001'"
+      + " AND entity_id = '00E00000000001'", true, text("DELETE SINGLE ROW"), 
defaultAttrs());
+  }
+
+  @Test
+  public void testDeleteServer() throws Exception {
+    verifyMutation("deleteServer", "DELETE FROM atable WHERE entity_id = 
'abc'", true,
+      text("DELETE ROWS SERVER SELECT", "CLIENT PARALLEL <N>-WAY FULL SCAN 
OVER ATABLE",
+        "    SERVER FILTER BY FIRST KEY ONLY AND ENTITY_ID = 'abc'"),
+      scanAttrs("FULL SCAN ", "ATABLE", "").put("abstractExplainPlan", "DELETE 
ROWS SERVER SELECT")
+        .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY ONLY AND 
ENTITY_ID = 'abc'"));
+  }
+
+  @Test
+  public void testDeleteClient() throws Exception {
+    verifyMutation("deleteClient", "DELETE FROM atable WHERE entity_id = 
'abc'", false,
+      text("DELETE ROWS CLIENT SELECT", "CLIENT PARALLEL <N>-WAY FULL SCAN 
OVER ATABLE",
+        "    SERVER FILTER BY FIRST KEY ONLY AND ENTITY_ID = 'abc'"),
+      scanAttrs("FULL SCAN ", "ATABLE", "").put("abstractExplainPlan", "DELETE 
ROWS CLIENT SELECT")
+        .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY ONLY AND 
ENTITY_ID = 'abc'"));
+  }
+
+  @Test
+  public void testSequenceNextValue() throws Exception {
+    verifyQuery("sequenceNextValue", "SELECT NEXT VALUE FOR " + SEQ + " FROM 
atable",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "    SERVER FILTER 
BY FIRST KEY ONLY",
+        "CLIENT RESERVE VALUES FROM 1 SEQUENCE"),
+      scanAttrs("FULL SCAN ", "ATABLE", "")
+        .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY 
ONLY").put("clientSequenceCount", 1));
+  }
+
+  @Test
+  public void testSaltedTableScan() throws Exception {
+    verifyQuery("saltedTableScan", "SELECT * FROM " + SALTED + " WHERE v = 7",
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER EO_SALTED", "    SERVER 
FILTER BY V = 7",
+        "CLIENT MERGE SORT"),
+      scanAttrs("FULL SCAN ", "EO_SALTED", "").put("serverWhereFilter", 
"SERVER FILTER BY V = 7")
+        .put("clientSortAlgo", "CLIENT MERGE SORT"));
+  }
+
+  @Test
+  public void testMultiTenantView() throws Exception {
+    Properties tenantProps = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    tenantProps.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
+    tenantProps.setProperty(TENANT_ID_ATTRIB, TENANT_ID);
+    verifyQuery("multiTenantView", "SELECT * FROM " + MT_VIEW + " LIMIT 1", 
tenantProps,
+      text("CLIENT SERIAL <N>-WAY RANGE SCAN OVER EO_MT_BASE ['tenant42']",
+        "    SERVER 1 ROW LIMIT", "CLIENT 1 ROW LIMIT"),
+      attrs().put("iteratorTypeAndScanSize", "SERIAL 
<N>-WAY").put("consistency", "STRONG")
+        .put("explainScanType", "RANGE SCAN ").put("tableName", "EO_MT_BASE")
+        .put("keyRanges", " ['tenant42']").put("serverRowLimit", 
1).put("clientRowLimit", 1));
+  }
+
+  @Test
+  public void testTextNormalizerCollapsesWayCount() {
+    assertEquals(Collections.singletonList("CLIENT PARALLEL <N>-WAY FULL SCAN 
OVER ATABLE"),
+      new ExplainTextNormalizer()
+        .normalize(Arrays.asList("CLIENT PARALLEL 400-WAY FULL SCAN OVER 
ATABLE")));
+  }
+
+  @Test
+  public void testTextNormalizerCollapsesChunkCount() {
+    assertEquals(
+      Collections.singletonList("CLIENT <N>-CHUNK PARALLEL <N>-WAY FULL SCAN 
OVER ATABLE"),
+      new ExplainTextNormalizer()
+        .normalize(Arrays.asList("CLIENT 5-CHUNK PARALLEL 16-WAY FULL SCAN 
OVER ATABLE")));
+  }
+
+  @Test
+  public void testTextNormalizerStripsRowsBytes() {
+    assertEquals(
+      Collections.singletonList("CLIENT <N>-CHUNK PARALLEL <N>-WAY FULL SCAN 
OVER ATABLE"),
+      new ExplainTextNormalizer().normalize(
+        Arrays.asList("CLIENT 1-CHUNK 100 ROWS 2048 BYTES PARALLEL 1-WAY FULL 
SCAN OVER ATABLE")));
+  }
+
+  @Test
+  public void testTextNormalizerDropsRegionLocationsLine() {
+    assertEquals(
+      Arrays.asList("CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE",
+        "    SERVER FILTER BY FIRST KEY ONLY"),
+      new ExplainTextNormalizer().normalize(Arrays.asList(
+        "CLIENT PARALLEL 1-WAY FULL SCAN OVER ATABLE", "    SERVER FILTER BY 
FIRST KEY ONLY",
+        " (region locations = [{startKey=\\x00, endKey=, 
server=foo,1234}])")));
+  }
+
+  @Test
+  public void testTextNormalizerPreservesAllGrammar() {
+    List<String> in = Arrays.asList("CLIENT PARALLEL 1-WAY RANGE SCAN OVER 
ATABLE ['a','b']",
+      "    SERVER FILTER BY (X = 1 AND Y = 'z')",
+      "    SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [Y]", "CLIENT MERGE 
SORT",
+      "CLIENT 3 ROW LIMIT");
+    assertEquals(Arrays.asList("CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['a','b']",
+      "    SERVER FILTER BY (X = 1 AND Y = 'z')",
+      "    SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [Y]", "CLIENT MERGE 
SORT",
+      "CLIENT 3 ROW LIMIT"), new ExplainTextNormalizer().normalize(in));
+  }
+
+  @Test
+  public void testJsonNormalizerErasesClusterFields() {
+    ObjectNode root = mapper.createObjectNode();
+    root.put("iteratorTypeAndScanSize", "PARALLEL 16-WAY");
+    root.put("splitsChunk", 4);
+    root.put("estimatedRows", 1234L);
+    root.put("estimatedSizeInBytes", 9876L);
+    root.put("numRegionLocationLookups", 7);
+    root.set("regionLocations", mapper.createArrayNode().add("anything"));
+
+    new ExplainJsonNormalizer().normalize(root);
+
+    assertEquals("PARALLEL <N>-WAY", 
root.get("iteratorTypeAndScanSize").asText());
+    assertTrue(root.get("splitsChunk").isNull());
+    assertTrue(root.get("estimatedRows").isNull());
+    assertTrue(root.get("estimatedSizeInBytes").isNull());
+    assertTrue(root.get("regionLocations").isNull());
+    assertEquals(0, root.get("numRegionLocationLookups").asInt());
+  }
+
+  @Test
+  public void testJsonNormalizerRecursesIntoRhsJoinQueryExplainPlan() {
+    ObjectNode root = mapper.createObjectNode();
+    root.put("iteratorTypeAndScanSize", "PARALLEL 5-WAY");
+    root.put("numRegionLocationLookups", 1);
+    ObjectNode rhs = mapper.createObjectNode();
+    rhs.put("iteratorTypeAndScanSize", "PARALLEL 12-WAY");
+    rhs.put("numRegionLocationLookups", 99);
+    rhs.set("regionLocations", mapper.createArrayNode().add(1));
+    root.set("rhsJoinQueryExplainPlan", rhs);
+
+    new ExplainJsonNormalizer().normalize(root);
+
+    assertEquals("PARALLEL <N>-WAY", 
root.get("iteratorTypeAndScanSize").asText());
+    assertEquals(0, root.get("numRegionLocationLookups").asInt());
+    JsonNode nestedRhs = root.get("rhsJoinQueryExplainPlan");
+    assertEquals("PARALLEL <N>-WAY", 
nestedRhs.get("iteratorTypeAndScanSize").asText());
+    assertEquals(0, nestedRhs.get("numRegionLocationLookups").asInt());
+    assertTrue(nestedRhs.get("regionLocations").isNull());
+  }
+
+  @Test
+  public void testJacksonFieldOrderMatchesPropertyOrderAnnotation() throws 
Exception {
+    ExplainPlanAttributes a = new ExplainPlanAttributesBuilder()
+      .setIteratorTypeAndScanSize("PARALLEL 1-WAY").setTableName("T").build();
+    String json = mapper.writeValueAsString(a);
+    int iAbstract = json.indexOf("\"abstractExplainPlan\"");
+    int iIter = json.indexOf("\"iteratorTypeAndScanSize\"");
+    int iTable = json.indexOf("\"tableName\"");
+    int iRhs = json.indexOf("\"rhsJoinQueryExplainPlan\"");
+    int iMerge = json.indexOf("\"serverMergeColumns\"");
+    int iRegions = json.indexOf("\"regionLocations\"");
+    int iLookups = json.indexOf("\"numRegionLocationLookups\"");
+    assertTrue("abstractExplainPlan first", iAbstract >= 0 && iAbstract < 
iIter);
+    assertTrue("iteratorTypeAndScanSize before tableName", iIter < iTable);
+    assertTrue("rhsJoinQueryExplainPlan before serverMergeColumns", iRhs < 
iMerge);
+    assertTrue("serverMergeColumns before regionLocations", iMerge < iRegions);
+    assertTrue("regionLocations before numRegionLocationLookups", iRegions < 
iLookups);
+  }
+
+  @Test
+  public void testRegionLocationsSerializerRendersTriple() throws Exception {
+    HRegionLocation loc = new HRegionLocation(
+      RegionInfoBuilder.newBuilder(TableName.valueOf("FOO")).setStartKey(new 
byte[] { 0x01, 0x02 })
+        .setEndKey(new byte[] { 0x03, 0x04 }).build(),
+      ServerName.valueOf("rs.example.com", 16020, 1234567890L));
+    ExplainPlanAttributes a = new ExplainPlanAttributesBuilder()
+      
.setRegionLocations(Collections.singletonList(loc)).setNumRegionLocationLookups(1).build();
+    JsonNode tree = mapper.readTree(mapper.writeValueAsString(a));
+    JsonNode entry = tree.get("regionLocations").get(0);
+    assertEquals("\\x01\\x02", entry.get("startKey").asText());
+    assertEquals("\\x03\\x04", entry.get("endKey").asText());
+    assertTrue(entry.get("server").asText().contains("rs.example.com"));
+  }
+
+  @Test
+  public void testServerMergeColumnsSerializerEmitsSortedNames() throws 
Exception {
+    Set<PColumn> cols = new HashSet<>(
+      Arrays.asList(column("CF", "B_COL"), column("CF", "A_COL"), column("CF", 
"C_COL")));
+    ExplainPlanAttributes a =
+      new ExplainPlanAttributesBuilder().setServerMergeColumns(cols).build();
+    JsonNode tree = mapper.readTree(mapper.writeValueAsString(a));
+    JsonNode array = tree.get("serverMergeColumns");
+    assertEquals(3, array.size());
+    // PColumn.toString() uses QueryConstants.NAME_SEPARATOR (".") between 
family and name.
+    assertEquals("CF.A_COL", array.get(0).asText());
+    assertEquals("CF.B_COL", array.get(1).asText());
+    assertEquals("CF.C_COL", array.get(2).asText());
+  }
+
+  @Test
+  public void testIdentityRuleChainPasses() throws Exception {
+    ExplainPlan plan = samplePlan("PARALLEL 4-WAY", "RANGE SCAN ");
+    List<String> expectedText =
+      text("CLIENT PARALLEL <N>-WAY RANGE SCAN OVER T", "    SERVER FILTER BY 
FIRST KEY ONLY");
+    // samplePlan() sets only four attributes on the builder; consistency 
stays null.
+    ObjectNode expectedJson = defaultAttrs().put("iteratorTypeAndScanSize", 
"PARALLEL <N>-WAY")
+      .put("explainScanType", "RANGE SCAN ").put("tableName", "T")
+      .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY ONLY");
+    // Identity rule: returns inputs unchanged.
+    new ExplainOracle(Collections.singletonList(new ExplainChangeRule() {
+    })).verify("identity", plan, expectedText, expectedJson);
+  }
+
+  @Test
+  public void testChangeRuleRewritesText() throws Exception {
+    // Today's plan emits "SERVER FILTER BY FIRST KEY ONLY".
+    ExplainPlanAttributes todayAttrs = new ExplainPlanAttributesBuilder()
+      .setIteratorTypeAndScanSize("PARALLEL 1-WAY").setExplainScanType("FULL 
SCAN ")
+      .setTableName("T").setServerWhereFilter("SERVER FILTER BY FIRST KEY 
ONLY").build();
+    ExplainPlan todayPlan = new ExplainPlan(Arrays.asList("CLIENT PARALLEL 
1-WAY FULL SCAN OVER T",
+      "    SERVER FILTER BY FIRST KEY ONLY"), todayAttrs);
+    List<String> todayExpectedText =
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER T", "    SERVER FILTER BY 
FIRST KEY ONLY");
+    // samplePlan-style builder leaves consistency null and key-ranges unset.
+    ObjectNode todayExpectedJson = 
defaultAttrs().put("iteratorTypeAndScanSize", "PARALLEL <N>-WAY")
+      .put("explainScanType", "FULL SCAN ").put("tableName", "T")
+      .put("serverWhereFilter", "SERVER FILTER BY FIRST KEY ONLY");
+
+    // Sanity: with no rules, today's plan compares against today's expected.
+    new ExplainOracle().verify("today", todayPlan, todayExpectedText, 
todayExpectedJson);
+
+    // Tomorrow: design renames the marker. Future plan emits the new form; 
the embedded baseline
+    // stays unchanged ("today's" expected); a rule transforms the baseline 
into the new shape and
+    // the comparison passes.
+    ExplainPlanAttributes futureAttrs = new ExplainPlanAttributesBuilder()
+      .setIteratorTypeAndScanSize("PARALLEL 1-WAY").setExplainScanType("FULL 
SCAN ")
+      .setTableName("T").setServerWhereFilter("SERVER PROJECTION FILTER BY 
FIRST KEY ONLY").build();
+    ExplainPlan futurePlan = new ExplainPlan(Arrays.asList("CLIENT PARALLEL 
1-WAY FULL SCAN OVER T",
+      "    SERVER PROJECTION FILTER BY FIRST KEY ONLY"), futureAttrs);
+
+    ExplainChangeRule rename = new ExplainChangeRule() {
+      @Override
+      public List<String> applyText(String caseId, List<String> goldenText) {
+        List<String> out = new ArrayList<>(goldenText.size());
+        for (String s : goldenText) {
+          out.add(s.replace("SERVER FILTER BY FIRST KEY ONLY",
+            "SERVER PROJECTION FILTER BY FIRST KEY ONLY"));
+        }
+        return out;
+      }
+
+      @Override
+      public JsonNode applyJson(String caseId, JsonNode goldenJson) {
+        if (goldenJson.isObject()) {
+          ObjectNode obj = (ObjectNode) goldenJson;
+          JsonNode swf = obj.get("serverWhereFilter");
+          if (
+            swf != null && swf.isTextual() && swf.asText().equals("SERVER 
FILTER BY FIRST KEY ONLY")
+          ) {
+            obj.put("serverWhereFilter", "SERVER PROJECTION FILTER BY FIRST 
KEY ONLY");
+          }
+        }
+        return goldenJson;
+      }
+    };
+
+    new ExplainOracle(Collections.singletonList(rename)).verify("today", 
futurePlan,
+      todayExpectedText, todayExpectedJson);
+  }
+
+  @Test
+  public void testDiffMessageShowsExpectedAndActualForTextMismatch() {
+    ExplainPlan plan = samplePlan("PARALLEL 1-WAY", "FULL SCAN ");
+    // Caller's "expected" disagrees with what the plan actually emits.
+    List<String> divergentExpectedText =
+      text("CLIENT PARALLEL <N>-WAY FULL SCAN OVER T", "    SERVER FILTER BY 
(X = 9)");
+    ObjectNode divergentExpectedJson = defaultAttrs()
+      .put("iteratorTypeAndScanSize", "PARALLEL 
<N>-WAY").put("explainScanType", "FULL SCAN ")
+      .put("tableName", "T").put("serverWhereFilter", "SERVER FILTER BY (X = 
9)");
+    try {
+      new ExplainOracle().verify("x", plan, divergentExpectedText, 
divergentExpectedJson);
+      fail("Expected AssertionError for diverged plan");
+    } catch (AssertionError expected) {
+      String msg = expected.getMessage();
+      assertTrue(msg.contains("Text mismatch for case 'x'"));
+      assertTrue(msg.contains("SERVER FILTER BY FIRST KEY ONLY"));
+      assertTrue(msg.contains("SERVER FILTER BY (X = 9)"));
+    } catch (Exception e) {
+      fail("Unexpected exception type: " + e);
+    }
+  }
+
+  private void verifyQuery(String caseId, String query, List<String> 
expectedText,
+    JsonNode expectedJson) throws Exception {
+    verifyQuery(caseId, query, defaultProps(), expectedText, expectedJson);
+  }
+
+  private void verifyQuery(String caseId, String query, Properties props, 
List<String> expectedText,
+    JsonNode expectedJson) throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      ExplainPlan plan = 
conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class)
+        .optimizeQuery().getExplainPlan();
+      oracle.verify(caseId, plan, expectedText, expectedJson);
+    }
+  }
+
+  private void verifyMutation(String caseId, String query, boolean autoCommit,
+    List<String> expectedText, JsonNode expectedJson) throws Exception {
+    Properties props = defaultProps();
+    if (autoCommit) {
+      props.setProperty(AUTO_COMMIT_ATTRIB, "true");
+    }
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      ExplainPlan plan = compileMutation(conn, query);
+      oracle.verify(caseId, plan, expectedText, expectedJson);
+    }
+  }
+
+  private ExplainPlan compileMutation(Connection conn, String query) throws 
SQLException {
+    PhoenixPreparedStatement ps =
+      conn.prepareStatement(query).unwrap(PhoenixPreparedStatement.class);
+    return ps.compileMutation().getExplainPlan();
+  }
+
+  private static Properties defaultProps() {
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    props.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
+    return props;
+  }
+
+  private static List<String> text(String... lines) {
+    return Arrays.asList(lines);
+  }
+
+  /**
+   * Returns a fresh {@link ObjectNode} populated with the JSON shape that
+   * {@link ExplainPlanAttributes#getDefaultExplainPlan()} serializes to 
(after normalization): all
+   * nullable fields are null, both booleans are false, and {@code 
numRegionLocationLookups} is 0.
+   * Each test case starts from this baseline and overrides only the fields it 
asserts on. Field
+   * order is irrelevant — {@link JsonNode#equals(Object)} compares maps, not 
order.
+   */
+  private static ObjectNode defaultAttrs() {
+    ObjectNode n = mapper.createObjectNode();
+    n.putNull("abstractExplainPlan");
+    n.putNull("splitsChunk");
+    n.putNull("estimatedRows");
+    n.putNull("estimatedSizeInBytes");
+    n.putNull("iteratorTypeAndScanSize");
+    n.putNull("samplingRate");
+    n.put("useRoundRobinIterator", false);
+    n.putNull("hexStringRVCOffset");
+    n.putNull("consistency");
+    n.putNull("hint");
+    n.putNull("serverSortedBy");
+    n.putNull("explainScanType");
+    n.putNull("tableName");
+    n.putNull("keyRanges");
+    n.putNull("scanTimeRangeMin");
+    n.putNull("scanTimeRangeMax");
+    n.putNull("serverWhereFilter");
+    n.putNull("serverDistinctFilter");
+    n.putNull("serverOffset");
+    n.putNull("serverRowLimit");
+    n.put("serverArrayElementProjection", false);
+    n.putNull("serverAggregate");
+    n.putNull("clientFilterBy");
+    n.putNull("clientAggregate");
+    n.putNull("clientSortedBy");
+    n.putNull("clientAfterAggregate");
+    n.putNull("clientDistinctFilter");
+    n.putNull("clientOffset");
+    n.putNull("clientRowLimit");
+    n.putNull("clientSequenceCount");
+    n.putNull("clientCursorName");
+    n.putNull("clientSortAlgo");
+    n.putNull("rhsJoinQueryExplainPlan");
+    n.putNull("serverMergeColumns");
+    n.putNull("regionLocations");
+    n.put("numRegionLocationLookups", 0);
+    return n;
+  }
+
+  /**
+   * Convenience wrapper that builds {@link #defaultAttrs()} and sets the five 
fields every
+   * connection-backed scan emits via {@code ExplainTable.explain}: {@code 
iteratorTypeAndScanSize},
+   * {@code consistency}, {@code explainScanType}, {@code tableName}, and 
{@code keyRanges}.
+   * @param scanType the {@code explainScanType} string (with its trailing 
space, e.g.
+   *                 {@code "FULL SCAN "})
+   * @param table    the {@code tableName} value
+   * @param keys     the {@code keyRanges} string (may be {@code null} or 
empty)
+   */
+  private static ObjectNode scanAttrs(String scanType, String table, String 
keys) {
+    ObjectNode n = defaultAttrs();
+    n.put("iteratorTypeAndScanSize", "PARALLEL <N>-WAY");
+    n.put("consistency", "STRONG");
+    n.put("explainScanType", scanType);
+    n.put("tableName", table);
+    if (keys != null) {
+      n.put("keyRanges", keys);
+    }
+    return n;
+  }
+
+  /** A plain {@link ObjectNode} alias for clarity in tests that don't use 
{@link #scanAttrs}. */
+  private static ObjectNode attrs() {
+    return defaultAttrs();
+  }
+
+  private static ExplainPlan samplePlan(String way, String scanType) {
+    ExplainPlanAttributes a = new 
ExplainPlanAttributesBuilder().setIteratorTypeAndScanSize(way)
+      .setExplainScanType(scanType).setTableName("T")
+      .setServerWhereFilter("SERVER FILTER BY FIRST KEY ONLY").build();
+    return new ExplainPlan(Arrays.asList("CLIENT " + way + " " + 
scanType.trim() + " OVER T",
+      "    SERVER FILTER BY FIRST KEY ONLY"), a);
+  }
+
+  private static PColumn column(String family, String name) {
+    PName fName = PNameFactory.newName(family);
+    PName cName = PNameFactory.newName(name);
+    return new PColumnImpl(cName, fName, PInteger.INSTANCE, null, null, false, 
0, SortOrder.ASC, 0,
+      null, false, "expression", false, false, name.getBytes(), 0L);
+  }
+}
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
new file mode 100644
index 0000000000..51144afd20
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonNormalizer.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.query.explain;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.regex.Pattern;
+
+/**
+ * Elides cluster- and connection-specific fields from the JSON view of
+ * {@code ExplainPlanAttributes} so the comparison is invariant under 
environment differences.
+ */
+public final class ExplainJsonNormalizer {
+
+  private static final Pattern WAY_COUNT = Pattern.compile("\\b\\d+-WAY\\b");
+
+  /**
+   * Recursively normalize the given attributes-shaped JSON node.
+   * @return the same node, for fluent chaining.
+   */
+  public JsonNode normalize(JsonNode node) {
+    if (node == null || node.isNull() || !node.isObject()) {
+      return node;
+    }
+    ObjectNode obj = (ObjectNode) node;
+
+    if (obj.has("regionLocations")) {
+      obj.set("regionLocations", NullNode.getInstance());
+    }
+    if (obj.has("numRegionLocationLookups")) {
+      obj.put("numRegionLocationLookups", 0);
+    }
+    if (obj.has("splitsChunk")) {
+      obj.set("splitsChunk", NullNode.getInstance());
+    }
+    if (obj.has("estimatedRows")) {
+      obj.set("estimatedRows", NullNode.getInstance());
+    }
+    if (obj.has("estimatedSizeInBytes")) {
+      obj.set("estimatedSizeInBytes", NullNode.getInstance());
+    }
+
+    JsonNode iter = obj.get("iteratorTypeAndScanSize");
+    if (iter != null && iter.isTextual()) {
+      obj.put("iteratorTypeAndScanSize", 
WAY_COUNT.matcher(iter.asText()).replaceAll("<N>-WAY"));
+    }
+
+    JsonNode rhs = obj.get("rhsJoinQueryExplainPlan");
+    if (rhs != null && rhs.isObject()) {
+      normalize(rhs);
+    }
+
+    return obj;
+  }
+}
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainOracle.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainOracle.java
new file mode 100644
index 0000000000..42ab8a2876
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainOracle.java
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.query.explain;
+
+import static org.junit.Assert.fail;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.phoenix.compile.ExplainPlan;
+import org.apache.phoenix.compile.ExplainPlanAttributes;
+
+/**
+ * Backward compatibility test for Phoenix EXPLAIN output.
+ * <p>
+ * For each case, the test compares the current EXPLAIN output against an 
expected baseline supplied
+ * by the caller (embedded directly in the test).
+ * <p>
+ * The {@link ExplainChangeRule} chain is applied to the expected side before 
comparison, so a
+ * future EXPLAIN change appends one rule transforming the embedded baseline 
into its new expected
+ * form.
+ */
+public final class ExplainOracle {
+
+  private final List<ExplainChangeRule> rules;
+  private final ExplainTextNormalizer textNormalizer;
+  private final ExplainJsonNormalizer jsonNormalizer;
+  private final ObjectMapper mapper;
+  private final ObjectWriter prettyWriter;
+
+  public ExplainOracle() {
+    this(Collections.<ExplainChangeRule> emptyList());
+  }
+
+  public ExplainOracle(List<ExplainChangeRule> rules) {
+    this.rules = new ArrayList<>(rules);
+    this.textNormalizer = new ExplainTextNormalizer();
+    this.jsonNormalizer = new ExplainJsonNormalizer();
+    this.mapper = new ObjectMapper();
+    this.mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
+    this.mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, 
false);
+    this.mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
+    this.prettyWriter = mapper.writerWithDefaultPrettyPrinter();
+  }
+
+  /** Test-side {@link ObjectMapper} for building expected JSON */
+  public ObjectMapper mapper() {
+    return mapper;
+  }
+
+  /**
+   * Verify the given plan against the embedded expected baseline for {@code 
caseId}.
+   * @param caseId       the corpus case identifier (used in diff messages)
+   * @param plan         the plan under test
+   * @param expectedText the embedded expected, post-normalization plan-steps 
text
+   * @param expectedJson the embedded expected, post-normalization JSON 
attributes tree
+   */
+  public void verify(String caseId, ExplainPlan plan, List<String> 
expectedText,
+    JsonNode expectedJson) throws IOException {
+    List<String> textCurrent = textNormalizer.normalize(plan.getPlanSteps());
+    JsonNode jsonCurrent = 
serializeNormalized(plan.getPlanStepsAsAttributes());
+
+    List<String> textExpected = new ArrayList<>(expectedText);
+    JsonNode jsonExpected = expectedJson == null ? null : 
expectedJson.deepCopy();
+
+    for (ExplainChangeRule rule : rules) {
+      textExpected = rule.applyText(caseId, textExpected);
+      if (jsonExpected != null) {
+        jsonExpected = rule.applyJson(caseId, jsonExpected);
+      }
+    }
+
+    if (!textExpected.equals(textCurrent)) {
+      fail(textDiffMessage(caseId, textExpected, textCurrent));
+    }
+    if (jsonExpected != null && !jsonExpected.equals(jsonCurrent)) {
+      fail(jsonDiffMessage(caseId, jsonExpected, jsonCurrent));
+    }
+  }
+
+  /**
+   * Serialize the given attributes to JSON and apply the cluster/connection 
normalizer in one step.
+   * Exposed so the test can render and inspect the normalized JSON.
+   */
+  public JsonNode serializeNormalized(ExplainPlanAttributes attributes) throws 
IOException {
+    String raw = mapper.writeValueAsString(attributes);
+    JsonNode node = mapper.readTree(raw);
+    jsonNormalizer.normalize(node);
+    return node;
+  }
+
+  /** Normalize plan-steps text. Exposed so the test can render and inspect 
normalized output. */
+  public List<String> normalizeText(List<String> raw) {
+    return textNormalizer.normalize(raw);
+  }
+
+  /** Pretty-print a JSON node using the oracle's mapper config. */
+  public String prettyJson(JsonNode node) throws JsonProcessingException {
+    return prettyWriter.writeValueAsString(node);
+  }
+
+  private static String textDiffMessage(String caseId, List<String> expected, 
List<String> actual) {
+    StringBuilder sb = new StringBuilder();
+    sb.append("Text mismatch for case '").append(caseId).append("'.\n");
+    sb.append("--- expected (").append(expected.size()).append(" lines)\n");
+    for (String l : expected) {
+      sb.append("  ").append(l).append('\n');
+    }
+    sb.append("--- actual (").append(actual.size()).append(" lines)\n");
+    for (String l : actual) {
+      sb.append("  ").append(l).append('\n');
+    }
+    sb.append("--- line-by-line diff\n");
+    int n = Math.max(expected.size(), actual.size());
+    for (int i = 0; i < n; i++) {
+      String e = i < expected.size() ? expected.get(i) : "<missing>";
+      String a = i < actual.size() ? actual.get(i) : "<missing>";
+      if (!e.equals(a)) {
+        sb.append(" @").append(i).append(":\n");
+        sb.append("  - expected: ").append(e).append('\n');
+        sb.append("  - actual:   ").append(a).append('\n');
+      }
+    }
+    return sb.toString();
+  }
+
+  private String jsonDiffMessage(String caseId, JsonNode expected, JsonNode 
actual) {
+    StringBuilder sb = new StringBuilder();
+    sb.append("JSON mismatch for case '").append(caseId).append("'.\n");
+    try {
+      sb.append("--- expected\n").append(prettyJson(expected)).append('\n');
+      sb.append("--- actual\n").append(prettyJson(actual)).append('\n');
+    } catch (JsonProcessingException e) {
+      sb.append("(failed to pretty-print: 
").append(e.getMessage()).append(")\n");
+    }
+    sb.append("--- pointer diff\n");
+    appendPointerDiff(sb, "", expected, actual);
+    return sb.toString();
+  }
+
+  private static void appendPointerDiff(StringBuilder sb, String pointer, 
JsonNode expected,
+    JsonNode actual) {
+    if (expected == null && actual == null) {
+      return;
+    }
+    if (expected == null || actual == null) {
+      sb.append("  ").append(pointer.isEmpty() ? "/" : pointer).append(" 
expected=")
+        .append(expected).append(" actual=").append(actual).append('\n');
+      return;
+    }
+    if (expected.equals(actual)) {
+      return;
+    }
+    if (expected.isObject() && actual.isObject()) {
+      List<String> fields = new ArrayList<>();
+      expected.fieldNames().forEachRemaining(fields::add);
+      actual.fieldNames().forEachRemaining(f -> {
+        if (!fields.contains(f)) {
+          fields.add(f);
+        }
+      });
+      Collections.sort(fields);
+      for (String f : fields) {
+        appendPointerDiff(sb, pointer + "/" + f, expected.get(f), 
actual.get(f));
+      }
+      return;
+    }
+    if (expected.isArray() && actual.isArray()) {
+      int n = Math.max(expected.size(), actual.size());
+      for (int i = 0; i < n; i++) {
+        appendPointerDiff(sb, pointer + "/" + i, i < expected.size() ? 
expected.get(i) : null,
+          i < actual.size() ? actual.get(i) : null);
+      }
+      return;
+    }
+    sb.append("  ").append(pointer.isEmpty() ? "/" : pointer).append(" 
expected=").append(expected)
+      .append(" actual=").append(actual).append('\n');
+  }
+
+  /** Assemble an ExplainOracle with the given rule chain. */
+  public static ExplainOracle forRules(ExplainChangeRule... rules) {
+    return new ExplainOracle(Arrays.asList(rules));
+  }
+}
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainTextNormalizer.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainTextNormalizer.java
new file mode 100644
index 0000000000..62b5c6854d
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainTextNormalizer.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.query.explain;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Elides cluster- and connection-specific details from the {@code 
List<String>} returned by
+ * {@code ExplainPlan.getPlanSteps()} so the EXPLAIN text can be compared 
across environments.
+ */
+public final class ExplainTextNormalizer {
+
+  // CLIENT 5-CHUNK -> CLIENT <N>-CHUNK ; matches any non-negative integer 
immediately before
+  // -CHUNK.
+  private static final Pattern CHUNK_COUNT = 
Pattern.compile("\\b\\d+-CHUNK\\b");
+
+  // PARALLEL 400-WAY -> PARALLEL <N>-WAY ; matches the iterator parallelism 
count.
+  private static final Pattern WAY_COUNT = Pattern.compile("\\b\\d+-WAY\\b");
+
+  // 1234 ROWS 5678 BYTES (stats-row-count gated; we strip when present).
+  private static final Pattern ROWS_BYTES = Pattern.compile("\\d+ ROWS \\d+ 
BYTES\\s*");
+
+  // " (region locations = [...]) " emitted via 
planSteps.add(regionLocationPlan); the line always
+  // begins with the leading-space form of ExplainTable.REGION_LOCATIONS.
+  private static final String REGION_LOCATIONS_PREFIX = " (region locations = 
";
+
+  /**
+   * @param raw the result of {@code ExplainPlan.getPlanSteps()}
+   * @return a new list with cluster/connection-specific detail elided. The 
original list is not
+   *         mutated.
+   */
+  public List<String> normalize(List<String> raw) {
+    List<String> out = new ArrayList<>(raw.size());
+    for (String line : raw) {
+      if (line == null) {
+        out.add(null);
+        continue;
+      }
+      // Drop region-location lines outright
+      if (line.contains(REGION_LOCATIONS_PREFIX)) {
+        continue;
+      }
+      String normalized = line;
+      normalized = CHUNK_COUNT.matcher(normalized).replaceAll("<N>-CHUNK");
+      normalized = WAY_COUNT.matcher(normalized).replaceAll("<N>-WAY");
+      normalized = ROWS_BYTES.matcher(normalized).replaceAll("");
+      out.add(normalized);
+    }
+    return out;
+  }
+}

Reply via email to