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

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

commit a9fa25659f01ccf40bacb66628e7581ed1e54374
Author: Andrew Purtell <[email protected]>
AuthorDate: Sat Jun 6 13:54:04 2026 -0700

    [WIP] Recursive UNION ALL EXPLAIN: surface nested structure via subPlans
    
    Rewrite UnionPlan.getExplainPlan() to compose recursively from each branch's
    explain plan. A new UnionResultIterators explainBranches helper provides the
    source of truth for branch composition. The two ResultIterators.explain
    overrides on UnionResultIterators are replaced with thin delegates. EXPLAIN
    of union plans no longer triggers sub-plan execution so connectionless tests
    can check them.
---
 .../java/org/apache/phoenix/execute/UnionPlan.java | 47 +++++++++--
 .../phoenix/iterate/UnionResultIterators.java      | 54 ++++++++----
 .../phoenix/end2end/CostBasedDecisionIT.java       | 25 +++---
 .../phoenix/end2end/QueryWithTableSampleIT.java    | 10 +--
 .../phoenix/end2end/SortMergeJoinMoreIT.java       |  2 +-
 .../org/apache/phoenix/end2end/UnionAllIT.java     | 97 +++++++++++++++++++---
 .../phoenix/query/explain/ExplainPlanTest.java     | 54 +++++++++++-
 7 files changed, 229 insertions(+), 60 deletions(-)

diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java
index 1a1e76f690..ca6bf3d69c 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/UnionPlan.java
@@ -233,19 +233,49 @@ public class UnionPlan implements QueryPlan {
 
   @Override
   public ExplainPlan getExplainPlan() throws SQLException {
+    // Build EXPLAIN by recursively composing each branch's explain plan.
     List<String> steps = new ArrayList<String>();
     ExplainPlanAttributesBuilder builder = new ExplainPlanAttributesBuilder();
     String abstractExplainPlan = "UNION ALL OVER " + this.plans.size() + " 
QUERIES";
     builder.setAbstractExplainPlan(abstractExplainPlan);
     steps.add(abstractExplainPlan);
-    ResultIterator iterator = iterator();
-    iterator.explain(steps, builder);
-    // Indent plans steps nested under union, except last client-side 
merge/concat step (if there is
-    // one)
-    int offset =
-      !orderBy.getOrderByExpressions().isEmpty() && limit != null ? 2 : limit 
!= null ? 1 : 0;
-    for (int i = 1; i < steps.size() - offset; i++) {
-      steps.set(i, "    " + steps.get(i));
+
+    UnionResultIterators.explainBranches(plans, steps, builder);
+
+    // Reconstruct the outer client-side pipeline.
+    boolean useMergeSort = !orderBy.isEmpty() || supportOrderByOptimize;
+    if (useMergeSort) {
+      // MergeSortTopN path: CLIENT MERGE SORT, then optional offset/limit 
(offset first).
+      builder.setClientSortAlgo("CLIENT MERGE SORT");
+      String mergeStep = "CLIENT MERGE SORT";
+      steps.add(mergeStep);
+      builder.addClientStep(mergeStep);
+      if (offset != null && offset > 0) {
+        builder.setClientOffset(offset);
+        String step = "CLIENT OFFSET " + offset;
+        steps.add(step);
+        builder.addClientStep(step);
+      }
+      if (limit != null && limit > 0) {
+        builder.setClientRowLimit(limit);
+        String step = "CLIENT LIMIT " + limit;
+        steps.add(step);
+        builder.addClientStep(step);
+      }
+    } else {
+      // Concat path: optional offset, then optional CLIENT n ROW LIMIT.
+      if (offset != null) {
+        builder.setClientOffset(offset);
+        String step = "CLIENT OFFSET " + offset;
+        steps.add(step);
+        builder.addClientStep(step);
+      }
+      if (limit != null) {
+        builder.setClientRowLimit(limit);
+        String step = "CLIENT " + limit + " ROW LIMIT";
+        steps.add(step);
+        builder.addClientStep(step);
+      }
     }
     return new ExplainPlan(steps, builder.build());
   }
@@ -307,7 +337,6 @@ public class UnionPlan implements QueryPlan {
 
   @Override
   public Set<TableRef> getSourceRefs() {
-    // TODO is this correct?
     Set<TableRef> sources = Sets.newHashSetWithExpectedSize(plans.size());
     for (QueryPlan plan : plans) {
       sources.addAll(plan.getSourceRefs());
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/UnionResultIterators.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/UnionResultIterators.java
index e5b981e7a6..8223fa06a2 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/UnionResultIterators.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/UnionResultIterators.java
@@ -18,9 +18,11 @@
 package org.apache.phoenix.iterate;
 
 import java.sql.SQLException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import org.apache.hadoop.hbase.client.Scan;
+import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import 
org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
 import org.apache.phoenix.compile.QueryPlan;
@@ -41,12 +43,14 @@ public class UnionResultIterators implements 
ResultIterators {
   private final List<PeekingResultIterator> iterators;
   private final List<ReadMetricQueue> readMetricsList;
   private final List<OverAllQueryMetrics> overAllQueryMetricsList;
+  private final List<QueryPlan> plans;
   private boolean closed;
   private final StatementContext parentStmtCtx;
 
   public UnionResultIterators(List<QueryPlan> plans, StatementContext 
parentStmtCtx)
     throws SQLException {
     this.parentStmtCtx = parentStmtCtx;
+    this.plans = plans;
     int nPlans = plans.size();
     iterators = Lists.newArrayListWithExpectedSize(nPlans);
     splits = Lists.newArrayListWithExpectedSize(nPlans * 30);
@@ -125,9 +129,7 @@ public class UnionResultIterators implements 
ResultIterators {
 
   @Override
   public void explain(List<String> planSteps) {
-    for (PeekingResultIterator iterator : iterators) {
-      iterator.explain(planSteps);
-    }
+    explainBranches(plans, planSteps, null);
   }
 
   @Override
@@ -138,22 +140,38 @@ public class UnionResultIterators implements 
ResultIterators {
   @Override
   public void explain(List<String> planSteps,
     ExplainPlanAttributesBuilder explainPlanAttributesBuilder) {
-    boolean moreThanOneIters = false;
-    ExplainPlanAttributesBuilder lhsPointer = null;
-    // For more than one iterators, explainPlanAttributes will create
-    // chain of objects as lhs and rhs query plans.
-    for (PeekingResultIterator iterator : iterators) {
-      if (moreThanOneIters) {
-        ExplainPlanAttributesBuilder rhsBuilder = new 
ExplainPlanAttributesBuilder();
-        iterator.explain(planSteps, rhsBuilder);
-        ExplainPlanAttributes rhsPlans = rhsBuilder.build();
-        lhsPointer.setRhsJoinQueryExplainPlan(rhsPlans);
-        lhsPointer = rhsBuilder;
-      } else {
-        iterator.explain(planSteps, explainPlanAttributesBuilder);
-        lhsPointer = explainPlanAttributesBuilder;
+    explainBranches(plans, planSteps, explainPlanAttributesBuilder);
+  }
+
+  /**
+   * Canonical union branch composition shared between {@link #explain(List)}/
+   * {@link #explain(List, ExplainPlanAttributesBuilder)} and
+   * {@link org.apache.phoenix.execute.UnionPlan#getExplainPlan()}. For each 
branch, appends the
+   * branch's {@link ExplainPlan#getPlanSteps()} indented by four spaces to 
{@code planSteps}, and,
+   * when {@code rootBuilder} is non-null, collects each branch's
+   * {@link ExplainPlan#getPlanStepsAsAttributes()} into {@code rootBuilder}'s 
{@code subPlans}
+   * list. Indentation compounds naturally for nested unions/joins.
+   */
+  public static void explainBranches(List<QueryPlan> plans, List<String> 
planSteps,
+    ExplainPlanAttributesBuilder rootBuilder) {
+    List<ExplainPlanAttributes> subPlans =
+      rootBuilder == null ? null : new 
ArrayList<ExplainPlanAttributes>(plans.size());
+    for (QueryPlan plan : plans) {
+      ExplainPlan branchPlan;
+      try {
+        branchPlan = plan.getExplainPlan();
+      } catch (SQLException e) {
+        throw new RuntimeException(e);
+      }
+      for (String step : branchPlan.getPlanSteps()) {
+        planSteps.add("    " + step);
       }
-      moreThanOneIters = true;
+      if (subPlans != null) {
+        subPlans.add(branchPlan.getPlanStepsAsAttributes());
+      }
+    }
+    if (rootBuilder != null) {
+      rootBuilder.setSubPlans(subPlans);
     }
   }
 }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
index 02d85a4f2e..a18f1bcc97 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
@@ -296,11 +296,11 @@ public class CostBasedDecisionIT extends BaseTest {
         + " where rowkey <= 'z' GROUP BY c1 " + "UNION ALL SELECT c1, 
max(rowkey), max(c2) FROM "
         + tableName + " where rowkey >= 'a' GROUP BY c1";
       // Use the default plan when stats are not available.
-      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 QUERIES")
-        .iteratorType("PARALLEL").scanType("RANGE 
SCAN").table(tableName).keyRanges(" [*] - ['z']")
-        .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
-        .clientSortAlgo("CLIENT MERGE 
SORT").rhs().iteratorType("PARALLEL").scanType("RANGE SCAN")
-        .table(tableName).keyRanges(" ['a'] - [*]")
+      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 
QUERIES").subPlanCount(2)
+        .subPlan(0).iteratorType("PARALLEL").scanType("RANGE 
SCAN").table(tableName)
+        .keyRanges(" [*] - ['z']").serverAggregate("SERVER AGGREGATE INTO 
DISTINCT ROWS BY [C1]")
+        .clientSortAlgo("CLIENT MERGE 
SORT").end().subPlan(1).iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(tableName).keyRanges(" ['a'] - [*]")
         .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
         .clientSortAlgo("CLIENT MERGE SORT");
 
@@ -317,14 +317,15 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the optimal plan based on cost when stats become available.
-      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 QUERIES")
-        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(indexName + "(" 
+ tableName + ")")
-        .keyRanges(" 
[1]").serverMergeColumns("[0.C2]").serverFirstKeyOnlyProjection(true)
-        .serverWhereFilter("\"ROWKEY\" <= 'z'")
-        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]")
-        .clientSortAlgo("CLIENT MERGE 
SORT").rhs().iteratorType("PARALLEL").scanType("RANGE SCAN")
+      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 
QUERIES").subPlanCount(2)
+        .subPlan(0).iteratorType("PARALLEL").scanType("RANGE SCAN")
         .table(indexName + "(" + tableName + ")").keyRanges(" 
[1]").serverMergeColumns("[0.C2]")
-        .serverFirstKeyOnlyProjection(true).serverWhereFilter("\"ROWKEY\" >= 
'a'")
+        .serverFirstKeyOnlyProjection(true).serverWhereFilter("\"ROWKEY\" <= 
'z'")
+        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]")
+        .clientSortAlgo("CLIENT MERGE 
SORT").end().subPlan(1).iteratorType("PARALLEL")
+        .scanType("RANGE SCAN").table(indexName + "(" + tableName + 
")").keyRanges(" [1]")
+        .serverMergeColumns("[0.C2]").serverFirstKeyOnlyProjection(true)
+        .serverWhereFilter("\"ROWKEY\" >= 'a'")
         .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[\"C1\"]")
         .clientSortAlgo("CLIENT MERGE SORT");
     } finally {
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithTableSampleIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithTableSampleIT.java
index 1a90a1c04a..5f8b966a7a 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithTableSampleIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/QueryWithTableSampleIT.java
@@ -225,11 +225,11 @@ public class QueryWithTableSampleIT extends 
ParallelStatsEnabledIT {
       String query =
         "SELECT * FROM " + tableName + " tablesample (100) where i1<2 union 
all SELECT * FROM "
           + tableName + " tablesample (2) where i2<6000";
-      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 
QUERIES").scanType("RANGE SCAN")
-        .table(tableName).keyRanges(" [*] - [2]").samplingRate(1.0d)
-        .serverFirstKeyOnlyProjection(true).rhs().scanType("FULL 
SCAN").table(tableName)
-        
.samplingRate(0.02d).serverFirstKeyOnlyProjection(true).serverWhereFilter("I2 < 
6000")
-        .end();
+      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 
QUERIES").subPlanCount(2)
+        .subPlan(0).scanType("RANGE SCAN").table(tableName).keyRanges(" [*] - 
[2]")
+        .samplingRate(1.0d).serverFirstKeyOnlyProjection(true).end().subPlan(1)
+        .scanType("FULL SCAN").table(tableName).samplingRate(0.02d)
+        .serverFirstKeyOnlyProjection(true).serverWhereFilter("I2 < 
6000").end();
     } finally {
       conn.close();
     }
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java
index 4a626c01a7..1d0980b60f 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/SortMergeJoinMoreIT.java
@@ -427,13 +427,13 @@ public class SortMergeJoinMoreIT extends 
ParallelStatsDisabledIT {
         String rhsClientSortedBy = i == 0 ? "[BUCKET, \"TIMESTAMP\"]" : null;
 
         assertPlan(conn, q).abstractExplainPlan("SORT-MERGE-JOIN 
(INNER)").sortMergeSkipMerge(true)
+          .clientAggregate("CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[E.BUCKET, E.TIMESTAMP]")
           .lhs().iteratorType("PARALLEL").scanType("SKIP SCAN ON 2 RANGES")
           
.table(eventCountTableName).keyRanges(lhsKeyRanges).serverFirstKeyOnlyProjection(true)
           .serverDistinctFilter("SERVER DISTINCT PREFIX FILTER OVER [BUCKET, 
TIMESTAMP, LOCATION]")
           .serverAggregate(
             "SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [BUCKET, 
TIMESTAMP, LOCATION]")
           .clientSortAlgo("CLIENT MERGE SORT").clientSortedBy("[BUCKET, 
TIMESTAMP]")
-          .clientAggregate("CLIENT AGGREGATE INTO ORDERED DISTINCT ROWS BY 
[E.BUCKET, E.TIMESTAMP]")
           .end().rhs().iteratorType("PARALLEL").scanType("SKIP SCAN ON 2 
RANGES").table(t[i])
           .keyRanges(rhsKeyRanges).serverFirstKeyOnlyProjection(true)
           .serverWhereFilter("SRC_LOCATION = DST_LOCATION")
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java
index 7144653792..85e535b16f 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/UnionAllIT.java
@@ -30,6 +30,7 @@ import java.sql.DriverManager;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.util.List;
 import java.util.Properties;
 import org.apache.phoenix.exception.SQLExceptionCode;
 import org.apache.phoenix.query.QueryServices;
@@ -616,34 +617,108 @@ public class UnionAllIT extends ParallelStatsDisabledIT {
         + "  CONSTRAINT pk PRIMARY KEY (a_string))\n";
       createTestTable(getUrl(), ddl);
 
+      // Each branch is sorted/limited server-side, and the union itself adds 
an outer CLIENT MERGE
+      // SORT + CLIENT LIMIT pipeline on the root.
       String orderLimit = "select a_string, col1 from " + tableName1
         + " union all select a_string, col1 from " + tableName2 + " order by 
col1 limit 1";
       assertPlan(conn, orderLimit).abstractExplainPlan("UNION ALL OVER 2 
QUERIES")
+        .clientSortAlgo("CLIENT MERGE SORT").clientRowLimit(1)
+        .clientSteps("CLIENT MERGE SORT", "CLIENT LIMIT 
1").subPlanCount(2).subPlan(0)
         .iteratorType("PARALLEL").scanType("FULL 
SCAN").table(tableName1).serverSortedBy("[COL1]")
-        .serverRowLimit(1L).clientRowLimit(1).clientSortAlgo("CLIENT MERGE 
SORT").rhs()
+        .serverRowLimit(1L).clientRowLimit(1).clientSortAlgo("CLIENT MERGE 
SORT").end().subPlan(1)
         .iteratorType("PARALLEL").scanType("FULL 
SCAN").table(tableName2).serverSortedBy("[COL1]")
         .serverRowLimit(1L).clientRowLimit(1).clientSortAlgo("CLIENT MERGE 
SORT");
 
+      // Each branch carries its own pushed-down limit, and the union root 
adds an outer CLIENT n
+      // ROW LIMIT.
       String limitOnly = "select a_string, col1 from " + tableName1
         + " union all select a_string, col1 from " + tableName2 + " limit 2";
-      // The LHS branch's builder is shared with the union plan's root 
builder, so the LHS
-      // root carries both the inner branch's CLIENT 2 ROW LIMIT and the 
union's outer
-      // CLIENT 2 ROW LIMIT in its clientSteps. The RHS branch has its own 
separate builder.
-      assertPlan(conn, limitOnly).abstractExplainPlan("UNION ALL OVER 2 
QUERIES")
-        .iteratorType("SERIAL").scanType("FULL 
SCAN").table(tableName1).serverSortedBy(null)
-        .serverRowLimit(2L).clientRowLimit(2)
-        .clientSteps("CLIENT 2 ROW LIMIT", "CLIENT 2 ROW 
LIMIT").rhs().iteratorType("SERIAL")
+      assertPlan(conn, limitOnly).abstractExplainPlan("UNION ALL OVER 2 
QUERIES").clientRowLimit(2)
+        .clientSteps("CLIENT 2 ROW 
LIMIT").subPlanCount(2).subPlan(0).iteratorType("SERIAL")
+        .scanType("FULL 
SCAN").table(tableName1).serverSortedBy(null).serverRowLimit(2L)
+        .clientRowLimit(2).clientSteps("CLIENT 2 ROW 
LIMIT").end().subPlan(1).iteratorType("SERIAL")
         .scanType("FULL 
SCAN").table(tableName2).serverSortedBy(null).serverRowLimit(2L)
         .clientRowLimit(2).clientSteps("CLIENT 2 ROW LIMIT");
 
+      // UNION ALL root has no outer client pipeline, just two sub-plans.
       String noLimit = "select a_string, col1 from " + tableName1
         + " union all select a_string, col1 from " + tableName2;
-      assertPlan(conn, noLimit).abstractExplainPlan("UNION ALL OVER 2 QUERIES")
-        .iteratorType("PARALLEL").scanType("FULL SCAN").table(tableName1).rhs()
-        .iteratorType("PARALLEL").scanType("FULL SCAN").table(tableName2);
+      assertPlan(conn, noLimit).abstractExplainPlan("UNION ALL OVER 2 
QUERIES").clientStepCount(0)
+        .subPlanCount(2).subPlan(0).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(tableName1)
+        .end().subPlan(1).iteratorType("PARALLEL").scanType("FULL 
SCAN").table(tableName2);
     }
   }
 
+  /**
+   * A {@code UNION ALL} of two hash joins must surface each branch's full 
join structure under
+   * {@code subPlans}, and the indented join detail lines must appear in the 
EXPLAIN result-set
+   * text.
+   */
+  @Test
+  public void testExplainUnionOfJoins() throws Exception {
+    String t1 = generateUniqueName();
+    String t2 = generateUniqueName();
+    String t3 = generateUniqueName();
+    String t4 = generateUniqueName();
+    Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
+    try (Connection conn = DriverManager.getConnection(getUrl(), props)) {
+      for (String t : new String[] { t1, t2, t3, t4 }) {
+        createTestTable(getUrl(), "CREATE TABLE " + t
+          + " (a_string varchar not null, col1 integer CONSTRAINT pk PRIMARY 
KEY (a_string))");
+      }
+      String query = "SELECT x.a_string, x.col1 FROM " + t1 + " x JOIN " + t2
+        + " y ON x.a_string = y.a_string" + " UNION ALL " + "SELECT 
p.a_string, p.col1 FROM " + t3
+        + " p JOIN " + t4 + " q ON p.a_string = q.a_string";
+
+      // In UNION ALL each branch is a hash-join node with its own delegate 
scan, dynamic server
+      // filter, and a single hash sub-plan for the build side.
+      assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 
QUERIES").clientStepCount(0)
+        .subPlanCount(2).subPlan(0).table(t1)
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY X.A_STRING IN 
(Y.A_STRING)").subPlanCount(1)
+        .subPlan(0).table(t2).end().end().subPlan(1).table(t3)
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY P.A_STRING IN 
(Q.A_STRING)").subPlanCount(1)
+        .subPlan(0).table(t4);
+
+      // Branch level hash-join lines must be indented +4 under the union, and 
the inner scan
+      // lines must be indented +4 again.
+      List<String> textSteps = new java.util.ArrayList<>();
+      try (ResultSet rs = conn.createStatement().executeQuery("EXPLAIN " + 
query)) {
+        while (rs.next()) {
+          textSteps.add(rs.getString(1));
+        }
+      }
+      assertTrue("EXPLAIN must start with union header, was: " + textSteps,
+        textSteps.get(0).startsWith("UNION ALL OVER 2 QUERIES"));
+      assertTrue("EXPLAIN must include indented join probe scan over " + t1 + 
", was: " + textSteps,
+        containsLine(textSteps, "    CLIENT", "FULL SCAN OVER " + t1));
+      assertTrue(
+        "EXPLAIN must include hash sub-plan header indented under branch, was: 
" + textSteps,
+        containsLine(textSteps, "        PARALLEL INNER-JOIN TABLE 0"));
+      assertTrue("EXPLAIN must include indented build-side scan over " + t2 + 
", was: " + textSteps,
+        containsLine(textSteps, "            CLIENT", "FULL SCAN OVER " + t2));
+      assertTrue("EXPLAIN must include dynamic server filter for branch 0, 
was: " + textSteps,
+        containsLine(textSteps, "DYNAMIC SERVER FILTER BY X.A_STRING IN 
(Y.A_STRING)"));
+      assertTrue("EXPLAIN must include indented build-side scan over " + t4 + 
", was: " + textSteps,
+        containsLine(textSteps, "            CLIENT", "FULL SCAN OVER " + t4));
+    }
+  }
+
+  /** Returns true iff some entry in {@code lines} contains every {@code 
needle}. */
+  private static boolean containsLine(java.util.List<String> lines, String... 
needles) {
+    outer: for (String l : lines) {
+      int from = 0;
+      for (String n : needles) {
+        int idx = l.indexOf(n, from);
+        if (idx < 0) {
+          continue outer;
+        }
+        from = idx + n.length();
+      }
+      return true;
+    }
+    return false;
+  }
+
   @Test
   public void testBug2295() throws Exception {
     String tableName1 = generateUniqueName();
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 e0880a2c2d..1ba3ce1a8d 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
@@ -546,7 +546,9 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
 
   @Test
   public void testUnionAll() throws Exception {
-    ObjectNode rhs = scanAttrs("RANGE SCAN ", "ATABLE", " 
['00D000000000002']");
+    ArrayNode subPlans = mapper.createArrayNode();
+    subPlans.add(scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']"));
+    subPlans.add(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'",
@@ -555,9 +557,53 @@ public class ExplainPlanTest extends 
BaseConnectionlessQueryTest {
         "        INDEX ATABLE", "        REGIONS PLANNED <N>",
         "    CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE 
['00D000000000002']",
         "        INDEX ATABLE", "        REGIONS PLANNED <N>"),
-      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']")
-        .put("abstractExplainPlan", "UNION ALL OVER 2 QUERIES")
-        .set("rhsJoinQueryExplainPlan", rhs));
+      defaultAttrs().put("abstractExplainPlan", "UNION ALL OVER 2 
QUERIES").set("subPlans",
+        subPlans));
+  }
+
+  /**
+   * Deeply nested EXPLAIN test. A {@code UNION ALL} of two hash joins must 
surface each branch's
+   * full join structure under {@code subPlans}, with the indentation 
compounding correctly in the
+   * textual output.
+   */
+  @Test
+  public void testUnionAllOfHashJoins() throws Exception {
+    ObjectNode child0 = scanAttrs("FULL SCAN ", "ATABLE", 
"").put("abstractExplainPlan",
+      "PARALLEL INNER-JOIN TABLE 0");
+    ObjectNode branch0 = scanAttrs("FULL SCAN ", "ATABLE", "");
+    branch0.set("subPlans", mapper.createArrayNode().add(child0));
+    branch0.put("dynamicServerFilter",
+      "DYNAMIC SERVER FILTER BY A.ORGANIZATION_ID IN (B.ORGANIZATION_ID)");
+
+    ObjectNode child1 = scanAttrs("FULL SCAN ", "ATABLE", 
"").put("abstractExplainPlan",
+      "PARALLEL INNER-JOIN TABLE 0");
+    ObjectNode branch1 = scanAttrs("FULL SCAN ", "ATABLE", "");
+    branch1.set("subPlans", mapper.createArrayNode().add(child1));
+
+    ArrayNode subPlans = mapper.createArrayNode().add(branch0).add(branch1);
+
+    verifyQuery("unionAllOfHashJoins",
+      "SELECT a.a_string, b.b_string FROM atable a"
+        + " JOIN atable b ON a.organization_id = b.organization_id" + " UNION 
ALL"
+        + " SELECT a.a_string, b.b_string FROM atable a"
+        + " JOIN atable b ON a.entity_id = b.entity_id",
+      text("UNION ALL OVER 2 QUERIES",
+        // Branch 0: hash join, indented +4 under the union.
+        "    CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "        INDEX 
ATABLE",
+        "        REGIONS PLANNED <N>",
+        "        PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */",
+        // Inner build-side scan, indented +4 again under the join.
+        "            CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "         
       INDEX ATABLE",
+        "                REGIONS PLANNED <N>",
+        "        DYNAMIC SERVER FILTER BY A.ORGANIZATION_ID IN 
(B.ORGANIZATION_ID)",
+        // Branch 1: hash join on a non-leading PK column, no dynamic filter.
+        "    CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "        INDEX 
ATABLE",
+        "        REGIONS PLANNED <N>",
+        "        PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */",
+        "            CLIENT PARALLEL <N>-WAY FULL SCAN OVER ATABLE", "         
       INDEX ATABLE",
+        "                REGIONS PLANNED <N>"),
+      defaultAttrs().put("abstractExplainPlan", "UNION ALL OVER 2 
QUERIES").set("subPlans",
+        subPlans));
   }
 
   @Test

Reply via email to