Skip to content

Commit 3631a0d

Browse files
apurtellclaude
andauthored
PHOENIX-7919 Support EXPLAIN FORMAT JSON (#2527)
Co-authored-by: Claude Opus 4.8[1m] <noreply@anthropic.com>
1 parent d18b98c commit 3631a0d

3 files changed

Lines changed: 197 additions & 5 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.phoenix.compile;
19+
20+
import com.fasterxml.jackson.annotation.JsonInclude;
21+
import com.fasterxml.jackson.core.JsonProcessingException;
22+
import com.fasterxml.jackson.core.util.DefaultIndenter;
23+
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
24+
import com.fasterxml.jackson.databind.ObjectMapper;
25+
import com.fasterxml.jackson.databind.ObjectWriter;
26+
import com.fasterxml.jackson.databind.SerializationFeature;
27+
import java.sql.SQLException;
28+
29+
/**
30+
* Serializes an {@link ExplainPlanAttributes} tree to a JSON document for the
31+
* {@code EXPLAIN (FORMAT JSON) <stmt>} statement.
32+
* <p>
33+
* The output is pretty-printed with two space indentation for both objects and arrays.
34+
* <p>
35+
* The JSON layout tracks the Java field names and structure of {@link ExplainPlanAttributes}. It is
36+
* deliberately not a stable contract and carries no version field. It is an opt-in view onto an
37+
* internal structure, useful for tooling and assertions.
38+
* <p>
39+
* This class intentionally does not reuse the shared {@link org.apache.phoenix.util.JacksonUtil}
40+
* mapper so that the general-purpose mapper configuration can change without affecting the EXPLAIN
41+
* JSON contract.
42+
*/
43+
public final class ExplainJsonRenderer {
44+
45+
private static final ObjectWriter WRITER = buildWriter();
46+
47+
private static ObjectWriter buildWriter() {
48+
ObjectMapper mapper = new ObjectMapper();
49+
// Emit every field, with an explicit null for any unset value, so the JSON view is a faithful
50+
// projection of the attributes tree.
51+
mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
52+
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
53+
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
54+
// Jackson's stock DefaultPrettyPrinter indents object fields but uses a single space
55+
// FixedSpaceIndenter for array elements. Set the same two space indenter on both so nested
56+
// objects and array elements each start on their own indented line.
57+
DefaultIndenter indenter = new DefaultIndenter(" ", "\n");
58+
DefaultPrettyPrinter printer =
59+
new DefaultPrettyPrinter().withObjectIndenter(indenter).withArrayIndenter(indenter);
60+
return mapper.writer(printer);
61+
}
62+
63+
private ExplainJsonRenderer() {
64+
}
65+
66+
/**
67+
* Serialize the given attributes to a pretty-printed JSON document.
68+
* @param attributes the plan attributes to serialize
69+
* @return the JSON document
70+
* @throws SQLException if serialization fails
71+
*/
72+
public static String render(ExplainPlanAttributes attributes) throws SQLException {
73+
try {
74+
return WRITER.writeValueAsString(attributes);
75+
} catch (JsonProcessingException e) {
76+
throw new SQLException("Failed to serialize EXPLAIN attributes as JSON", e);
77+
}
78+
}
79+
}

phoenix-core-client/src/main/java/org/apache/phoenix/jdbc/PhoenixStatement.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
import org.apache.phoenix.compile.DeclareCursorCompiler;
9292
import org.apache.phoenix.compile.DeleteCompiler;
9393
import org.apache.phoenix.compile.DropSequenceCompiler;
94+
import org.apache.phoenix.compile.ExplainJsonRenderer;
9495
import org.apache.phoenix.compile.ExplainPlan;
9596
import org.apache.phoenix.compile.ExplainPlanAttributes;
9697
import org.apache.phoenix.compile.ExpressionProjector;
@@ -1005,11 +1006,20 @@ public QueryPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp seqAction)
10051006
plan.getContext().setExplainOptions(getOptions());
10061007
}
10071008
ExplainPlan explainPlan = plan.getExplainPlan();
1008-
// Prepend the top-of-plan disclosure blocks. This is the only place the disclosure text is
1009-
// emitted.
1010-
List<String> planSteps = new ArrayList<>(explainPlan.getPlanSteps());
1011-
ExplainTable.renderTopOfPlanText(planSteps, explainPlan.getPlanStepsAsAttributes());
1012-
planSteps = Collections.unmodifiableList(planSteps);
1009+
List<String> planSteps;
1010+
if (getOptions().getFormat() == ExplainOptions.Format.JSON) {
1011+
// FORMAT JSON returns a single row whose cell carries the serialized attributes tree. The
1012+
// top-of-plan disclosure block is already inside the attributes, so renderTopOfPlanText is
1013+
// not invoked here.
1014+
planSteps = Collections
1015+
.singletonList(ExplainJsonRenderer.render(explainPlan.getPlanStepsAsAttributes()));
1016+
} else {
1017+
// Prepend the top-of-plan disclosure blocks. This is the only place the disclosure text is
1018+
// emitted.
1019+
planSteps = new ArrayList<>(explainPlan.getPlanSteps());
1020+
ExplainTable.renderTopOfPlanText(planSteps, explainPlan.getPlanStepsAsAttributes());
1021+
planSteps = Collections.unmodifiableList(planSteps);
1022+
}
10131023
List<Tuple> tuples = Lists.newArrayListWithExpectedSize(planSteps.size());
10141024
Long estimatedBytesToScan = plan.getEstimatedBytesToScan();
10151025
Long estimatedRowsToScan = plan.getEstimatedRowsToScan();
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.phoenix.query.explain;
19+
20+
import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
21+
import static org.junit.Assert.assertEquals;
22+
import static org.junit.Assert.assertFalse;
23+
import static org.junit.Assert.assertTrue;
24+
25+
import com.fasterxml.jackson.databind.JsonNode;
26+
import java.sql.Connection;
27+
import java.sql.DriverManager;
28+
import java.sql.ResultSet;
29+
import java.sql.Statement;
30+
import java.util.Properties;
31+
import org.apache.phoenix.compile.ExplainPlan;
32+
import org.apache.phoenix.query.BaseConnectionlessQueryTest;
33+
import org.apache.phoenix.util.PropertiesUtil;
34+
import org.junit.BeforeClass;
35+
import org.junit.Test;
36+
37+
/**
38+
* End-to-end tests for {@code EXPLAIN (FORMAT JSON) <stmt>}. Exercises the
39+
* {@code Statement.executeQuery} ResultSet path and confirms the single emitted row carries a
40+
* pretty-printed JSON document that matches the in process {@code ExplainPlanAttributes} tree for
41+
* the same query.
42+
*/
43+
public class ExplainJsonOutputTest extends BaseConnectionlessQueryTest {
44+
45+
private static final String QUERY = "SELECT a_string, b_string FROM atable"
46+
+ " WHERE organization_id = '00D000000000001' AND entity_id = '00E00000000001'"
47+
+ " AND x_integer = 2 AND a_integer < 5";
48+
49+
private static ExplainOracle oracle;
50+
51+
@BeforeClass
52+
public static synchronized void setUpOracle() throws Exception {
53+
oracle = new ExplainOracle();
54+
}
55+
56+
private static Properties defaultProps() {
57+
return PropertiesUtil.deepCopy(TEST_PROPERTIES);
58+
}
59+
60+
/**
61+
* Read the single VARCHAR cell of an {@code EXPLAIN (FORMAT JSON)} result set, asserting that
62+
* exactly one row is returned.
63+
*/
64+
private static String readSingleRow(Connection conn, String explainSql) throws Exception {
65+
try (Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(explainSql)) {
66+
assertTrue("expected at least one row", rs.next());
67+
String cell = rs.getString(1);
68+
assertFalse("expected exactly one row", rs.next());
69+
return cell;
70+
}
71+
}
72+
73+
@Test
74+
public void testFormatJsonMatchesInProcessAttributes() throws Exception {
75+
try (Connection conn = DriverManager.getConnection(getUrl(), defaultProps())) {
76+
String json = readSingleRow(conn, "EXPLAIN (FORMAT JSON) " + QUERY);
77+
// The emitted document is pretty-printed.
78+
assertTrue("expected pretty-printed JSON with newlines", json.contains("\n"));
79+
assertTrue("expected two-space indentation", json.contains("\n \""));
80+
// The e2e ResultSet path must match the in process attributes path after normalization.
81+
JsonNode actual = oracle.mapper().readTree(json);
82+
new ExplainJsonNormalizer().normalize(actual);
83+
ExplainPlan plan = ExplainPlanTestUtil.getExplainPlan(conn, QUERY);
84+
JsonNode expected = oracle.serializeNormalized(plan.getPlanStepsAsAttributes());
85+
assertEquals(expected, actual);
86+
}
87+
}
88+
89+
@Test
90+
public void testFormatJsonWithRegions() throws Exception {
91+
try (Connection conn = DriverManager.getConnection(getUrl(), defaultProps())) {
92+
String json = readSingleRow(conn, "EXPLAIN (REGIONS, FORMAT JSON) " + QUERY);
93+
assertTrue("expected pretty-printed JSON with newlines", json.contains("\n"));
94+
// This case confirms the (REGIONS, FORMAT JSON) combination produces a single well formed
95+
// JSON row.
96+
JsonNode actual = oracle.mapper().readTree(json);
97+
new ExplainJsonNormalizer().normalize(actual);
98+
ExplainPlan plan = ExplainPlanTestUtil.getExplainPlan(conn, QUERY);
99+
JsonNode expected = oracle.serializeNormalized(plan.getPlanStepsAsAttributes());
100+
assertEquals(expected, actual);
101+
}
102+
}
103+
}

0 commit comments

Comments
 (0)