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);
+ }
+ }
+}