You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iotdb.apache.org by ja...@apache.org on 2026/04/02 10:45:29 UTC
(iotdb) 01/06: First step by opus-4.6
This is an automated email from the ASF dual-hosted git repository.
jackietien pushed a commit to branch ty/explain_format
in repository https://gitbox.apache.org/repos/asf/iotdb.git
commit e2e91d5a22eb3e37cc7bc189b80135fb9beae0cb
Author: JackieTien97 <ja...@gmail.com>
AuthorDate: Wed Apr 1 19:20:48 2026 +0800
First step by opus-4.6
---
.../it/query/recent/IoTExplainJsonFormatIT.java | 251 ++++++++++++++
.../db/queryengine/common/MPPQueryContext.java | 10 +
.../execution/operator/ExplainAnalyzeOperator.java | 37 ++
.../TableModelStatementMemorySourceVisitor.java | 65 +++-
.../plan/planner/TableOperatorGenerator.java | 7 +-
.../planner/plan/node/PlanGraphJsonPrinter.java | 220 ++++++++++++
.../relational/analyzer/StatementAnalyzer.java | 2 +
.../relational/planner/TableLogicalPlanner.java | 3 +-
.../planner/node/ExplainAnalyzeNode.java | 39 ++-
.../plan/relational/sql/ast/Explain.java | 22 +-
.../plan/relational/sql/ast/ExplainAnalyze.java | 24 +-
.../relational/sql/ast/ExplainOutputFormat.java | 26 ++
.../plan/relational/sql/parser/AstBuilder.java | 35 +-
.../FragmentInstanceStatisticsJsonDrawer.java | 371 +++++++++++++++++++++
.../planner/node/PlanGraphJsonPrinterTest.java | 120 +++++++
.../plan/relational/sql/ExplainFormatTest.java | 127 +++++++
.../db/relational/grammar/sql/RelationalSql.g4 | 4 +-
17 files changed, 1345 insertions(+), 18 deletions(-)
diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java
new file mode 100644
index 00000000000..94ed692ab64
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java
@@ -0,0 +1,251 @@
+/*
+ *
+ * * 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.iotdb.relational.it.query.recent;
+
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.TableLocalStandaloneIT;
+import org.apache.iotdb.itbase.env.BaseEnv;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Locale;
+
+import static org.junit.Assert.fail;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({TableLocalStandaloneIT.class})
+public class IoTExplainJsonFormatIT {
+ private static final String DATABASE_NAME = "testdb_json";
+
+ @BeforeClass
+ public static void setUp() {
+ Locale.setDefault(Locale.ENGLISH);
+
+ EnvFactory.getEnv().getConfig().getCommonConfig().setPartitionInterval(1000);
+ EnvFactory.getEnv().initClusterEnvironment();
+
+ try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = connection.createStatement()) {
+ statement.execute("CREATE DATABASE IF NOT EXISTS " + DATABASE_NAME);
+ statement.execute("USE " + DATABASE_NAME);
+ statement.execute(
+ "CREATE TABLE IF NOT EXISTS testtb(deviceid STRING TAG, voltage FLOAT FIELD)");
+ statement.execute("INSERT INTO testtb VALUES(1000, 'd1', 100.0)");
+ statement.execute("INSERT INTO testtb VALUES(2000, 'd1', 200.0)");
+ statement.execute("INSERT INTO testtb VALUES(1000, 'd2', 300.0)");
+ } catch (Exception e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @AfterClass
+ public static void tearDown() {
+ try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = connection.createStatement()) {
+ statement.execute("DROP DATABASE IF EXISTS " + DATABASE_NAME);
+ } catch (Exception e) {
+ // ignore
+ }
+ EnvFactory.getEnv().cleanClusterEnvironment();
+ }
+
+ @Test
+ public void testExplainJsonFormat() {
+ String sql = "EXPLAIN (FORMAT JSON) SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ Assert.assertTrue("Should have at least one row", resultSet.next());
+ String jsonStr = resultSet.getString(1);
+ Assert.assertNotNull(jsonStr);
+
+ // Verify it's valid JSON
+ JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+ Assert.assertTrue("JSON should have 'name' field", root.has("name"));
+ Assert.assertTrue("JSON should have 'id' field", root.has("id"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainDefaultFormatIsNotJson() {
+ String sql = "EXPLAIN SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ Assert.assertTrue("Should have at least one row", resultSet.next());
+ String firstLine = resultSet.getString(1);
+ // Default format (GRAPHVIZ) produces box-drawing characters, not JSON
+ Assert.assertFalse(
+ "Default EXPLAIN should not produce JSON", firstLine.trim().startsWith("{"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeJsonFormat() {
+ String sql = "EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ // JSON format should produce a single row with full JSON
+ Assert.assertTrue("Should have at least one row", resultSet.next());
+ String jsonStr = resultSet.getString(1);
+ Assert.assertNotNull(jsonStr);
+
+ // Verify it's valid JSON
+ JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+ Assert.assertTrue("JSON should have 'planStatistics' field", root.has("planStatistics"));
+ Assert.assertTrue(
+ "JSON should have 'fragmentInstances' field", root.has("fragmentInstances"));
+ Assert.assertTrue(
+ "JSON should have 'fragmentInstancesCount' field", root.has("fragmentInstancesCount"));
+
+ // Plan statistics should contain known keys
+ JsonObject planStats = root.getAsJsonObject("planStatistics");
+ Assert.assertTrue(planStats.has("analyzeCostMs"));
+ Assert.assertTrue(planStats.has("logicalPlanCostMs"));
+ Assert.assertTrue(planStats.has("distributionPlanCostMs"));
+ Assert.assertTrue(planStats.has("dispatchCostMs"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeVerboseJsonFormat() {
+ String sql = "EXPLAIN ANALYZE VERBOSE (FORMAT JSON) SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ Assert.assertTrue("Should have at least one row", resultSet.next());
+ String jsonStr = resultSet.getString(1);
+ Assert.assertNotNull(jsonStr);
+
+ // Verify it's valid JSON
+ JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+ Assert.assertTrue(root.has("planStatistics"));
+ Assert.assertTrue(root.has("fragmentInstances"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeDefaultIsText() {
+ String sql = "EXPLAIN ANALYZE SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ Assert.assertTrue("Should have at least one row", resultSet.next());
+ String firstLine = resultSet.getString(1);
+ // Default format (TEXT) should not start with '{'
+ Assert.assertFalse(
+ "Default EXPLAIN ANALYZE should not produce JSON", firstLine.trim().startsWith("{"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainGraphvizFormatExplicit() {
+ String sql = "EXPLAIN (FORMAT GRAPHVIZ) SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ Assert.assertTrue("Should have at least one row", resultSet.next());
+ String firstLine = resultSet.getString(1);
+ // GRAPHVIZ format produces box-drawing characters, not JSON
+ Assert.assertFalse(
+ "GRAPHVIZ EXPLAIN should not produce JSON", firstLine.trim().startsWith("{"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeTextFormatExplicit() {
+ String sql = "EXPLAIN ANALYZE (FORMAT TEXT) SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ Assert.assertTrue("Should have at least one row", resultSet.next());
+ String firstLine = resultSet.getString(1);
+ Assert.assertFalse(
+ "TEXT EXPLAIN ANALYZE should not produce JSON", firstLine.trim().startsWith("{"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test(expected = SQLException.class)
+ public void testExplainInvalidFormat() throws SQLException {
+ String sql = "EXPLAIN (FORMAT XML) SELECT * FROM testtb";
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ statement.executeQuery(sql);
+ }
+ }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java
index 88bd1998f68..43b214f226c 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java
@@ -34,6 +34,7 @@ import org.apache.iotdb.db.queryengine.plan.analyze.lock.SchemaLockType;
import org.apache.iotdb.db.queryengine.plan.planner.memory.MemoryReservationManager;
import org.apache.iotdb.db.queryengine.plan.planner.memory.NotThreadSafeMemoryReservationManager;
import org.apache.iotdb.db.queryengine.plan.relational.analyzer.NodeRef;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Identifier;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Query;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Table;
@@ -108,6 +109,7 @@ public class MPPQueryContext implements IAuditEntity {
// - EXPLAIN: Show the logical and physical query plan without execution
// - EXPLAIN_ANALYZE: Execute the query and collect detailed execution statistics
private ExplainType explainType = ExplainType.NONE;
+ private ExplainOutputFormat explainOutputFormat = null;
private boolean verbose = false;
private QueryPlanStatistics queryPlanStatistics = null;
@@ -346,6 +348,14 @@ public class MPPQueryContext implements IAuditEntity {
return explainType;
}
+ public void setExplainOutputFormat(ExplainOutputFormat explainOutputFormat) {
+ this.explainOutputFormat = explainOutputFormat;
+ }
+
+ public ExplainOutputFormat getExplainOutputFormat() {
+ return explainOutputFormat;
+ }
+
public boolean isExplainAnalyze() {
return explainType == ExplainType.EXPLAIN_ANALYZE;
}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java
index b4f1a5c7261..52af0bb7455 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java
@@ -33,8 +33,10 @@ import org.apache.iotdb.db.queryengine.plan.Coordinator;
import org.apache.iotdb.db.queryengine.plan.execution.QueryExecution;
import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
import org.apache.iotdb.db.queryengine.plan.relational.analyzer.NodeRef;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Table;
import org.apache.iotdb.db.queryengine.statistics.FragmentInstanceStatisticsDrawer;
+import org.apache.iotdb.db.queryengine.statistics.FragmentInstanceStatisticsJsonDrawer;
import org.apache.iotdb.db.queryengine.statistics.QueryStatisticsFetcher;
import org.apache.iotdb.db.queryengine.statistics.StatisticLine;
import org.apache.iotdb.db.utils.SetThreadName;
@@ -78,9 +80,12 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
private final boolean verbose;
private boolean outputResult = false;
private final List<FragmentInstance> instances;
+ private final ExplainOutputFormat outputFormat;
private final FragmentInstanceStatisticsDrawer fragmentInstanceStatisticsDrawer =
new FragmentInstanceStatisticsDrawer();
+ private final FragmentInstanceStatisticsJsonDrawer fragmentInstanceStatisticsJsonDrawer =
+ new FragmentInstanceStatisticsJsonDrawer();
private final ScheduledFuture<?> logRecordTask;
private final IClientManager<TEndPoint, SyncDataNodeInternalServiceClient> clientManager;
@@ -92,9 +97,20 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
long queryId,
boolean verbose,
long timeout) {
+ this(operatorContext, child, queryId, verbose, timeout, ExplainOutputFormat.TEXT);
+ }
+
+ public ExplainAnalyzeOperator(
+ OperatorContext operatorContext,
+ Operator child,
+ long queryId,
+ boolean verbose,
+ long timeout,
+ ExplainOutputFormat outputFormat) {
this.operatorContext = operatorContext;
this.child = child;
this.verbose = verbose;
+ this.outputFormat = outputFormat;
Coordinator coordinator = Coordinator.getInstance();
this.clientManager = coordinator.getInternalServiceClientManager();
@@ -103,6 +119,7 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
this.instances = queryExecution.getDistributedPlan().getInstances();
mppQueryContext = queryExecution.getContext();
fragmentInstanceStatisticsDrawer.renderPlanStatistics(mppQueryContext);
+ fragmentInstanceStatisticsJsonDrawer.renderPlanStatistics(mppQueryContext);
// The time interval guarantees the result of EXPLAIN ANALYZE will be printed at least three
// times.
@@ -130,6 +147,7 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
}
fragmentInstanceStatisticsDrawer.renderDispatchCost(mppQueryContext);
+ fragmentInstanceStatisticsJsonDrawer.renderDispatchCost(mppQueryContext);
// fetch statics from all fragment instances
TsBlock result = buildResult();
@@ -182,6 +200,9 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
}
private TsBlock buildResult() throws FragmentInstanceFetchException {
+ if (outputFormat == ExplainOutputFormat.JSON) {
+ return buildJsonResult();
+ }
Map<NodeRef<Table>, Pair<Integer, List<String>>> cteAnalyzeResults =
mppQueryContext.getCteExplainResults();
List<String> mainAnalyzeResult = buildFragmentInstanceStatistics(instances, verbose);
@@ -199,6 +220,22 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
return builder.build();
}
+ private TsBlock buildJsonResult() throws FragmentInstanceFetchException {
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStatistics =
+ QueryStatisticsFetcher.fetchAllStatistics(instances, clientManager);
+ String jsonResult =
+ fragmentInstanceStatisticsJsonDrawer.renderFragmentInstancesAsJson(
+ instances, allStatistics, verbose);
+
+ TsBlockBuilder builder = new TsBlockBuilder(Collections.singletonList(TSDataType.TEXT));
+ TimeColumnBuilder timeColumnBuilder = builder.getTimeColumnBuilder();
+ ColumnBuilder columnBuilder = builder.getColumnBuilder(0);
+ timeColumnBuilder.writeLong(0);
+ columnBuilder.writeBinary(new Binary(jsonResult.getBytes()));
+ builder.declarePosition();
+ return builder.build();
+ }
+
private List<String> mergeAnalyzeResults(
Map<NodeRef<Table>, Pair<Integer, List<String>>> cteAnalyzeResults,
List<String> mainAnalyzeResult) {
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java
index 932d9419792..c6a7692395a 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java
@@ -25,6 +25,7 @@ import org.apache.iotdb.db.queryengine.common.header.DatasetHeader;
import org.apache.iotdb.db.queryengine.plan.Coordinator;
import org.apache.iotdb.db.queryengine.plan.planner.LocalExecutionPlanner;
import org.apache.iotdb.db.queryengine.plan.planner.plan.LogicalQueryPlan;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphJsonPrinter;
import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphPrinter;
import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNode;
import org.apache.iotdb.db.queryengine.plan.relational.analyzer.NodeRef;
@@ -35,6 +36,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.planner.distribute.TableD
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AstVisitor;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.CountDevice;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Explain;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Node;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ShowDevice;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Table;
@@ -103,15 +105,26 @@ public class TableModelStatementMemorySourceVisitor
Coordinator.getInstance().getDataNodeLocationSupplier())
.generateDistributedPlanWithOptimize(planContext);
- List<String> mainExplainResult =
- outputNodeWithExchange.accept(
- new PlanGraphPrinter(),
- new PlanGraphPrinter.GraphContext(
- context.getQueryContext().getTypeProvider().getTemplatedInfo()));
+ List<String> mainExplainResult;
+ if (node.getOutputFormat() == ExplainOutputFormat.JSON) {
+ mainExplainResult = PlanGraphJsonPrinter.getJsonLines(outputNodeWithExchange);
+ } else {
+ mainExplainResult =
+ outputNodeWithExchange.accept(
+ new PlanGraphPrinter(),
+ new PlanGraphPrinter.GraphContext(
+ context.getQueryContext().getTypeProvider().getTemplatedInfo()));
+ }
Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults =
context.getQueryContext().getCteExplainResults();
- List<String> lines = mergeExplainResults(cteExplainResults, mainExplainResult);
+ List<String> lines;
+ if (node.getOutputFormat() == ExplainOutputFormat.JSON) {
+ // For JSON format, we merge CTE results into the JSON output
+ lines = mergeExplainResultsJson(cteExplainResults, mainExplainResult);
+ } else {
+ lines = mergeExplainResults(cteExplainResults, mainExplainResult);
+ }
return getStatementMemorySource(header, lines);
}
@@ -149,4 +162,44 @@ public class TableModelStatementMemorySourceVisitor
return analyzeResult;
}
+
+ private List<String> mergeExplainResultsJson(
+ Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults,
+ List<String> mainExplainResult) {
+ if (cteExplainResults.isEmpty()) {
+ return mainExplainResult;
+ }
+
+ // For JSON format with CTEs, wrap everything in a combined JSON object
+ StringBuilder sb = new StringBuilder();
+ sb.append("{\n");
+ sb.append(" \"cteQueries\": [\n");
+ int cteIndex = 0;
+ int cteSize = cteExplainResults.size();
+ for (Map.Entry<NodeRef<Table>, Pair<Integer, List<String>>> entry :
+ cteExplainResults.entrySet()) {
+ sb.append(" {\n");
+ sb.append(" \"name\": \"").append(entry.getKey().getNode().getName()).append("\",\n");
+ sb.append(" \"plan\": ");
+ // Each CTE's plan is already a JSON string
+ for (String line : entry.getValue().getRight()) {
+ sb.append(line);
+ }
+ sb.append("\n }");
+ if (++cteIndex < cteSize) {
+ sb.append(",");
+ }
+ sb.append("\n");
+ }
+ sb.append(" ],\n");
+ sb.append(" \"mainQuery\": ");
+ for (String line : mainExplainResult) {
+ sb.append(line);
+ }
+ sb.append("\n}");
+
+ List<String> result = new ArrayList<>();
+ result.add(sb.toString());
+ return result;
+ }
}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java
index c31a2061f49..fe96177ee5f 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java
@@ -3472,7 +3472,12 @@ public class TableOperatorGenerator extends PlanVisitor<Operator, LocalExecution
node.getPlanNodeId(),
ExplainAnalyzeOperator.class.getSimpleName());
return new ExplainAnalyzeOperator(
- operatorContext, operator, node.getQueryId(), node.isVerbose(), node.getTimeout());
+ operatorContext,
+ operator,
+ node.getQueryId(),
+ node.isVerbose(),
+ node.getTimeout(),
+ node.getOutputFormat());
}
@Override
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java
new file mode 100644
index 00000000000..e35c23d4660
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java
@@ -0,0 +1,220 @@
+/*
+ * 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.iotdb.db.queryengine.plan.planner.plan.node;
+
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.DeviceTableScanNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExchangeNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExplainAnalyzeNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.TableScanNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.TreeDeviceViewScanNode;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Converts a plan tree into a JSON representation. Each plan node becomes a JSON object with: -
+ * "name": the node type with its plan node ID - "id": the plan node ID - "properties": key-value
+ * pairs - "children": array of child nodes
+ */
+public class PlanGraphJsonPrinter {
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+ private static final String REGION_NOT_ASSIGNED = "Not Assigned";
+
+ public static String toPrettyJson(PlanNode node) {
+ JsonObject root = buildJsonNode(node);
+ return GSON.toJson(root);
+ }
+
+ private static JsonObject buildJsonNode(PlanNode node) {
+ JsonObject jsonNode = new JsonObject();
+ String simpleName = node.getClass().getSimpleName();
+ String nodeId = node.getPlanNodeId().getId();
+
+ jsonNode.addProperty("name", simpleName + "-" + nodeId);
+ jsonNode.addProperty("id", nodeId);
+
+ JsonObject properties = buildProperties(node);
+ if (properties.size() > 0) {
+ jsonNode.add("properties", properties);
+ }
+
+ List<PlanNode> children = node.getChildren();
+ if (children != null && !children.isEmpty()) {
+ JsonArray childrenArray = new JsonArray();
+ for (PlanNode child : children) {
+ childrenArray.add(buildJsonNode(child));
+ }
+ jsonNode.add("children", childrenArray);
+ }
+
+ return jsonNode;
+ }
+
+ private static JsonObject buildProperties(PlanNode node) {
+ JsonObject properties = new JsonObject();
+
+ if (node instanceof OutputNode) {
+ OutputNode n = (OutputNode) node;
+ properties.addProperty("OutputColumns", String.valueOf(n.getOutputColumnNames()));
+ properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+ } else if (node instanceof ExplainAnalyzeNode) {
+ ExplainAnalyzeNode n = (ExplainAnalyzeNode) node;
+ properties.addProperty("ChildPermittedOutputs", String.valueOf(n.getChildPermittedOutputs()));
+ } else if (node instanceof TableScanNode) {
+ buildTableScanProperties(properties, (TableScanNode) node);
+ } else if (node instanceof ExchangeNode) {
+ // No extra properties needed
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode) {
+ buildAggregationProperties(
+ properties,
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode) node);
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode) node;
+ properties.addProperty("Predicate", String.valueOf(n.getPredicate()));
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.ProjectNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.ProjectNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.ProjectNode) node;
+ properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+ properties.addProperty("Expressions", String.valueOf(n.getAssignments().getMap().values()));
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode) node;
+ properties.addProperty("Count", String.valueOf(n.getCount()));
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.OffsetNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.OffsetNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.OffsetNode) node;
+ properties.addProperty("Count", String.valueOf(n.getCount()));
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.SortNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.SortNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.SortNode) node;
+ properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.MergeSortNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.MergeSortNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.MergeSortNode) node;
+ properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.JoinNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.JoinNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.JoinNode) node;
+ properties.addProperty("JoinType", String.valueOf(n.getJoinType()));
+ properties.addProperty("Criteria", String.valueOf(n.getCriteria()));
+ properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+ } else if (node
+ instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.UnionNode) {
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.UnionNode n =
+ (org.apache.iotdb.db.queryengine.plan.relational.planner.node.UnionNode) node;
+ properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+ }
+
+ return properties;
+ }
+
+ private static void buildTableScanProperties(JsonObject properties, TableScanNode node) {
+ properties.addProperty("QualifiedTableName", node.getQualifiedObjectName().toString());
+ properties.addProperty("OutputSymbols", String.valueOf(node.getOutputSymbols()));
+
+ if (node instanceof DeviceTableScanNode) {
+ DeviceTableScanNode deviceNode = (DeviceTableScanNode) node;
+ properties.addProperty("DeviceNumber", String.valueOf(deviceNode.getDeviceEntries().size()));
+ properties.addProperty("ScanOrder", String.valueOf(deviceNode.getScanOrder()));
+ if (deviceNode.getTimePredicate().isPresent()) {
+ properties.addProperty(
+ "TimePredicate", String.valueOf(deviceNode.getTimePredicate().get()));
+ }
+ }
+
+ if (node.getPushDownPredicate() != null) {
+ properties.addProperty("PushDownPredicate", String.valueOf(node.getPushDownPredicate()));
+ }
+ properties.addProperty("PushDownOffset", String.valueOf(node.getPushDownOffset()));
+ properties.addProperty("PushDownLimit", String.valueOf(node.getPushDownLimit()));
+
+ if (node instanceof DeviceTableScanNode) {
+ properties.addProperty(
+ "PushDownLimitToEachDevice",
+ String.valueOf(((DeviceTableScanNode) node).isPushLimitToEachDevice()));
+ }
+
+ properties.addProperty(
+ "RegionId",
+ node.getRegionReplicaSet() == null || node.getRegionReplicaSet().getRegionId() == null
+ ? REGION_NOT_ASSIGNED
+ : String.valueOf(node.getRegionReplicaSet().getRegionId().getId()));
+
+ if (node instanceof TreeDeviceViewScanNode) {
+ TreeDeviceViewScanNode treeNode = (TreeDeviceViewScanNode) node;
+ properties.addProperty("TreeDB", treeNode.getTreeDBName());
+ properties.addProperty(
+ "MeasurementToColumnName", String.valueOf(treeNode.getMeasurementColumnNameMap()));
+ }
+ }
+
+ private static void buildAggregationProperties(
+ JsonObject properties,
+ org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode node) {
+ properties.addProperty("OutputSymbols", String.valueOf(node.getOutputSymbols()));
+
+ JsonArray aggregators = new JsonArray();
+ int i = 0;
+ for (org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode.Aggregation
+ aggregation : node.getAggregations().values()) {
+ JsonObject agg = new JsonObject();
+ agg.addProperty("index", i++);
+ agg.addProperty("function", aggregation.getResolvedFunction().toString());
+ if (aggregation.hasMask()) {
+ agg.addProperty("mask", String.valueOf(aggregation.getMask().get()));
+ }
+ if (aggregation.isDistinct()) {
+ agg.addProperty("distinct", true);
+ }
+ aggregators.add(agg);
+ }
+ properties.add("Aggregators", aggregators);
+
+ properties.addProperty("GroupingKeys", String.valueOf(node.getGroupingKeys()));
+ if (node.isStreamable()) {
+ properties.addProperty("Streamable", true);
+ properties.addProperty("PreGroupedSymbols", String.valueOf(node.getPreGroupedSymbols()));
+ }
+ properties.addProperty("Step", String.valueOf(node.getStep()));
+ }
+
+ public static List<String> getJsonLines(PlanNode node) {
+ List<String> lines = new ArrayList<>();
+ lines.add(toPrettyJson(node));
+ return lines;
+ }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
index cff29c0d83a..eb4e1241d14 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
@@ -847,6 +847,7 @@ public class StatementAnalyzer {
@Override
protected Scope visitExplain(Explain node, Optional<Scope> context) {
queryContext.setExplainType(ExplainType.EXPLAIN);
+ queryContext.setExplainOutputFormat(node.getOutputFormat());
analysis.setFinishQueryAfterAnalyze();
return visitQuery((Query) node.getStatement(), context);
}
@@ -863,6 +864,7 @@ public class StatementAnalyzer {
protected Scope visitExplainAnalyze(ExplainAnalyze node, Optional<Scope> context) {
queryContext.setExplainType(ExplainType.EXPLAIN_ANALYZE);
queryContext.setVerbose(node.isVerbose());
+ queryContext.setExplainOutputFormat(node.getOutputFormat());
return visitQuery((Query) node.getStatement(), context);
}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java
index c33cb05250b..538890b1568 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java
@@ -625,7 +625,8 @@ public class TableLogicalPlanner {
queryContext.getTimeOut(),
symbol,
// recording permittedOutputs of ExplainAnalyzeNode's child
- getChildPermittedOutputs(analysis, statement.getStatement(), originalQueryPlan));
+ getChildPermittedOutputs(analysis, statement.getStatement(), originalQueryPlan),
+ statement.getOutputFormat());
return new RelationPlan(
newRoot,
originalQueryPlan.getScope(),
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java
index 23aa8baad2d..d50d07521ec 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java
@@ -24,6 +24,7 @@ import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNodeId;
import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanVisitor;
import org.apache.iotdb.db.queryengine.plan.planner.plan.node.process.SingleChildProcessNode;
import org.apache.iotdb.db.queryengine.plan.relational.planner.Symbol;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -38,6 +39,7 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
private final long timeout;
private final Symbol outputSymbol;
private final List<Symbol> childPermittedOutputs;
+ private final ExplainOutputFormat outputFormat;
public ExplainAnalyzeNode(
PlanNodeId id,
@@ -47,18 +49,46 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
long timeout,
Symbol outputSymbol,
List<Symbol> childPermittedOutputs) {
+ this(
+ id,
+ child,
+ verbose,
+ queryId,
+ timeout,
+ outputSymbol,
+ childPermittedOutputs,
+ ExplainOutputFormat.TEXT);
+ }
+
+ public ExplainAnalyzeNode(
+ PlanNodeId id,
+ PlanNode child,
+ boolean verbose,
+ long queryId,
+ long timeout,
+ Symbol outputSymbol,
+ List<Symbol> childPermittedOutputs,
+ ExplainOutputFormat outputFormat) {
super(id, child);
this.verbose = verbose;
this.timeout = timeout;
this.queryId = queryId;
this.outputSymbol = outputSymbol;
this.childPermittedOutputs = childPermittedOutputs;
+ this.outputFormat = outputFormat;
}
@Override
public PlanNode clone() {
return new ExplainAnalyzeNode(
- getPlanNodeId(), child, verbose, queryId, timeout, outputSymbol, childPermittedOutputs);
+ getPlanNodeId(),
+ child,
+ verbose,
+ queryId,
+ timeout,
+ outputSymbol,
+ childPermittedOutputs,
+ outputFormat);
}
@Override
@@ -89,7 +119,8 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
queryId,
timeout,
outputSymbol,
- childPermittedOutputs);
+ childPermittedOutputs,
+ outputFormat);
}
// ExplainAnalyze should be at the same region as Coordinator all the time. Therefore, there will
@@ -116,6 +147,10 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
return timeout;
}
+ public ExplainOutputFormat getOutputFormat() {
+ return outputFormat;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java
index f3a50175269..6e51e9bae60 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java
@@ -33,21 +33,34 @@ public class Explain extends Statement {
private static final long INSTANCE_SIZE = RamUsageEstimator.shallowSizeOfInstance(Explain.class);
private final Statement statement;
+ private final ExplainOutputFormat outputFormat;
public Explain(Statement statement) {
super(null);
this.statement = requireNonNull(statement, "statement is null");
+ this.outputFormat = ExplainOutputFormat.GRAPHVIZ;
}
public Explain(NodeLocation location, Statement statement) {
super(requireNonNull(location, "location is null"));
this.statement = requireNonNull(statement, "statement is null");
+ this.outputFormat = ExplainOutputFormat.GRAPHVIZ;
+ }
+
+ public Explain(NodeLocation location, Statement statement, ExplainOutputFormat outputFormat) {
+ super(requireNonNull(location, "location is null"));
+ this.statement = requireNonNull(statement, "statement is null");
+ this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
}
public Statement getStatement() {
return statement;
}
+ public ExplainOutputFormat getOutputFormat() {
+ return outputFormat;
+ }
+
@Override
public <R, C> R accept(AstVisitor<R, C> visitor, C context) {
return visitor.visitExplain(this, context);
@@ -60,7 +73,7 @@ public class Explain extends Statement {
@Override
public int hashCode() {
- return Objects.hash(statement);
+ return Objects.hash(statement, outputFormat);
}
@Override
@@ -72,12 +85,15 @@ public class Explain extends Statement {
return false;
}
Explain o = (Explain) obj;
- return Objects.equals(statement, o.statement);
+ return Objects.equals(statement, o.statement) && outputFormat == o.outputFormat;
}
@Override
public String toString() {
- return toStringHelper(this).add("statement", statement).toString();
+ return toStringHelper(this)
+ .add("statement", statement)
+ .add("outputFormat", outputFormat)
+ .toString();
}
@Override
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java
index cd3df879fc5..dc7798967e4 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java
@@ -35,17 +35,31 @@ public class ExplainAnalyze extends Statement {
private final Statement statement;
private final boolean verbose;
+ private final ExplainOutputFormat outputFormat;
public ExplainAnalyze(Statement statement, boolean verbose) {
super(null);
this.statement = requireNonNull(statement, "statement is null");
this.verbose = verbose;
+ this.outputFormat = ExplainOutputFormat.TEXT;
}
public ExplainAnalyze(NodeLocation location, boolean verbose, Statement statement) {
super(requireNonNull(location, "location is null"));
this.statement = requireNonNull(statement, "statement is null");
this.verbose = verbose;
+ this.outputFormat = ExplainOutputFormat.TEXT;
+ }
+
+ public ExplainAnalyze(
+ NodeLocation location,
+ boolean verbose,
+ Statement statement,
+ ExplainOutputFormat outputFormat) {
+ super(requireNonNull(location, "location is null"));
+ this.statement = requireNonNull(statement, "statement is null");
+ this.verbose = verbose;
+ this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
}
public Statement getStatement() {
@@ -56,6 +70,10 @@ public class ExplainAnalyze extends Statement {
return verbose;
}
+ public ExplainOutputFormat getOutputFormat() {
+ return outputFormat;
+ }
+
@Override
public <R, C> R accept(AstVisitor<R, C> visitor, C context) {
return visitor.visitExplainAnalyze(this, context);
@@ -85,7 +103,11 @@ public class ExplainAnalyze extends Statement {
@Override
public String toString() {
- return toStringHelper(this).add("statement", statement).add("verbose", verbose).toString();
+ return toStringHelper(this)
+ .add("statement", statement)
+ .add("verbose", verbose)
+ .add("outputFormat", outputFormat)
+ .toString();
}
@Override
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java
new file mode 100644
index 00000000000..9dba3649a7f
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java
@@ -0,0 +1,26 @@
+/*
+ * 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.iotdb.db.queryengine.plan.relational.sql.ast;
+
+public enum ExplainOutputFormat {
+ GRAPHVIZ,
+ TEXT,
+ JSON
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
index a31b48c24b4..c825d6b92cb 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
@@ -98,6 +98,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExecuteImmediate;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExistsPredicate;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Explain;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainAnalyze;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Expression;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExtendRegion;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Extract;
@@ -1804,7 +1805,22 @@ public class AstBuilder extends RelationalSqlBaseVisitor<Node> {
} else {
innerStatement = (Statement) visit(ctx.executeImmediateStatement());
}
- return new Explain(getLocation(ctx), innerStatement);
+ ExplainOutputFormat format = ExplainOutputFormat.GRAPHVIZ;
+ if (ctx.identifier() != null) {
+ String formatStr = ((Identifier) visit(ctx.identifier())).getValue().toUpperCase();
+ switch (formatStr) {
+ case "GRAPHVIZ":
+ format = ExplainOutputFormat.GRAPHVIZ;
+ break;
+ case "JSON":
+ format = ExplainOutputFormat.JSON;
+ break;
+ default:
+ throw new SemanticException(
+ "Invalid EXPLAIN format: " + formatStr + ". Supported formats: GRAPHVIZ, JSON");
+ }
+ }
+ return new Explain(getLocation(ctx), innerStatement, format);
}
@Override
@@ -1817,7 +1833,22 @@ public class AstBuilder extends RelationalSqlBaseVisitor<Node> {
} else {
innerStatement = (Statement) visit(ctx.executeImmediateStatement());
}
- return new ExplainAnalyze(getLocation(ctx), ctx.VERBOSE() != null, innerStatement);
+ ExplainOutputFormat format = ExplainOutputFormat.TEXT;
+ if (ctx.identifier() != null) {
+ String formatStr = ((Identifier) visit(ctx.identifier())).getValue().toUpperCase();
+ switch (formatStr) {
+ case "TEXT":
+ format = ExplainOutputFormat.TEXT;
+ break;
+ case "JSON":
+ format = ExplainOutputFormat.JSON;
+ break;
+ default:
+ throw new SemanticException(
+ "Invalid EXPLAIN ANALYZE format: " + formatStr + ". Supported formats: TEXT, JSON");
+ }
+ }
+ return new ExplainAnalyze(getLocation(ctx), ctx.VERBOSE() != null, innerStatement, format);
}
// ********************** author expressions ********************
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java
new file mode 100644
index 00000000000..3c6ff7bfb41
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java
@@ -0,0 +1,371 @@
+/*
+ * 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.iotdb.db.queryengine.statistics;
+
+import org.apache.iotdb.db.queryengine.common.FragmentInstanceId;
+import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNode;
+import org.apache.iotdb.mpp.rpc.thrift.TFetchFragmentInstanceStatisticsResp;
+import org.apache.iotdb.mpp.rpc.thrift.TOperatorStatistics;
+import org.apache.iotdb.mpp.rpc.thrift.TQueryStatistics;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Produces JSON output for EXPLAIN ANALYZE results, mirroring the same data as {@link
+ * FragmentInstanceStatisticsDrawer} but in JSON format.
+ */
+public class FragmentInstanceStatisticsJsonDrawer {
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private static final double NS_TO_MS_FACTOR = 1.0 / 1000000;
+ private static final double EPSILON = 1e-10;
+
+ private final JsonObject planStatistics = new JsonObject();
+
+ public void renderPlanStatistics(MPPQueryContext context) {
+ planStatistics.addProperty(
+ "analyzeCostMs", formatMs(context.getAnalyzeCost() * NS_TO_MS_FACTOR));
+ planStatistics.addProperty(
+ "fetchPartitionCostMs", formatMs(context.getFetchPartitionCost() * NS_TO_MS_FACTOR));
+ planStatistics.addProperty(
+ "fetchSchemaCostMs", formatMs(context.getFetchSchemaCost() * NS_TO_MS_FACTOR));
+ planStatistics.addProperty(
+ "logicalPlanCostMs", formatMs(context.getLogicalPlanCost() * NS_TO_MS_FACTOR));
+ planStatistics.addProperty(
+ "logicalOptimizationCostMs",
+ formatMs(context.getLogicalOptimizationCost() * NS_TO_MS_FACTOR));
+ planStatistics.addProperty(
+ "distributionPlanCostMs", formatMs(context.getDistributionPlanCost() * NS_TO_MS_FACTOR));
+ }
+
+ public void renderDispatchCost(MPPQueryContext context) {
+ planStatistics.addProperty(
+ "dispatchCostMs", formatMs(context.getDispatchCost() * NS_TO_MS_FACTOR));
+ }
+
+ public String renderFragmentInstancesAsJson(
+ List<FragmentInstance> instancesToBeRendered,
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStatistics,
+ boolean verbose) {
+
+ JsonObject root = new JsonObject();
+ root.add("planStatistics", planStatistics);
+
+ List<FragmentInstance> validInstances =
+ instancesToBeRendered.stream()
+ .filter(
+ instance -> {
+ TFetchFragmentInstanceStatisticsResp statistics =
+ allStatistics.get(instance.getId());
+ return statistics != null && statistics.getDataRegion() != null;
+ })
+ .collect(Collectors.toList());
+
+ root.addProperty("fragmentInstancesCount", validInstances.size());
+
+ JsonArray fragmentInstancesArray = new JsonArray();
+ for (FragmentInstance instance : validInstances) {
+ TFetchFragmentInstanceStatisticsResp statistics = allStatistics.get(instance.getId());
+ JsonObject fiJson = new JsonObject();
+
+ fiJson.addProperty("id", instance.getId().toString());
+ fiJson.addProperty("ip", statistics.getIp());
+ fiJson.addProperty("dataRegion", statistics.getDataRegion());
+ fiJson.addProperty("state", statistics.getState());
+ fiJson.addProperty(
+ "totalWallTimeMs", statistics.getEndTimeInMS() - statistics.getStartTimeInMS());
+ fiJson.addProperty(
+ "initDataQuerySourceCostMs",
+ formatMs(statistics.getInitDataQuerySourceCost() * NS_TO_MS_FACTOR));
+
+ if (statistics.isSetInitDataQuerySourceRetryCount()
+ && statistics.getInitDataQuerySourceRetryCount() > 0) {
+ fiJson.addProperty(
+ "initDataQuerySourceRetryCount", statistics.getInitDataQuerySourceRetryCount());
+ }
+
+ fiJson.addProperty("seqFileUnclosed", statistics.getSeqUnclosedNum());
+ fiJson.addProperty("seqFileClosed", statistics.getSeqClosednNum());
+ fiJson.addProperty("unseqFileUnclosed", statistics.getUnseqUnclosedNum());
+ fiJson.addProperty("unseqFileClosed", statistics.getUnseqClosedNum());
+ fiJson.addProperty(
+ "readyQueuedTimeMs", formatMs(statistics.getReadyQueuedTime() * NS_TO_MS_FACTOR));
+ fiJson.addProperty(
+ "blockQueuedTimeMs", formatMs(statistics.getBlockQueuedTime() * NS_TO_MS_FACTOR));
+
+ // Query statistics
+ JsonObject queryStats = renderQueryStatisticsJson(statistics.getQueryStatistics(), verbose);
+ fiJson.add("queryStatistics", queryStats);
+
+ // Operators
+ PlanNode planNodeTree = instance.getFragment().getPlanNodeTree();
+ JsonObject operatorTree =
+ renderOperatorJson(planNodeTree, statistics.getOperatorStatisticsMap());
+ if (operatorTree != null) {
+ fiJson.add("operators", operatorTree);
+ }
+
+ fragmentInstancesArray.add(fiJson);
+ }
+
+ root.add("fragmentInstances", fragmentInstancesArray);
+ return GSON.toJson(root);
+ }
+
+ private JsonObject renderQueryStatisticsJson(TQueryStatistics qs, boolean verbose) {
+ JsonObject stats = new JsonObject();
+
+ if (verbose) {
+ stats.addProperty("loadBloomFilterFromCacheCount", qs.loadBloomFilterFromCacheCount);
+ stats.addProperty("loadBloomFilterFromDiskCount", qs.loadBloomFilterFromDiskCount);
+ stats.addProperty("loadBloomFilterActualIOSize", qs.loadBloomFilterActualIOSize);
+ stats.addProperty(
+ "loadBloomFilterTimeMs", formatMs(qs.loadBloomFilterTime * NS_TO_MS_FACTOR));
+
+ addIfNonZero(
+ stats, "loadTimeSeriesMetadataDiskSeqCount", qs.loadTimeSeriesMetadataDiskSeqCount);
+ addIfNonZero(
+ stats, "loadTimeSeriesMetadataDiskUnSeqCount", qs.loadTimeSeriesMetadataDiskUnSeqCount);
+ addIfNonZero(
+ stats, "loadTimeSeriesMetadataMemSeqCount", qs.loadTimeSeriesMetadataMemSeqCount);
+ addIfNonZero(
+ stats, "loadTimeSeriesMetadataMemUnSeqCount", qs.loadTimeSeriesMetadataMemUnSeqCount);
+ addIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedDiskSeqCount",
+ qs.loadTimeSeriesMetadataAlignedDiskSeqCount);
+ addIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedDiskUnSeqCount",
+ qs.loadTimeSeriesMetadataAlignedDiskUnSeqCount);
+ addIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedMemSeqCount",
+ qs.loadTimeSeriesMetadataAlignedMemSeqCount);
+ addIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedMemUnSeqCount",
+ qs.loadTimeSeriesMetadataAlignedMemUnSeqCount);
+
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataDiskSeqTimeMs",
+ qs.loadTimeSeriesMetadataDiskSeqTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataDiskUnSeqTimeMs",
+ qs.loadTimeSeriesMetadataDiskUnSeqTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataMemSeqTimeMs",
+ qs.loadTimeSeriesMetadataMemSeqTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataMemUnSeqTimeMs",
+ qs.loadTimeSeriesMetadataMemUnSeqTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedDiskSeqTimeMs",
+ qs.loadTimeSeriesMetadataAlignedDiskSeqTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedDiskUnSeqTimeMs",
+ qs.loadTimeSeriesMetadataAlignedDiskUnSeqTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedMemSeqTimeMs",
+ qs.loadTimeSeriesMetadataAlignedMemSeqTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "loadTimeSeriesMetadataAlignedMemUnSeqTimeMs",
+ qs.loadTimeSeriesMetadataAlignedMemUnSeqTime * NS_TO_MS_FACTOR);
+
+ stats.addProperty(
+ "loadTimeSeriesMetadataFromCacheCount", qs.loadTimeSeriesMetadataFromCacheCount);
+ stats.addProperty(
+ "loadTimeSeriesMetadataFromDiskCount", qs.loadTimeSeriesMetadataFromDiskCount);
+ stats.addProperty(
+ "loadTimeSeriesMetadataActualIOSize", qs.loadTimeSeriesMetadataActualIOSize);
+
+ addIfNonZero(
+ stats,
+ "alignedTimeSeriesMetadataModificationCount",
+ qs.getAlignedTimeSeriesMetadataModificationCount());
+ addMsIfNonZero(
+ stats,
+ "alignedTimeSeriesMetadataModificationTimeMs",
+ qs.getAlignedTimeSeriesMetadataModificationTime() * NS_TO_MS_FACTOR);
+ addIfNonZero(
+ stats,
+ "nonAlignedTimeSeriesMetadataModificationCount",
+ qs.getNonAlignedTimeSeriesMetadataModificationCount());
+ addMsIfNonZero(
+ stats,
+ "nonAlignedTimeSeriesMetadataModificationTimeMs",
+ qs.getNonAlignedTimeSeriesMetadataModificationTime() * NS_TO_MS_FACTOR);
+
+ addIfNonZero(
+ stats,
+ "constructNonAlignedChunkReadersDiskCount",
+ qs.constructNonAlignedChunkReadersDiskCount);
+ addIfNonZero(
+ stats,
+ "constructNonAlignedChunkReadersMemCount",
+ qs.constructNonAlignedChunkReadersMemCount);
+ addIfNonZero(
+ stats, "constructAlignedChunkReadersDiskCount", qs.constructAlignedChunkReadersDiskCount);
+ addIfNonZero(
+ stats, "constructAlignedChunkReadersMemCount", qs.constructAlignedChunkReadersMemCount);
+ addMsIfNonZero(
+ stats,
+ "constructNonAlignedChunkReadersDiskTimeMs",
+ qs.constructNonAlignedChunkReadersDiskTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "constructNonAlignedChunkReadersMemTimeMs",
+ qs.constructNonAlignedChunkReadersMemTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "constructAlignedChunkReadersDiskTimeMs",
+ qs.constructAlignedChunkReadersDiskTime * NS_TO_MS_FACTOR);
+ addMsIfNonZero(
+ stats,
+ "constructAlignedChunkReadersMemTimeMs",
+ qs.constructAlignedChunkReadersMemTime * NS_TO_MS_FACTOR);
+
+ stats.addProperty("loadChunkFromCacheCount", qs.loadChunkFromCacheCount);
+ stats.addProperty("loadChunkFromDiskCount", qs.loadChunkFromDiskCount);
+ stats.addProperty("loadChunkActualIOSize", qs.loadChunkActualIOSize);
+
+ addIfNonZero(
+ stats, "pageReadersDecodeAlignedDiskCount", qs.pageReadersDecodeAlignedDiskCount);
+ addMsIfNonZero(
+ stats,
+ "pageReadersDecodeAlignedDiskTimeMs",
+ qs.pageReadersDecodeAlignedDiskTime * NS_TO_MS_FACTOR);
+ addIfNonZero(stats, "pageReadersDecodeAlignedMemCount", qs.pageReadersDecodeAlignedMemCount);
+ addMsIfNonZero(
+ stats,
+ "pageReadersDecodeAlignedMemTimeMs",
+ qs.pageReadersDecodeAlignedMemTime * NS_TO_MS_FACTOR);
+ addIfNonZero(
+ stats, "pageReadersDecodeNonAlignedDiskCount", qs.pageReadersDecodeNonAlignedDiskCount);
+ addMsIfNonZero(
+ stats,
+ "pageReadersDecodeNonAlignedDiskTimeMs",
+ qs.pageReadersDecodeNonAlignedDiskTime * NS_TO_MS_FACTOR);
+ addIfNonZero(
+ stats, "pageReadersDecodeNonAlignedMemCount", qs.pageReadersDecodeNonAlignedMemCount);
+ addMsIfNonZero(
+ stats,
+ "pageReadersDecodeNonAlignedMemTimeMs",
+ qs.pageReadersDecodeNonAlignedMemTime * NS_TO_MS_FACTOR);
+ addIfNonZero(stats, "pageReaderMaxUsedMemorySize", qs.pageReaderMaxUsedMemorySize);
+ addIfNonZero(stats, "chunkWithMetadataErrorsCount", qs.chunkWithMetadataErrorsCount);
+ }
+
+ stats.addProperty("timeSeriesIndexFilteredRows", qs.timeSeriesIndexFilteredRows);
+ stats.addProperty("chunkIndexFilteredRows", qs.chunkIndexFilteredRows);
+ stats.addProperty("pageIndexFilteredRows", qs.pageIndexFilteredRows);
+
+ if (verbose) {
+ stats.addProperty("rowScanFilteredRows", qs.rowScanFilteredRows);
+ }
+
+ return stats;
+ }
+
+ private JsonObject renderOperatorJson(
+ PlanNode planNodeTree, Map<String, TOperatorStatistics> operatorStatistics) {
+ if (planNodeTree == null) {
+ return null;
+ }
+
+ JsonObject operatorJson = new JsonObject();
+ TOperatorStatistics opStats = operatorStatistics.get(planNodeTree.getPlanNodeId().toString());
+
+ operatorJson.addProperty("planNodeId", planNodeTree.getPlanNodeId().toString());
+ operatorJson.addProperty("nodeType", planNodeTree.getClass().getSimpleName());
+
+ if (opStats != null) {
+ operatorJson.addProperty("operatorType", opStats.getOperatorType());
+ if (opStats.isSetCount()) {
+ operatorJson.addProperty("count", opStats.getCount());
+ }
+ operatorJson.addProperty(
+ "cpuTimeMs", formatMs(opStats.getTotalExecutionTimeInNanos() * NS_TO_MS_FACTOR));
+ operatorJson.addProperty("outputRows", opStats.getOutputRows());
+ operatorJson.addProperty("hasNextCalledCount", opStats.hasNextCalledCount);
+ operatorJson.addProperty("nextCalledCount", opStats.nextCalledCount);
+ if (opStats.getMemoryUsage() != 0) {
+ operatorJson.addProperty("estimatedMemorySize", opStats.getMemoryUsage());
+ }
+
+ if (opStats.getSpecifiedInfoSize() != 0) {
+ JsonObject specifiedInfo = new JsonObject();
+ for (Map.Entry<String, String> entry : opStats.getSpecifiedInfo().entrySet()) {
+ specifiedInfo.addProperty(entry.getKey(), entry.getValue());
+ }
+ operatorJson.add("specifiedInfo", specifiedInfo);
+ }
+ }
+
+ List<PlanNode> children = planNodeTree.getChildren();
+ if (children != null && !children.isEmpty()) {
+ JsonArray childrenArray = new JsonArray();
+ for (PlanNode child : children) {
+ JsonObject childJson = renderOperatorJson(child, operatorStatistics);
+ if (childJson != null) {
+ childrenArray.add(childJson);
+ }
+ }
+ if (childrenArray.size() > 0) {
+ operatorJson.add("children", childrenArray);
+ }
+ }
+
+ return operatorJson;
+ }
+
+ private static double formatMs(double ms) {
+ return Math.round(ms * 1000.0) / 1000.0;
+ }
+
+ private static void addIfNonZero(JsonObject obj, String key, long value) {
+ if (value != 0) {
+ obj.addProperty(key, value);
+ }
+ }
+
+ private static void addMsIfNonZero(JsonObject obj, String key, double value) {
+ if (Math.abs(value) > EPSILON) {
+ obj.addProperty(key, formatMs(value));
+ }
+ }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java
new file mode 100644
index 00000000000..a600957b25f
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.iotdb.db.queryengine.plan.planner.node;
+
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphJsonPrinter;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.Symbol;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ComparisonExpression;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.GenericLiteral;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.SymbolReference;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class PlanGraphJsonPrinterTest {
+
+ @Test
+ public void testSimplePlanToJson() {
+ // Build a simple plan: Output -> Limit -> Filter
+ LimitNode placeholder = new LimitNode(new PlanNodeId("placeholder"), null, 0, Optional.empty());
+ FilterNode filterNode =
+ new FilterNode(
+ new PlanNodeId("3"),
+ placeholder,
+ new ComparisonExpression(
+ ComparisonExpression.Operator.EQUAL,
+ new SymbolReference("s1"),
+ new GenericLiteral("INT32", "1")));
+
+ LimitNode limitNode = new LimitNode(new PlanNodeId("2"), filterNode, 10, Optional.empty());
+
+ List<Symbol> outputSymbols = Arrays.asList(new Symbol("s1"), new Symbol("s2"));
+ OutputNode outputNode =
+ new OutputNode(new PlanNodeId("1"), limitNode, Arrays.asList("s1", "s2"), outputSymbols);
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(outputNode);
+
+ assertNotNull(json);
+ assertTrue(json.length() > 0);
+
+ // Parse the JSON and verify structure
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+ assertEquals("OutputNode-1", root.get("name").getAsString());
+ assertEquals("1", root.get("id").getAsString());
+ assertTrue(root.has("properties"));
+ assertTrue(root.has("children"));
+
+ // Verify child (LimitNode)
+ JsonObject limitJson = root.getAsJsonArray("children").get(0).getAsJsonObject();
+ assertEquals("LimitNode-2", limitJson.get("name").getAsString());
+
+ // Verify grandchild (FilterNode)
+ JsonObject filterJson = limitJson.getAsJsonArray("children").get(0).getAsJsonObject();
+ assertEquals("FilterNode-3", filterJson.get("name").getAsString());
+ }
+
+ @Test
+ public void testGetJsonLinesReturnsSingleElement() {
+ LimitNode limitNode =
+ new LimitNode(
+ new PlanNodeId("1"),
+ new LimitNode(new PlanNodeId("placeholder"), null, 0, Optional.empty()),
+ 5,
+ Optional.empty());
+
+ List<String> lines = PlanGraphJsonPrinter.getJsonLines(limitNode);
+ assertEquals(1, lines.size());
+
+ // The single element should be valid JSON
+ JsonObject root = JsonParser.parseString(lines.get(0)).getAsJsonObject();
+ assertNotNull(root.get("name"));
+ }
+
+ @Test
+ public void testJsonIsValidFormat() {
+ LimitNode limitNode =
+ new LimitNode(
+ new PlanNodeId("1"),
+ new LimitNode(new PlanNodeId("placeholder"), null, 0, Optional.empty()),
+ 42,
+ Optional.empty());
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(limitNode);
+
+ // Should parse without errors
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+ assertEquals("LimitNode-1", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ assertEquals("42", root.getAsJsonObject("properties").get("Count").getAsString());
+ }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java
new file mode 100644
index 00000000000..56e204b38df
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.iotdb.db.queryengine.plan.relational.sql;
+
+import org.apache.iotdb.db.protocol.session.IClientSession;
+import org.apache.iotdb.db.protocol.session.InternalClientSession;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Explain;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainAnalyze;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Statement;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.parser.SqlParser;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.ZoneId;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class ExplainFormatTest {
+
+ private SqlParser sqlParser;
+ private IClientSession clientSession;
+
+ @Before
+ public void setUp() {
+ sqlParser = new SqlParser();
+ clientSession = new InternalClientSession("testClient");
+ clientSession.setDatabaseName("testdb");
+ }
+
+ private Statement parseSQL(String sql) {
+ return sqlParser.createStatement(sql, ZoneId.systemDefault(), clientSession);
+ }
+
+ @Test
+ public void testExplainDefaultFormat() {
+ Statement stmt = parseSQL("EXPLAIN SELECT * FROM table1");
+ assertTrue(stmt instanceof Explain);
+ assertEquals(ExplainOutputFormat.GRAPHVIZ, ((Explain) stmt).getOutputFormat());
+ }
+
+ @Test
+ public void testExplainGraphvizFormat() {
+ Statement stmt = parseSQL("EXPLAIN (FORMAT GRAPHVIZ) SELECT * FROM table1");
+ assertTrue(stmt instanceof Explain);
+ assertEquals(ExplainOutputFormat.GRAPHVIZ, ((Explain) stmt).getOutputFormat());
+ }
+
+ @Test
+ public void testExplainJsonFormat() {
+ Statement stmt = parseSQL("EXPLAIN (FORMAT JSON) SELECT * FROM table1");
+ assertTrue(stmt instanceof Explain);
+ assertEquals(ExplainOutputFormat.JSON, ((Explain) stmt).getOutputFormat());
+ }
+
+ @Test
+ public void testExplainJsonFormatCaseInsensitive() {
+ Statement stmt = parseSQL("EXPLAIN (FORMAT json) SELECT * FROM table1");
+ assertTrue(stmt instanceof Explain);
+ assertEquals(ExplainOutputFormat.JSON, ((Explain) stmt).getOutputFormat());
+ }
+
+ @Test(expected = Exception.class)
+ public void testExplainInvalidFormat() {
+ parseSQL("EXPLAIN (FORMAT XML) SELECT * FROM table1");
+ }
+
+ @Test
+ public void testExplainAnalyzeDefaultFormat() {
+ Statement stmt = parseSQL("EXPLAIN ANALYZE SELECT * FROM table1");
+ assertTrue(stmt instanceof ExplainAnalyze);
+ assertEquals(ExplainOutputFormat.TEXT, ((ExplainAnalyze) stmt).getOutputFormat());
+ }
+
+ @Test
+ public void testExplainAnalyzeTextFormat() {
+ Statement stmt = parseSQL("EXPLAIN ANALYZE (FORMAT TEXT) SELECT * FROM table1");
+ assertTrue(stmt instanceof ExplainAnalyze);
+ assertEquals(ExplainOutputFormat.TEXT, ((ExplainAnalyze) stmt).getOutputFormat());
+ }
+
+ @Test
+ public void testExplainAnalyzeJsonFormat() {
+ Statement stmt = parseSQL("EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM table1");
+ assertTrue(stmt instanceof ExplainAnalyze);
+ assertEquals(ExplainOutputFormat.JSON, ((ExplainAnalyze) stmt).getOutputFormat());
+ }
+
+ @Test
+ public void testExplainAnalyzeVerboseJsonFormat() {
+ Statement stmt = parseSQL("EXPLAIN ANALYZE VERBOSE (FORMAT JSON) SELECT * FROM table1");
+ assertTrue(stmt instanceof ExplainAnalyze);
+ ExplainAnalyze ea = (ExplainAnalyze) stmt;
+ assertEquals(ExplainOutputFormat.JSON, ea.getOutputFormat());
+ assertTrue(ea.isVerbose());
+ }
+
+ @Test(expected = Exception.class)
+ public void testExplainAnalyzeInvalidFormat() {
+ parseSQL("EXPLAIN ANALYZE (FORMAT GRAPHVIZ) SELECT * FROM table1");
+ }
+
+ @Test(expected = Exception.class)
+ public void testExplainTextFormatInvalid() {
+ // TEXT is not valid for EXPLAIN (only GRAPHVIZ and JSON)
+ parseSQL("EXPLAIN (FORMAT TEXT) SELECT * FROM table1");
+ }
+}
diff --git a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4 b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
index 9b09d6ebad0..c7f1cf4b9bc 100644
--- a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
+++ b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
@@ -932,8 +932,8 @@ copyToStatementOption
// ------------------------------------------- Query Statement ---------------------------------------------------------
queryStatement
: query #statementDefault
- | EXPLAIN (query | executeStatement | executeImmediateStatement) #explain
- | EXPLAIN ANALYZE VERBOSE? (query | executeStatement | executeImmediateStatement) #explainAnalyze
+ | EXPLAIN ('(' FORMAT identifier ')')? (query | executeStatement | executeImmediateStatement) #explain
+ | EXPLAIN ANALYZE VERBOSE? ('(' FORMAT identifier ')')? (query | executeStatement | executeImmediateStatement) #explainAnalyze
;
query