Skip to content

Commit 7c66201

Browse files
JackieTien97claude
andcommitted
Remove '|' borders from JSON content in CLI EXPLAIN FORMAT JSON output
When using EXPLAIN (FORMAT JSON) or EXPLAIN ANALYZE (FORMAT JSON) in the CLI, the JSON content was wrapped in table borders (|), making it hard to copy for visualization. Now the header retains its table border formatting while JSON content lines are printed without '|' borders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c8cc5d7 commit 7c66201

2 files changed

Lines changed: 338 additions & 1 deletion

File tree

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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+
ProcessBuilder explainBuilder =
113+
new ProcessBuilder(
114+
"cmd.exe",
115+
"/c",
116+
sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
117+
"-h",
118+
ip,
119+
"-p",
120+
port,
121+
"-sql_dialect",
122+
"table",
123+
"-e",
124+
"\"USE test_cli_json; EXPLAIN (FORMAT JSON) SELECT * FROM t1\"",
125+
"&",
126+
"exit",
127+
"%^errorlevel%");
128+
explainBuilder.environment().put("IOTDB_HOME", homePath);
129+
assertRawJsonOutput(explainBuilder, "distribution plan");
130+
131+
// Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
132+
ProcessBuilder analyzeBuilder =
133+
new ProcessBuilder(
134+
"cmd.exe",
135+
"/c",
136+
sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
137+
"-h",
138+
ip,
139+
"-p",
140+
port,
141+
"-sql_dialect",
142+
"table",
143+
"-e",
144+
"\"USE test_cli_json; EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM t1\"",
145+
"&",
146+
"exit",
147+
"%^errorlevel%");
148+
analyzeBuilder.environment().put("IOTDB_HOME", homePath);
149+
assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
150+
}
151+
152+
@Override
153+
protected void testOnUnix() throws IOException {
154+
// Setup test data
155+
ProcessBuilder setupBuilder =
156+
new ProcessBuilder(
157+
"bash",
158+
sbinPath + File.separator + "start-cli.sh",
159+
"-h",
160+
ip,
161+
"-p",
162+
port,
163+
"-sql_dialect",
164+
"table",
165+
"-e",
166+
"\"CREATE DATABASE IF NOT EXISTS test_cli_json;"
167+
+ " USE test_cli_json;"
168+
+ " CREATE TABLE IF NOT EXISTS t1(id STRING TAG, v FLOAT FIELD);"
169+
+ " INSERT INTO t1 VALUES(1000, 'd1', 1.0)\"");
170+
setupBuilder.environment().put("IOTDB_HOME", homePath);
171+
testOutput(setupBuilder, new String[] {"Msg: The statement is executed successfully."}, 0);
172+
173+
// Test EXPLAIN (FORMAT JSON) output has no table borders
174+
ProcessBuilder explainBuilder =
175+
new ProcessBuilder(
176+
"bash",
177+
sbinPath + File.separator + "start-cli.sh",
178+
"-h",
179+
ip,
180+
"-p",
181+
port,
182+
"-sql_dialect",
183+
"table",
184+
"-e",
185+
"\"USE test_cli_json; EXPLAIN (FORMAT JSON) SELECT * FROM t1\"");
186+
explainBuilder.environment().put("IOTDB_HOME", homePath);
187+
assertRawJsonOutput(explainBuilder, "distribution plan");
188+
189+
// Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
190+
ProcessBuilder analyzeBuilder =
191+
new ProcessBuilder(
192+
"bash",
193+
sbinPath + File.separator + "start-cli.sh",
194+
"-h",
195+
ip,
196+
"-p",
197+
port,
198+
"-sql_dialect",
199+
"table",
200+
"-e",
201+
"\"USE test_cli_json; EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM t1\"");
202+
analyzeBuilder.environment().put("IOTDB_HOME", homePath);
203+
assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
204+
}
205+
206+
/**
207+
* Collects all output lines from a process, then verifies: 1. The column header is printed before
208+
* JSON content 2. No JSON content line has table border characters ('|' prefix or '+---'
209+
* separator) 3. The combined JSON content is valid JSON
210+
*/
211+
private void assertRawJsonOutput(ProcessBuilder builder, String expectedHeader)
212+
throws IOException {
213+
builder.redirectErrorStream(true);
214+
Process p = builder.start();
215+
BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
216+
String line;
217+
List<String> outputList = new ArrayList<>();
218+
while (true) {
219+
line = r.readLine();
220+
if (line == null) {
221+
break;
222+
} else {
223+
outputList.add(line);
224+
}
225+
}
226+
r.close();
227+
p.destroy();
228+
229+
System.out.println("Process output:");
230+
for (String s : outputList) {
231+
System.out.println(s);
232+
}
233+
234+
// Find the JSON content region: from the first line starting with '{' to the corresponding '}'
235+
int jsonStart = -1;
236+
int jsonEnd = -1;
237+
for (int i = 0; i < outputList.size(); i++) {
238+
String trimmed = outputList.get(i).trim();
239+
if (jsonStart == -1 && trimmed.startsWith("{")) {
240+
jsonStart = i;
241+
}
242+
// The last line that is just '}' marks the end of JSON
243+
if (jsonStart != -1 && trimmed.equals("}")) {
244+
jsonEnd = i;
245+
}
246+
}
247+
248+
assertTrue("Should find JSON start '{'", jsonStart >= 0);
249+
assertTrue("Should find JSON end '}'", jsonEnd >= jsonStart);
250+
251+
// Verify the column header with table border appears before JSON content
252+
// Expected format: +---+ |header| +---+ {json...} +---+
253+
assertTrue("Header border should appear before JSON content", jsonStart >= 3);
254+
assertTrue(
255+
"Header border line should be present",
256+
outputList.get(jsonStart - 3).trim().matches("\\+[-+]+\\+"));
257+
assertTrue(
258+
"Column header '" + expectedHeader + "' should be present",
259+
outputList.get(jsonStart - 2).contains(expectedHeader));
260+
assertTrue(
261+
"Header border line should be present",
262+
outputList.get(jsonStart - 1).trim().matches("\\+[-+]+\\+"));
263+
264+
// Verify JSON content lines do not have '|' borders
265+
for (int i = jsonStart; i <= jsonEnd; i++) {
266+
String s = outputList.get(i).trim();
267+
assertFalse("JSON line should not start with '|', but found: " + s, s.startsWith("|"));
268+
}
269+
270+
// Concatenate JSON lines and verify it's valid JSON
271+
StringBuilder jsonBuilder = new StringBuilder();
272+
for (int i = jsonStart; i <= jsonEnd; i++) {
273+
jsonBuilder.append(outputList.get(i));
274+
}
275+
String jsonStr = jsonBuilder.toString();
276+
try {
277+
JsonParser.parseString(jsonStr).getAsJsonObject();
278+
} catch (Exception e) {
279+
fail("Output should be valid JSON, but got parse error: " + e.getMessage());
280+
}
281+
282+
// Verify process exit code
283+
while (p.isAlive()) {
284+
try {
285+
Thread.sleep(100);
286+
} catch (InterruptedException e) {
287+
e.printStackTrace();
288+
fail();
289+
}
290+
}
291+
assertEquals(0, p.exitValue());
292+
}
293+
}

iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,11 @@ private static int executeQuery(CliContext ctx, IoTDBConnection connection, Stri
565565
List<Integer> maxSizeList = new ArrayList<>(columnLength);
566566
List<List<String>> lists =
567567
cacheResult(ctx, resultSet, maxSizeList, columnLength, resultSetMetaData, zoneId);
568-
output(ctx, lists, maxSizeList);
568+
if (isJsonExplainResult(lists)) {
569+
outputRawJson(ctx, lists, maxSizeList);
570+
} else {
571+
output(ctx, lists, maxSizeList);
572+
}
569573
ctx.getPrinter().println(String.format("It costs %.3fs", costTime / 1000.0));
570574
while (!isReachEnd) {
571575
if (continuePrint) {
@@ -815,6 +819,46 @@ private static List<List<String>> cacheTracingInfo(ResultSet resultSet, List<Int
815819
return lists;
816820
}
817821

822+
private static final String COLUMN_DISTRIBUTION_PLAN = "distribution plan";
823+
private static final String COLUMN_EXPLAIN_ANALYZE = "Explain Analyze";
824+
825+
private static boolean isJsonExplainResult(List<List<String>> lists) {
826+
if (lists.size() != 1 || lists.get(0).size() < 2) {
827+
return false;
828+
}
829+
String columnName = lists.get(0).get(0);
830+
if (!COLUMN_DISTRIBUTION_PLAN.equalsIgnoreCase(columnName)
831+
&& !COLUMN_EXPLAIN_ANALYZE.equalsIgnoreCase(columnName)) {
832+
return false;
833+
}
834+
String value = lists.get(0).get(1).trim();
835+
return value.startsWith("{") || value.startsWith("[");
836+
}
837+
838+
private static void outputRawJson(
839+
CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) {
840+
// Use header text length for border width instead of the full content length
841+
String header = lists.get(0).get(0);
842+
int headerLen = header.length() + ctx.getPrinter().computeHANCount(header);
843+
List<Integer> headerSizeList = new ArrayList<>(1);
844+
headerSizeList.add(headerLen);
845+
// Print header with table border
846+
ctx.getPrinter().printBlockLine(headerSizeList);
847+
ctx.getPrinter().printRow(lists, 0, headerSizeList);
848+
ctx.getPrinter().printBlockLine(headerSizeList);
849+
// Print JSON content without '|' borders
850+
for (int i = 1; i < lists.get(0).size(); i++) {
851+
ctx.getPrinter().println(lists.get(0).get(i));
852+
}
853+
ctx.getPrinter().printBlockLine(headerSizeList);
854+
if (isReachEnd) {
855+
lineCount += lists.get(0).size() - 1;
856+
ctx.getPrinter().printCount(lineCount);
857+
} else {
858+
lineCount += maxPrintRowCount;
859+
}
860+
}
861+
818862
private static void output(CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) {
819863
ctx.getPrinter().printBlockLine(maxSizeList);
820864
ctx.getPrinter().printRow(lists, 0, maxSizeList);

0 commit comments

Comments
 (0)