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

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


The following commit(s) were added to refs/heads/PHOENIX-7876-feature by this 
push:
     new 3631a0d7fb PHOENIX-7919 Support EXPLAIN FORMAT JSON (#2527)
3631a0d7fb is described below

commit 3631a0d7fbc9ae6df58bf2b07ccbd82e3f4622f0
Author: Andrew Purtell <[email protected]>
AuthorDate: Sat Jun 13 12:54:27 2026 -0700

    PHOENIX-7919 Support EXPLAIN FORMAT JSON (#2527)
    
    Co-authored-by: Claude Opus 4.8[1m] <[email protected]>
---
 .../phoenix/compile/ExplainJsonRenderer.java       |  79 ++++++++++++++++
 .../org/apache/phoenix/jdbc/PhoenixStatement.java  |  20 +++-
 .../query/explain/ExplainJsonOutputTest.java       | 103 +++++++++++++++++++++
 3 files changed, 197 insertions(+), 5 deletions(-)

diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainJsonRenderer.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainJsonRenderer.java
new file mode 100644
index 0000000000..fed80dc995
--- /dev/null
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainJsonRenderer.java
@@ -0,0 +1,79 @@
+/*
+ * 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.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.util.DefaultIndenter;
+import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import java.sql.SQLException;
+
+/**
+ * Serializes an {@link ExplainPlanAttributes} tree to a JSON document for the
+ * {@code EXPLAIN (FORMAT JSON) <stmt>} statement.
+ * <p>
+ * The output is pretty-printed with two space indentation for both objects 
and arrays.
+ * <p>
+ * The JSON layout tracks the Java field names and structure of {@link 
ExplainPlanAttributes}. It is
+ * deliberately not a stable contract and carries no version field. It is an 
opt-in view onto an
+ * internal structure, useful for tooling and assertions.
+ * <p>
+ * This class intentionally does not reuse the shared {@link 
org.apache.phoenix.util.JacksonUtil}
+ * mapper so that the general-purpose mapper configuration can change without 
affecting the EXPLAIN
+ * JSON contract.
+ */
+public final class ExplainJsonRenderer {
+
+  private static final ObjectWriter WRITER = buildWriter();
+
+  private static ObjectWriter buildWriter() {
+    ObjectMapper mapper = new ObjectMapper();
+    // Emit every field, with an explicit null for any unset value, so the 
JSON view is a faithful
+    // projection of the attributes tree.
+    mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
+    mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+    mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
+    // Jackson's stock DefaultPrettyPrinter indents object fields but uses a 
single space
+    // FixedSpaceIndenter for array elements. Set the same two space indenter 
on both so nested
+    // objects and array elements each start on their own indented line.
+    DefaultIndenter indenter = new DefaultIndenter("  ", "\n");
+    DefaultPrettyPrinter printer =
+      new 
DefaultPrettyPrinter().withObjectIndenter(indenter).withArrayIndenter(indenter);
+    return mapper.writer(printer);
+  }
+
+  private ExplainJsonRenderer() {
+  }
+
+  /**
+   * Serialize the given attributes to a pretty-printed JSON document.
+   * @param attributes the plan attributes to serialize
+   * @return the JSON document
+   * @throws SQLException if serialization fails
+   */
+  public static String render(ExplainPlanAttributes attributes) throws 
SQLException {
+    try {
+      return WRITER.writeValueAsString(attributes);
+    } catch (JsonProcessingException e) {
+      throw new SQLException("Failed to serialize EXPLAIN attributes as JSON", 
e);
+    }
+  }
+}
diff --git 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
index b6b89aa646..b064bd78d7 100644
--- 
a/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
+++ 
b/phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java
@@ -91,6 +91,7 @@ import org.apache.phoenix.compile.CreateTableCompiler;
 import org.apache.phoenix.compile.DeclareCursorCompiler;
 import org.apache.phoenix.compile.DeleteCompiler;
 import org.apache.phoenix.compile.DropSequenceCompiler;
+import org.apache.phoenix.compile.ExplainJsonRenderer;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.compile.ExpressionProjector;
@@ -1005,11 +1006,20 @@ public class PhoenixStatement implements 
PhoenixMonitoredStatement, SQLCloseable
         plan.getContext().setExplainOptions(getOptions());
       }
       ExplainPlan explainPlan = plan.getExplainPlan();
-      // Prepend the top-of-plan disclosure blocks. This is the only place the 
disclosure text is
-      // emitted.
-      List<String> planSteps = new ArrayList<>(explainPlan.getPlanSteps());
-      ExplainTable.renderTopOfPlanText(planSteps, 
explainPlan.getPlanStepsAsAttributes());
-      planSteps = Collections.unmodifiableList(planSteps);
+      List<String> planSteps;
+      if (getOptions().getFormat() == ExplainOptions.Format.JSON) {
+        // FORMAT JSON returns a single row whose cell carries the serialized 
attributes tree. The
+        // top-of-plan disclosure block is already inside the attributes, so 
renderTopOfPlanText is
+        // not invoked here.
+        planSteps = Collections
+          
.singletonList(ExplainJsonRenderer.render(explainPlan.getPlanStepsAsAttributes()));
+      } else {
+        // Prepend the top-of-plan disclosure blocks. This is the only place 
the disclosure text is
+        // emitted.
+        planSteps = new ArrayList<>(explainPlan.getPlanSteps());
+        ExplainTable.renderTopOfPlanText(planSteps, 
explainPlan.getPlanStepsAsAttributes());
+        planSteps = Collections.unmodifiableList(planSteps);
+      }
       List<Tuple> tuples = 
Lists.newArrayListWithExpectedSize(planSteps.size());
       Long estimatedBytesToScan = plan.getEstimatedBytesToScan();
       Long estimatedRowsToScan = plan.getEstimatedRowsToScan();
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonOutputTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonOutputTest.java
new file mode 100644
index 0000000000..8856c0c0b4
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainJsonOutputTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.util.TestUtil.TEST_PROPERTIES;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Properties;
+import org.apache.phoenix.compile.ExplainPlan;
+import org.apache.phoenix.query.BaseConnectionlessQueryTest;
+import org.apache.phoenix.util.PropertiesUtil;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * End-to-end tests for {@code EXPLAIN (FORMAT JSON) <stmt>}. Exercises the
+ * {@code Statement.executeQuery} ResultSet path and confirms the single 
emitted row carries a
+ * pretty-printed JSON document that matches the in process {@code 
ExplainPlanAttributes} tree for
+ * the same query.
+ */
+public class ExplainJsonOutputTest extends BaseConnectionlessQueryTest {
+
+  private static final String QUERY = "SELECT a_string, b_string FROM atable"
+    + " WHERE organization_id = '00D000000000001' AND entity_id = 
'00E00000000001'"
+    + " AND x_integer = 2 AND a_integer < 5";
+
+  private static ExplainOracle oracle;
+
+  @BeforeClass
+  public static synchronized void setUpOracle() throws Exception {
+    oracle = new ExplainOracle();
+  }
+
+  private static Properties defaultProps() {
+    return PropertiesUtil.deepCopy(TEST_PROPERTIES);
+  }
+
+  /**
+   * Read the single VARCHAR cell of an {@code EXPLAIN (FORMAT JSON)} result 
set, asserting that
+   * exactly one row is returned.
+   */
+  private static String readSingleRow(Connection conn, String explainSql) 
throws Exception {
+    try (Statement stmt = conn.createStatement(); ResultSet rs = 
stmt.executeQuery(explainSql)) {
+      assertTrue("expected at least one row", rs.next());
+      String cell = rs.getString(1);
+      assertFalse("expected exactly one row", rs.next());
+      return cell;
+    }
+  }
+
+  @Test
+  public void testFormatJsonMatchesInProcessAttributes() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      String json = readSingleRow(conn, "EXPLAIN (FORMAT JSON) " + QUERY);
+      // The emitted document is pretty-printed.
+      assertTrue("expected pretty-printed JSON with newlines", 
json.contains("\n"));
+      assertTrue("expected two-space indentation", json.contains("\n  \""));
+      // The e2e ResultSet path must match the in process attributes path 
after normalization.
+      JsonNode actual = oracle.mapper().readTree(json);
+      new ExplainJsonNormalizer().normalize(actual);
+      ExplainPlan plan = ExplainPlanTestUtil.getExplainPlan(conn, QUERY);
+      JsonNode expected = 
oracle.serializeNormalized(plan.getPlanStepsAsAttributes());
+      assertEquals(expected, actual);
+    }
+  }
+
+  @Test
+  public void testFormatJsonWithRegions() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), 
defaultProps())) {
+      String json = readSingleRow(conn, "EXPLAIN (REGIONS, FORMAT JSON) " + 
QUERY);
+      assertTrue("expected pretty-printed JSON with newlines", 
json.contains("\n"));
+      // This case confirms the (REGIONS, FORMAT JSON) combination produces a 
single well formed
+      // JSON row.
+      JsonNode actual = oracle.mapper().readTree(json);
+      new ExplainJsonNormalizer().normalize(actual);
+      ExplainPlan plan = ExplainPlanTestUtil.getExplainPlan(conn, QUERY);
+      JsonNode expected = 
oracle.serializeNormalized(plan.getPlanStepsAsAttributes());
+      assertEquals(expected, actual);
+    }
+  }
+}

Reply via email to