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 00000000000..fed80dc9950 --- /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) } statement. + *

+ * The output is pretty-printed with two space indentation for both objects and arrays. + *

+ * 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. + *

+ * 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 b6b89aa6462..b064bd78d7c 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.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 QueryPlan compilePlan(PhoenixStatement stmt, Sequence.ValueOp seqAction) 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 planSteps = new ArrayList<>(explainPlan.getPlanSteps()); - ExplainTable.renderTopOfPlanText(planSteps, explainPlan.getPlanStepsAsAttributes()); - planSteps = Collections.unmodifiableList(planSteps); + List 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 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 00000000000..8856c0c0b4b --- /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) }. 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); + } + } +}