Skip to content

Commit 1f94e11

Browse files
JackieTien97robertMileaNi
authored andcommitted
Support EXPLAIN FORMAT JSON for Table Model (apache#17430)
1 parent 0c23ab1 commit 1f94e11

34 files changed

Lines changed: 3812 additions & 43 deletions

File tree

.codex/hooks.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"hooks": {
3+
"PostToolUse": [
4+
{
5+
"matcher": "Write|Edit",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"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; }",
10+
"timeout": 30,
11+
"statusMessage": "Running spotless format..."
12+
}
13+
]
14+
}
15+
]
16+
}
17+
}

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,4 @@ iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/gra
138138
.claude/settings.local.json
139139
.claude/todos/
140140
.claude/worktrees/
141-
.claude/scheduled_tasks.json
141+
.claude/scheduled_tasks.json
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.iotdb.cli.it;
21+
22+
import org.apache.iotdb.it.env.EnvFactory;
23+
import org.apache.iotdb.it.framework.IoTDBTestRunner;
24+
import org.apache.iotdb.itbase.category.TableLocalStandaloneIT;
25+
26+
import com.google.gson.JsonParser;
27+
import org.junit.AfterClass;
28+
import org.junit.BeforeClass;
29+
import org.junit.Test;
30+
import org.junit.experimental.categories.Category;
31+
import org.junit.runner.RunWith;
32+
33+
import java.io.BufferedReader;
34+
import java.io.File;
35+
import java.io.IOException;
36+
import java.io.InputStreamReader;
37+
import java.util.ArrayList;
38+
import java.util.List;
39+
40+
import static org.junit.Assert.assertEquals;
41+
import static org.junit.Assert.assertFalse;
42+
import static org.junit.Assert.assertTrue;
43+
import static org.junit.Assert.fail;
44+
45+
/**
46+
* Tests that EXPLAIN (FORMAT JSON) and EXPLAIN ANALYZE (FORMAT JSON) output raw JSON in CLI without
47+
* table borders (no '|' or '+---' formatting), so users can directly copy the JSON for
48+
* visualization.
49+
*/
50+
@RunWith(IoTDBTestRunner.class)
51+
@Category({TableLocalStandaloneIT.class})
52+
public class ExplainJsonCliOutputIT extends AbstractScriptIT {
53+
54+
private static String ip;
55+
private static String port;
56+
private static String sbinPath;
57+
private static String libPath;
58+
private static String homePath;
59+
60+
@BeforeClass
61+
public static void setUp() throws Exception {
62+
EnvFactory.getEnv().initClusterEnvironment();
63+
ip = EnvFactory.getEnv().getIP();
64+
port = EnvFactory.getEnv().getPort();
65+
sbinPath = EnvFactory.getEnv().getSbinPath();
66+
libPath = EnvFactory.getEnv().getLibPath();
67+
homePath =
68+
libPath.substring(0, libPath.lastIndexOf(File.separator + "lib" + File.separator + "*"));
69+
}
70+
71+
@AfterClass
72+
public static void tearDown() throws Exception {
73+
EnvFactory.getEnv().cleanClusterEnvironment();
74+
}
75+
76+
@Test
77+
public void test() throws IOException {
78+
String os = System.getProperty("os.name").toLowerCase();
79+
if (os.startsWith("windows")) {
80+
testOnWindows();
81+
} else {
82+
testOnUnix();
83+
}
84+
}
85+
86+
@Override
87+
protected void testOnWindows() throws IOException {
88+
// Setup test data
89+
ProcessBuilder setupBuilder =
90+
new ProcessBuilder(
91+
"cmd.exe",
92+
"/c",
93+
sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
94+
"-h",
95+
ip,
96+
"-p",
97+
port,
98+
"-sql_dialect",
99+
"table",
100+
"-e",
101+
"CREATE DATABASE IF NOT EXISTS test_cli_json;"
102+
+ " USE test_cli_json;"
103+
+ " CREATE TABLE IF NOT EXISTS t1(id STRING TAG, v FLOAT FIELD);"
104+
+ " INSERT INTO t1 VALUES(1000, 'd1', 1.0)",
105+
"&",
106+
"exit",
107+
"%^errorlevel%");
108+
setupBuilder.environment().put("IOTDB_HOME", homePath);
109+
testOutput(setupBuilder, new String[] {"Msg: The statement is executed successfully."}, 0);
110+
111+
// Test EXPLAIN (FORMAT JSON) output has no table borders
112+
// Use fully qualified table name (database.table) to avoid multi-statement quoting issues
113+
// on Windows where cmd.exe mangles ';' separated SQL through the batch script
114+
ProcessBuilder explainBuilder =
115+
new ProcessBuilder(
116+
"cmd.exe",
117+
"/c",
118+
sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
119+
"-h",
120+
ip,
121+
"-p",
122+
port,
123+
"-sql_dialect",
124+
"table",
125+
"-e",
126+
"EXPLAIN (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1",
127+
"&",
128+
"exit",
129+
"%^errorlevel%");
130+
explainBuilder.environment().put("IOTDB_HOME", homePath);
131+
assertRawJsonOutput(explainBuilder, "distribution plan");
132+
133+
// Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
134+
ProcessBuilder analyzeBuilder =
135+
new ProcessBuilder(
136+
"cmd.exe",
137+
"/c",
138+
sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
139+
"-h",
140+
ip,
141+
"-p",
142+
port,
143+
"-sql_dialect",
144+
"table",
145+
"-e",
146+
"EXPLAIN ANALYZE (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1",
147+
"&",
148+
"exit",
149+
"%^errorlevel%");
150+
analyzeBuilder.environment().put("IOTDB_HOME", homePath);
151+
assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
152+
}
153+
154+
@Override
155+
protected void testOnUnix() throws IOException {
156+
// Setup test data
157+
ProcessBuilder setupBuilder =
158+
new ProcessBuilder(
159+
"bash",
160+
sbinPath + File.separator + "start-cli.sh",
161+
"-h",
162+
ip,
163+
"-p",
164+
port,
165+
"-sql_dialect",
166+
"table",
167+
"-e",
168+
"\"CREATE DATABASE IF NOT EXISTS test_cli_json;"
169+
+ " USE test_cli_json;"
170+
+ " CREATE TABLE IF NOT EXISTS t1(id STRING TAG, v FLOAT FIELD);"
171+
+ " INSERT INTO t1 VALUES(1000, 'd1', 1.0)\"");
172+
setupBuilder.environment().put("IOTDB_HOME", homePath);
173+
testOutput(setupBuilder, new String[] {"Msg: The statement is executed successfully."}, 0);
174+
175+
// Test EXPLAIN (FORMAT JSON) output has no table borders
176+
// Use fully qualified table name (database.table) for consistency with Windows tests
177+
ProcessBuilder explainBuilder =
178+
new ProcessBuilder(
179+
"bash",
180+
sbinPath + File.separator + "start-cli.sh",
181+
"-h",
182+
ip,
183+
"-p",
184+
port,
185+
"-sql_dialect",
186+
"table",
187+
"-e",
188+
"\"EXPLAIN (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1\"");
189+
explainBuilder.environment().put("IOTDB_HOME", homePath);
190+
assertRawJsonOutput(explainBuilder, "distribution plan");
191+
192+
// Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
193+
ProcessBuilder analyzeBuilder =
194+
new ProcessBuilder(
195+
"bash",
196+
sbinPath + File.separator + "start-cli.sh",
197+
"-h",
198+
ip,
199+
"-p",
200+
port,
201+
"-sql_dialect",
202+
"table",
203+
"-e",
204+
"\"EXPLAIN ANALYZE (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1\"");
205+
analyzeBuilder.environment().put("IOTDB_HOME", homePath);
206+
assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
207+
}
208+
209+
/**
210+
* Formats all output lines into a single string for use in assertion messages. Each line is
211+
* prefixed with its index for easy identification.
212+
*/
213+
private String formatOutputForDiag(List<String> outputList) {
214+
StringBuilder sb = new StringBuilder();
215+
sb.append("Total lines: ").append(outputList.size()).append("\n");
216+
for (int i = 0; i < outputList.size(); i++) {
217+
sb.append(String.format(" [%3d] |%s|%n", i, outputList.get(i)));
218+
}
219+
return sb.toString();
220+
}
221+
222+
/**
223+
* Collects all output lines from a process, then verifies: 1. The column header is printed before
224+
* JSON content 2. No JSON content line has table border characters ('|' prefix or '+---'
225+
* separator) 3. The combined JSON content is valid JSON
226+
*/
227+
private void assertRawJsonOutput(ProcessBuilder builder, String expectedHeader)
228+
throws IOException {
229+
builder.redirectErrorStream(true);
230+
Process p = builder.start();
231+
BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
232+
String line;
233+
List<String> outputList = new ArrayList<>();
234+
while (true) {
235+
line = r.readLine();
236+
if (line == null) {
237+
break;
238+
} else {
239+
outputList.add(line);
240+
}
241+
}
242+
r.close();
243+
p.destroy();
244+
245+
System.out.println("Process output:");
246+
for (String s : outputList) {
247+
System.out.println(s);
248+
}
249+
250+
String fullOutput = formatOutputForDiag(outputList);
251+
252+
// Find the JSON content region: from the first line starting with '{' to the corresponding '}'
253+
int jsonStart = -1;
254+
int jsonEnd = -1;
255+
for (int i = 0; i < outputList.size(); i++) {
256+
String trimmed = outputList.get(i).trim();
257+
if (jsonStart == -1 && trimmed.startsWith("{")) {
258+
jsonStart = i;
259+
}
260+
// The last line that is just '}' marks the end of JSON
261+
if (jsonStart != -1 && trimmed.equals("}")) {
262+
jsonEnd = i;
263+
}
264+
}
265+
266+
assertTrue("Should find JSON start '{'. Full process output:\n" + fullOutput, jsonStart >= 0);
267+
assertTrue(
268+
"Should find JSON end '}'. jsonStart="
269+
+ jsonStart
270+
+ ". Full process output:\n"
271+
+ fullOutput,
272+
jsonEnd >= jsonStart);
273+
274+
// Verify the column header with table border appears before JSON content
275+
// Expected format: +---+ |header| +---+ {json...} +---+
276+
assertTrue(
277+
"Header border should appear before JSON content. jsonStart="
278+
+ jsonStart
279+
+ ". Full process output:\n"
280+
+ fullOutput,
281+
jsonStart >= 3);
282+
assertTrue(
283+
"Header border line should be present at line "
284+
+ (jsonStart - 3)
285+
+ ", got: '"
286+
+ outputList.get(jsonStart - 3).trim()
287+
+ "'. Full process output:\n"
288+
+ fullOutput,
289+
outputList.get(jsonStart - 3).trim().matches("\\+[-+]+\\+"));
290+
assertTrue(
291+
"Column header '"
292+
+ expectedHeader
293+
+ "' should be present at line "
294+
+ (jsonStart - 2)
295+
+ ", got: '"
296+
+ outputList.get(jsonStart - 2)
297+
+ "'. Full process output:\n"
298+
+ fullOutput,
299+
outputList.get(jsonStart - 2).contains(expectedHeader));
300+
assertTrue(
301+
"Header border line should be present at line "
302+
+ (jsonStart - 1)
303+
+ ", got: '"
304+
+ outputList.get(jsonStart - 1).trim()
305+
+ "'. Full process output:\n"
306+
+ fullOutput,
307+
outputList.get(jsonStart - 1).trim().matches("\\+[-+]+\\+"));
308+
309+
// Verify JSON content lines do not have '|' borders
310+
for (int i = jsonStart; i <= jsonEnd; i++) {
311+
String s = outputList.get(i).trim();
312+
assertFalse(
313+
"JSON line " + i + " should not start with '|', but found: " + s, s.startsWith("|"));
314+
}
315+
316+
// Concatenate JSON lines and verify it's valid JSON
317+
StringBuilder jsonBuilder = new StringBuilder();
318+
for (int i = jsonStart; i <= jsonEnd; i++) {
319+
jsonBuilder.append(outputList.get(i));
320+
}
321+
String jsonStr = jsonBuilder.toString();
322+
try {
323+
JsonParser.parseString(jsonStr).getAsJsonObject();
324+
} catch (Exception e) {
325+
fail(
326+
"Output should be valid JSON, but got parse error: "
327+
+ e.getMessage()
328+
+ "\nJSON string: "
329+
+ jsonStr
330+
+ "\nFull process output:\n"
331+
+ fullOutput);
332+
}
333+
334+
// Verify process exit code
335+
while (p.isAlive()) {
336+
try {
337+
Thread.sleep(100);
338+
} catch (InterruptedException e) {
339+
e.printStackTrace();
340+
fail();
341+
}
342+
}
343+
assertEquals("Process exit code should be 0. Full output:\n" + fullOutput, 0, p.exitValue());
344+
}
345+
}

integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ public void testExplain() throws SQLException {
460460
e.getMessage(),
461461
e.getMessage()
462462
.contains(
463-
"700: line 1:9: mismatched input 'INSERT'. Expecting: 'ANALYZE', 'EXECUTE', <query>"));
463+
"700: line 1:9: mismatched input 'INSERT'. Expecting: '(', 'ANALYZE', 'EXECUTE', <query>"));
464464
}
465465

466466
try {
@@ -472,7 +472,7 @@ public void testExplain() throws SQLException {
472472
e.getMessage(),
473473
e.getMessage()
474474
.contains(
475-
"700: line 1:17: mismatched input 'INSERT'. Expecting: 'EXECUTE', 'VERBOSE', <query>"));
475+
"700: line 1:17: mismatched input 'INSERT'. Expecting: '(', 'EXECUTE', 'VERBOSE', <query>"));
476476
}
477477
}
478478

0 commit comments

Comments
 (0)