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/06/11 08:06:56 UTC
(iotdb) branch master updated: Support EXPLAIN FORMAT JSON for Table Model (#17430)
This is an automated email from the ASF dual-hosted git repository.
JackieTien97 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb.git
The following commit(s) were added to refs/heads/master by this push:
new a440038f1e5 Support EXPLAIN FORMAT JSON for Table Model (#17430)
a440038f1e5 is described below
commit a440038f1e54cbc58d7592b17f3ccc44fa2d0c98
Author: Jackie Tien <ja...@gmail.com>
AuthorDate: Thu Jun 11 16:06:49 2026 +0800
Support EXPLAIN FORMAT JSON for Table Model (#17430)
---
.codex/hooks.json | 17 +
.gitignore | 2 +-
.../iotdb/cli/it/ExplainJsonCliOutputIT.java | 345 +++++++++
.../it/insertquery/IoTDBInsertQueryIT.java | 4 +-
.../it/query/recent/IoTExplainJsonFormatIT.java | 855 +++++++++++++++++++++
.../java/org/apache/iotdb/cli/AbstractCli.java | 46 +-
.../java/org/apache/iotdb/cli/AbstractCliTest.java | 83 +-
iotdb-core/datanode/pom.xml | 4 +
.../db/queryengine/common/MPPQueryContext.java | 10 +
.../execution/operator/ExplainAnalyzeOperator.java | 70 +-
.../iotdb/db/queryengine/plan/Coordinator.java | 17 +-
.../TableModelStatementMemorySourceVisitor.java | 54 +-
.../planner/DataNodeTableOperatorGenerator.java | 7 +-
.../planner/plan/node/PlanGraphJsonPrinter.java | 225 ++++++
.../relational/analyzer/StatementAnalyzer.java | 2 +
.../plan/relational/planner/CteMaterializer.java | 1 +
.../relational/planner/TableLogicalPlanner.java | 3 +-
.../distribute/TableDistributedPlanner.java | 16 +-
...ithUncorrelatedScalarSubqueryReconstructor.java | 2 +
.../planner/node/ExplainAnalyzeNode.java | 39 +-
.../optimizations/UnaliasSymbolReferences.java | 3 +-
.../plan/relational/sql/ast/Explain.java | 28 +-
.../plan/relational/sql/ast/ExplainAnalyze.java | 37 +-
.../relational/sql/ast/ExplainOutputFormat.java | 35 +
.../plan/relational/sql/parser/AstBuilder.java | 35 +-
.../FragmentInstanceStatisticsJsonDrawer.java | 372 +++++++++
.../operator/ExplainAnalyzeOperatorTest.java | 96 +++
...TableModelStatementMemorySourceVisitorTest.java | 88 +++
.../planner/node/PlanGraphJsonPrinterTest.java | 573 ++++++++++++++
.../relational/planner/CteMaterializerTest.java | 1 +
.../plan/relational/planner/CteSubqueryTest.java | 1 +
.../plan/relational/sql/ExplainFormatTest.java | 186 +++++
.../FragmentInstanceStatisticsJsonDrawerTest.java | 594 ++++++++++++++
.../db/relational/grammar/sql/RelationalSql.g4 | 4 +-
34 files changed, 3812 insertions(+), 43 deletions(-)
diff --git a/.codex/hooks.json b/.codex/hooks.json
new file mode 100644
index 00000000000..d00d98ae0b8
--- /dev/null
+++ b/.codex/hooks.json
@@ -0,0 +1,17 @@
+{
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Write|Edit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r f; echo \"$f\" | grep -q '\\.java$' && mvn spotless:apply -f \"$(git rev-parse --show-toplevel)/pom.xml\" -DspotlessFiles=\"$(echo \"$f\" | sed 's/[.[\\\\/^$|?*+(){}]/\\\\&/g')\" -q || true; }",
+ "timeout": 30,
+ "statusMessage": "Running spotless format..."
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index a66cf2b1aa9..76e774d9286 100644
--- a/.gitignore
+++ b/.gitignore
@@ -138,4 +138,4 @@ iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/gra
.claude/settings.local.json
.claude/todos/
.claude/worktrees/
-.claude/scheduled_tasks.json
\ No newline at end of file
+.claude/scheduled_tasks.json
diff --git a/integration-test/src/test/java/org/apache/iotdb/cli/it/ExplainJsonCliOutputIT.java b/integration-test/src/test/java/org/apache/iotdb/cli/it/ExplainJsonCliOutputIT.java
new file mode 100644
index 00000000000..5ea757e9904
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/cli/it/ExplainJsonCliOutputIT.java
@@ -0,0 +1,345 @@
+/*
+ * 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.cli.it;
+
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.TableLocalStandaloneIT;
+
+import com.google.gson.JsonParser;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests that EXPLAIN (FORMAT JSON) and EXPLAIN ANALYZE (FORMAT JSON) output raw JSON in CLI without
+ * table borders (no '|' or '+---' formatting), so users can directly copy the JSON for
+ * visualization.
+ */
+@RunWith(IoTDBTestRunner.class)
+@Category({TableLocalStandaloneIT.class})
+public class ExplainJsonCliOutputIT extends AbstractScriptIT {
+
+ private static String ip;
+ private static String port;
+ private static String sbinPath;
+ private static String libPath;
+ private static String homePath;
+
+ @BeforeClass
+ public static void setUp() throws Exception {
+ EnvFactory.getEnv().initClusterEnvironment();
+ ip = EnvFactory.getEnv().getIP();
+ port = EnvFactory.getEnv().getPort();
+ sbinPath = EnvFactory.getEnv().getSbinPath();
+ libPath = EnvFactory.getEnv().getLibPath();
+ homePath =
+ libPath.substring(0, libPath.lastIndexOf(File.separator + "lib" + File.separator + "*"));
+ }
+
+ @AfterClass
+ public static void tearDown() throws Exception {
+ EnvFactory.getEnv().cleanClusterEnvironment();
+ }
+
+ @Test
+ public void test() throws IOException {
+ String os = System.getProperty("os.name").toLowerCase();
+ if (os.startsWith("windows")) {
+ testOnWindows();
+ } else {
+ testOnUnix();
+ }
+ }
+
+ @Override
+ protected void testOnWindows() throws IOException {
+ // Setup test data
+ ProcessBuilder setupBuilder =
+ new ProcessBuilder(
+ "cmd.exe",
+ "/c",
+ sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-sql_dialect",
+ "table",
+ "-e",
+ "CREATE DATABASE IF NOT EXISTS test_cli_json;"
+ + " USE test_cli_json;"
+ + " CREATE TABLE IF NOT EXISTS t1(id STRING TAG, v FLOAT FIELD);"
+ + " INSERT INTO t1 VALUES(1000, 'd1', 1.0)",
+ "&",
+ "exit",
+ "%^errorlevel%");
+ setupBuilder.environment().put("IOTDB_HOME", homePath);
+ testOutput(setupBuilder, new String[] {"Msg: The statement is executed successfully."}, 0);
+
+ // Test EXPLAIN (FORMAT JSON) output has no table borders
+ // Use fully qualified table name (database.table) to avoid multi-statement quoting issues
+ // on Windows where cmd.exe mangles ';' separated SQL through the batch script
+ ProcessBuilder explainBuilder =
+ new ProcessBuilder(
+ "cmd.exe",
+ "/c",
+ sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-sql_dialect",
+ "table",
+ "-e",
+ "EXPLAIN (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1",
+ "&",
+ "exit",
+ "%^errorlevel%");
+ explainBuilder.environment().put("IOTDB_HOME", homePath);
+ assertRawJsonOutput(explainBuilder, "distribution plan");
+
+ // Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
+ ProcessBuilder analyzeBuilder =
+ new ProcessBuilder(
+ "cmd.exe",
+ "/c",
+ sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-sql_dialect",
+ "table",
+ "-e",
+ "EXPLAIN ANALYZE (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1",
+ "&",
+ "exit",
+ "%^errorlevel%");
+ analyzeBuilder.environment().put("IOTDB_HOME", homePath);
+ assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
+ }
+
+ @Override
+ protected void testOnUnix() throws IOException {
+ // Setup test data
+ ProcessBuilder setupBuilder =
+ new ProcessBuilder(
+ "bash",
+ sbinPath + File.separator + "start-cli.sh",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-sql_dialect",
+ "table",
+ "-e",
+ "\"CREATE DATABASE IF NOT EXISTS test_cli_json;"
+ + " USE test_cli_json;"
+ + " CREATE TABLE IF NOT EXISTS t1(id STRING TAG, v FLOAT FIELD);"
+ + " INSERT INTO t1 VALUES(1000, 'd1', 1.0)\"");
+ setupBuilder.environment().put("IOTDB_HOME", homePath);
+ testOutput(setupBuilder, new String[] {"Msg: The statement is executed successfully."}, 0);
+
+ // Test EXPLAIN (FORMAT JSON) output has no table borders
+ // Use fully qualified table name (database.table) for consistency with Windows tests
+ ProcessBuilder explainBuilder =
+ new ProcessBuilder(
+ "bash",
+ sbinPath + File.separator + "start-cli.sh",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-sql_dialect",
+ "table",
+ "-e",
+ "\"EXPLAIN (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1\"");
+ explainBuilder.environment().put("IOTDB_HOME", homePath);
+ assertRawJsonOutput(explainBuilder, "distribution plan");
+
+ // Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
+ ProcessBuilder analyzeBuilder =
+ new ProcessBuilder(
+ "bash",
+ sbinPath + File.separator + "start-cli.sh",
+ "-h",
+ ip,
+ "-p",
+ port,
+ "-sql_dialect",
+ "table",
+ "-e",
+ "\"EXPLAIN ANALYZE (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1\"");
+ analyzeBuilder.environment().put("IOTDB_HOME", homePath);
+ assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
+ }
+
+ /**
+ * Formats all output lines into a single string for use in assertion messages. Each line is
+ * prefixed with its index for easy identification.
+ */
+ private String formatOutputForDiag(List<String> outputList) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Total lines: ").append(outputList.size()).append("\n");
+ for (int i = 0; i < outputList.size(); i++) {
+ sb.append(String.format(" [%3d] |%s|%n", i, outputList.get(i)));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Collects all output lines from a process, then verifies: 1. The column header is printed before
+ * JSON content 2. No JSON content line has table border characters ('|' prefix or '+---'
+ * separator) 3. The combined JSON content is valid JSON
+ */
+ private void assertRawJsonOutput(ProcessBuilder builder, String expectedHeader)
+ throws IOException {
+ builder.redirectErrorStream(true);
+ Process p = builder.start();
+ BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
+ String line;
+ List<String> outputList = new ArrayList<>();
+ while (true) {
+ line = r.readLine();
+ if (line == null) {
+ break;
+ } else {
+ outputList.add(line);
+ }
+ }
+ r.close();
+ p.destroy();
+
+ System.out.println("Process output:");
+ for (String s : outputList) {
+ System.out.println(s);
+ }
+
+ String fullOutput = formatOutputForDiag(outputList);
+
+ // Find the JSON content region: from the first line starting with '{' to the corresponding '}'
+ int jsonStart = -1;
+ int jsonEnd = -1;
+ for (int i = 0; i < outputList.size(); i++) {
+ String trimmed = outputList.get(i).trim();
+ if (jsonStart == -1 && trimmed.startsWith("{")) {
+ jsonStart = i;
+ }
+ // The last line that is just '}' marks the end of JSON
+ if (jsonStart != -1 && trimmed.equals("}")) {
+ jsonEnd = i;
+ }
+ }
+
+ assertTrue("Should find JSON start '{'. Full process output:\n" + fullOutput, jsonStart >= 0);
+ assertTrue(
+ "Should find JSON end '}'. jsonStart="
+ + jsonStart
+ + ". Full process output:\n"
+ + fullOutput,
+ jsonEnd >= jsonStart);
+
+ // Verify the column header with table border appears before JSON content
+ // Expected format: +---+ |header| +---+ {json...} +---+
+ assertTrue(
+ "Header border should appear before JSON content. jsonStart="
+ + jsonStart
+ + ". Full process output:\n"
+ + fullOutput,
+ jsonStart >= 3);
+ assertTrue(
+ "Header border line should be present at line "
+ + (jsonStart - 3)
+ + ", got: '"
+ + outputList.get(jsonStart - 3).trim()
+ + "'. Full process output:\n"
+ + fullOutput,
+ outputList.get(jsonStart - 3).trim().matches("\\+[-+]+\\+"));
+ assertTrue(
+ "Column header '"
+ + expectedHeader
+ + "' should be present at line "
+ + (jsonStart - 2)
+ + ", got: '"
+ + outputList.get(jsonStart - 2)
+ + "'. Full process output:\n"
+ + fullOutput,
+ outputList.get(jsonStart - 2).contains(expectedHeader));
+ assertTrue(
+ "Header border line should be present at line "
+ + (jsonStart - 1)
+ + ", got: '"
+ + outputList.get(jsonStart - 1).trim()
+ + "'. Full process output:\n"
+ + fullOutput,
+ outputList.get(jsonStart - 1).trim().matches("\\+[-+]+\\+"));
+
+ // Verify JSON content lines do not have '|' borders
+ for (int i = jsonStart; i <= jsonEnd; i++) {
+ String s = outputList.get(i).trim();
+ assertFalse(
+ "JSON line " + i + " should not start with '|', but found: " + s, s.startsWith("|"));
+ }
+
+ // Concatenate JSON lines and verify it's valid JSON
+ StringBuilder jsonBuilder = new StringBuilder();
+ for (int i = jsonStart; i <= jsonEnd; i++) {
+ jsonBuilder.append(outputList.get(i));
+ }
+ String jsonStr = jsonBuilder.toString();
+ try {
+ JsonParser.parseString(jsonStr).getAsJsonObject();
+ } catch (Exception e) {
+ fail(
+ "Output should be valid JSON, but got parse error: "
+ + e.getMessage()
+ + "\nJSON string: "
+ + jsonStr
+ + "\nFull process output:\n"
+ + fullOutput);
+ }
+
+ // Verify process exit code
+ while (p.isAlive()) {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ fail();
+ }
+ }
+ assertEquals("Process exit code should be 0. Full output:\n" + fullOutput, 0, p.exitValue());
+ }
+}
diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java
index b67fe732f16..174b2d4711a 100644
--- a/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java
@@ -460,7 +460,7 @@ public class IoTDBInsertQueryIT {
e.getMessage(),
e.getMessage()
.contains(
- "700: line 1:9: mismatched input 'INSERT'. Expecting: 'ANALYZE', 'EXECUTE', <query>"));
+ "700: line 1:9: mismatched input 'INSERT'. Expecting: '(', 'ANALYZE', 'EXECUTE', <query>"));
}
try {
@@ -472,7 +472,7 @@ public class IoTDBInsertQueryIT {
e.getMessage(),
e.getMessage()
.contains(
- "700: line 1:17: mismatched input 'INSERT'. Expecting: 'EXECUTE', 'VERBOSE', <query>"));
+ "700: line 1:17: mismatched input 'INSERT'. Expecting: '(', 'EXECUTE', 'VERBOSE', <query>"));
}
}
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..3e640c200e8
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java
@@ -0,0 +1,855 @@
+/*
+ *
+ * * 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 org.apache.iotdb.itbase.runtime.ClusterTestConnection;
+
+import com.google.gson.JsonArray;
+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)
+ .setMemtableSizeThreshold(10000)
+ .setMaxRowsInCteBuffer(100);
+ 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)");
+ // Insert data across multiple time partitions (partitionInterval=1000)
+ 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(3000, 'd1', 150.0)");
+ statement.execute("INSERT INTO testtb VALUES(1000, 'd2', 300.0)");
+ statement.execute("INSERT INTO testtb VALUES(2000, 'd2', 250.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() {
+ // Expected output (single row, single JSON object representing the distributed plan tree):
+ // {
+ // "name": "OutputNode-<id>",
+ // "id": "<id>",
+ // "properties": {
+ // "OutputColumns": ["time", "deviceid", "voltage"],
+ // "OutputSymbols": ["time", "deviceid", "voltage"]
+ // },
+ // "children": [
+ // {
+ // "name": "CollectNode-<id>",
+ // "id": "<id>",
+ // "children": [
+ // {
+ // "name": "ExchangeNode-<id>",
+ // "id": "<id>",
+ // "children": [
+ // {
+ // "name": "DeviceTableScanNode-<id>",
+ // "id": "<id>",
+ // "properties": {
+ // "QualifiedTableName": "testdb_json.testtb",
+ // "OutputSymbols": ["time", "deviceid", "voltage"],
+ // "DeviceNumber": "1",
+ // "ScanOrder": "ASC",
+ // "PushDownOffset": "0",
+ // "PushDownLimit": "0",
+ // "PushDownLimitToEachDevice": "false",
+ // "RegionId": "<regionId>"
+ // }
+ // }
+ // ]
+ // },
+ // {
+ // "name": "ExchangeNode-<id>",
+ // "id": "<id>",
+ // "children": [
+ // {
+ // "name": "DeviceTableScanNode-<id>",
+ // "id": "<id>",
+ // "properties": { ... }
+ // }
+ // ]
+ // }
+ // ]
+ // }
+ // ]
+ // }
+ 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 testExplainJsonWithExecuteKeepsFormat() {
+ // Expected output for both EXECUTE and EXECUTE IMMEDIATE (single row, single JSON object
+ // representing the resolved query's distributed plan tree; the outer FORMAT JSON must be
+ // preserved when the prepared/immediate SQL is unwrapped):
+ // {
+ // "name": "OutputNode-<id>",
+ // "id": "<id>",
+ // "properties": {
+ // "OutputColumns": ["time", "deviceid", "voltage"],
+ // "OutputSymbols": ["time", "deviceid", "voltage"]
+ // },
+ // "children": [
+ // {
+ // "name": "CollectNode-<id>",
+ // "children": [
+ // {
+ // "name": "ExchangeNode-<id>",
+ // "children": [
+ // {
+ // "name": "DeviceTableScanNode-<id>",
+ // "properties": {
+ // "QualifiedTableName": "testdb_json.testtb",
+ // "PushDownPredicate": "(\"deviceid\" = 'd1')",
+ // ...
+ // }
+ // }
+ // ]
+ // }
+ // ]
+ // }
+ // ]
+ // }
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ Statement sessionStatement = getSessionScopedStatement(conn, statement);
+ try {
+ sessionStatement.execute("USE " + DATABASE_NAME);
+ sessionStatement.execute(
+ "PREPARE explain_json_stmt FROM SELECT * FROM testtb WHERE deviceid = ?");
+
+ ResultSet executeResultSet =
+ sessionStatement.executeQuery(
+ "EXPLAIN (FORMAT JSON) EXECUTE explain_json_stmt USING 'd1'");
+ Assert.assertTrue(executeResultSet.next());
+ JsonObject executeJson =
+ JsonParser.parseString(executeResultSet.getString(1)).getAsJsonObject();
+ Assert.assertTrue("EXECUTE explain JSON should have 'name' field", executeJson.has("name"));
+ executeResultSet.close();
+
+ ResultSet immediateResultSet =
+ sessionStatement.executeQuery(
+ "EXPLAIN (FORMAT JSON) EXECUTE IMMEDIATE "
+ + "'SELECT * FROM testtb WHERE deviceid = ?' USING 'd1'");
+ Assert.assertTrue(immediateResultSet.next());
+ JsonObject immediateJson =
+ JsonParser.parseString(immediateResultSet.getString(1)).getAsJsonObject();
+ Assert.assertTrue(
+ "EXECUTE IMMEDIATE explain JSON should have 'name' field", immediateJson.has("name"));
+ immediateResultSet.close();
+
+ sessionStatement.execute("DEALLOCATE PREPARE explain_json_stmt");
+ } finally {
+ closeSessionScopedStatement(sessionStatement, statement);
+ }
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeJsonFormat() {
+ // Expected output (single row, single JSON object with plan statistics + fragment instances):
+ // {
+ // "planStatistics": {
+ // "analyzeCostMs": <ms>,
+ // "fetchPartitionCostMs": <ms>,
+ // "fetchSchemaCostMs": <ms>,
+ // "logicalPlanCostMs": <ms>,
+ // "logicalOptimizationCostMs": <ms>,
+ // "distributionPlanCostMs": <ms>,
+ // "dispatchCostMs": <ms>
+ // },
+ // "fragmentInstancesCount": 3,
+ // "fragmentInstances": [
+ // {
+ // "id": "<queryId>.<fragmentId>.<instanceId>",
+ // "ip": "127.0.0.1:<port>",
+ // "dataRegion": "virtual_data_region",
+ // "state": "FINISHED",
+ // "totalWallTimeMs": <ms>,
+ // "initDataQuerySourceCostMs": <ms>,
+ // "seqFileUnclosed": 0, "seqFileClosed": 0,
+ // "unseqFileUnclosed": 0, "unseqFileClosed": 0,
+ // "readyQueuedTimeMs": <ms>,
+ // "blockQueuedTimeMs": <ms>,
+ // "queryStatistics": {
+ // "timeSeriesIndexFilteredRows": 0,
+ // "chunkIndexFilteredRows": 0,
+ // "pageIndexFilteredRows": 0
+ // },
+ // "operators": {
+ // "planNodeId": "<id>",
+ // "nodeType": "IdentitySinkNode",
+ // "operatorType": "IdentitySinkOperator",
+ // "cpuTimeMs": <ms>,
+ // "outputRows": 5,
+ // "hasNextCalledCount": 5,
+ // "nextCalledCount": 4,
+ // "estimatedMemorySize": 1024,
+ // "specifiedInfo": { "DownStreamPlanNodeId": "<id>" },
+ // "children": [
+ // {
+ // "planNodeId": "<id>",
+ // "nodeType": "CollectNode",
+ // "operatorType": "CollectOperator",
+ // ...
+ // "children": [
+ // { "planNodeId": "<id>", "nodeType": "ExchangeNode", ... },
+ // { "planNodeId": "<id>", "nodeType": "ExchangeNode", ... }
+ // ]
+ // }
+ // ]
+ // }
+ // },
+ // {
+ // "id": "...", "dataRegion": "4", "state": "FINISHED",
+ // ...
+ // "operators": {
+ // "nodeType": "IdentitySinkNode", ...
+ // "children": [
+ // { "nodeType": "DeviceTableScanNode", "operatorType": "TableScanOperator", ... }
+ // ]
+ // }
+ // },
+ // {
+ // "id": "...", "dataRegion": "3", "state": "FINISHED",
+ // ...
+ // "operators": { ... }
+ // }
+ // ]
+ // }
+ 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 testExplainAnalyzeJsonWithExecuteKeepsFormat() {
+ // Expected output for both EXECUTE and EXECUTE IMMEDIATE (single row, single JSON object
+ // with plan statistics + fragment instances; the outer FORMAT JSON must be preserved when the
+ // prepared/immediate SQL is unwrapped):
+ // {
+ // "planStatistics": {
+ // "analyzeCostMs": <ms>,
+ // "fetchPartitionCostMs": <ms>,
+ // "fetchSchemaCostMs": <ms>,
+ // "logicalPlanCostMs": <ms>,
+ // "logicalOptimizationCostMs": <ms>,
+ // "distributionPlanCostMs": <ms>,
+ // "dispatchCostMs": <ms>
+ // },
+ // "fragmentInstancesCount": <count>,
+ // "fragmentInstances": [
+ // {
+ // "id": "<queryId>.<fragmentId>.<instanceId>",
+ // "ip": "127.0.0.1:<port>",
+ // "dataRegion": "<region>",
+ // "state": "FINISHED",
+ // "queryStatistics": { ... },
+ // "operators": { ... }
+ // }
+ // ]
+ // }
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ Statement sessionStatement = getSessionScopedStatement(conn, statement);
+ try {
+ sessionStatement.execute("USE " + DATABASE_NAME);
+ sessionStatement.execute(
+ "PREPARE explain_analyze_json_stmt FROM SELECT * FROM testtb WHERE deviceid = ?");
+
+ ResultSet executeResultSet =
+ sessionStatement.executeQuery(
+ "EXPLAIN ANALYZE (FORMAT JSON) EXECUTE explain_analyze_json_stmt USING 'd1'");
+ Assert.assertTrue(executeResultSet.next());
+ JsonObject executeJson =
+ JsonParser.parseString(executeResultSet.getString(1)).getAsJsonObject();
+ Assert.assertTrue(
+ "EXECUTE explain analyze JSON should have 'planStatistics' field",
+ executeJson.has("planStatistics"));
+ Assert.assertTrue(
+ "EXECUTE explain analyze JSON should have 'fragmentInstances' field",
+ executeJson.has("fragmentInstances"));
+ executeResultSet.close();
+
+ ResultSet immediateResultSet =
+ sessionStatement.executeQuery(
+ "EXPLAIN ANALYZE (FORMAT JSON) EXECUTE IMMEDIATE "
+ + "'SELECT * FROM testtb WHERE deviceid = ?' USING 'd1'");
+ Assert.assertTrue(immediateResultSet.next());
+ JsonObject immediateJson =
+ JsonParser.parseString(immediateResultSet.getString(1)).getAsJsonObject();
+ Assert.assertTrue(
+ "EXECUTE IMMEDIATE explain analyze JSON should have 'planStatistics' field",
+ immediateJson.has("planStatistics"));
+ Assert.assertTrue(
+ "EXECUTE IMMEDIATE explain analyze JSON should have 'fragmentInstances' field",
+ immediateJson.has("fragmentInstances"));
+ immediateResultSet.close();
+
+ sessionStatement.execute("DEALLOCATE PREPARE explain_analyze_json_stmt");
+ } finally {
+ closeSessionScopedStatement(sessionStatement, statement);
+ }
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeVerboseJsonFormat() {
+ // Expected output (same structure as testExplainAnalyzeJsonFormat, but queryStatistics
+ // includes verbose fields like bloom filter, metadata, chunk reader, and page decoder stats):
+ // {
+ // "planStatistics": { ... },
+ // "fragmentInstancesCount": 3,
+ // "fragmentInstances": [
+ // {
+ // "id": "...", "dataRegion": "virtual_data_region", ...
+ // "queryStatistics": {
+ // "loadBloomFilterFromCacheCount": 0,
+ // "loadBloomFilterFromDiskCount": 0,
+ // "loadBloomFilterActualIOSize": 0,
+ // "loadBloomFilterTimeMs": 0.0,
+ // "loadTimeSeriesMetadataFromCacheCount": 0,
+ // "loadTimeSeriesMetadataFromDiskCount": 0,
+ // "loadTimeSeriesMetadataActualIOSize": 0,
+ // "loadChunkFromCacheCount": 0,
+ // "loadChunkFromDiskCount": 0,
+ // "loadChunkActualIOSize": 0,
+ // "timeSeriesIndexFilteredRows": 0,
+ // "chunkIndexFilteredRows": 0,
+ // "pageIndexFilteredRows": 0,
+ // "rowScanFilteredRows": 0
+ // },
+ // "operators": { ... }
+ // },
+ // {
+ // "id": "...", "dataRegion": "4", ...
+ // "queryStatistics": {
+ // ... (same as above, plus non-zero fields like:)
+ // "loadTimeSeriesMetadataAlignedMemSeqCount": 2,
+ // "loadTimeSeriesMetadataAlignedMemSeqTimeMs": <ms>,
+ // "pageReadersDecodeAlignedMemCount": 2,
+ // "pageReadersDecodeAlignedMemTimeMs": <ms>,
+ // ...
+ // },
+ // "operators": { ... }
+ // },
+ // { "id": "...", "dataRegion": "3", ... }
+ // ]
+ // }
+ 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
+ public void testExplainInvalidFormat() {
+ 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);
+ fail("Expected SQLException for invalid format");
+ } catch (SQLException e) {
+ Assert.assertTrue(
+ "Error message should mention the invalid format",
+ e.getMessage().toUpperCase().contains("FORMAT")
+ || e.getMessage().toUpperCase().contains("XML"));
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeJsonMultipleFragmentInstances() {
+ // Expected output (same structure as testExplainAnalyzeJsonFormat):
+ // {
+ // "planStatistics": { ... },
+ // "fragmentInstancesCount": 3, // >= 2 due to multi-partition data
+ // "fragmentInstances": [
+ // { "id": "...", "dataRegion": "virtual_data_region", "state": "FINISHED", ... },
+ // { "id": "...", "dataRegion": "4", "state": "FINISHED", ... },
+ // { "id": "...", "dataRegion": "3", "state": "FINISHED", ... }
+ // ]
+ // }
+ // Each fragment instance must have "id", "state", and "dataRegion" fields.
+ 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);
+
+ Assert.assertTrue(resultSet.next());
+ String jsonStr = resultSet.getString(1);
+ JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+ // Verify fragmentInstancesCount matches the size of fragmentInstances array
+ int declaredCount = root.get("fragmentInstancesCount").getAsInt();
+ JsonArray fragmentInstances = root.getAsJsonArray("fragmentInstances");
+ Assert.assertNotNull("fragmentInstances array should be present", fragmentInstances);
+ Assert.assertEquals(
+ "fragmentInstancesCount should match fragmentInstances array size",
+ declaredCount,
+ fragmentInstances.size());
+ Assert.assertTrue(
+ "Should have at least 2 fragment instances for multi-partition data", declaredCount >= 2);
+
+ // Verify each fragment instance has required fields
+ for (int i = 0; i < fragmentInstances.size(); i++) {
+ JsonObject fi = fragmentInstances.get(i).getAsJsonObject();
+ Assert.assertTrue("Fragment instance should have 'id'", fi.has("id"));
+ Assert.assertTrue("Fragment instance should have 'state'", fi.has("state"));
+ Assert.assertTrue("Fragment instance should have 'dataRegion'", fi.has("dataRegion"));
+ }
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainJsonWithCte() {
+ // Expected output (when CTE is present, the JSON wraps cteQueries + mainQuery):
+ // {
+ // "cteQueries": [
+ // {
+ // "name": "cte1",
+ // "plan": <plan node for CTE query>
+ // }
+ // ],
+ // "mainQuery": {
+ // "name": "OutputNode-<id>",
+ // "id": "<id>",
+ // "properties": {
+ // "OutputColumns": ["time", "deviceid", "voltage"],
+ // "OutputSymbols": ["time", "deviceid", "voltage"]
+ // },
+ // "children": [
+ // {
+ // "name": "ProjectNode-<id>", ...
+ // "children": [
+ // {
+ // "name": "FilterNode-<id>", ...
+ // "children": [
+ // {
+ // "name": "SemiJoinNode-<id>", ...
+ // "children": [
+ // { "name": "ExchangeNode-<id>", ... }, // main table scan branch
+ // { "name": "ExchangeNode-<id>", ... } // CTE scan branch
+ // ]
+ // }
+ // ]
+ // }
+ // ]
+ // }
+ // ]
+ // }
+ // }
+ try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+ Statement statement = conn.createStatement()) {
+ statement.execute("USE " + DATABASE_NAME);
+ statement.execute(
+ "CREATE TABLE IF NOT EXISTS cte_tb(deviceid STRING TAG, voltage FLOAT FIELD)");
+ statement.execute("INSERT INTO cte_tb VALUES(1000, 'd1', 50.0)");
+
+ String sql =
+ "EXPLAIN (FORMAT JSON) WITH cte1 AS MATERIALIZED (SELECT * FROM cte_tb) "
+ + "SELECT * FROM testtb WHERE testtb.deviceid IN (SELECT deviceid FROM cte1)";
+ ResultSet resultSet = statement.executeQuery(sql);
+
+ Assert.assertTrue(resultSet.next());
+ String jsonStr = resultSet.getString(1);
+ JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+ // When CTEs are present, the JSON should have cteQueries and mainQuery
+ Assert.assertTrue("JSON with CTE should have 'cteQueries' field", root.has("cteQueries"));
+ Assert.assertTrue("JSON with CTE should have 'mainQuery' field", root.has("mainQuery"));
+
+ JsonArray cteQueries = root.getAsJsonArray("cteQueries");
+ Assert.assertEquals("Should have exactly 1 CTE query", 1, cteQueries.size());
+
+ JsonObject cte = cteQueries.get(0).getAsJsonObject();
+ Assert.assertTrue("CTE should have 'name' field", cte.has("name"));
+ Assert.assertEquals("cte1", cte.get("name").getAsString());
+ Assert.assertTrue("CTE should have 'plan' field", cte.has("plan"));
+ Assert.assertTrue("CTE plan should be a JSON object", cte.get("plan").isJsonObject());
+ Assert.assertTrue(
+ "CTE plan should have 'name' field", cte.getAsJsonObject("plan").has("name"));
+
+ // The main query plan should be a JSON object with 'name' field (plan node)
+ JsonObject mainQuery = root.getAsJsonObject("mainQuery");
+ Assert.assertTrue("Main query plan should have 'name' field", mainQuery.has("name"));
+
+ statement.execute("DROP TABLE IF EXISTS cte_tb");
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainJsonWithScalarSubquery() {
+ // Expected output (scalar subquery is inlined during optimization, so the plan is a simple
+ // tree with the constant predicate pushed down to DeviceTableScanNode):
+ // {
+ // "name": "OutputNode-<id>",
+ // "id": "<id>",
+ // "properties": {
+ // "OutputColumns": ["time", "deviceid", "voltage"],
+ // "OutputSymbols": ["time", "deviceid", "voltage"]
+ // },
+ // "children": [
+ // {
+ // "name": "CollectNode-<id>",
+ // "id": "<id>",
+ // "children": [
+ // {
+ // "name": "ExchangeNode-<id>", ...
+ // "children": [
+ // {
+ // "name": "DeviceTableScanNode-<id>",
+ // "properties": {
+ // "QualifiedTableName": "testdb_json.testtb",
+ // "PushDownPredicate": "(\"voltage\" > 2E2)",
+ // ...
+ // }
+ // }
+ // ]
+ // },
+ // {
+ // "name": "ExchangeNode-<id>", ...
+ // "children": [
+ // { "name": "DeviceTableScanNode-<id>", ... }
+ // ]
+ // }
+ // ]
+ // }
+ // ]
+ // }
+ String sql =
+ "EXPLAIN (FORMAT JSON) SELECT * FROM testtb "
+ + "WHERE voltage > (SELECT avg(voltage) 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(resultSet.next());
+ String jsonStr = resultSet.getString(1);
+ JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+ // Verify it's a valid plan tree with children (subquery creates a more complex plan)
+ Assert.assertTrue("JSON should have 'name' field", root.has("name"));
+ Assert.assertTrue("JSON should have 'id' field", root.has("id"));
+ Assert.assertTrue("Plan with scalar subquery should have 'children'", root.has("children"));
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ @Test
+ public void testExplainAnalyzeJsonWithScalarSubquery() {
+ // Expected output (same EXPLAIN ANALYZE JSON structure, but the scalar subquery is resolved
+ // at planning time, so the executed plan only scans with a constant predicate):
+ // {
+ // "planStatistics": {
+ // "analyzeCostMs": <ms>,
+ // "fetchPartitionCostMs": <ms>,
+ // "fetchSchemaCostMs": <ms>,
+ // "logicalPlanCostMs": <ms>, // higher than simple query due to subquery planning
+ // "logicalOptimizationCostMs": <ms>,
+ // "distributionPlanCostMs": <ms>,
+ // "dispatchCostMs": <ms>
+ // },
+ // "fragmentInstancesCount": 3,
+ // "fragmentInstances": [
+ // {
+ // "id": "...", "dataRegion": "virtual_data_region", "state": "FINISHED",
+ // ...
+ // "operators": {
+ // "nodeType": "IdentitySinkNode", ...
+ // "children": [
+ // {
+ // "nodeType": "CollectNode", ...
+ // "children": [
+ // { "nodeType": "ExchangeNode", "outputRows": 2, ... },
+ // { "nodeType": "ExchangeNode", "outputRows": 0, ... }
+ // ]
+ // }
+ // ]
+ // }
+ // },
+ // {
+ // "dataRegion": "4", ...
+ // "operators": {
+ // "nodeType": "IdentitySinkNode", ...
+ // "children": [
+ // { "nodeType": "DeviceTableScanNode", "operatorType": "TableScanOperator", ... }
+ // ]
+ // }
+ // },
+ // {
+ // "dataRegion": "3", ...
+ // "operators": {
+ // "nodeType": "IdentitySinkNode", ...
+ // "children": [
+ // { "nodeType": "DeviceTableScanNode", "outputRows": 0, ... }
+ // ]
+ // }
+ // }
+ // ]
+ // }
+ String sql =
+ "EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM testtb "
+ + "WHERE voltage > (SELECT avg(voltage) 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(resultSet.next());
+ String jsonStr = resultSet.getString(1);
+ JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+ Assert.assertTrue(root.has("planStatistics"));
+ Assert.assertTrue(root.has("fragmentInstances"));
+ Assert.assertTrue(root.has("fragmentInstancesCount"));
+
+ int declaredCount = root.get("fragmentInstancesCount").getAsInt();
+ JsonArray fragmentInstances = root.getAsJsonArray("fragmentInstances");
+ Assert.assertEquals(declaredCount, fragmentInstances.size());
+
+ resultSet.close();
+ } catch (SQLException e) {
+ fail(e.getMessage());
+ }
+ }
+
+ private static Statement getSessionScopedStatement(Connection connection, Statement statement)
+ throws SQLException {
+ if (connection instanceof ClusterTestConnection) {
+ return ((ClusterTestConnection) connection)
+ .writeConnection
+ .getUnderlyingConnection()
+ .createStatement();
+ }
+ return statement;
+ }
+
+ private static void closeSessionScopedStatement(Statement sessionStatement, Statement statement)
+ throws SQLException {
+ if (sessionStatement != statement) {
+ sessionStatement.close();
+ }
+ }
+}
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java
index 22ecdc3a40e..3d8535aac3c 100644
--- a/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java
@@ -566,7 +566,11 @@ public abstract class AbstractCli {
List<Integer> maxSizeList = new ArrayList<>(columnLength);
List<List<String>> lists =
cacheResult(ctx, resultSet, maxSizeList, columnLength, resultSetMetaData, zoneId);
- output(ctx, lists, maxSizeList);
+ if (isJsonExplainResult(lists)) {
+ outputRawJson(ctx, lists, maxSizeList);
+ } else {
+ output(ctx, lists, maxSizeList);
+ }
ctx.getPrinter().println(String.format("It costs %.3fs", costTime / 1000.0));
while (!isReachEnd) {
if (continuePrint) {
@@ -816,6 +820,46 @@ public abstract class AbstractCli {
return lists;
}
+ private static final String COLUMN_DISTRIBUTION_PLAN = "distribution plan";
+ private static final String COLUMN_EXPLAIN_ANALYZE = "Explain Analyze";
+
+ private static boolean isJsonExplainResult(List<List<String>> lists) {
+ if (lists.size() != 1 || lists.get(0).size() < 2) {
+ return false;
+ }
+ String columnName = lists.get(0).get(0);
+ if (!COLUMN_DISTRIBUTION_PLAN.equalsIgnoreCase(columnName)
+ && !COLUMN_EXPLAIN_ANALYZE.equalsIgnoreCase(columnName)) {
+ return false;
+ }
+ String value = lists.get(0).get(1).trim();
+ return value.startsWith("{") || value.startsWith("[");
+ }
+
+ private static void outputRawJson(
+ CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) {
+ // Use header text length for border width instead of the full content length
+ String header = lists.get(0).get(0);
+ int headerLen = header.length() + ctx.getPrinter().computeHANCount(header);
+ List<Integer> headerSizeList = new ArrayList<>(1);
+ headerSizeList.add(headerLen);
+ // Print header with table border
+ ctx.getPrinter().printBlockLine(headerSizeList);
+ ctx.getPrinter().printRow(lists, 0, headerSizeList);
+ ctx.getPrinter().printBlockLine(headerSizeList);
+ // Print JSON content without '|' borders
+ for (int i = 1; i < lists.get(0).size(); i++) {
+ ctx.getPrinter().println(lists.get(0).get(i));
+ }
+ ctx.getPrinter().printBlockLine(headerSizeList);
+ if (isReachEnd) {
+ lineCount += lists.get(0).size() - 1;
+ ctx.getPrinter().printCount(lineCount);
+ } else {
+ lineCount += maxPrintRowCount;
+ }
+ }
+
private static void output(CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) {
ctx.getPrinter().printBlockLine(maxSizeList);
ctx.getPrinter().printRow(lists, 0, maxSizeList);
diff --git a/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java b/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java
index 635676ce2af..01644d58412 100644
--- a/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java
+++ b/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java
@@ -39,7 +39,18 @@ import org.mockito.MockitoAnnotations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.when;
@@ -57,7 +68,10 @@ public class AbstractCliTest {
}
@After
- public void tearDown() {}
+ public void tearDown() throws Exception {
+ setStaticField("lineCount", 0);
+ setStaticField("isReachEnd", false);
+ }
@Test
public void testInit() {
@@ -199,4 +213,71 @@ public class AbstractCliTest {
AbstractCli.handleInputCmd(
ctx, String.format("%s=111", AbstractCli.SET_FETCH_SIZE), connection));
}
+
+ @Test
+ public void testJsonExplainResultDetection() throws Exception {
+ assertFalse(isJsonExplainResult(Collections.emptyList()));
+ assertFalse(isJsonExplainResult(Collections.singletonList(Collections.singletonList("Time"))));
+ assertFalse(
+ isJsonExplainResult(
+ Arrays.asList(
+ Arrays.asList("distribution plan", "{}"), Arrays.asList("extra", "value"))));
+ assertFalse(isJsonExplainResult(column("Time", "{}")));
+ assertFalse(isJsonExplainResult(column("distribution plan", "OutputNode-1")));
+
+ assertTrue(isJsonExplainResult(column("distribution plan", " {\"name\":\"OutputNode-1\"}")));
+ assertTrue(isJsonExplainResult(column("Explain Analyze", "[{\"id\":\"fragment\"}]")));
+ assertTrue(isJsonExplainResult(column("explain analyze", "\n{\"planStatistics\":{}}")));
+ }
+
+ @Test
+ public void testOutputRawJsonKeepsJsonContentUnframed() throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ PrintStream printer = new PrintStream(out, true, StandardCharsets.UTF_8.name());
+ CliContext ctx = new CliContext(System.in, printer, System.err, ExitType.EXCEPTION);
+ List<List<String>> lists =
+ Collections.singletonList(
+ Arrays.asList("distribution plan", "{", " \"name\": \"OutputNode-1\"", "}"));
+
+ setStaticField("lineCount", 0);
+ setStaticField("isReachEnd", true);
+ outputRawJson(ctx, lists, Collections.singletonList(128));
+
+ List<String> lines =
+ Arrays.asList(new String(out.toByteArray(), StandardCharsets.UTF_8).split("\\R"));
+ assertEquals("+-----------------+", lines.get(0));
+ assertEquals("|distribution plan|", lines.get(1));
+ assertEquals("+-----------------+", lines.get(2));
+ assertEquals("{", lines.get(3));
+ assertEquals(" \"name\": \"OutputNode-1\"", lines.get(4));
+ assertEquals("}", lines.get(5));
+ assertEquals("+-----------------+", lines.get(6));
+ assertEquals("Total line number = 3", lines.get(7));
+ assertFalse(lines.get(3).startsWith("|"));
+ }
+
+ private static List<List<String>> column(String header, String value) {
+ return Collections.singletonList(Arrays.asList(header, value));
+ }
+
+ private static boolean isJsonExplainResult(List<List<String>> lists) throws Exception {
+ Method method = AbstractCli.class.getDeclaredMethod("isJsonExplainResult", List.class);
+ method.setAccessible(true);
+ return (boolean) method.invoke(null, lists);
+ }
+
+ private static void outputRawJson(
+ CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) throws Exception {
+ Method method =
+ AbstractCli.class.getDeclaredMethod(
+ "outputRawJson", CliContext.class, List.class, List.class);
+ method.setAccessible(true);
+ method.invoke(null, ctx, lists, maxSizeList);
+ }
+
+ private static void setStaticField(String name, Object value) throws Exception {
+ Field field = AbstractCli.class.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(null, value);
+ }
}
diff --git a/iotdb-core/datanode/pom.xml b/iotdb-core/datanode/pom.xml
index 97a296c9a9e..ce879cb43cf 100644
--- a/iotdb-core/datanode/pom.xml
+++ b/iotdb-core/datanode/pom.xml
@@ -259,6 +259,10 @@
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ </dependency>
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
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 1e5bd2bff77..6ef5804fac8 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
@@ -39,6 +39,7 @@ import org.apache.iotdb.db.queryengine.plan.analyze.QueryType;
import org.apache.iotdb.db.queryengine.plan.analyze.TypeProvider;
import org.apache.iotdb.db.queryengine.plan.analyze.lock.SchemaLockType;
import org.apache.iotdb.db.queryengine.plan.planner.memory.NotThreadSafeMemoryReservationManager;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import org.apache.iotdb.db.queryengine.statistics.QueryPlanStatistics;
import com.google.common.collect.ImmutableList;
@@ -109,6 +110,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 = ExplainOutputFormat.TEXT;
private boolean verbose = false;
private QueryPlanStatistics queryPlanStatistics = null;
@@ -353,6 +355,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 c2601a2ebc2..7beedf6d67c 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
@@ -36,7 +36,9 @@ import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
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.sql.ast.ExplainOutputFormat;
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;
@@ -80,23 +82,36 @@ 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 FragmentInstanceStatisticsDrawer fragmentInstanceStatisticsDrawer;
+ private final FragmentInstanceStatisticsJsonDrawer fragmentInstanceStatisticsJsonDrawer;
private final ScheduledFuture<?> logRecordTask;
private final IClientManager<TEndPoint, SyncDataNodeInternalServiceClient> clientManager;
private final MPPQueryContext mppQueryContext;
+ @Deprecated
public ExplainAnalyzeOperator(
OperatorContext operatorContext,
Operator child,
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();
@@ -104,7 +119,16 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
QueryExecution queryExecution = (QueryExecution) coordinator.getQueryExecution(queryId);
this.instances = queryExecution.getDistributedPlan().getInstances();
mppQueryContext = queryExecution.getContext();
- fragmentInstanceStatisticsDrawer.renderPlanStatistics(mppQueryContext);
+
+ if (outputFormat == ExplainOutputFormat.JSON) {
+ this.fragmentInstanceStatisticsDrawer = null;
+ this.fragmentInstanceStatisticsJsonDrawer = new FragmentInstanceStatisticsJsonDrawer();
+ fragmentInstanceStatisticsJsonDrawer.renderPlanStatistics(mppQueryContext);
+ } else {
+ this.fragmentInstanceStatisticsDrawer = new FragmentInstanceStatisticsDrawer();
+ this.fragmentInstanceStatisticsJsonDrawer = null;
+ fragmentInstanceStatisticsDrawer.renderPlanStatistics(mppQueryContext);
+ }
// The time interval guarantees the result of EXPLAIN ANALYZE will be printed at least three
// times.
@@ -131,7 +155,11 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
return null;
}
- fragmentInstanceStatisticsDrawer.renderDispatchCost(mppQueryContext);
+ if (outputFormat == ExplainOutputFormat.JSON) {
+ fragmentInstanceStatisticsJsonDrawer.renderDispatchCost(mppQueryContext);
+ } else {
+ fragmentInstanceStatisticsDrawer.renderDispatchCost(mppQueryContext);
+ }
// fetch statics from all fragment instances
TsBlock result = buildResult();
@@ -161,6 +189,14 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
return analyzeResult;
}
+ private String buildFragmentInstanceStatisticsJson(
+ List<FragmentInstance> instances, boolean verbose) throws FragmentInstanceFetchException {
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStatistics =
+ QueryStatisticsFetcher.fetchAllStatistics(instances, clientManager);
+ return fragmentInstanceStatisticsJsonDrawer.renderFragmentInstancesAsJson(
+ instances, allStatistics, verbose);
+ }
+
// We will log the intermediate result of analyze if timeout
// It can be used to analyze deadlock problem.
private void logIntermediateResultIfTimeout() {
@@ -169,12 +205,15 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
String.format(
"%s-Explain-Analyze-Logger",
operatorContext.getInstanceContext().getId().getQueryId()))) {
- List<String> analyzeResult = buildFragmentInstanceStatistics(instances, verbose);
-
StringBuilder logContent = new StringBuilder();
logContent.append("\n").append(LOG_TITLE).append("\n");
- for (String line : analyzeResult) {
- logContent.append(line).append("\n");
+ if (outputFormat == ExplainOutputFormat.JSON) {
+ logContent.append(buildFragmentInstanceStatisticsJson(instances, verbose)).append("\n");
+ } else {
+ List<String> analyzeResult = buildFragmentInstanceStatistics(instances, verbose);
+ for (String line : analyzeResult) {
+ logContent.append(line).append("\n");
+ }
}
String res = logContent.toString();
logger.info(res);
@@ -185,6 +224,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);
@@ -202,6 +244,18 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
return builder.build();
}
+ private TsBlock buildJsonResult() throws FragmentInstanceFetchException {
+ String jsonResult = buildFragmentInstanceStatisticsJson(instances, 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/Coordinator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/Coordinator.java
index bd1512aa428..d520bdf6a2c 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/Coordinator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/Coordinator.java
@@ -98,6 +98,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Execute;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExecuteImmediate;
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.ExtendRegion;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Flush;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.KillQuery;
@@ -441,6 +442,7 @@ public class Coordinator {
Metadata metadata,
Map<NodeRef<Table>, Query> cteQueries,
ExplainType explainType,
+ ExplainOutputFormat explainOutputFormat,
long timeOut,
boolean userQuery,
boolean debug,
@@ -455,6 +457,7 @@ public class Coordinator {
queryContext.setInnerTriggeredQuery(true);
queryContext.setCteQueries(cteQueries);
queryContext.setExplainType(explainType);
+ queryContext.setExplainOutputFormat(explainOutputFormat);
queryContext.setVerbose(isVerbose);
return createQueryExecutionForTableModel(
statement,
@@ -720,9 +723,12 @@ public class Coordinator {
parameters = new ArrayList<>(executeStatement.getParameters());
if (statement instanceof Explain) {
- statementToUse = new Explain(resolvedSql);
+ statementToUse = new Explain(resolvedSql, ((Explain) statement).getOutputFormat());
} else if (statement instanceof ExplainAnalyze) {
- statementToUse = new ExplainAnalyze(resolvedSql, ((ExplainAnalyze) statement).isVerbose());
+ ExplainAnalyze explainAnalyze = (ExplainAnalyze) statement;
+ statementToUse =
+ new ExplainAnalyze(
+ resolvedSql, explainAnalyze.isVerbose(), explainAnalyze.getOutputFormat());
} else {
statementToUse = resolvedSql;
}
@@ -742,9 +748,12 @@ public class Coordinator {
}
if (statement instanceof Explain) {
- statementToUse = new Explain(resolvedSql);
+ statementToUse = new Explain(resolvedSql, ((Explain) statement).getOutputFormat());
} else if (statement instanceof ExplainAnalyze) {
- statementToUse = new ExplainAnalyze(resolvedSql, ((ExplainAnalyze) statement).isVerbose());
+ ExplainAnalyze explainAnalyze = (ExplainAnalyze) statement;
+ statementToUse =
+ new ExplainAnalyze(
+ resolvedSql, explainAnalyze.isVerbose(), explainAnalyze.getOutputFormat());
} else {
statementToUse = resolvedSql;
}
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 39b771c45c3..38fc437d1b7 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
@@ -29,6 +29,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.relational.planner.SymbolAllocator;
import org.apache.iotdb.db.queryengine.plan.relational.planner.TableLogicalPlanner;
@@ -37,8 +38,14 @@ 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.ShowDevice;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import org.apache.tsfile.enums.TSDataType;
import org.apache.tsfile.read.common.block.TsBlock;
import org.apache.tsfile.utils.Pair;
@@ -58,6 +65,8 @@ import static org.apache.iotdb.db.queryengine.plan.execution.memory.StatementMem
public class TableModelStatementMemorySourceVisitor
implements AstVisitor<StatementMemorySource, TableModelStatementMemorySourceContext> {
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
@Override
public StatementMemorySource visitNode(
final Node node, final TableModelStatementMemorySourceContext context) {
@@ -103,15 +112,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 +169,26 @@ 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;
+ }
+
+ JsonObject wrapper = new JsonObject();
+ JsonArray cteArray = new JsonArray();
+ for (Map.Entry<NodeRef<Table>, Pair<Integer, List<String>>> entry :
+ cteExplainResults.entrySet()) {
+ JsonObject cte = new JsonObject();
+ cte.addProperty("name", entry.getKey().getNode().getName().toString());
+ cte.add("plan", JsonParser.parseString(entry.getValue().getRight().get(0)));
+ cteArray.add(cte);
+ }
+ wrapper.add("cteQueries", cteArray);
+ wrapper.add("mainQuery", JsonParser.parseString(mainExplainResult.get(0)));
+
+ return Collections.singletonList(GSON.toJson(wrapper));
+ }
}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java
index b5de9a5561e..9f9d69e23e3 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java
@@ -1867,7 +1867,12 @@ public class DataNodeTableOperatorGenerator
addOperatorContext(
context, 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..323c816d8bd
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java
@@ -0,0 +1,225 @@
+/*
+ * 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.commons.queryengine.plan.planner.plan.node.PlanNode;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.TableScanNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.AggregationNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.FilterNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.JoinNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.MergeSortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OffsetNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.ProjectNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.SortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.UnionNode;
+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.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);
+ // JsonObject.isEmpty() is not available in all Gson versions
+ 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.add("OutputColumns", toJsonArray(n.getOutputColumnNames()));
+ properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+ } else if (node instanceof ExplainAnalyzeNode) {
+ ExplainAnalyzeNode n = (ExplainAnalyzeNode) node;
+ properties.add("ChildPermittedOutputs", toJsonArray(n.getChildPermittedOutputs()));
+ } else if (node instanceof TableScanNode) {
+ buildTableScanProperties(properties, (TableScanNode) node);
+ } else if (node instanceof ExchangeNode) {
+ // No extra properties needed
+ } else if (node instanceof AggregationNode) {
+ buildAggregationProperties(properties, (AggregationNode) node);
+ } else if (node instanceof FilterNode) {
+ FilterNode n = (FilterNode) node;
+ properties.addProperty("Predicate", String.valueOf(n.getPredicate()));
+ } else if (node instanceof ProjectNode) {
+ ProjectNode n = (ProjectNode) node;
+ properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+ properties.add("Expressions", toJsonArray(n.getAssignments().getMap().values()));
+ } else if (node instanceof LimitNode) {
+ LimitNode n = (LimitNode) node;
+ properties.addProperty("Count", n.getCount());
+ } else if (node instanceof OffsetNode) {
+ OffsetNode n = (OffsetNode) node;
+ properties.addProperty("Count", n.getCount());
+ } else if (node instanceof SortNode) {
+ SortNode n = (SortNode) node;
+ properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+ } else if (node instanceof MergeSortNode) {
+ MergeSortNode n = (MergeSortNode) node;
+ properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+ } else if (node instanceof JoinNode) {
+ JoinNode n = (JoinNode) node;
+ properties.addProperty("JoinType", String.valueOf(n.getJoinType()));
+ properties.add("Criteria", toJsonArray(n.getCriteria()));
+ properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+ } else if (node instanceof UnionNode) {
+ UnionNode n = (UnionNode) node;
+ properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+ }
+
+ return properties;
+ }
+
+ private static <T> JsonArray toJsonArray(java.util.Collection<T> items) {
+ JsonArray array = new JsonArray();
+ for (T item : items) {
+ array.add(String.valueOf(item));
+ }
+ return array;
+ }
+
+ private static <T> JsonArray toJsonArray(List<T> items) {
+ JsonArray array = new JsonArray();
+ for (T item : items) {
+ array.add(String.valueOf(item));
+ }
+ return array;
+ }
+
+ private static void buildTableScanProperties(JsonObject properties, TableScanNode node) {
+ properties.addProperty("QualifiedTableName", node.getQualifiedObjectName().toString());
+ properties.add("OutputSymbols", toJsonArray(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, AggregationNode node) {
+ properties.add("OutputSymbols", toJsonArray(node.getOutputSymbols()));
+
+ JsonArray aggregators = new JsonArray();
+ int i = 0;
+ for (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.add("GroupingKeys", toJsonArray(node.getGroupingKeys()));
+ if (node.isStreamable()) {
+ properties.addProperty("Streamable", true);
+ properties.add("PreGroupedSymbols", toJsonArray(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 132a2470299..a2d606dbd06 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
@@ -927,6 +927,7 @@ public class StatementAnalyzer {
@Override
public Scope visitExplain(Explain node, Optional<Scope> context) {
queryContext.setExplainType(ExplainType.EXPLAIN);
+ queryContext.setExplainOutputFormat(node.getOutputFormat());
analysis.setFinishQueryAfterAnalyze();
return visitQuery((Query) node.getStatement(), context);
}
@@ -943,6 +944,7 @@ public class StatementAnalyzer {
public 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/CteMaterializer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializer.java
index 0d949340513..d1dec3e8665 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializer.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializer.java
@@ -142,6 +142,7 @@ public class CteMaterializer {
LocalExecutionPlanner.getInstance().metadata,
context.getCteQueries(),
context.getExplainType(),
+ context.getExplainOutputFormat(),
context.getTimeOut(),
false,
context.isDebug(),
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 77507fc8bab..6ddf235e392 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
@@ -627,7 +627,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/distribute/TableDistributedPlanner.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanner.java
index 20d53390439..2e89fa578fc 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanner.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanner.java
@@ -33,6 +33,7 @@ import org.apache.iotdb.db.queryengine.plan.planner.plan.DistributedQueryPlan;
import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
import org.apache.iotdb.db.queryengine.plan.planner.plan.LogicalQueryPlan;
import org.apache.iotdb.db.queryengine.plan.planner.plan.SubPlan;
+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.WritePlanNode;
import org.apache.iotdb.db.queryengine.plan.planner.plan.node.sink.IdentitySinkNode;
@@ -45,6 +46,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExchangeNode
import org.apache.iotdb.db.queryengine.plan.relational.planner.optimizations.DataNodeLocationSupplierFactory;
import org.apache.iotdb.db.queryengine.plan.relational.planner.optimizations.DistributedOptimizeFactory;
import org.apache.iotdb.db.queryengine.plan.relational.planner.optimizations.PlanOptimizer;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import java.util.Collections;
import java.util.HashMap;
@@ -104,11 +106,15 @@ public class TableDistributedPlanner {
PlanNode outputNodeWithExchange = generateDistributedPlanWithOptimize(planContext);
List<String> planText = null;
if (mppQueryContext.isExplain() && mppQueryContext.isInnerTriggeredQuery()) {
- planText =
- outputNodeWithExchange.accept(
- new PlanGraphPrinter(),
- new PlanGraphPrinter.GraphContext(
- mppQueryContext.getTypeProvider().getTemplatedInfo()));
+ if (mppQueryContext.getExplainOutputFormat() == ExplainOutputFormat.JSON) {
+ planText = PlanGraphJsonPrinter.getJsonLines(outputNodeWithExchange);
+ } else {
+ planText =
+ outputNodeWithExchange.accept(
+ new PlanGraphPrinter(),
+ new PlanGraphPrinter.GraphContext(
+ mppQueryContext.getTypeProvider().getTemplatedInfo()));
+ }
}
if (analysis.isQuery()) {
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java
index 3e0ef250eb4..acd900179a9 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java
@@ -47,6 +47,7 @@ import org.apache.iotdb.db.queryengine.plan.Coordinator;
import org.apache.iotdb.db.queryengine.plan.execution.ExecutionResult;
import org.apache.iotdb.db.queryengine.plan.planner.LocalExecutionPlanner;
import org.apache.iotdb.db.queryengine.plan.relational.analyzer.Analysis;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import org.apache.iotdb.db.queryengine.plan.relational.sql.parser.SqlParser;
import org.apache.iotdb.rpc.TSStatusCode;
@@ -153,6 +154,7 @@ public class PredicateWithUncorrelatedScalarSubqueryReconstructor {
LocalExecutionPlanner.getInstance().metadata,
context.getCteQueries(),
ExplainType.NONE,
+ ExplainOutputFormat.GRAPHVIZ,
context.getTimeOut(),
false,
q.isDebug(),
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 389fbc5bfac..820bdc96285 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
@@ -26,6 +26,7 @@ import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.process.Singl
import org.apache.iotdb.commons.queryengine.plan.relational.planner.Symbol;
import org.apache.iotdb.db.i18n.DataNodeQueryMessages;
import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanVisitor;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -40,6 +41,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,
@@ -49,18 +51,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
@@ -91,7 +121,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
@@ -120,6 +151,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/planner/optimizations/UnaliasSymbolReferences.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/optimizations/UnaliasSymbolReferences.java
index eeda5d77094..58fd2dbd613 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/optimizations/UnaliasSymbolReferences.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/optimizations/UnaliasSymbolReferences.java
@@ -418,7 +418,8 @@ public class UnaliasSymbolReferences implements PlanOptimizer {
node.getQueryId(),
node.getTimeout(),
node.getOutputSymbols().get(0),
- newChildPermittedOutputs),
+ newChildPermittedOutputs,
+ node.getOutputFormat()),
mapping);
}
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 0e98aabfd12..f9417e75d42 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
@@ -39,21 +39,40 @@ 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(Statement statement, ExplainOutputFormat outputFormat) {
+ super(null);
+ this.statement = requireNonNull(statement, "statement is null");
+ this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
}
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(IAstVisitor<R, C> visitor, C context) {
return ((AstVisitor<R, C>) visitor).visitExplain(this, context);
@@ -66,7 +85,7 @@ public class Explain extends Statement {
@Override
public int hashCode() {
- return Objects.hash(statement);
+ return Objects.hash(statement, outputFormat);
}
@Override
@@ -78,12 +97,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 1c158a1b279..9e7c2f24de1 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
@@ -41,17 +41,38 @@ 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(Statement statement, boolean verbose, ExplainOutputFormat outputFormat) {
+ super(null);
+ this.statement = requireNonNull(statement, "statement is null");
+ this.verbose = verbose;
+ this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
}
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() {
@@ -62,6 +83,10 @@ public class ExplainAnalyze extends Statement {
return verbose;
}
+ public ExplainOutputFormat getOutputFormat() {
+ return outputFormat;
+ }
+
@Override
public <R, C> R accept(IAstVisitor<R, C> visitor, C context) {
return ((AstVisitor<R, C>) visitor).visitExplainAnalyze(this, context);
@@ -74,7 +99,7 @@ public class ExplainAnalyze extends Statement {
@Override
public int hashCode() {
- return Objects.hash(statement, verbose);
+ return Objects.hash(statement, verbose, outputFormat);
}
@Override
@@ -86,12 +111,18 @@ public class ExplainAnalyze extends Statement {
return false;
}
ExplainAnalyze o = (ExplainAnalyze) obj;
- return Objects.equals(statement, o.statement);
+ return Objects.equals(statement, o.statement)
+ && verbose == o.verbose
+ && outputFormat == o.outputFormat;
}
@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..d7d1fc08bb3
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java
@@ -0,0 +1,35 @@
+/*
+ * 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;
+
+/**
+ * Output format for EXPLAIN and EXPLAIN ANALYZE statements.
+ *
+ * <ul>
+ * <li>{@link #GRAPHVIZ} - Box-drawing plan visualization. Valid for EXPLAIN only (default).
+ * <li>{@link #TEXT} - Text-based output. Valid for EXPLAIN ANALYZE only (default).
+ * <li>{@link #JSON} - Structured JSON output. Valid for both EXPLAIN and EXPLAIN ANALYZE.
+ * </ul>
+ */
+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 a19168b50f4..b32efbbccf4 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
@@ -194,6 +194,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Execute;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExecuteImmediate;
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.ExtendRegion;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Flush;
import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Insert;
@@ -1821,7 +1822,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
@@ -1834,7 +1850,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..74571ad152f
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java
@@ -0,0 +1,372 @@
+/*
+ * 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.commons.queryengine.plan.planner.plan.node.PlanNode;
+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.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);
+ }
+ }
+ // JsonArray.isEmpty() is not available in all Gson versions
+ 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/execution/operator/ExplainAnalyzeOperatorTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperatorTest.java
new file mode 100644
index 00000000000..bb5b007ab44
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperatorTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.execution.operator;
+
+import org.apache.iotdb.calc.execution.operator.Operator;
+import org.apache.iotdb.common.rpc.thrift.TEndPoint;
+import org.apache.iotdb.commons.queryengine.common.SessionInfo;
+import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
+import org.apache.iotdb.db.queryengine.common.QueryId;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
+import org.apache.iotdb.db.queryengine.statistics.FragmentInstanceStatisticsJsonDrawer;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.tsfile.common.conf.TSFileConfig;
+import org.apache.tsfile.read.common.block.TsBlock;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.time.ZoneId;
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+public class ExplainAnalyzeOperatorTest {
+
+ @Test
+ public void testNextBuildsSingleJsonAnalyzeResult() throws Exception {
+ ExplainAnalyzeOperator explainAnalyzeOperator =
+ Mockito.mock(ExplainAnalyzeOperator.class, Mockito.CALLS_REAL_METHODS);
+ Operator child = Mockito.mock(Operator.class);
+ when(child.hasNextWithTimer()).thenReturn(false);
+
+ MPPQueryContext context = createTestContext();
+ context.setAnalyzeCost(1000000L);
+ context.recordDispatchCost(2000000L);
+ FragmentInstanceStatisticsJsonDrawer jsonDrawer = new FragmentInstanceStatisticsJsonDrawer();
+ jsonDrawer.renderPlanStatistics(context);
+
+ setField(explainAnalyzeOperator, "child", child);
+ setField(explainAnalyzeOperator, "verbose", true);
+ setField(explainAnalyzeOperator, "instances", Collections.emptyList());
+ setField(explainAnalyzeOperator, "outputFormat", ExplainOutputFormat.JSON);
+ setField(explainAnalyzeOperator, "fragmentInstanceStatisticsJsonDrawer", jsonDrawer);
+ setField(explainAnalyzeOperator, "clientManager", null);
+ setField(explainAnalyzeOperator, "mppQueryContext", context);
+
+ TsBlock result = explainAnalyzeOperator.next();
+
+ assertEquals(1, result.getPositionCount());
+ String json = result.getColumn(0).getBinary(0).getStringValue(TSFileConfig.STRING_CHARSET);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+ assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+ assertTrue(root.getAsJsonObject("planStatistics").has("analyzeCostMs"));
+ assertTrue(root.getAsJsonObject("planStatistics").has("dispatchCostMs"));
+ }
+
+ private static MPPQueryContext createTestContext() {
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ MPPQueryContext context =
+ new MPPQueryContext(
+ "EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM table1",
+ new QueryId("test_query_id"),
+ sessionInfo,
+ new TEndPoint("127.0.0.1", 6667),
+ new TEndPoint("127.0.0.1", 10730));
+ context.setAnalyzeCost(0);
+ return context;
+ }
+
+ private static void setField(Object target, String name, Object value) throws Exception {
+ Field field = ExplainAnalyzeOperator.class.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitorTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitorTest.java
new file mode 100644
index 00000000000..134d94aca36
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitorTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.execution.memory;
+
+import org.apache.iotdb.commons.queryengine.plan.relational.analyzer.NodeRef;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.QualifiedName;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Table;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.tsfile.utils.Pair;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+public class TableModelStatementMemorySourceVisitorTest {
+
+ @Test
+ public void testMergeExplainResultsJsonReturnsMainResultWithoutCte() throws Exception {
+ List<String> mainExplainResult =
+ Collections.singletonList("{\"name\":\"OutputNode-main\",\"id\":\"main\"}");
+
+ List<String> result = mergeExplainResultsJson(Collections.emptyMap(), mainExplainResult);
+
+ assertSame(mainExplainResult, result);
+ }
+
+ @Test
+ public void testMergeExplainResultsJsonWrapsCteAndMainPlans() throws Exception {
+ Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults = new LinkedHashMap<>();
+ cteExplainResults.put(
+ NodeRef.of(new Table(QualifiedName.of("cte1"))),
+ new Pair<>(0, Collections.singletonList("{\"name\":\"OutputNode-cte\",\"id\":\"cte\"}")));
+ List<String> mainExplainResult =
+ Collections.singletonList("{\"name\":\"OutputNode-main\",\"id\":\"main\"}");
+
+ List<String> result = mergeExplainResultsJson(cteExplainResults, mainExplainResult);
+
+ assertEquals(1, result.size());
+ JsonObject root = JsonParser.parseString(result.get(0)).getAsJsonObject();
+ JsonArray cteQueries = root.getAsJsonArray("cteQueries");
+ assertEquals(1, cteQueries.size());
+
+ JsonObject cte = cteQueries.get(0).getAsJsonObject();
+ assertEquals("cte1", cte.get("name").getAsString());
+ assertEquals("OutputNode-cte", cte.getAsJsonObject("plan").get("name").getAsString());
+ assertEquals("OutputNode-main", root.getAsJsonObject("mainQuery").get("name").getAsString());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<String> mergeExplainResultsJson(
+ Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults,
+ List<String> mainExplainResult)
+ throws Exception {
+ Method method =
+ TableModelStatementMemorySourceVisitor.class.getDeclaredMethod(
+ "mergeExplainResultsJson", Map.class, List.class);
+ method.setAccessible(true);
+ return (List<String>)
+ method.invoke(
+ new TableModelStatementMemorySourceVisitor(), cteExplainResults, mainExplainResult);
+ }
+}
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..8c901d21243
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java
@@ -0,0 +1,573 @@
+/*
+ * 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.common.rpc.thrift.TConsensusGroupId;
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupType;
+import org.apache.iotdb.common.rpc.thrift.TRegionReplicaSet;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.commons.queryengine.plan.relational.function.BoundSignature;
+import org.apache.iotdb.commons.queryengine.plan.relational.function.FunctionId;
+import org.apache.iotdb.commons.queryengine.plan.relational.function.FunctionKind;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.ColumnSchema;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.FunctionNullability;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.QualifiedObjectName;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.ResolvedFunction;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.Assignments;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.OrderingScheme;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.SortOrder;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.Symbol;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.AggregationNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.FilterNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.JoinNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.MergeSortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OffsetNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.ProjectNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.SortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.UnionNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.ComparisonExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.GenericLiteral;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.SymbolReference;
+import org.apache.iotdb.commons.schema.table.column.TsTableColumnCategory;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphJsonPrinter;
+import org.apache.iotdb.db.queryengine.plan.relational.metadata.AlignedDeviceEntry;
+import org.apache.iotdb.db.queryengine.plan.relational.metadata.DeviceEntry;
+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.TreeDeviceViewScanNode;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
+import org.apache.iotdb.db.queryengine.plan.statement.component.Ordering;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.file.metadata.StringArrayDeviceID;
+import org.apache.tsfile.read.common.type.TypeFactory;
+import org.apache.tsfile.utils.Binary;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+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());
+ }
+
+ @Test
+ public void testOffsetNode() {
+ LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+ OffsetNode offsetNode = new OffsetNode(new PlanNodeId("10"), child, 5);
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(offsetNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("OffsetNode-10", root.get("name").getAsString());
+ assertEquals("10", root.get("id").getAsString());
+ assertTrue(root.has("properties"));
+ assertEquals("5", root.getAsJsonObject("properties").get("Count").getAsString());
+ assertTrue(root.has("children"));
+ }
+
+ @Test
+ public void testProjectNode() {
+ LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+ Symbol s1 = new Symbol("s1");
+ Symbol s2 = new Symbol("s2");
+ Assignments assignments =
+ Assignments.of(s1, new SymbolReference("s1"), s2, new SymbolReference("s2"));
+
+ ProjectNode projectNode = new ProjectNode(new PlanNodeId("20"), child, assignments);
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(projectNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("ProjectNode-20", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ JsonObject props = root.getAsJsonObject("properties");
+ assertTrue(props.has("OutputSymbols"));
+ assertTrue(props.has("Expressions"));
+ }
+
+ @Test
+ public void testFilterNodeWithPredicate() {
+ LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+ FilterNode filterNode =
+ new FilterNode(
+ new PlanNodeId("30"),
+ child,
+ new ComparisonExpression(
+ ComparisonExpression.Operator.GREATER_THAN,
+ new SymbolReference("temperature"),
+ new GenericLiteral("FLOAT", "25.0")));
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(filterNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("FilterNode-30", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ assertTrue(root.getAsJsonObject("properties").has("Predicate"));
+ }
+
+ @Test
+ public void testSortNode() {
+ LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+ Symbol s1 = new Symbol("s1");
+ OrderingScheme orderingScheme =
+ new OrderingScheme(ImmutableList.of(s1), ImmutableMap.of(s1, SortOrder.ASC_NULLS_FIRST));
+
+ SortNode sortNode = new SortNode(new PlanNodeId("40"), child, orderingScheme, false, false);
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(sortNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("SortNode-40", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ assertTrue(root.getAsJsonObject("properties").has("OrderBy"));
+ }
+
+ @Test
+ public void testMergeSortNode() {
+ Symbol s1 = new Symbol("s1");
+ OrderingScheme orderingScheme =
+ new OrderingScheme(ImmutableList.of(s1), ImmutableMap.of(s1, SortOrder.DESC_NULLS_LAST));
+
+ LimitNode child1 = new LimitNode(new PlanNodeId("c1"), null, 0, Optional.empty());
+ LimitNode child2 = new LimitNode(new PlanNodeId("c2"), null, 0, Optional.empty());
+
+ MergeSortNode mergeSortNode =
+ new MergeSortNode(
+ new PlanNodeId("50"),
+ Arrays.asList(child1, child2),
+ orderingScheme,
+ ImmutableList.of(s1));
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(mergeSortNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("MergeSortNode-50", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ assertTrue(root.getAsJsonObject("properties").has("OrderBy"));
+ assertTrue(root.has("children"));
+ assertEquals(2, root.getAsJsonArray("children").size());
+ }
+
+ @Test
+ public void testExchangeNode() {
+ ExchangeNode exchangeNode = new ExchangeNode(new PlanNodeId("60"));
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(exchangeNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("ExchangeNode-60", root.get("name").getAsString());
+ assertEquals("60", root.get("id").getAsString());
+ // ExchangeNode has no extra properties
+ assertFalse(root.has("properties"));
+ }
+
+ @Test
+ public void testExplainAnalyzeNode() {
+ LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+ Symbol outputSymbol = new Symbol("explain_result");
+ List<Symbol> childPermittedOutputs = Arrays.asList(new Symbol("s1"), new Symbol("s2"));
+
+ ExplainAnalyzeNode explainNode =
+ new ExplainAnalyzeNode(
+ new PlanNodeId("70"),
+ child,
+ true,
+ 12345L,
+ 60000L,
+ outputSymbol,
+ childPermittedOutputs,
+ ExplainOutputFormat.JSON);
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(explainNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("ExplainAnalyzeNode-70", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ JsonArray permittedOutputs =
+ root.getAsJsonObject("properties").getAsJsonArray("ChildPermittedOutputs");
+ assertNotNull(permittedOutputs);
+ assertEquals(2, permittedOutputs.size());
+ }
+
+ @Test
+ public void testJoinNode() {
+ ExchangeNode leftChild = new ExchangeNode(new PlanNodeId("l"));
+ leftChild.setOutputSymbols(ImmutableList.of(new Symbol("left_s1")));
+ ExchangeNode rightChild = new ExchangeNode(new PlanNodeId("r"));
+ rightChild.setOutputSymbols(ImmutableList.of(new Symbol("right_s1")));
+ Symbol leftSymbol = new Symbol("left_s1");
+ Symbol rightSymbol = new Symbol("right_s1");
+
+ JoinNode joinNode =
+ new JoinNode(
+ new PlanNodeId("80"),
+ JoinNode.JoinType.INNER,
+ leftChild,
+ rightChild,
+ ImmutableList.of(new JoinNode.EquiJoinClause(leftSymbol, rightSymbol)),
+ Optional.empty(),
+ ImmutableList.of(leftSymbol),
+ ImmutableList.of(rightSymbol),
+ Optional.empty(),
+ Optional.empty());
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(joinNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("JoinNode-80", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ JsonObject props = root.getAsJsonObject("properties");
+ assertEquals("INNER", props.get("JoinType").getAsString());
+ assertTrue(props.has("Criteria"));
+ assertTrue(props.has("OutputSymbols"));
+ assertTrue(root.has("children"));
+ assertEquals(2, root.getAsJsonArray("children").size());
+ }
+
+ @Test
+ public void testUnionNode() {
+ ExchangeNode child1 = new ExchangeNode(new PlanNodeId("c1"));
+ child1.setOutputSymbols(ImmutableList.of(new Symbol("input_s1_a")));
+ ExchangeNode child2 = new ExchangeNode(new PlanNodeId("c2"));
+ child2.setOutputSymbols(ImmutableList.of(new Symbol("input_s1_b")));
+ Symbol out = new Symbol("output_s1");
+
+ Symbol in1 = new Symbol("input_s1_a");
+ Symbol in2 = new Symbol("input_s1_b");
+
+ UnionNode unionNode =
+ new UnionNode(
+ new PlanNodeId("90"),
+ Arrays.asList(child1, child2),
+ ImmutableListMultimap.of(out, in1, out, in2),
+ ImmutableList.of(out));
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(unionNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("UnionNode-90", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ assertTrue(root.getAsJsonObject("properties").has("OutputSymbols"));
+ assertTrue(root.has("children"));
+ assertEquals(2, root.getAsJsonArray("children").size());
+ }
+
+ @Test
+ public void testNodeWithNoChildren() {
+ ExchangeNode exchangeNode = new ExchangeNode(new PlanNodeId("100"));
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(exchangeNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ // ExchangeNode has no children added
+ assertFalse(root.has("children"));
+ }
+
+ @Test
+ public void testOutputNodeProperties() {
+ LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+ List<Symbol> outputSymbols =
+ Arrays.asList(new Symbol("time"), new Symbol("device"), new Symbol("temperature"));
+ OutputNode outputNode =
+ new OutputNode(
+ new PlanNodeId("110"),
+ child,
+ Arrays.asList("time", "device", "temperature"),
+ outputSymbols);
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(outputNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ JsonObject props = root.getAsJsonObject("properties");
+ assertNotNull(props);
+ JsonArray outputColumns = props.getAsJsonArray("OutputColumns");
+ assertEquals(3, outputColumns.size());
+ assertEquals("time", outputColumns.get(0).getAsString());
+ assertEquals("device", outputColumns.get(1).getAsString());
+ assertEquals("temperature", outputColumns.get(2).getAsString());
+
+ JsonArray outputSymbolsArray = props.getAsJsonArray("OutputSymbols");
+ assertEquals(3, outputSymbolsArray.size());
+ }
+
+ @Test
+ public void testDeepPlanHierarchy() {
+ // Build a 4-level deep plan: Output -> Sort -> Filter -> Limit
+ LimitNode leaf = new LimitNode(new PlanNodeId("leaf"), null, 100, Optional.empty());
+
+ FilterNode filterNode =
+ new FilterNode(
+ new PlanNodeId("filter"),
+ leaf,
+ new ComparisonExpression(
+ ComparisonExpression.Operator.LESS_THAN,
+ new SymbolReference("v"),
+ new GenericLiteral("INT32", "50")));
+
+ Symbol s1 = new Symbol("v");
+ OrderingScheme orderingScheme =
+ new OrderingScheme(ImmutableList.of(s1), ImmutableMap.of(s1, SortOrder.ASC_NULLS_LAST));
+ SortNode sortNode =
+ new SortNode(new PlanNodeId("sort"), filterNode, orderingScheme, false, false);
+
+ OutputNode outputNode =
+ new OutputNode(
+ new PlanNodeId("output"),
+ sortNode,
+ Collections.singletonList("v"),
+ ImmutableList.of(s1));
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(outputNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ // Verify 4 levels
+ assertEquals("OutputNode-output", root.get("name").getAsString());
+ JsonObject sort = root.getAsJsonArray("children").get(0).getAsJsonObject();
+ assertEquals("SortNode-sort", sort.get("name").getAsString());
+ JsonObject filter = sort.getAsJsonArray("children").get(0).getAsJsonObject();
+ assertEquals("FilterNode-filter", filter.get("name").getAsString());
+ JsonObject limit = filter.getAsJsonArray("children").get(0).getAsJsonObject();
+ assertEquals("LimitNode-leaf", limit.get("name").getAsString());
+ assertEquals("100", limit.getAsJsonObject("properties").get("Count").getAsString());
+ }
+
+ @Test
+ public void testExplainAnalyzeNodeDefaultFormat() {
+ LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+ Symbol outputSymbol = new Symbol("result");
+ List<Symbol> childPermittedOutputs = ImmutableList.of(new Symbol("s1"));
+
+ // Use constructor with default TEXT format
+ ExplainAnalyzeNode explainNode =
+ new ExplainAnalyzeNode(
+ new PlanNodeId("ea"), child, false, 1L, 30000L, outputSymbol, childPermittedOutputs);
+
+ String json = PlanGraphJsonPrinter.toPrettyJson(explainNode);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals("ExplainAnalyzeNode-ea", root.get("name").getAsString());
+ assertTrue(root.has("properties"));
+ }
+
+ @Test
+ public void testTreeDeviceTableScanProperties() {
+ Symbol time = new Symbol("time");
+ Symbol value = new Symbol("value");
+ Symbol tag = new Symbol("tag1");
+ List<DeviceEntry> deviceEntries =
+ Collections.singletonList(
+ new AlignedDeviceEntry(new StringArrayDeviceID("table1", "d1"), new Binary[0]));
+ TreeDeviceViewScanNode scanNode =
+ new TreeDeviceViewScanNode(
+ new PlanNodeId("scan"),
+ new QualifiedObjectName("testdb", "table1"),
+ Arrays.asList(time, value),
+ ImmutableMap.of(
+ time,
+ new ColumnSchema(
+ "time",
+ TypeFactory.getType(TSDataType.TIMESTAMP),
+ false,
+ TsTableColumnCategory.TIME),
+ value,
+ new ColumnSchema(
+ "value",
+ TypeFactory.getType(TSDataType.INT64),
+ false,
+ TsTableColumnCategory.FIELD)),
+ deviceEntries,
+ ImmutableMap.of(tag, 0),
+ Ordering.DESC,
+ new ComparisonExpression(
+ ComparisonExpression.Operator.GREATER_THAN,
+ new SymbolReference("time"),
+ new GenericLiteral("INT64", "100")),
+ new ComparisonExpression(
+ ComparisonExpression.Operator.GREATER_THAN,
+ new SymbolReference("value"),
+ new GenericLiteral("INT64", "10")),
+ 100,
+ 5,
+ true,
+ false,
+ "root.testdb",
+ ImmutableMap.of("s1", "value"));
+ TRegionReplicaSet regionReplicaSet = new TRegionReplicaSet();
+ regionReplicaSet.setRegionId(new TConsensusGroupId(TConsensusGroupType.DataRegion, 7));
+ scanNode.setRegionReplicaSet(regionReplicaSet);
+
+ JsonObject root =
+ JsonParser.parseString(PlanGraphJsonPrinter.toPrettyJson(scanNode)).getAsJsonObject();
+ JsonObject props = root.getAsJsonObject("properties");
+
+ assertEquals("TreeDeviceViewScanNode-scan", root.get("name").getAsString());
+ assertEquals("testdb.table1", props.get("QualifiedTableName").getAsString());
+ assertEquals("1", props.get("DeviceNumber").getAsString());
+ assertEquals("DESC", props.get("ScanOrder").getAsString());
+ assertTrue(props.has("TimePredicate"));
+ assertTrue(props.has("PushDownPredicate"));
+ assertEquals("5", props.get("PushDownOffset").getAsString());
+ assertEquals("100", props.get("PushDownLimit").getAsString());
+ assertEquals("true", props.get("PushDownLimitToEachDevice").getAsString());
+ assertEquals("7", props.get("RegionId").getAsString());
+ assertEquals("root.testdb", props.get("TreeDB").getAsString());
+ assertTrue(props.get("MeasurementToColumnName").getAsString().contains("s1=value"));
+ }
+
+ @Test
+ public void testAggregationNodeProperties() {
+ Symbol group = new Symbol("device");
+ Symbol value = new Symbol("value");
+ Symbol mask = new Symbol("mask");
+ Symbol output = new Symbol("sum_value");
+ ResolvedFunction function =
+ new ResolvedFunction(
+ new BoundSignature(
+ "sum",
+ TypeFactory.getType(TSDataType.INT64),
+ Collections.singletonList(TypeFactory.getType(TSDataType.INT64))),
+ new FunctionId("sum"),
+ FunctionKind.AGGREGATE,
+ true,
+ FunctionNullability.getAggregationFunctionNullability(1));
+ AggregationNode.Aggregation aggregation =
+ new AggregationNode.Aggregation(
+ function,
+ Collections.singletonList(new SymbolReference("value")),
+ true,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.of(mask));
+ AggregationNode aggregationNode =
+ new AggregationNode(
+ new PlanNodeId("agg"),
+ new ExchangeNode(new PlanNodeId("source")),
+ ImmutableMap.of(output, aggregation),
+ AggregationNode.singleGroupingSet(Collections.singletonList(group)),
+ Collections.singletonList(group),
+ AggregationNode.Step.SINGLE,
+ Optional.empty(),
+ Optional.empty());
+
+ JsonObject root =
+ JsonParser.parseString(PlanGraphJsonPrinter.toPrettyJson(aggregationNode))
+ .getAsJsonObject();
+ JsonObject props = root.getAsJsonObject("properties");
+ JsonObject firstAggregator = props.getAsJsonArray("Aggregators").get(0).getAsJsonObject();
+
+ assertEquals("AggregationNode-agg", root.get("name").getAsString());
+ assertEquals("sum(INT64):INT64", firstAggregator.get("function").getAsString());
+ assertEquals("mask", firstAggregator.get("mask").getAsString());
+ assertTrue(firstAggregator.get("distinct").getAsBoolean());
+ assertEquals("device", props.getAsJsonArray("GroupingKeys").get(0).getAsString());
+ assertTrue(props.get("Streamable").getAsBoolean());
+ assertEquals("device", props.getAsJsonArray("PreGroupedSymbols").get(0).getAsString());
+ assertEquals("SINGLE", props.get("Step").getAsString());
+ }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java
index 97273efb6ec..21225e38e59 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java
@@ -115,6 +115,7 @@ public class CteMaterializerTest {
Mockito.any(), // Metadata
Mockito.anyMap(), // Map<NodeRef<Table>, CteDataStore>
Mockito.any(), // ExplainType
+ Mockito.any(), // ExplainOutputFormat
Mockito.anyLong(), // timeOut
Mockito.anyBoolean(), // userQuery
Mockito.anyBoolean(), // debug
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java
index 2a8c3e4777d..ef1b289a892 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java
@@ -138,6 +138,7 @@ public class CteSubqueryTest {
Mockito.any(), // Metadata
Mockito.anyMap(), // Map<NodeRef<Table>, CteDataStore>
Mockito.any(), // ExplainType
+ Mockito.any(), // ExplainOutputFormat
Mockito.anyLong(), // timeOut
Mockito.anyBoolean(), // userQuery
Mockito.anyBoolean(), // debug
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..9a90bc754ac
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java
@@ -0,0 +1,186 @@
+/*
+ * 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.commons.queryengine.plan.relational.sql.ast.NodeLocation;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Statement;
+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.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.assertNotEquals;
+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
+ public void testExplainWithoutLocationKeepsOutputFormat() {
+ Statement stmt = parseSQL("SELECT * FROM table1");
+ Explain explain = new Explain(stmt, ExplainOutputFormat.JSON);
+
+ assertEquals(ExplainOutputFormat.JSON, explain.getOutputFormat());
+ assertEquals(stmt, explain.getStatement());
+ }
+
+ @Test
+ public void testExplainNodeOutputFormatParticipatesInNodeSemantics() {
+ Statement stmt = parseSQL("SELECT * FROM table1");
+ NodeLocation location = new NodeLocation(1, 1);
+ Explain defaultExplain = new Explain(location, stmt);
+ Explain jsonExplain = new Explain(location, stmt, ExplainOutputFormat.JSON);
+ Explain sameJsonExplain = new Explain(new NodeLocation(2, 1), stmt, ExplainOutputFormat.JSON);
+
+ assertEquals(ExplainOutputFormat.GRAPHVIZ, defaultExplain.getOutputFormat());
+ assertEquals(ExplainOutputFormat.JSON, jsonExplain.getOutputFormat());
+ assertEquals(stmt, jsonExplain.getChildren().get(0));
+ assertEquals(jsonExplain, sameJsonExplain);
+ assertEquals(jsonExplain.hashCode(), sameJsonExplain.hashCode());
+ assertNotEquals(defaultExplain, jsonExplain);
+ assertTrue(jsonExplain.toString().contains("outputFormat=JSON"));
+ assertTrue(jsonExplain.ramBytesUsed() > 0);
+ }
+
+ @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
+ public void testExplainAnalyzeWithoutLocationKeepsOutputFormat() {
+ Statement stmt = parseSQL("SELECT * FROM table1");
+ ExplainAnalyze explainAnalyze = new ExplainAnalyze(stmt, true, ExplainOutputFormat.JSON);
+
+ assertEquals(ExplainOutputFormat.JSON, explainAnalyze.getOutputFormat());
+ assertTrue(explainAnalyze.isVerbose());
+ assertEquals(stmt, explainAnalyze.getStatement());
+ }
+
+ @Test
+ public void testExplainAnalyzeNodeOutputFormatParticipatesInNodeSemantics() {
+ Statement stmt = parseSQL("SELECT * FROM table1");
+ NodeLocation location = new NodeLocation(1, 1);
+ ExplainAnalyze defaultAnalyze = new ExplainAnalyze(location, false, stmt);
+ ExplainAnalyze jsonAnalyze = new ExplainAnalyze(location, true, stmt, ExplainOutputFormat.JSON);
+ ExplainAnalyze sameJsonAnalyze =
+ new ExplainAnalyze(new NodeLocation(2, 1), true, stmt, ExplainOutputFormat.JSON);
+
+ assertEquals(ExplainOutputFormat.TEXT, defaultAnalyze.getOutputFormat());
+ assertEquals(ExplainOutputFormat.JSON, jsonAnalyze.getOutputFormat());
+ assertEquals(stmt, jsonAnalyze.getChildren().get(0));
+ assertEquals(jsonAnalyze, sameJsonAnalyze);
+ assertEquals(jsonAnalyze.hashCode(), sameJsonAnalyze.hashCode());
+ assertNotEquals(defaultAnalyze, jsonAnalyze);
+ assertNotEquals(new ExplainAnalyze(stmt, false, ExplainOutputFormat.JSON), jsonAnalyze);
+ assertTrue(jsonAnalyze.toString().contains("outputFormat=JSON"));
+ assertTrue(jsonAnalyze.ramBytesUsed() > 0);
+ }
+
+ @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/datanode/src/test/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawerTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawerTest.java
new file mode 100644
index 00000000000..b83501a277c
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawerTest.java
@@ -0,0 +1,594 @@
+/*
+ * 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.commons.queryengine.common.SessionInfo;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.db.queryengine.common.FragmentInstanceId;
+import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
+import org.apache.iotdb.db.queryengine.common.PlanFragmentId;
+import org.apache.iotdb.db.queryengine.common.QueryId;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.PlanFragment;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExchangeNode;
+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.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Test;
+
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class FragmentInstanceStatisticsJsonDrawerTest {
+
+ @Test
+ public void testRenderPlanStatistics() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ context.setAnalyzeCost(1000000L); // 1ms
+ context.setFetchPartitionCost(2000000L); // 2ms
+ context.setFetchSchemaCost(3000000L); // 3ms
+ context.setLogicalPlanCost(4000000L); // 4ms
+ context.setLogicalOptimizationCost(5000000L); // 5ms
+ context.setDistributionPlanCost(6000000L); // 6ms
+
+ drawer.renderPlanStatistics(context);
+
+ // Now render to JSON and verify planStatistics
+ drawer.renderDispatchCost(context);
+ String json =
+ drawer.renderFragmentInstancesAsJson(
+ Collections.emptyList(), Collections.emptyMap(), false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+ JsonObject planStats = root.getAsJsonObject("planStatistics");
+
+ assertNotNull(planStats);
+ assertEquals(1.0, planStats.get("analyzeCostMs").getAsDouble(), 0.01);
+ assertEquals(2.0, planStats.get("fetchPartitionCostMs").getAsDouble(), 0.01);
+ assertEquals(3.0, planStats.get("fetchSchemaCostMs").getAsDouble(), 0.01);
+ assertEquals(4.0, planStats.get("logicalPlanCostMs").getAsDouble(), 0.01);
+ assertEquals(5.0, planStats.get("logicalOptimizationCostMs").getAsDouble(), 0.01);
+ assertEquals(6.0, planStats.get("distributionPlanCostMs").getAsDouble(), 0.01);
+ }
+
+ @Test
+ public void testRenderDispatchCost() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ context.recordDispatchCost(7000000L); // 7ms
+
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(
+ Collections.emptyList(), Collections.emptyMap(), false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+ JsonObject planStats = root.getAsJsonObject("planStatistics");
+
+ assertNotNull(planStats);
+ assertEquals(7.0, planStats.get("dispatchCostMs").getAsDouble(), 0.01);
+ }
+
+ @Test
+ public void testRenderEmptyFragmentInstances() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(
+ Collections.emptyList(), Collections.emptyMap(), false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertNotNull(root);
+ assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+ assertTrue(root.has("fragmentInstances"));
+ assertEquals(0, root.getAsJsonArray("fragmentInstances").size());
+ }
+
+ @Test
+ public void testRenderFragmentInstanceWithStatistics() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ // Create a simple plan node tree
+ LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+ PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+ FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ FragmentInstance instance =
+ new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+ // Create statistics
+ TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+ stats.setDataRegion("root.db.region1");
+ stats.setIp("127.0.0.1");
+ stats.setState("FINISHED");
+ stats.setStartTimeInMS(1000L);
+ stats.setEndTimeInMS(2000L);
+ stats.setInitDataQuerySourceCost(500000L);
+ stats.setSeqUnclosedNum(0);
+ stats.setSeqClosednNum(5);
+ stats.setUnseqUnclosedNum(1);
+ stats.setUnseqClosedNum(3);
+ stats.setReadyQueuedTime(100000L);
+ stats.setBlockQueuedTime(50000L);
+
+ TQueryStatistics queryStats = createTestQueryStatistics();
+ stats.setQueryStatistics(queryStats);
+
+ // Create operator statistics
+ TOperatorStatistics opStats = new TOperatorStatistics();
+ opStats.setOperatorType("LimitOperator");
+ opStats.setTotalExecutionTimeInNanos(300000L);
+ opStats.setOutputRows(50);
+ opStats.hasNextCalledCount = 100;
+ opStats.nextCalledCount = 50;
+ opStats.setMemoryUsage(1024);
+ opStats.setCount(10);
+ Map<String, TOperatorStatistics> operatorMap = new HashMap<>();
+ operatorMap.put("node1", opStats);
+ stats.setOperatorStatisticsMap(operatorMap);
+
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+ allStats.put(fiId, stats);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ assertEquals(1, root.get("fragmentInstancesCount").getAsInt());
+ JsonArray instances = root.getAsJsonArray("fragmentInstances");
+ assertEquals(1, instances.size());
+
+ JsonObject fi = instances.get(0).getAsJsonObject();
+ assertEquals("127.0.0.1", fi.get("ip").getAsString());
+ assertEquals("root.db.region1", fi.get("dataRegion").getAsString());
+ assertEquals("FINISHED", fi.get("state").getAsString());
+ assertEquals(1000, fi.get("totalWallTimeMs").getAsLong());
+ assertEquals(5, fi.get("seqFileClosed").getAsInt());
+ assertEquals(3, fi.get("unseqFileClosed").getAsInt());
+
+ // Verify operator tree
+ assertTrue(fi.has("operators"));
+ JsonObject operators = fi.getAsJsonObject("operators");
+ assertEquals("LimitOperator", operators.get("operatorType").getAsString());
+ assertEquals(50, operators.get("outputRows").getAsInt());
+ assertEquals(1024, operators.get("estimatedMemorySize").getAsInt());
+ assertEquals(10, operators.get("count").getAsInt());
+
+ // Verify query statistics
+ assertTrue(fi.has("queryStatistics"));
+ JsonObject qs = fi.getAsJsonObject("queryStatistics");
+ assertEquals(100, qs.get("timeSeriesIndexFilteredRows").getAsLong());
+ assertEquals(50, qs.get("chunkIndexFilteredRows").getAsLong());
+ assertEquals(25, qs.get("pageIndexFilteredRows").getAsLong());
+ }
+
+ @Test
+ public void testRenderWithVerboseQueryStatistics() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+ PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+ FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ FragmentInstance instance =
+ new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+ TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+ stats.setDataRegion("root.db.region1");
+ stats.setIp("127.0.0.1");
+ stats.setState("FINISHED");
+ stats.setStartTimeInMS(1000L);
+ stats.setEndTimeInMS(2000L);
+ stats.setInitDataQuerySourceCost(500000L);
+ stats.setSeqUnclosedNum(0);
+ stats.setSeqClosednNum(5);
+ stats.setUnseqUnclosedNum(1);
+ stats.setUnseqClosedNum(3);
+ stats.setReadyQueuedTime(100000L);
+ stats.setBlockQueuedTime(50000L);
+
+ TQueryStatistics queryStats = createVerboseQueryStatistics();
+ stats.setQueryStatistics(queryStats);
+ stats.setOperatorStatisticsMap(new HashMap<>());
+
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+ allStats.put(fiId, stats);
+
+ // Test with verbose=true to cover verbose code paths
+ String json =
+ drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, true);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+ JsonObject qs = fi.getAsJsonObject("queryStatistics");
+
+ // Verbose fields should be present
+ assertTrue(qs.has("loadBloomFilterFromCacheCount"));
+ assertTrue(qs.has("loadBloomFilterFromDiskCount"));
+ assertTrue(qs.has("loadBloomFilterActualIOSize"));
+ assertTrue(qs.has("loadBloomFilterTimeMs"));
+ assertTrue(qs.has("loadTimeSeriesMetadataFromCacheCount"));
+ assertTrue(qs.has("loadTimeSeriesMetadataFromDiskCount"));
+ assertTrue(qs.has("loadTimeSeriesMetadataActualIOSize"));
+ assertTrue(qs.has("loadChunkFromCacheCount"));
+ assertTrue(qs.has("loadChunkFromDiskCount"));
+ assertTrue(qs.has("loadChunkActualIOSize"));
+ assertTrue(qs.has("rowScanFilteredRows"));
+
+ // Non-zero disk seq counts should appear
+ assertTrue(qs.has("loadTimeSeriesMetadataDiskSeqCount"));
+ assertTrue(qs.has("loadTimeSeriesMetadataDiskUnSeqCount"));
+ }
+
+ @Test
+ public void testRenderWithNullStatistics() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+ PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+ FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ FragmentInstance instance =
+ new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+ // No statistics for this instance
+ String json =
+ drawer.renderFragmentInstancesAsJson(
+ Collections.singletonList(instance), Collections.emptyMap(), false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ // Instance is filtered out because statistics is null
+ assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+ }
+
+ @Test
+ public void testRenderWithNullDataRegion() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+ PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+ FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ FragmentInstance instance =
+ new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+ TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+ // DataRegion is null -> instance should be filtered out
+ stats.setIp("127.0.0.1");
+
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+ allStats.put(fiId, stats);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ // Instance is filtered out because dataRegion is null
+ assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+ }
+
+ @Test
+ public void testRenderOperatorWithSpecifiedInfo() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+ PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+ FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ FragmentInstance instance =
+ new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+ TFetchFragmentInstanceStatisticsResp stats = createBasicStats();
+
+ // Create operator with specifiedInfo
+ TOperatorStatistics opStats = new TOperatorStatistics();
+ opStats.setOperatorType("TableScanOperator");
+ opStats.setTotalExecutionTimeInNanos(500000L);
+ opStats.setOutputRows(200);
+ opStats.hasNextCalledCount = 50;
+ opStats.nextCalledCount = 25;
+ Map<String, String> specifiedInfo = new HashMap<>();
+ specifiedInfo.put("tableName", "test_table");
+ specifiedInfo.put("scanOrder", "ASC");
+ opStats.setSpecifiedInfo(specifiedInfo);
+ Map<String, TOperatorStatistics> operatorMap = new HashMap<>();
+ operatorMap.put("node1", opStats);
+ stats.setOperatorStatisticsMap(operatorMap);
+
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+ allStats.put(fiId, stats);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+ JsonObject operators = fi.getAsJsonObject("operators");
+ assertTrue(operators.has("specifiedInfo"));
+ JsonObject info = operators.getAsJsonObject("specifiedInfo");
+ assertEquals("test_table", info.get("tableName").getAsString());
+ assertEquals("ASC", info.get("scanOrder").getAsString());
+ }
+
+ @Test
+ public void testRenderOperatorWithChildren() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ // Create a plan with children: Limit -> Exchange
+ ExchangeNode exchangeNode = new ExchangeNode(new PlanNodeId("child1"));
+ LimitNode limitNode =
+ new LimitNode(new PlanNodeId("parent"), exchangeNode, 100, Optional.empty());
+
+ PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), limitNode);
+ FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ FragmentInstance instance =
+ new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+ TFetchFragmentInstanceStatisticsResp stats = createBasicStats();
+
+ TOperatorStatistics parentOp = new TOperatorStatistics();
+ parentOp.setOperatorType("LimitOperator");
+ parentOp.setTotalExecutionTimeInNanos(300000L);
+ parentOp.setOutputRows(50);
+ parentOp.hasNextCalledCount = 100;
+ parentOp.nextCalledCount = 50;
+
+ TOperatorStatistics childOp = new TOperatorStatistics();
+ childOp.setOperatorType("ExchangeOperator");
+ childOp.setTotalExecutionTimeInNanos(200000L);
+ childOp.setOutputRows(100);
+ childOp.hasNextCalledCount = 80;
+ childOp.nextCalledCount = 40;
+
+ Map<String, TOperatorStatistics> operatorMap = new HashMap<>();
+ operatorMap.put("parent", parentOp);
+ operatorMap.put("child1", childOp);
+ stats.setOperatorStatisticsMap(operatorMap);
+
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+ allStats.put(fiId, stats);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+ JsonObject operators = fi.getAsJsonObject("operators");
+ assertEquals("LimitOperator", operators.get("operatorType").getAsString());
+ assertTrue(operators.has("children"));
+ JsonArray children = operators.getAsJsonArray("children");
+ assertEquals(1, children.size());
+ assertEquals(
+ "ExchangeOperator", children.get(0).getAsJsonObject().get("operatorType").getAsString());
+ }
+
+ @Test
+ public void testRenderWithRetryCount() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+ PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+ FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ FragmentInstance instance =
+ new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+ TFetchFragmentInstanceStatisticsResp stats = createBasicStats();
+ stats.setInitDataQuerySourceRetryCount(3);
+ stats.setOperatorStatisticsMap(new HashMap<>());
+
+ TQueryStatistics queryStats = createTestQueryStatistics();
+ stats.setQueryStatistics(queryStats);
+
+ Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+ allStats.put(fiId, stats);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+ JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+ assertTrue(fi.has("initDataQuerySourceRetryCount"));
+ assertEquals(3, fi.get("initDataQuerySourceRetryCount").getAsInt());
+ }
+
+ @Test
+ public void testJsonOutputIsValid() {
+ FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+ MPPQueryContext context = createTestContext();
+ context.setAnalyzeCost(1000000L);
+ drawer.renderPlanStatistics(context);
+ drawer.renderDispatchCost(context);
+
+ String json =
+ drawer.renderFragmentInstancesAsJson(
+ Collections.emptyList(), Collections.emptyMap(), false);
+
+ // Verify the JSON is pretty-printed (contains newlines and indentation)
+ assertTrue(json.contains("\n"));
+ assertTrue(json.contains(" "));
+
+ // Verify it's valid JSON
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+ assertNotNull(root);
+ assertTrue(root.has("planStatistics"));
+ assertTrue(root.has("fragmentInstancesCount"));
+ assertTrue(root.has("fragmentInstances"));
+ }
+
+ private MPPQueryContext createTestContext() {
+ QueryId queryId = new QueryId("test_query_id");
+ SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+ MPPQueryContext context =
+ new MPPQueryContext(
+ "SELECT * FROM test",
+ queryId,
+ sessionInfo,
+ new org.apache.iotdb.common.rpc.thrift.TEndPoint("127.0.0.1", 6667),
+ new org.apache.iotdb.common.rpc.thrift.TEndPoint("127.0.0.1", 10730));
+ // Initialize queryPlanStatistics to avoid NPE on getAnalyzeCost() etc.
+ context.setAnalyzeCost(0);
+ return context;
+ }
+
+ private TQueryStatistics createTestQueryStatistics() {
+ TQueryStatistics qs = new TQueryStatistics();
+ qs.timeSeriesIndexFilteredRows = 100;
+ qs.chunkIndexFilteredRows = 50;
+ qs.pageIndexFilteredRows = 25;
+ qs.rowScanFilteredRows = 10;
+ qs.loadTimeSeriesMetadataFromCacheCount = 0;
+ qs.loadTimeSeriesMetadataFromDiskCount = 0;
+ qs.loadTimeSeriesMetadataActualIOSize = 0;
+ qs.loadChunkFromCacheCount = 0;
+ qs.loadChunkFromDiskCount = 0;
+ qs.loadChunkActualIOSize = 0;
+ qs.loadBloomFilterFromCacheCount = 0;
+ qs.loadBloomFilterFromDiskCount = 0;
+ qs.loadBloomFilterActualIOSize = 0;
+ qs.loadBloomFilterTime = 0;
+ return qs;
+ }
+
+ private TQueryStatistics createVerboseQueryStatistics() {
+ TQueryStatistics qs = new TQueryStatistics();
+ qs.timeSeriesIndexFilteredRows = 100;
+ qs.chunkIndexFilteredRows = 50;
+ qs.pageIndexFilteredRows = 25;
+ qs.rowScanFilteredRows = 10;
+
+ // Bloom filter stats
+ qs.loadBloomFilterFromCacheCount = 5;
+ qs.loadBloomFilterFromDiskCount = 3;
+ qs.loadBloomFilterActualIOSize = 1024;
+ qs.loadBloomFilterTime = 500000; // 0.5ms
+
+ // TimeSeries metadata stats
+ qs.loadTimeSeriesMetadataDiskSeqCount = 10;
+ qs.loadTimeSeriesMetadataDiskUnSeqCount = 5;
+ qs.loadTimeSeriesMetadataMemSeqCount = 8;
+ qs.loadTimeSeriesMetadataMemUnSeqCount = 3;
+ qs.loadTimeSeriesMetadataDiskSeqTime = 1000000; // 1ms
+ qs.loadTimeSeriesMetadataDiskUnSeqTime = 500000; // 0.5ms
+ qs.loadTimeSeriesMetadataMemSeqTime = 200000;
+ qs.loadTimeSeriesMetadataMemUnSeqTime = 100000;
+
+ // Aligned metadata stats
+ qs.loadTimeSeriesMetadataAlignedDiskSeqCount = 2;
+ qs.loadTimeSeriesMetadataAlignedDiskUnSeqCount = 1;
+ qs.loadTimeSeriesMetadataAlignedMemSeqCount = 4;
+ qs.loadTimeSeriesMetadataAlignedMemUnSeqCount = 2;
+ qs.loadTimeSeriesMetadataAlignedDiskSeqTime = 300000;
+ qs.loadTimeSeriesMetadataAlignedDiskUnSeqTime = 150000;
+ qs.loadTimeSeriesMetadataAlignedMemSeqTime = 100000;
+ qs.loadTimeSeriesMetadataAlignedMemUnSeqTime = 50000;
+
+ qs.loadTimeSeriesMetadataFromCacheCount = 20;
+ qs.loadTimeSeriesMetadataFromDiskCount = 15;
+ qs.loadTimeSeriesMetadataActualIOSize = 4096;
+
+ // Chunk reader stats
+ qs.constructNonAlignedChunkReadersDiskCount = 5;
+ qs.constructNonAlignedChunkReadersMemCount = 3;
+ qs.constructAlignedChunkReadersDiskCount = 2;
+ qs.constructAlignedChunkReadersMemCount = 1;
+ qs.constructNonAlignedChunkReadersDiskTime = 200000;
+ qs.constructNonAlignedChunkReadersMemTime = 100000;
+ qs.constructAlignedChunkReadersDiskTime = 150000;
+ qs.constructAlignedChunkReadersMemTime = 75000;
+
+ // Chunk stats
+ qs.loadChunkFromCacheCount = 10;
+ qs.loadChunkFromDiskCount = 8;
+ qs.loadChunkActualIOSize = 2048;
+
+ // Page reader stats
+ qs.pageReadersDecodeAlignedDiskCount = 6;
+ qs.pageReadersDecodeAlignedDiskTime = 300000;
+ qs.pageReadersDecodeAlignedMemCount = 4;
+ qs.pageReadersDecodeAlignedMemTime = 200000;
+ qs.pageReadersDecodeNonAlignedDiskCount = 3;
+ qs.pageReadersDecodeNonAlignedDiskTime = 150000;
+ qs.pageReadersDecodeNonAlignedMemCount = 2;
+ qs.pageReadersDecodeNonAlignedMemTime = 100000;
+ qs.pageReaderMaxUsedMemorySize = 65536;
+ qs.chunkWithMetadataErrorsCount = 1;
+
+ return qs;
+ }
+
+ private TFetchFragmentInstanceStatisticsResp createBasicStats() {
+ TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+ stats.setDataRegion("root.db.region1");
+ stats.setIp("127.0.0.1");
+ stats.setState("FINISHED");
+ stats.setStartTimeInMS(1000L);
+ stats.setEndTimeInMS(2000L);
+ stats.setInitDataQuerySourceCost(500000L);
+ stats.setSeqUnclosedNum(0);
+ stats.setSeqClosednNum(5);
+ stats.setUnseqUnclosedNum(1);
+ stats.setUnseqClosedNum(3);
+ stats.setReadyQueuedTime(100000L);
+ stats.setBlockQueuedTime(50000L);
+
+ TQueryStatistics queryStats = createTestQueryStatistics();
+ stats.setQueryStatistics(queryStats);
+ return stats;
+ }
+}
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 93dfa8bb1f4..eac3a68361e 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
@@ -938,8 +938,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