This is an automated email from the ASF dual-hosted git repository.

Jackie-Jiang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new f290065ed21 Add -outputFormat and -outputFile options to PostQuery CLI 
command (#19610)
f290065ed21 is described below

commit f290065ed212f147292f6efa01720863faaef2f0
Author: Kazuki Kanda <[email protected]>
AuthorDate: Sat Oct 24 06:36:32 2026 +0900

    Add -outputFormat and -outputFile options to PostQuery CLI command (#19610)
---
 docker/images/pinot/Dockerfile                     |   2 +-
 docker/images/pinot/Dockerfile.build               |   2 +-
 docker/images/pinot/Dockerfile.package             |   2 +-
 pinot-tools/pom.xml                                |   4 +
 .../tools/admin/command/PostQueryCommand.java      | 130 +++++++-
 .../tools/admin/command/PostQueryCommandTest.java  | 330 +++++++++++++++++++++
 6 files changed, 466 insertions(+), 4 deletions(-)

diff --git a/docker/images/pinot/Dockerfile b/docker/images/pinot/Dockerfile
index 3d5d857c4a7..b121007ad9a 100644
--- a/docker/images/pinot/Dockerfile
+++ b/docker/images/pinot/Dockerfile
@@ -49,7 +49,7 @@ FROM apachepinot/pinot-base-runtime:${PINOT_BASE_IMAGE_TAG}
 LABEL [email protected]
 
 ENV PINOT_HOME=/opt/pinot
-ENV JAVA_OPTS="-Xms4G -Xmx4G -Dpinot.admin.system.exit=false"
+ENV JAVA_OPTS="-Xms4G -Xmx4G -Dpinot.admin.system.exit=true"
 
 VOLUME ["${PINOT_HOME}/configs", "${PINOT_HOME}/data"]
 
diff --git a/docker/images/pinot/Dockerfile.build 
b/docker/images/pinot/Dockerfile.build
index 0fe132a4b11..f82b415db69 100644
--- a/docker/images/pinot/Dockerfile.build
+++ b/docker/images/pinot/Dockerfile.build
@@ -44,7 +44,7 @@ RUN git clone https://github.com/apache/pinot.git 
${PINOT_BUILD_DIR} && \
     cp -r build/* ${PINOT_HOME}/. && \
     chmod +x ${PINOT_HOME}/bin/*.sh
 
-ENV JAVA_OPTS="-Xms4G -Xmx4G -Dpinot.admin.system.exit=false"
+ENV JAVA_OPTS="-Xms4G -Xmx4G -Dpinot.admin.system.exit=true"
 
 VOLUME ["${PINOT_HOME}/configs", "${PINOT_HOME}/data"]
 
diff --git a/docker/images/pinot/Dockerfile.package 
b/docker/images/pinot/Dockerfile.package
index 2390adce39b..76b6d3053ba 100644
--- a/docker/images/pinot/Dockerfile.package
+++ b/docker/images/pinot/Dockerfile.package
@@ -23,7 +23,7 @@ FROM apachepinot/pinot-base-runtime:${PINOT_RUNTIME_IMAGE_TAG}
 LABEL [email protected]
 
 ENV PINOT_HOME=/opt/pinot
-ENV JAVA_OPTS="-Xms4G -Xmx4G -Dpinot.admin.system.exit=false"
+ENV JAVA_OPTS="-Xms4G -Xmx4G -Dpinot.admin.system.exit=true"
 
 VOLUME ["${PINOT_HOME}/configs", "${PINOT_HOME}/data"]
 
diff --git a/pinot-tools/pom.xml b/pinot-tools/pom.xml
index fff7a755564..57827e8d0a2 100644
--- a/pinot-tools/pom.xml
+++ b/pinot-tools/pom.xml
@@ -69,6 +69,10 @@
       <groupId>org.apache.pinot</groupId>
       <artifactId>pinot-csv</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-csv</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.pinot</groupId>
       <artifactId>pinot-protobuf</artifactId>
diff --git 
a/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/PostQueryCommand.java
 
b/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/PostQueryCommand.java
index d0e281132c1..0c5d715ad45 100644
--- 
a/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/PostQueryCommand.java
+++ 
b/pinot-tools/src/main/java/org/apache/pinot/tools/admin/command/PostQueryCommand.java
@@ -18,8 +18,17 @@
  */
 package org.apache.pinot.tools.admin.command;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.File;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.Map;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVPrinter;
 import org.apache.pinot.common.auth.AuthProviderUtils;
 import org.apache.pinot.spi.auth.AuthProvider;
 import org.apache.pinot.spi.utils.CommonConstants;
@@ -36,6 +45,12 @@ import picocli.CommandLine;
 public class PostQueryCommand extends AbstractBaseAdminCommand implements 
Command {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(PostQueryCommand.class.getName());
 
+  /// Supported output formats for the query response: `JSON` (default, raw 
broker response) or
+  /// `CSV` (rendered from `resultTable`; see `formatResponse` for fallback 
behavior).
+  public enum OutputFormat {
+    JSON, CSV
+  }
+
   @CommandLine.Option(names = {"-brokerHost"}, required = false, description = 
"host name for broker.")
   private String _brokerHost;
 
@@ -63,8 +78,22 @@ public class PostQueryCommand extends 
AbstractBaseAdminCommand implements Comman
   @CommandLine.Option(names = {"-o", "-option"}, required = false, description 
= "Additional options '-o key=value'")
   private Map<String, String> _additionalOptions = new HashMap<>();
 
+  @CommandLine.Option(names = {"-outputFormat"}, required = false,
+      description = "Output format for the query response: JSON (default) or 
CSV (upper-case).")
+  private OutputFormat _outputFormat = OutputFormat.JSON;
+
+  @CommandLine.Option(names = {"-outputFile"}, required = false,
+      description = "File path to write the query response to. If omitted, the 
response is only "
+          + "logged (pre-existing behavior).")
+  private String _outputFile;
+
   private AuthProvider _authProvider;
 
+  /// Whether the most recent `formatResponse()` result is safe to write to 
`_outputFile` in the
+  /// requested format. This is `false` when CSV formatting falls back to the 
raw response or when
+  /// the CSV would omit response-level failure metadata such as exceptions or 
`partialResult`.
+  private boolean _lastResponseSafeForOutputFile = true;
+
   @Override
   public String getName() {
     return "PostQuery";
@@ -125,6 +154,16 @@ public class PostQueryCommand extends 
AbstractBaseAdminCommand implements Comman
     return this;
   }
 
+  public PostQueryCommand setOutputFormat(OutputFormat outputFormat) {
+    _outputFormat = outputFormat;
+    return this;
+  }
+
+  public PostQueryCommand setOutputFile(String outputFile) {
+    _outputFile = outputFile;
+    return this;
+  }
+
   public String run()
       throws Exception {
     if (_brokerHost == null) {
@@ -138,8 +177,86 @@ public class PostQueryCommand extends 
AbstractBaseAdminCommand implements Comman
       payload.putAll(_additionalOptions);
     }
     String request = JsonUtils.objectToString(payload);
-    return sendRequest("POST", url, request, AuthProviderUtils.makeAuthHeaders(
+    String response = sendRequest("POST", url, request, 
AuthProviderUtils.makeAuthHeaders(
         AuthProviderUtils.makeAuthProvider(_authProvider, _authTokenUrl, 
_authToken, _user, _password)));
+    return formatResponse(response);
+  }
+
+  /// Renders the raw broker response according to `_outputFormat`. Never 
throws for a
+  /// CSV-incompatible response: falls back to the raw response whenever the 
response isn't valid
+  /// JSON, or has no `resultTable` (e.g. the query errored out), so no error 
detail is ever lost
+  /// from the logged/returned string. Marks the response unsafe for file 
output whenever this
+  /// fallback happens, so `execute()` can refuse to write a non-CSV body to 
`-outputFile`
+  /// when CSV was requested. A partial response is still rendered for 
logging, but is marked
+  /// unsafe for file output because CSV cannot preserve its response-level 
diagnostics.
+  String formatResponse(String rawResponse) {
+    _lastResponseSafeForOutputFile = true;
+    if (_outputFormat != OutputFormat.CSV) {
+      return rawResponse;
+    }
+    JsonNode root;
+    try {
+      root = JsonUtils.stringToJsonNode(rawResponse);
+    } catch (IOException e) {
+      LOGGER.warn("Response is not valid JSON (e.g. a broker/proxy error 
page); "
+          + "falling back to the raw response instead of CSV.", e);
+      _lastResponseSafeForOutputFile = false;
+      return rawResponse;
+    }
+    JsonNode resultTable = root.get("resultTable");
+    if (resultTable == null || resultTable.isNull()) {
+      LOGGER.warn("Response has no 'resultTable' (e.g. the query may have 
errored out); "
+          + "falling back to JSON output instead of CSV.");
+      _lastResponseSafeForOutputFile = false;
+      return rawResponse;
+    }
+    JsonNode exceptions = root.path("exceptions");
+    if (exceptions.size() > 0 || root.path("partialResult").asBoolean(false)) {
+      LOGGER.warn("Response has a 'resultTable' but also reports exceptions 
and/or a partial "
+          + "result; CSV output only renders resultTable rows, so this detail 
is not reflected "
+          + "in the CSV. Full broker response: {}", rawResponse);
+      _lastResponseSafeForOutputFile = false;
+    }
+    JsonNode columnNames = resultTable.path("dataSchema").path("columnNames");
+    JsonNode rows = resultTable.path("rows");
+    StringWriter stringWriter = new StringWriter();
+    try (CSVPrinter csvPrinter = new CSVPrinter(stringWriter, 
CSVFormat.DEFAULT)) {
+      for (JsonNode columnName : columnNames) {
+        csvPrinter.print(columnName.asText());
+      }
+      csvPrinter.println();
+      for (JsonNode row : rows) {
+        Iterator<JsonNode> cells = row.elements();
+        while (cells.hasNext()) {
+          JsonNode cell = cells.next();
+          // SQL NULL (JSON null) must render as an empty CSV field, matching 
Pinot CLI's
+          // existing CSV output convention. Without this, a null cell and a 
STRING cell holding
+          // the literal text "null" would be indistinguishable in the export. 
Note:
+          // csvPrinter.print(null) (not print("")) is used here for 
correctness in the general
+          // case: for a *leading* column, commons-csv's MINIMAL quote mode 
quotes an explicit
+          // empty string as `""` (to keep it distinguishable from a 
null/omitted leading field)
+          // but leaves print(null) unquoted, so only print(null) is 
guaranteed to produce an
+          // unquoted empty field regardless of column position. (For 
non-leading columns,
+          // print("") and print(null) already produce the same unquoted 
output; a STRING cell
+          // holding an actual empty string in a non-leading column remains 
indistinguishable
+          // from NULL either way -- a pre-existing, unrelated ambiguity this 
change does not
+          // introduce or attempt to fix.)
+          if (cell.isNull()) {
+            csvPrinter.print(null);
+          } else {
+            csvPrinter.print(cell.isTextual() ? cell.asText() : 
cell.toString());
+          }
+        }
+        csvPrinter.println();
+      }
+    } catch (IOException e) {
+      // CSVPrinter only throws IOException for the underlying Appendable; a 
StringWriter never
+      // throws, so this is unreachable in practice. Fall back to JSON rather 
than propagate.
+      LOGGER.warn("Unexpected error rendering CSV; falling back to JSON 
output.", e);
+      _lastResponseSafeForOutputFile = false;
+      return rawResponse;
+    }
+    return stringWriter.toString();
   }
 
   @Override
@@ -147,6 +264,17 @@ public class PostQueryCommand extends 
AbstractBaseAdminCommand implements Comman
       throws Exception {
     String result = run();
     LOGGER.info("Result: {}", result);
+    if (_outputFile != null) {
+      if (_outputFormat == OutputFormat.CSV && 
!_lastResponseSafeForOutputFile) {
+        // The response either could not be rendered as CSV or the CSV would 
omit failure metadata.
+        // Refuse the write so a file consumer cannot mistake fallback JSON or 
incomplete rows for
+        // a successful CSV export. The failure details remain visible in the 
logs.
+        LOGGER.warn("Not writing '{}': response is not safe for CSV file 
output. Use "
+            + "-outputFormat JSON to inspect the full response.", _outputFile);
+        return false;
+      }
+      Files.write(new File(_outputFile).toPath(), 
result.getBytes(StandardCharsets.UTF_8));
+    }
     return true;
   }
 }
diff --git 
a/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/PostQueryCommandTest.java
 
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/PostQueryCommandTest.java
new file mode 100644
index 00000000000..349f83eb929
--- /dev/null
+++ 
b/pinot-tools/src/test/java/org/apache/pinot/tools/admin/command/PostQueryCommandTest.java
@@ -0,0 +1,330 @@
+/**
+ * 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.pinot.tools.admin.command;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+import picocli.CommandLine;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+public class PostQueryCommandTest {
+  private static final String JSON_RESPONSE_WITH_RESULT_TABLE =
+      "{\"resultTable\":{\"dataSchema\":{\"columnNames\":[\"col1\",\"col2\"],"
+          + 
"\"columnDataTypes\":[\"STRING\",\"LONG\"]},\"rows\":[[\"a\",1],[\"b,c\",2]]},"
+          + "\"exceptions\":[],\"numRowsResultSet\":2}";
+
+  private static final String JSON_RESPONSE_WITH_NULL_AND_LITERAL_NULL_STRING =
+      "{\"resultTable\":{\"dataSchema\":{\"columnNames\":[\"col1\",\"col2\"],"
+          + 
"\"columnDataTypes\":[\"STRING\",\"STRING\"]},\"rows\":[[null,\"null\"]]},"
+          + "\"exceptions\":[],\"numRowsResultSet\":1}";
+
+  private static final String JSON_RESPONSE_WITHOUT_RESULT_TABLE =
+      
"{\"exceptions\":[{\"errorCode\":150,\"message\":\"QueryExecutionError\"}]}";
+
+  private static final String JSON_RESPONSE_WITH_RESULT_TABLE_AND_EXCEPTIONS =
+      
"{\"resultTable\":{\"dataSchema\":{\"columnNames\":[\"col1\"],\"columnDataTypes\":[\"STRING\"]},"
+          + 
"\"rows\":[[\"a\"]]},\"exceptions\":[{\"errorCode\":200,\"message\":\"partial 
failure\"}]}";
+
+  private static final String JSON_RESPONSE_WITH_PARTIAL_RESULT =
+      
"{\"resultTable\":{\"dataSchema\":{\"columnNames\":[\"col1\"],\"columnDataTypes\":[\"STRING\"]},"
+          + "\"rows\":[[\"a\"]]},\"exceptions\":[],\"partialResult\":true}";
+
+  private static final String NOT_JSON_RESPONSE = "<html><body>502 Bad 
Gateway</body></html>";
+
+  // A dedicated per-test temp directory, not the shared system temp dir. 
Pinot CI runs unit-test
+  // classes in three parallel forks, and other pinot-tools tests also create 
files under the
+  // shared system temp dir; snapshotting that shared directory would make 
file-count assertions
+  // flaky. This directory is created fresh in setUp() and is only ever 
touched by this test.
+  private File _tempDir;
+  private File _tempOutputFile;
+
+  @BeforeMethod
+  public void setUp()
+      throws Exception {
+    _tempDir = 
Files.createTempDirectory("post-query-command-test-dir-").toFile();
+    _tempOutputFile = File.createTempFile("post-query-command-test-", ".out", 
_tempDir);
+  }
+
+  @AfterMethod
+  public void tearDown() {
+    if (_tempDir != null) {
+      File[] files = _tempDir.listFiles();
+      if (files != null) {
+        for (File file : files) {
+          file.delete();
+        }
+      }
+      _tempDir.delete();
+    }
+  }
+
+  @Test
+  public void testFormatResponseDefaultIsJson() {
+    PostQueryCommand command = new PostQueryCommand();
+    String formatted = command.formatResponse(JSON_RESPONSE_WITH_RESULT_TABLE);
+    assertEquals(formatted, JSON_RESPONSE_WITH_RESULT_TABLE);
+  }
+
+  @Test
+  public void testFormatResponseCsvRendersResultTable() {
+    PostQueryCommand command = new PostQueryCommand();
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    String csv = command.formatResponse(JSON_RESPONSE_WITH_RESULT_TABLE);
+    String[] lines = csv.split("\r\n");
+    assertEquals(lines.length, 3);
+    assertEquals(lines[0], "col1,col2");
+    assertEquals(lines[1], "a,1");
+    // A comma-containing cell value must be quoted by CSV rendering, not 
silently break columns.
+    assertEquals(lines[2], "\"b,c\",2");
+  }
+
+  @Test
+  public void 
testFormatResponseCsvRendersSqlNullAsEmptyFieldDistinctFromLiteralNullString() {
+    // A JSON null cell (SQL NULL) must render as an empty CSV field, matching 
Pinot CLI's
+    // existing convention -- distinct from a STRING cell that literally 
contains "null".
+    PostQueryCommand command = new PostQueryCommand();
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    String csv = 
command.formatResponse(JSON_RESPONSE_WITH_NULL_AND_LITERAL_NULL_STRING);
+    String[] lines = csv.split("\r\n");
+    assertEquals(lines.length, 2);
+    assertEquals(lines[0], "col1,col2");
+    assertEquals(lines[1], ",null");
+  }
+
+  @Test
+  public void testFormatResponseCsvFallsBackToJsonWithoutResultTable() {
+    PostQueryCommand command = new PostQueryCommand();
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    String formatted = 
command.formatResponse(JSON_RESPONSE_WITHOUT_RESULT_TABLE);
+    assertEquals(formatted, JSON_RESPONSE_WITHOUT_RESULT_TABLE);
+  }
+
+  @Test
+  public void testFormatResponseCsvFallsBackToRawResponseWhenNotJson() {
+    // e.g. a proxy/broker error page that isn't valid JSON at all -- must not 
throw.
+    PostQueryCommand command = new PostQueryCommand();
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    String formatted = command.formatResponse(NOT_JSON_RESPONSE);
+    assertEquals(formatted, NOT_JSON_RESPONSE);
+  }
+
+  @Test
+  public void 
testFormatResponseCsvWithResultTableAndExceptionsStillRendersCsv() {
+    // A resultTable can coexist with a non-empty 'exceptions' list (partial 
failure). CSV mode
+    // should still render the table (rather than silently discarding it) 
while separately
+    // warning that the CSV doesn't carry the exception detail.
+    PostQueryCommand command = new PostQueryCommand();
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    String csv = 
command.formatResponse(JSON_RESPONSE_WITH_RESULT_TABLE_AND_EXCEPTIONS);
+    assertEquals(csv, "col1\r\na\r\n");
+  }
+
+  @Test
+  public void testFormatResponseCsvWithPartialResultStillRendersCsv() {
+    PostQueryCommand command = new PostQueryCommand();
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    String csv = command.formatResponse(JSON_RESPONSE_WITH_PARTIAL_RESULT);
+    assertEquals(csv, "col1\r\na\r\n");
+  }
+
+  @Test
+  public void testCliParsingDefaultOutputFormatIsJson() {
+    PostQueryCommand command = new PostQueryCommand();
+    new CommandLine(command).parseArgs("-query", "select 1");
+    assertEquals(command.formatResponse(JSON_RESPONSE_WITH_RESULT_TABLE), 
JSON_RESPONSE_WITH_RESULT_TABLE);
+  }
+
+  @Test
+  public void testCliParsingOutputFormatCsv() {
+    PostQueryCommand command = new PostQueryCommand();
+    new CommandLine(command).parseArgs("-query", "select 1", "-outputFormat", 
"CSV");
+    String csv = command.formatResponse(JSON_RESPONSE_WITH_RESULT_TABLE);
+    assertTrue(csv.startsWith("col1,col2"));
+  }
+
+  @Test
+  public void testExecuteWritesFormattedResponseToOutputFile()
+      throws Exception {
+    PostQueryCommand command = new PostQueryCommand() {
+      @Override
+      public String run()
+          throws Exception {
+        // Skip the real broker HTTP call; exercise execute()'s output-file 
writing with a
+        // canned CSV-formatted response instead.
+        return "col1,col2\r\na,1\r\n";
+      }
+    };
+    command.setOutputFile(_tempOutputFile.getAbsolutePath());
+    command.execute();
+    String written = new String(Files.readAllBytes(_tempOutputFile.toPath()), 
StandardCharsets.UTF_8);
+    assertEquals(written, "col1,col2\r\na,1\r\n");
+  }
+
+  @Test
+  public void 
testExecuteDoesNotWriteOutputFileWhenCsvRequestedButResponseNotRenderableAsCsv()
+      throws Exception {
+    // execute() must not let a file consumer mistake a raw JSON/error 
fallback body for a
+    // successful CSV export: when -outputFormat CSV was requested but 
formatResponse() fell back
+    // (e.g. broker error response with no resultTable), execute() must report 
failure and must
+    // not write -outputFile at all.
+    assertTrue(_tempOutputFile.delete());
+    PostQueryCommand command = new PostQueryCommand() {
+      @Override
+      public String run() {
+        // formatResponse() is invoked for real here (not overridden), so the 
CSV-fallback path
+        // and _lastResponseSafeForOutputFile bookkeeping are exercised 
end-to-end.
+        return formatResponse(JSON_RESPONSE_WITHOUT_RESULT_TABLE);
+      }
+    };
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    command.setOutputFile(_tempOutputFile.getAbsolutePath());
+    boolean result = command.execute();
+    assertFalse(result);
+    assertFalse(_tempOutputFile.exists());
+  }
+
+  @Test
+  public void testExecuteDoesNotWriteCsvFileForRowsWithExceptions()
+      throws Exception {
+    assertTrue(_tempOutputFile.delete());
+    PostQueryCommand command = 
commandReturning(JSON_RESPONSE_WITH_RESULT_TABLE_AND_EXCEPTIONS);
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    command.setOutputFile(_tempOutputFile.getAbsolutePath());
+
+    assertFalse(command.execute());
+    assertFalse(_tempOutputFile.exists());
+  }
+
+  @Test
+  public void testExecuteDoesNotWriteCsvFileForPartialResult()
+      throws Exception {
+    assertTrue(_tempOutputFile.delete());
+    PostQueryCommand command = 
commandReturning(JSON_RESPONSE_WITH_PARTIAL_RESULT);
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    command.setOutputFile(_tempOutputFile.getAbsolutePath());
+
+    assertFalse(command.execute());
+    assertFalse(_tempOutputFile.exists());
+  }
+
+  @Test
+  public void testExecuteRejectedCsvLeavesExistingOutputFileUntouched()
+      throws Exception {
+    String sentinel = "previous successful export";
+    Files.write(_tempOutputFile.toPath(), 
sentinel.getBytes(StandardCharsets.UTF_8));
+    PostQueryCommand command = 
commandReturning(JSON_RESPONSE_WITH_PARTIAL_RESULT);
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    command.setOutputFile(_tempOutputFile.getAbsolutePath());
+
+    assertFalse(command.execute());
+    String contents = new String(Files.readAllBytes(_tempOutputFile.toPath()), 
StandardCharsets.UTF_8);
+    assertEquals(contents, sentinel);
+  }
+
+  @Test
+  public void 
testExecuteWritesCsvFormattedResponseToOutputFileOnSuccessfulCsvRendering()
+      throws Exception {
+    // End-to-end coverage for the success path that the CSV-fallback-failure 
test above does not
+    // exercise: -outputFormat CSV + -outputFile, with a response that CAN be 
rendered as CSV,
+    // must both report success and write the actual CSV-rendered body (not 
the raw JSON) to disk.
+    assertTrue(_tempOutputFile.delete());
+    PostQueryCommand command = new PostQueryCommand() {
+      @Override
+      public String run() {
+        // formatResponse() is invoked for real here, so the CSV success path 
and
+        // _lastResponseSafeForOutputFile bookkeeping are exercised 
end-to-end, matching the
+        // fallback-failure test's style above.
+        return formatResponse(JSON_RESPONSE_WITH_RESULT_TABLE);
+      }
+    };
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    command.setOutputFile(_tempOutputFile.getAbsolutePath());
+    boolean result = command.execute();
+    assertTrue(result);
+    String written = new String(Files.readAllBytes(_tempOutputFile.toPath()), 
StandardCharsets.UTF_8);
+    assertEquals(written, "col1,col2\r\na,1\r\n\"b,c\",2\r\n");
+  }
+
+  @Test
+  public void testExecuteWithoutOutputFileDoesNotWriteAnyFile()
+      throws Exception {
+    // Regression guard: execute() must not write to any file when -outputFile 
was never set.
+    // Uses a dedicated per-test temp directory (see setUp()) rather than the 
shared system temp
+    // dir, so a file created/removed by an unrelated parallel test can't make 
this flaky.
+    String[] filesBefore = _tempDir.list();
+    PostQueryCommand command = new PostQueryCommand() {
+      @Override
+      public String run()
+          throws Exception {
+        return "col1,col2\r\na,1\r\n";
+      }
+    };
+    command.execute();
+    String[] filesAfter = _tempDir.list();
+    assertEquals(filesAfter, filesBefore);
+  }
+
+  @Test
+  public void testExecuteRecomputesCsvFileSafetyForEachResponseOnSameInstance()
+      throws Exception {
+    assertTrue(_tempOutputFile.delete());
+    String[] responses = {
+        JSON_RESPONSE_WITH_PARTIAL_RESULT,
+        JSON_RESPONSE_WITH_RESULT_TABLE,
+        JSON_RESPONSE_WITH_RESULT_TABLE_AND_EXCEPTIONS
+    };
+    AtomicInteger responseIndex = new AtomicInteger();
+    PostQueryCommand command = new PostQueryCommand() {
+      @Override
+      public String run() {
+        return formatResponse(responses[responseIndex.getAndIncrement()]);
+      }
+    };
+    command.setOutputFormat(PostQueryCommand.OutputFormat.CSV);
+    command.setOutputFile(_tempOutputFile.getAbsolutePath());
+
+    assertFalse(command.execute());
+    assertFalse(_tempOutputFile.exists());
+
+    assertTrue(command.execute());
+    String successfulCsv = "col1,col2\r\na,1\r\n\"b,c\",2\r\n";
+    assertEquals(Files.readString(_tempOutputFile.toPath(), 
StandardCharsets.UTF_8), successfulCsv);
+
+    assertFalse(command.execute());
+    assertEquals(Files.readString(_tempOutputFile.toPath(), 
StandardCharsets.UTF_8), successfulCsv);
+  }
+
+  private static PostQueryCommand commandReturning(String rawResponse) {
+    return new PostQueryCommand() {
+      @Override
+      public String run() {
+        return formatResponse(rawResponse);
+      }
+    };
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to