You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by "xiangfu0 (via GitHub)" <gi...@apache.org> on 2025/10/16 19:50:01 UTC

[PR] Add pinot-cli: interactive and batch command-line client [pinot]

xiangfu0 opened a new pull request, #17029:
URL: https://github.com/apache/pinot/pull/17029

   Adds a new `pinot-cli` module providing a modern CLI for Apache Pinot:
   - Interactive REPL with multi-line SQL, history, and optional pager
   - Batch execution with multiple output formats: CSV/TSV/JSON/ALIGNED/VERTICAL/AUTO/MARKDOWN
   - Config via properties file (`PINOT_CONFIG`) and environment variables, forwards session options
   - Supports extra HTTP headers and debug diagnostics
   - Shaded executable JAR and thin JAR artifacts
   
   Build:
   ./mvnw -DskipTests -pl pinot-clients/pinot-cli -am package
   
   Artifacts:
   - pinot-clients/pinot-cli/target/pinot-cli-executable.jar (shaded, executable)
   - pinot-clients/pinot-cli/target/pinot-cli-1.5.0-SNAPSHOT.jar (thin)
   
   Notes:
   - Uses `pinot-jdbc-client`, `picocli`, and `jline`
   - Keeps default interactive format `ALIGNED`; `AUTO` switches based on terminal width
   - Backward-compatible `-o/--output` retained; prefer `--output-format`
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org


Re: [PR] Add pinot-cli: interactive and batch command-line client [pinot]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #17029:
URL: https://github.com/apache/pinot/pull/17029#discussion_r2437299682


##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,961 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file)))) {

Review Comment:
   Consider using `Files.newBufferedReader(Paths.get(file))` instead of manually wrapping `FileInputStream` with `InputStreamReader` and `BufferedReader`. This simplifies the code and ensures proper encoding handling (defaults to UTF-8).



##########
pinot-clients/pinot-cli/README.md:
##########
@@ -0,0 +1,154 @@
+## Pinot CLI
+
+An interactive and batch command-line client for Apache Pinot. It supports a rich interactive REPL, multiple output formats, history, pagination, configuration files, and batch execution.
+
+Features are modeled after the Trino CLI for familiarity and ergonomics. See the Trino CLI docs for reference: [Trino CLI documentation](https://trino.io/docs/current/client/cli.html).
+
+## Requirements
+
+- Java 11+ on PATH (Java 22+ recommended for performance)
+
+## Build
+
+From the repository root:
+
+```bash
+./mvnw -DskipTests -pl pinot-clients/pinot-cli -am package
+```
+
+Artifacts:
+
+- `pinot-clients/pinot-cli/target/pinot-cli-jar-with-dependencies.jar` (executable, recommended)
+- `pinot-clients/pinot-cli/target/pinot-cli-1.5.0-SNAPSHOT.jar` (thin)
+
+## Running
+
+### Interactive mode
+
+```bash
+java -jar pinot-clients/pinot-cli/target/pinot-cli-jar-with-dependencies.jar \

Review Comment:
   Throughout the README, references to `pinot-cli-jar-with-dependencies.jar` should be updated to `pinot-cli-executable.jar` to match the actual artifact produced by the shade plugin.



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,961 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file)))) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // For CSV unquoted, double quotes are not escaped intentionally; keep simple semantics
+    if (delimiter == ',') {
+      return s.replace("\n", " ").replace("\r", " ");
+    }

Review Comment:
   The conditional check `if (delimiter == ',')` is redundant since both branches return the same result. Either remove the condition or document why this distinction exists for future extension.
   ```suggestion
   
   ```



##########
pinot-clients/pinot-cli/README.md:
##########
@@ -0,0 +1,154 @@
+## Pinot CLI
+
+An interactive and batch command-line client for Apache Pinot. It supports a rich interactive REPL, multiple output formats, history, pagination, configuration files, and batch execution.
+
+Features are modeled after the Trino CLI for familiarity and ergonomics. See the Trino CLI docs for reference: [Trino CLI documentation](https://trino.io/docs/current/client/cli.html).
+
+## Requirements
+
+- Java 11+ on PATH (Java 22+ recommended for performance)
+
+## Build
+
+From the repository root:
+
+```bash
+./mvnw -DskipTests -pl pinot-clients/pinot-cli -am package
+```
+
+Artifacts:
+
+- `pinot-clients/pinot-cli/target/pinot-cli-jar-with-dependencies.jar` (executable, recommended)

Review Comment:
   The documented artifact filename `pinot-cli-jar-with-dependencies.jar` is inconsistent with the actual Maven shade plugin configuration in `pom.xml`, which produces `pinot-cli-executable.jar`. Update the documentation to reflect the correct artifact name.



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,961 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file)))) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // For CSV unquoted, double quotes are not escaped intentionally; keep simple semantics
+    if (delimiter == ',') {
+      return s.replace("\n", " ").replace("\r", " ");
+    }
+    return s.replace("\n", " ").replace("\r", " ");
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
+  }
+
+  public static void main(String[] args) {
+    int code = new CommandLine(new PinotCli()).execute(args);
+    System.exit(code);
+  }
+
+  private OutputFormat resolveOutputFormat() {
+    // Backward compatibility for -o/--output
+    if (_outputFormat == null) {
+      if (_execute != null || _file != null) {
+        // batch default
+        if ("json".equalsIgnoreCase(_output)) {
+          return OutputFormat.JSON;
+        } else if ("csv".equalsIgnoreCase(_output)) {
+          return OutputFormat.CSV;
+        } else {
+          return OutputFormat.ALIGNED;
+        }
+      } else {
+        // interactive default
+        return parseFormat(_outputFormatInteractive);
+      }
+    }
+    return parseFormat(_outputFormat);
+  }
+
+  private OutputFormat parseFormat(String fmt) {
+    if (fmt == null) {
+      return OutputFormat.ALIGNED;
+    }
+    String f = fmt.trim().toUpperCase();
+    try {
+      if ("TABLE".equals(f)) {
+        return OutputFormat.ALIGNED;
+      }
+      return OutputFormat.valueOf(f);
+    } catch (IllegalArgumentException iae) {
+      return OutputFormat.ALIGNED;
+    }
+  }
+
+  private boolean shouldUsePager() {
+    if (_execute != null || _file != null) {
+      return false; // batch mode: no pager
+    }
+    String pager = effectivePager();
+    return pager != null && !pager.isEmpty();
+  }
+
+  private String effectivePager() {
+    if (_pager != null) {
+      return _pager;
+    }
+    String env = System.getenv("TRINO_PAGER");
+    if (env == null || env.isEmpty()) {
+      env = System.getenv("PINOT_PAGER");
+    }
+    return env;
+  }
+
+  private void page(String content)
+      throws SQLException {
+    String pager = effectivePager();
+    if (pager == null || pager.isEmpty()) {
+      System.out.print(content);
+      return;
+    }
+    ProcessBuilder pb = new ProcessBuilder("sh", "-c", pager);
+    try {
+      Process p = pb.start();
+      try (OutputStream os = p.getOutputStream()) {
+        os.write(content.getBytes());
+        os.flush();
+      }
+      p.waitFor();
+    } catch (Exception e) {
+      throw new SQLException("Failed to run pager: " + pager, e);
+    }
+  }
+
+  private int getTerminalWidthIfInteractive() {
+    if (_execute != null || _file != null) {
+      return -1;
+    }
+    try {
+      Terminal t = TerminalBuilder.builder().system(true).build();
+      return t.getWidth();
+    } catch (IOException e) {
+      return -1;
+    }
+  }
+
+  private void printHelp() {
+    System.out.println("Supported commands:");
+    System.out.println("HELP");
+    System.out.println("CLEAR");
+    System.out.println("EXIT | QUIT");
+    System.out.println("SHOW SESSION");
+    System.out.println("SET key=value");
+    System.out.println("UNSET key");
+    System.out.println("USE <schema> | SET PATH <schema>");
+    System.out.println("SET ROLE <role> | RESET ROLE (client-side only)");
+    System.out.println("Execute SQL statements directly.");
+    System.out.println("Multi-line SQL is supported; terminate statements with ';' or \\G for vertical output.");
+  }
+
+  private boolean isCompleteStatement(StringBuilder buf) {
+    // Simple heuristic: last non-whitespace char is ';'
+    for (int i = buf.length() - 1; i >= 0; i--) {
+      char c = buf.charAt(i);
+      if (!Character.isWhitespace(c)) {
+        return c == ';';
+      }
+    }
+    return false;
+  }
+
+  private String stripTrailingSemicolon(String sql) {
+    int end = sql.length();
+    while (end > 0 && Character.isWhitespace(sql.charAt(end - 1))) {
+      end--;
+    }
+    if (end > 0 && sql.charAt(end - 1) == ';') {
+      end--;
+    }
+    return sql.substring(0, end);
+  }
+

Review Comment:
   The method `stripTrailingSemicolon` is unused. The code uses `stripTrailingTerminator` instead which handles both `;` and `\\G` terminators. Remove this unused method to improve code maintainability.
   ```suggestion
   
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,961 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file)))) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // For CSV unquoted, double quotes are not escaped intentionally; keep simple semantics
+    if (delimiter == ',') {
+      return s.replace("\n", " ").replace("\r", " ");
+    }
+    return s.replace("\n", " ").replace("\r", " ");
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
+  }
+
+  public static void main(String[] args) {
+    int code = new CommandLine(new PinotCli()).execute(args);
+    System.exit(code);
+  }
+
+  private OutputFormat resolveOutputFormat() {
+    // Backward compatibility for -o/--output
+    if (_outputFormat == null) {
+      if (_execute != null || _file != null) {
+        // batch default
+        if ("json".equalsIgnoreCase(_output)) {
+          return OutputFormat.JSON;
+        } else if ("csv".equalsIgnoreCase(_output)) {
+          return OutputFormat.CSV;
+        } else {
+          return OutputFormat.ALIGNED;
+        }
+      } else {
+        // interactive default
+        return parseFormat(_outputFormatInteractive);
+      }
+    }
+    return parseFormat(_outputFormat);
+  }
+
+  private OutputFormat parseFormat(String fmt) {
+    if (fmt == null) {
+      return OutputFormat.ALIGNED;
+    }
+    String f = fmt.trim().toUpperCase();
+    try {
+      if ("TABLE".equals(f)) {
+        return OutputFormat.ALIGNED;
+      }
+      return OutputFormat.valueOf(f);
+    } catch (IllegalArgumentException iae) {
+      return OutputFormat.ALIGNED;
+    }
+  }
+
+  private boolean shouldUsePager() {
+    if (_execute != null || _file != null) {
+      return false; // batch mode: no pager
+    }
+    String pager = effectivePager();
+    return pager != null && !pager.isEmpty();
+  }
+
+  private String effectivePager() {
+    if (_pager != null) {
+      return _pager;
+    }
+    String env = System.getenv("TRINO_PAGER");
+    if (env == null || env.isEmpty()) {
+      env = System.getenv("PINOT_PAGER");
+    }
+    return env;
+  }
+
+  private void page(String content)
+      throws SQLException {
+    String pager = effectivePager();
+    if (pager == null || pager.isEmpty()) {
+      System.out.print(content);
+      return;
+    }
+    ProcessBuilder pb = new ProcessBuilder("sh", "-c", pager);
+    try {
+      Process p = pb.start();
+      try (OutputStream os = p.getOutputStream()) {
+        os.write(content.getBytes());
+        os.flush();
+      }
+      p.waitFor();
+    } catch (Exception e) {
+      throw new SQLException("Failed to run pager: " + pager, e);
+    }
+  }
+
+  private int getTerminalWidthIfInteractive() {
+    if (_execute != null || _file != null) {
+      return -1;
+    }
+    try {
+      Terminal t = TerminalBuilder.builder().system(true).build();
+      return t.getWidth();
+    } catch (IOException e) {
+      return -1;
+    }
+  }
+
+  private void printHelp() {
+    System.out.println("Supported commands:");
+    System.out.println("HELP");
+    System.out.println("CLEAR");
+    System.out.println("EXIT | QUIT");
+    System.out.println("SHOW SESSION");
+    System.out.println("SET key=value");
+    System.out.println("UNSET key");
+    System.out.println("USE <schema> | SET PATH <schema>");
+    System.out.println("SET ROLE <role> | RESET ROLE (client-side only)");
+    System.out.println("Execute SQL statements directly.");
+    System.out.println("Multi-line SQL is supported; terminate statements with ';' or \\G for vertical output.");
+  }
+
+  private boolean isCompleteStatement(StringBuilder buf) {
+    // Simple heuristic: last non-whitespace char is ';'
+    for (int i = buf.length() - 1; i >= 0; i--) {
+      char c = buf.charAt(i);
+      if (!Character.isWhitespace(c)) {
+        return c == ';';
+      }
+    }
+    return false;
+  }
+

Review Comment:
   The method `isCompleteStatement` is unused in the code. It has been replaced by the more sophisticated `detectTerminator` method. Consider removing this unused method to improve code maintainability.
   ```suggestion
   
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,961 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file)))) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // For CSV unquoted, double quotes are not escaped intentionally; keep simple semantics
+    if (delimiter == ',') {
+      return s.replace("\n", " ").replace("\r", " ");
+    }
+    return s.replace("\n", " ").replace("\r", " ");
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
+  }
+
+  public static void main(String[] args) {
+    int code = new CommandLine(new PinotCli()).execute(args);
+    System.exit(code);
+  }
+
+  private OutputFormat resolveOutputFormat() {
+    // Backward compatibility for -o/--output
+    if (_outputFormat == null) {
+      if (_execute != null || _file != null) {
+        // batch default
+        if ("json".equalsIgnoreCase(_output)) {
+          return OutputFormat.JSON;
+        } else if ("csv".equalsIgnoreCase(_output)) {
+          return OutputFormat.CSV;
+        } else {
+          return OutputFormat.ALIGNED;
+        }
+      } else {
+        // interactive default
+        return parseFormat(_outputFormatInteractive);
+      }
+    }
+    return parseFormat(_outputFormat);
+  }
+
+  private OutputFormat parseFormat(String fmt) {
+    if (fmt == null) {
+      return OutputFormat.ALIGNED;
+    }
+    String f = fmt.trim().toUpperCase();
+    try {
+      if ("TABLE".equals(f)) {
+        return OutputFormat.ALIGNED;
+      }
+      return OutputFormat.valueOf(f);
+    } catch (IllegalArgumentException iae) {
+      return OutputFormat.ALIGNED;
+    }
+  }
+
+  private boolean shouldUsePager() {
+    if (_execute != null || _file != null) {
+      return false; // batch mode: no pager
+    }
+    String pager = effectivePager();
+    return pager != null && !pager.isEmpty();
+  }
+
+  private String effectivePager() {
+    if (_pager != null) {
+      return _pager;
+    }
+    String env = System.getenv("TRINO_PAGER");
+    if (env == null || env.isEmpty()) {
+      env = System.getenv("PINOT_PAGER");
+    }
+    return env;
+  }
+
+  private void page(String content)
+      throws SQLException {
+    String pager = effectivePager();
+    if (pager == null || pager.isEmpty()) {
+      System.out.print(content);
+      return;
+    }
+    ProcessBuilder pb = new ProcessBuilder("sh", "-c", pager);
+    try {
+      Process p = pb.start();
+      try (OutputStream os = p.getOutputStream()) {
+        os.write(content.getBytes());
+        os.flush();
+      }
+      p.waitFor();
+    } catch (Exception e) {
+      throw new SQLException("Failed to run pager: " + pager, e);
+    }
+  }
+
+  private int getTerminalWidthIfInteractive() {
+    if (_execute != null || _file != null) {
+      return -1;
+    }
+    try {
+      Terminal t = TerminalBuilder.builder().system(true).build();
+      return t.getWidth();
+    } catch (IOException e) {
+      return -1;
+    }
+  }
+
+  private void printHelp() {
+    System.out.println("Supported commands:");
+    System.out.println("HELP");
+    System.out.println("CLEAR");
+    System.out.println("EXIT | QUIT");
+    System.out.println("SHOW SESSION");
+    System.out.println("SET key=value");
+    System.out.println("UNSET key");
+    System.out.println("USE <schema> | SET PATH <schema>");
+    System.out.println("SET ROLE <role> | RESET ROLE (client-side only)");
+    System.out.println("Execute SQL statements directly.");
+    System.out.println("Multi-line SQL is supported; terminate statements with ';' or \\G for vertical output.");
+  }
+
+  private boolean isCompleteStatement(StringBuilder buf) {
+    // Simple heuristic: last non-whitespace char is ';'
+    for (int i = buf.length() - 1; i >= 0; i--) {
+      char c = buf.charAt(i);
+      if (!Character.isWhitespace(c)) {
+        return c == ';';
+      }
+    }
+    return false;
+  }
+
+  private String stripTrailingSemicolon(String sql) {
+    int end = sql.length();
+    while (end > 0 && Character.isWhitespace(sql.charAt(end - 1))) {
+      end--;
+    }
+    if (end > 0 && sql.charAt(end - 1) == ';') {
+      end--;
+    }
+    return sql.substring(0, end);
+  }
+
+  private Terminator detectTerminator(StringBuilder buf) {
+    int i = buf.length() - 1;
+    while (i >= 0 && Character.isWhitespace(buf.charAt(i))) {
+      i--;
+    }
+    if (i < 0) {
+      return new Terminator(false, false);
+    }
+    if (buf.charAt(i) == ';') {
+      return new Terminator(true, false);
+    }
+    if (i >= 1 && buf.charAt(i) == 'G' && buf.charAt(i - 1) == '\\') {
+      return new Terminator(true, true);
+    }
+    return new Terminator(false, false);
+  }
+
+  private String stripTrailingTerminator(String sql, Terminator t) {
+    int end = sql.length();
+    while (end > 0 && Character.isWhitespace(sql.charAt(end - 1))) {
+      end--;
+    }
+    if (t._vertical) {
+      if (end >= 2 && sql.charAt(end - 1) == 'G' && sql.charAt(end - 2) == '\\') {
+        end -= 2;
+      }
+    } else {
+      if (end > 0 && sql.charAt(end - 1) == ';') {
+        end--;
+      }
+    }
+    return sql.substring(0, end);
+  }
+
+  private void loadConfigDefaults()
+      throws IOException {
+    if (_configFile == null) {
+      String env = System.getenv("PINOT_CONFIG");
+      if (env == null || env.isEmpty()) {
+        env = System.getenv("TRINO_CONFIG");
+      }
+      if (env != null && !env.isEmpty()) {
+        _configFile = new File(env);
+      }
+    }
+    if (_configFile == null) {
+      return;
+    }
+    java.util.Properties p = new java.util.Properties();
+    try (java.io.InputStream in = new java.io.FileInputStream(_configFile)) {
+      p.load(in);
+    }
+    // Map supported options from properties to fields if not set on CLI
+    if (_jdbcUrl == null && p.getProperty("server") != null) {
+      _jdbcUrl = p.getProperty("server");
+    }
+    if (_user == null && p.getProperty("user") != null) {
+      _user = p.getProperty("user");
+    }
+    if (_password == null && p.getProperty("password") != null) {
+      _password = p.getProperty("password");
+    }
+    if (_outputFormat == null && p.getProperty("output-format") != null) {
+      _outputFormat = p.getProperty("output-format");
+    }
+    if ("table".equalsIgnoreCase(_output) && p.getProperty("output") != null) {
+      _output = p.getProperty("output");
+    }
+    if ("ALIGNED".equalsIgnoreCase(_outputFormatInteractive)
+        && p.getProperty("output-format-interactive") != null) {
+      _outputFormatInteractive = p.getProperty("output-format-interactive");
+    }
+    if (_pager == null && p.getProperty("pager") != null) {
+      _pager = p.getProperty("pager");
+    }
+    if (_historyFile == null && p.getProperty("history-file") != null) {
+      _historyFile = new File(p.getProperty("history-file"));
+    }
+    if (!_debug && p.getProperty("debug") != null) {
+      _debug = Boolean.parseBoolean(p.getProperty("debug"));
+    }
+    // headers.* and arbitrary options
+    for (String name : p.stringPropertyNames()) {
+      if (name.startsWith("headers.")) {
+        String key = name.substring("headers.".length());
+        if (!_headers.containsKey(key)) {
+          _headers.put(key, p.getProperty(name));
+        }
+      }
+    }
+    for (String name : p.stringPropertyNames()) {
+      if (!name.startsWith("headers.")) {
+        // don't override explicit --set
+        if (!_options.containsKey(name)) {
+          _options.put(name, p.getProperty(name));
+        }
+      }
+    }

Review Comment:
   This logic adds all non-header properties to `_options`, including system properties like 'server', 'user', 'password', 'output-format', etc. This could cause these configuration keys to be incorrectly passed as query options to the JDBC connection. Add explicit filtering to exclude known configuration keys that are not query options.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org


Re: [PR] Add pinot-cli: interactive and batch command-line client [pinot]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #17029:
URL: https://github.com/apache/pinot/pull/17029#discussion_r2439977536


##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,997 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // Normalize CRLF/CR to LF for consistency; quote if delimiter, quotes, or newlines present
+    String value = s.replace("\r\n", "\n").replace('\r', '\n');
+    boolean needsQuoting = value.indexOf(delimiter) >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0;
+    if (needsQuoting) {
+      String escaped = value.replace("\"", "\"\"");
+      return "\"" + escaped + "\"";
+    }
+    return value;
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");

Review Comment:
   The JSON escaping is incomplete and may produce invalid JSON. Missing escapes for tab (`\\t`), backspace (`\\b`), form feed (`\\f`), and control characters. Consider using a proper JSON library or implementing comprehensive escaping including `\\t`, `\\b`, `\\f`, and control characters (U+0000 to U+001F).



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,997 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // Normalize CRLF/CR to LF for consistency; quote if delimiter, quotes, or newlines present
+    String value = s.replace("\r\n", "\n").replace('\r', '\n');
+    boolean needsQuoting = value.indexOf(delimiter) >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0;
+    if (needsQuoting) {
+      String escaped = value.replace("\"", "\"\"");
+      return "\"" + escaped + "\"";
+    }
+    return value;
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
+  }
+
+  // Minimal tokenizer that splits a command string into argv respecting simple quotes
+  private static String[] tokenizeCommand(String command) {
+    List<String> parts = new ArrayList<>();
+    StringBuilder current = new StringBuilder();
+    boolean inSingle = false;
+    boolean inDouble = false;
+    for (int i = 0; i < command.length(); i++) {
+      char c = command.charAt(i);
+      if (c == '\'' && !inDouble) {
+        inSingle = !inSingle;
+        continue;
+      }
+      if (c == '"' && !inSingle) {
+        inDouble = !inDouble;
+        continue;
+      }
+      if (Character.isWhitespace(c) && !inSingle && !inDouble) {
+        if (current.length() > 0) {
+          parts.add(current.toString());
+          current.setLength(0);
+        }
+      } else {
+        current.append(c);
+      }
+    }
+    if (current.length() > 0) {
+      parts.add(current.toString());
+    }
+    return parts.toArray(new String[0]);
+  }
+
+  public static void main(String[] args) {
+    int code = new CommandLine(new PinotCli()).execute(args);
+    System.exit(code);
+  }
+
+  private OutputFormat resolveOutputFormat() {
+    // Backward compatibility for -o/--output
+    if (_outputFormat == null) {
+      if (_execute != null || _file != null) {
+        // batch default
+        if ("json".equalsIgnoreCase(_output)) {
+          return OutputFormat.JSON;
+        } else if ("csv".equalsIgnoreCase(_output)) {
+          return OutputFormat.CSV;
+        } else {
+          return OutputFormat.ALIGNED;
+        }
+      } else {
+        // interactive default
+        return parseFormat(_outputFormatInteractive);
+      }
+    }
+    return parseFormat(_outputFormat);
+  }
+
+  private OutputFormat parseFormat(String fmt) {
+    if (fmt == null) {
+      return OutputFormat.ALIGNED;
+    }
+    String f = fmt.trim().toUpperCase();
+    try {
+      if ("TABLE".equals(f)) {
+        return OutputFormat.ALIGNED;
+      }
+      return OutputFormat.valueOf(f);
+    } catch (IllegalArgumentException iae) {
+      return OutputFormat.ALIGNED;
+    }
+  }
+
+  private boolean shouldUsePager() {
+    if (_execute != null || _file != null) {
+      return false; // batch mode: no pager
+    }
+    String pager = effectivePager();
+    return pager != null && !pager.isEmpty();
+  }
+
+  private String effectivePager() {
+    if (_pager != null) {
+      return _pager;
+    }
+    String env = System.getenv("TRINO_PAGER");
+    if (env == null || env.isEmpty()) {
+      env = System.getenv("PINOT_PAGER");
+    }
+    return env;
+  }
+
+  private void page(String content)
+      throws SQLException {
+    String pager = effectivePager();
+    if (pager == null || pager.isEmpty()) {
+      System.out.print(content);
+      return;
+    }
+    // Reject shell metacharacters and avoid invoking a shell to prevent command injection
+    if (pager.matches(".*[|&;`$><].*")) {

Review Comment:
   The regex pattern allows backslash characters which could be used for escape sequences or path manipulation. Consider adding backslash to the blacklist pattern: `.*[|&;`$><\\\\].*`
   ```suggestion
       if (pager.matches(".*[|&;`$><\\\\].*")) {
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,997 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // Normalize CRLF/CR to LF for consistency; quote if delimiter, quotes, or newlines present
+    String value = s.replace("\r\n", "\n").replace('\r', '\n');
+    boolean needsQuoting = value.indexOf(delimiter) >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0;
+    if (needsQuoting) {
+      String escaped = value.replace("\"", "\"\"");
+      return "\"" + escaped + "\"";

Review Comment:
   The method performs multiple passes over the string with `indexOf` calls. Consider using a single pass through the string to check for special characters while building the escaped result, which would be more efficient for large strings.
   ```suggestion
       boolean needsQuoting = false;
       StringBuilder sb = null;
       for (int i = 0; i < value.length(); i++) {
         char c = value.charAt(i);
         if (c == delimiter || c == '"' || c == '\n') {
           needsQuoting = true;
           if (sb == null) {
             sb = new StringBuilder(value.length() + 2);
             sb.append(value, 0, i);
           }
           if (c == '"') {
             sb.append("\"\"");
           } else {
             sb.append(c);
           }
         } else if (sb != null) {
           sb.append(c);
         }
       }
       if (needsQuoting) {
         if (sb == null) {
           // All special chars at the end, so no sb built yet
           sb = new StringBuilder(value);
         }
         return "\"" + sb.toString() + "\"";
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org


Re: [PR] Add pinot-cli: interactive and batch command-line client [pinot]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #17029:
URL: https://github.com/apache/pinot/pull/17029#discussion_r2440123906


##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,1044 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // Normalize CRLF/CR to LF for consistency
+    String value = s.replace("\r\n", "\n").replace('\r', '\n');
+    boolean needsQuoting = false;
+    StringBuilder sb = null;
+    for (int i = 0; i < value.length(); i++) {
+      char c = value.charAt(i);
+      if (c == delimiter || c == '"' || c == '\n') {
+        needsQuoting = true;
+        if (sb == null) {
+          sb = new StringBuilder(value.length() + 2);
+          sb.append(value, 0, i);
+        }
+        if (c == '"') {
+          sb.append("\"\"");
+        } else {
+          sb.append(c);
+        }
+      } else if (sb != null) {
+        sb.append(c);
+      }
+    }
+    if (!needsQuoting) {
+      return value;
+    }
+    if (sb == null) {
+      sb = new StringBuilder(value);
+    }
+    return '"' + sb.toString() + '"';
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";

Review Comment:
   The null handling in `escapeCsv` returns an empty string for null values, which is inconsistent with other output formats that display 'NULL' (e.g., ALIGNED, VERTICAL formats). This creates an inconsistency where a null value in CSV format appears as an empty string while it appears as 'NULL' in other formats. Consider using a consistent null representation across all output formats.
   ```suggestion
         return "NULL";
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,1044 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // Normalize CRLF/CR to LF for consistency
+    String value = s.replace("\r\n", "\n").replace('\r', '\n');
+    boolean needsQuoting = false;
+    StringBuilder sb = null;
+    for (int i = 0; i < value.length(); i++) {
+      char c = value.charAt(i);
+      if (c == delimiter || c == '"' || c == '\n') {
+        needsQuoting = true;
+        if (sb == null) {
+          sb = new StringBuilder(value.length() + 2);
+          sb.append(value, 0, i);
+        }
+        if (c == '"') {
+          sb.append("\"\"");
+        } else {
+          sb.append(c);
+        }
+      } else if (sb != null) {
+        sb.append(c);
+      }
+    }
+    if (!needsQuoting) {
+      return value;
+    }
+    if (sb == null) {
+      sb = new StringBuilder(value);
+    }
+    return '"' + sb.toString() + '"';
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();

Review Comment:
   The `repeat` method is inefficient for large counts. Java 11 provides `String.repeat()` for this purpose. Replace the manual loop with `String.valueOf(ch).repeat(count)` for better performance and cleaner code.
   ```suggestion
       return String.valueOf(ch).repeat(count);
   ```



##########
pinot-clients/pinot-cli/README.md:
##########
@@ -0,0 +1,174 @@
+<!--
+
+    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.
+
+-->
+## Pinot CLI
+
+An interactive and batch command-line client for Apache Pinot. It supports a rich interactive REPL, multiple output formats, history, pagination, configuration files, and batch execution.
+
+Features are modeled after the Trino CLI for familiarity and ergonomics. See the Trino CLI docs for reference: [Trino CLI documentation](https://trino.io/docs/current/client/cli.html).

Review Comment:
   The reference to Trino CLI documentation uses '/docs/current/' which may point to different content over time. Consider using a versioned URL (e.g., '/docs/449/client/cli.html') to ensure the reference remains stable and accurate for future readers.
   ```suggestion
   Features are modeled after the Trino CLI for familiarity and ergonomics. See the Trino CLI docs for reference: [Trino CLI documentation](https://trino.io/docs/449/client/cli.html).
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,1044 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // Normalize CRLF/CR to LF for consistency
+    String value = s.replace("\r\n", "\n").replace('\r', '\n');
+    boolean needsQuoting = false;
+    StringBuilder sb = null;
+    for (int i = 0; i < value.length(); i++) {
+      char c = value.charAt(i);
+      if (c == delimiter || c == '"' || c == '\n') {
+        needsQuoting = true;
+        if (sb == null) {
+          sb = new StringBuilder(value.length() + 2);
+          sb.append(value, 0, i);
+        }
+        if (c == '"') {
+          sb.append("\"\"");
+        } else {
+          sb.append(c);
+        }
+      } else if (sb != null) {
+        sb.append(c);
+      }
+    }
+    if (!needsQuoting) {
+      return value;
+    }
+    if (sb == null) {
+      sb = new StringBuilder(value);
+    }
+    return '"' + sb.toString() + '"';
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    if (s == null) {
+      return "null"; // Should not be called with nulls normally
+    }
+    StringBuilder b = new StringBuilder(s.length() + 8);
+    for (int i = 0; i < s.length(); i++) {
+      char c = s.charAt(i);
+      switch (c) {
+        case '"': b.append("\\\""); break;
+        case '\\': b.append("\\\\"); break;
+        case '\n': b.append("\\n"); break;
+        case '\r': b.append("\\r"); break;
+        case '\t': b.append("\\t"); break;
+        case '\b': b.append("\\b"); break;
+        case '\f': b.append("\\f"); break;
+        default:
+          if (c <= 0x1F) {
+            String hex = Integer.toHexString(c);
+            b.append("\\u");
+            for (int k = hex.length(); k < 4; k++) {
+              b.append('0');
+            }
+            b.append(hex);
+          } else {
+            b.append(c);
+          }
+      }
+    }
+    return b.toString();
+  }
+
+  // Minimal tokenizer that splits a command string into argv respecting simple quotes
+  private static String[] tokenizeCommand(String command) {
+    List<String> parts = new ArrayList<>();
+    StringBuilder current = new StringBuilder();
+    boolean inSingle = false;
+    boolean inDouble = false;
+    for (int i = 0; i < command.length(); i++) {
+      char c = command.charAt(i);
+      if (c == '\'' && !inDouble) {
+        inSingle = !inSingle;
+        continue;
+      }
+      if (c == '"' && !inSingle) {
+        inDouble = !inDouble;
+        continue;
+      }
+      if (Character.isWhitespace(c) && !inSingle && !inDouble) {
+        if (current.length() > 0) {
+          parts.add(current.toString());
+          current.setLength(0);
+        }
+      } else {
+        current.append(c);
+      }
+    }
+    if (current.length() > 0) {
+      parts.add(current.toString());
+    }
+    return parts.toArray(new String[0]);
+  }
+
+  public static void main(String[] args) {
+    int code = new CommandLine(new PinotCli()).execute(args);
+    System.exit(code);
+  }
+
+  private OutputFormat resolveOutputFormat() {
+    // Backward compatibility for -o/--output
+    if (_outputFormat == null) {
+      if (_execute != null || _file != null) {
+        // batch default
+        if ("json".equalsIgnoreCase(_output)) {
+          return OutputFormat.JSON;
+        } else if ("csv".equalsIgnoreCase(_output)) {
+          return OutputFormat.CSV;
+        } else {
+          return OutputFormat.ALIGNED;
+        }
+      } else {
+        // interactive default
+        return parseFormat(_outputFormatInteractive);
+      }
+    }
+    return parseFormat(_outputFormat);
+  }
+
+  private OutputFormat parseFormat(String fmt) {
+    if (fmt == null) {
+      return OutputFormat.ALIGNED;
+    }
+    String f = fmt.trim().toUpperCase();
+    try {
+      if ("TABLE".equals(f)) {
+        return OutputFormat.ALIGNED;
+      }
+      return OutputFormat.valueOf(f);
+    } catch (IllegalArgumentException iae) {
+      return OutputFormat.ALIGNED;
+    }
+  }
+
+  private boolean shouldUsePager() {
+    if (_execute != null || _file != null) {
+      return false; // batch mode: no pager
+    }
+    String pager = effectivePager();
+    return pager != null && !pager.isEmpty();
+  }
+
+  private String effectivePager() {
+    if (_pager != null) {
+      return _pager;
+    }
+    String env = System.getenv("TRINO_PAGER");
+    if (env == null || env.isEmpty()) {
+      env = System.getenv("PINOT_PAGER");
+    }
+    return env;
+  }
+
+  private void page(String content)
+      throws SQLException {
+    String pager = effectivePager();
+    if (pager == null || pager.isEmpty()) {
+      System.out.print(content);
+      return;
+    }
+    // Reject shell metacharacters and avoid invoking a shell to prevent command injection
+    if (pager.matches(".*[|&;`$><\\\\].*")) {

Review Comment:
   The shell metacharacter validation regex doesn't include newline characters (`\n`, `\r`) which could be used for command injection. Add `\n` and `\r` to the rejected character set: `.*[|&;`$><\\\\\n\r].*`
   ```suggestion
       if (pager.matches(".*[|&;`$><\\\\\n\r].*")) {
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,1044 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";

Review Comment:
   The null handling in `escapeSeparated` returns an empty string for null values, but other render methods (e.g., `renderAlignedFromResultSet` at line 411, `renderVertical` at line 528) display null as 'NULL'. This inconsistency in null representation across formats could confuse users. Consider using a consistent null representation strategy across all output formats.
   ```suggestion
         return "NULL";
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org


Re: [PR] Add pinot-cli: interactive and batch command-line client [pinot]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #17029:
URL: https://github.com/apache/pinot/pull/17029#discussion_r2439722568


##########
pinot-clients/pinot-cli/pom.xml:
##########
@@ -0,0 +1,111 @@
+<?xml version="1.0"?>
+<!--
+
+    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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <artifactId>pinot-clients</artifactId>
+    <groupId>org.apache.pinot</groupId>
+    <version>1.5.0-SNAPSHOT</version>
+  </parent>
+  <artifactId>pinot-cli</artifactId>
+  <name>Pinot CLI</name>
+  <url>https://pinot.apache.org/</url>
+  <properties>
+    <pinot.root>${basedir}/../..</pinot.root>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.pinot</groupId>
+      <artifactId>pinot-jdbc-client</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.jline</groupId>
+      <artifactId>jline</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>info.picocli</groupId>
+      <artifactId>picocli</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-simple</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <source>${jdk.version}</source>
+          <target>${jdk.version}</target>

Review Comment:
   The PR description and README specify Java 11+ requirement, but this module is in `pinot-clients` which uses Java 8 according to the coding guidelines. The compiler configuration references `${jdk.version}` which may resolve to Java 8 from the parent POM. Explicitly set source/target to 11 or verify parent property alignment.



##########
pinot-clients/pinot-cli/pom.xml:
##########
@@ -0,0 +1,111 @@
+<?xml version="1.0"?>
+<!--
+
+    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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <artifactId>pinot-clients</artifactId>
+    <groupId>org.apache.pinot</groupId>
+    <version>1.5.0-SNAPSHOT</version>
+  </parent>
+  <artifactId>pinot-cli</artifactId>
+  <name>Pinot CLI</name>
+  <url>https://pinot.apache.org/</url>
+  <properties>
+    <pinot.root>${basedir}/../..</pinot.root>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.pinot</groupId>
+      <artifactId>pinot-jdbc-client</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.jline</groupId>
+      <artifactId>jline</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>info.picocli</groupId>
+      <artifactId>picocli</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-simple</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <source>${jdk.version}</source>
+          <target>${jdk.version}</target>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-shade-plugin</artifactId>
+        <executions>
+          <execution>
+            <phase>package</phase>
+            <goals>
+              <goal>shade</goal>
+            </goals>
+            <configuration>
+              <shadedArtifactAttached>true</shadedArtifactAttached>
+              <shadedClassifierName>executable</shadedClassifierName>
+              <transformers>
+                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
+                  <mainClass>org.apache.pinot.cli.PinotCli</mainClass>
+                </transformer>
+              </transformers>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.skife.maven</groupId>
+        <artifactId>really-executable-jar-maven-plugin</artifactId>
+        <configuration>
+          <flags>-Xmx1G --enable-native-access=ALL-UNNAMED -XX:+IgnoreUnrecognizedVMOptions</flags>

Review Comment:
   The `--enable-native-access=ALL-UNNAMED` flag grants unrestricted native access to all unnamed modules, which is a broad security permission. This should be scoped to specific modules that require native access (like jline) or documented why this broad access is necessary.
   ```suggestion
             <flags>-Xmx1G --enable-native-access=org.jline -XX:+IgnoreUnrecognizedVMOptions</flags>
             <flags>-Xmx1G --enable-native-access=org.jline -XX:+IgnoreUnrecognizedVMOptions</flags>
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,951 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // For separated formats, normalize newlines to spaces for readability
+    return s.replace("\n", " ").replace("\r", " ");
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
+  }
+
+  public static void main(String[] args) {
+    int code = new CommandLine(new PinotCli()).execute(args);
+    System.exit(code);
+  }
+
+  private OutputFormat resolveOutputFormat() {
+    // Backward compatibility for -o/--output
+    if (_outputFormat == null) {
+      if (_execute != null || _file != null) {
+        // batch default
+        if ("json".equalsIgnoreCase(_output)) {
+          return OutputFormat.JSON;
+        } else if ("csv".equalsIgnoreCase(_output)) {
+          return OutputFormat.CSV;
+        } else {
+          return OutputFormat.ALIGNED;
+        }
+      } else {
+        // interactive default
+        return parseFormat(_outputFormatInteractive);
+      }
+    }
+    return parseFormat(_outputFormat);
+  }
+
+  private OutputFormat parseFormat(String fmt) {
+    if (fmt == null) {
+      return OutputFormat.ALIGNED;
+    }
+    String f = fmt.trim().toUpperCase();
+    try {
+      if ("TABLE".equals(f)) {
+        return OutputFormat.ALIGNED;
+      }
+      return OutputFormat.valueOf(f);
+    } catch (IllegalArgumentException iae) {
+      return OutputFormat.ALIGNED;
+    }
+  }
+
+  private boolean shouldUsePager() {
+    if (_execute != null || _file != null) {
+      return false; // batch mode: no pager
+    }
+    String pager = effectivePager();
+    return pager != null && !pager.isEmpty();
+  }
+
+  private String effectivePager() {
+    if (_pager != null) {
+      return _pager;
+    }
+    String env = System.getenv("TRINO_PAGER");
+    if (env == null || env.isEmpty()) {
+      env = System.getenv("PINOT_PAGER");
+    }
+    return env;
+  }
+
+  private void page(String content)
+      throws SQLException {
+    String pager = effectivePager();
+    if (pager == null || pager.isEmpty()) {
+      System.out.print(content);
+      return;
+    }
+    ProcessBuilder pb = new ProcessBuilder("sh", "-c", pager);
+    try {
+      Process p = pb.start();
+      try (OutputStream os = p.getOutputStream()) {
+        os.write(content.getBytes());

Review Comment:
   Missing charset specification when converting String to bytes. This will use platform default encoding which may cause issues. Use `content.getBytes(StandardCharsets.UTF_8)` instead.
   ```suggestion
           os.write(content.getBytes(StandardCharsets.UTF_8));
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,951 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();

Review Comment:
   The scheduler thread pool is created on every query execution but never properly shut down in all error paths. If an exception occurs before line 309, the executor will leak. Consider using try-with-resources or ensuring shutdown in a finally block that covers the entire method.
   ```suggestion
       ScheduledExecutorService scheduler = null;
       ScheduledFuture<?> spinner = null;
       try {
         scheduler = Executors.newSingleThreadScheduledExecutor();
         spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
         try (Statement stmt = conn.createStatement()) {
           boolean hasResult = stmt.execute(composed);
           if (!hasResult) {
             System.out.println("OK");
             printSqlWarnings(stmt.getWarnings());
             return;
           }
           try (ResultSet rs = stmt.getResultSet()) {
             OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
             boolean usePager = shouldUsePager();
             Appendable out;
             StringBuilder buffer = null;
             if (usePager) {
               buffer = new StringBuilder();
               out = buffer;
             } else {
               out = System.out;
             }
             int rows = render(rs, format, out, getTerminalWidthIfInteractive());
             printSqlWarnings(rs.getWarnings());
             if (usePager && buffer != null) {
               page(buffer.toString());
             }
             if (_debug) {
               Instant end = Instant.now();
               Duration d = Duration.between(start, end);
               System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
             }
           }
         }
       } finally {
         if (spinner != null) {
           spinner.cancel(true);
         }
         if (scheduler != null) {
           scheduler.shutdownNow();
         }
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,951 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // For separated formats, normalize newlines to spaces for readability
+    return s.replace("\n", " ").replace("\r", " ");

Review Comment:
   The delimiter character is not being escaped in the output. If a field value contains the delimiter character (comma for CSV, tab for TSV), it will corrupt the output format. The function should escape or quote values containing the delimiter.
   ```suggestion
       // For separated formats, quote and escape values containing the delimiter, double quotes, or newlines
       boolean needsQuoting = s.indexOf(delimiter) >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0;
       String value = s.replace("\r\n", "\n").replace('\r', '\n'); // Normalize newlines
       if (needsQuoting) {
         value = value.replace("\"", "\"\""); // Escape double quotes
         return "\"" + value + "\"";
       } else {
         return value;
       }
   ```



##########
pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java:
##########
@@ -0,0 +1,951 @@
+/**
+ * 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.cli;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.jline.reader.EndOfFileException;
+import org.jline.reader.LineReader;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import picocli.CommandLine;
+
+
+@CommandLine.Command(name = "pinot-cli", mixinStandardHelpOptions = true, version = "1.0",
+    description = "Interactive and batch CLI for Apache Pinot")
+public class PinotCli implements Callable<Integer> {
+
+  @CommandLine.Option(names = {"-u", "--url"}, required = true,
+      description = "JDBC URL. e.g. jdbc:pinot://controller:9000 or jdbc:pinotgrpc://controller:9000")
+  private String _jdbcUrl;
+
+  @CommandLine.Option(names = {"-n", "--user"}, description = "Username")
+  private String _user;
+
+  @CommandLine.Option(names = {"-p", "--password"}, description = "Password")
+  private String _password;
+
+  @CommandLine.Option(names = {"--header"}, description = "Extra header key=value (repeatable)")
+  private final Map<String, String> _headers = new LinkedHashMap<>();
+
+  @CommandLine.Option(names = {"-e", "--execute"}, description = "Execute SQL and exit")
+  private String _execute;
+
+  @CommandLine.Option(names = {"-f", "--file"}, description = "Execute SQL from file and exit")
+  private String _file;
+
+  @CommandLine.Option(names = {"-o", "--output"}, description = "Output format: table|csv|json (default: table)")
+  private String _output = "table";
+
+  @CommandLine.Option(names = {"--output-format"},
+      description = "Batch output format: "
+          + "CSV|CSV_HEADER|CSV_UNQUOTED|CSV_HEADER_UNQUOTED|"
+          + "TSV|TSV_HEADER|JSON|ALIGNED|VERTICAL|AUTO|MARKDOWN|NULL")
+  private String _outputFormat;
+
+  @CommandLine.Option(names = {"--output-format-interactive"},
+      description = "Interactive output format: "
+          + "ALIGNED|VERTICAL|AUTO|MARKDOWN|CSV|CSV_HEADER|CSV_UNQUOTED|"
+          + "CSV_HEADER_UNQUOTED|TSV|TSV_HEADER|JSON|NULL (default: ALIGNED)")
+  private String _outputFormatInteractive = "ALIGNED";
+
+  @CommandLine.Option(names = {"--pager"},
+      description = "Pager program for interactive results (empty to disable). Example: less -SRFXK")
+  private String _pager;
+
+  @CommandLine.Option(names = {"--history-file"},
+      description = "Path to history file for interactive mode")
+  private File _historyFile;
+
+  @CommandLine.Option(names = {"--config"},
+      description = "Path to config properties file to set defaults")
+  private File _configFile;
+
+  @CommandLine.Option(names = {"--debug"}, description = "Enable debug output and stack traces")
+  private boolean _debug = false;
+
+  @CommandLine.Option(names = {"--set"}, description = "Query option key=value (repeatable)")
+  private final Map<String, String> _options = new LinkedHashMap<>();
+
+  // Client-side extras for Trino-like UX
+  private String _path; // displayed in prompt
+  private final Set<String> _roles = new HashSet<>();
+  private OutputFormat _overrideFormat; // set when using \G
+
+  @Override
+  public Integer call()
+      throws Exception {
+    loadConfigDefaults();
+    Properties props = new Properties();
+    if (_user != null) {
+      props.setProperty("user", _user);
+    }
+    if (_password != null) {
+      props.setProperty("password", _password);
+    }
+    // headers.Authorization or headers.X-... supported by PinotDriver
+    for (Map.Entry<String, String> e : _headers.entrySet()) {
+      props.setProperty("headers." + e.getKey(), e.getValue());
+    }
+    // query options are passed as properties; PinotConnection will convert to SET statements
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      props.setProperty(e.getKey(), e.getValue());
+    }
+
+    try (Connection conn = DriverManager.getConnection(_jdbcUrl, props)) {
+      if (_execute != null) {
+        runSingle(conn, _execute);
+        return 0;
+      }
+      if (_file != null) {
+        runFile(conn, _file);
+        return 0;
+      }
+      runInteractive(conn);
+    }
+    return 0;
+  }
+
+  private void runInteractive(Connection conn)
+      throws IOException {
+    Terminal terminal = TerminalBuilder.builder().system(true).build();
+    LineReaderBuilder readerBuilder = LineReaderBuilder.builder().terminal(terminal);
+    if (_historyFile == null) {
+      File home = new File(System.getProperty("user.home"));
+      _historyFile = new File(home, ".pinot_history");
+    }
+    readerBuilder.variable(LineReader.HISTORY_FILE, _historyFile.toPath());
+    LineReader reader = readerBuilder.build();
+    String basePrompt = "pinot";
+    String contPrompt = "....> ";
+    StringBuilder sqlBuffer = new StringBuilder();
+    while (true) {
+      try {
+        String prompt = basePrompt;
+        if (_path != null && !_path.isEmpty()) {
+          prompt += ":" + _path;
+        }
+        prompt += "> ";
+        String line = reader.readLine(sqlBuffer.length() == 0 ? prompt : contPrompt);
+        if (line == null) {
+          break;
+        }
+        String trimmed = line.trim();
+        if (sqlBuffer.length() == 0) {
+          if (trimmed.isEmpty()) {
+            continue;
+          }
+          if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) {
+            break;
+          }
+          if ("help".equalsIgnoreCase(trimmed)) {
+            printHelp();
+            continue;
+          }
+          if ("clear".equalsIgnoreCase(trimmed)) {
+            // ANSI clear screen
+            terminal.writer().print("\u001b[H\u001b[2J");
+            terminal.flush();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ")) {
+            handleSet(trimmed.substring(4));
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("UNSET ")) {
+            handleUnset(trimmed.substring(6));
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("SHOW SESSION")) {
+            showSession();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("USE ")) {
+            _path = trimmed.substring(4).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET PATH ")) {
+            _path = trimmed.substring("SET PATH ".length()).trim();
+            continue;
+          }
+          if (trimmed.toUpperCase().startsWith("SET ROLE ")) {
+            String role = trimmed.substring("SET ROLE ".length()).trim();
+            if (!role.isEmpty()) {
+              _roles.add(role);
+              System.out.println("Role set: " + role + " (client-side only)");
+            }
+            continue;
+          }
+          if (trimmed.equalsIgnoreCase("RESET ROLE")) {
+            _roles.clear();
+            System.out.println("Roles cleared (client-side only)");
+            continue;
+          }
+        }
+
+        // Accumulate SQL; submit when terminated with ';' or '\\G'
+        sqlBuffer.append(line).append('\n');
+        Terminator t = detectTerminator(sqlBuffer);
+        if (t._completed) {
+          String sql = stripTrailingTerminator(sqlBuffer.toString(), t);
+          sqlBuffer.setLength(0);
+          _overrideFormat = t._vertical ? OutputFormat.VERTICAL : null;
+          if (!sql.trim().isEmpty()) {
+            executeAndRender(conn, sql);
+          }
+        }
+      } catch (UserInterruptException e) {
+        // Ctrl-C: skip current line
+        sqlBuffer.setLength(0);
+      } catch (EndOfFileException e) {
+        break;
+      } catch (Exception e) {
+        System.err.println("Error: " + e.getMessage());
+        if (_debug) {
+          e.printStackTrace(System.err);
+        }
+      }
+    }
+  }
+
+  private void runFile(Connection conn, String file)
+      throws IOException, SQLException {
+    try (BufferedReader br = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
+      String sql;
+      StringBuilder buf = new StringBuilder();
+      while ((sql = br.readLine()) != null) {
+        buf.append(sql).append('\n');
+      }
+      executeAndRender(conn, buf.toString());
+    }
+  }
+
+  private void runSingle(Connection conn, String sql)
+      throws SQLException {
+    executeAndRender(conn, sql);
+  }
+
+  private void executeAndRender(Connection conn, String sql)
+      throws SQLException {
+    String composed = prefixSessionOptions(sql);
+    Instant start = Instant.now();
+    Progress progress = new Progress();
+    ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+    ScheduledFuture<?> spinner = scheduler.scheduleAtFixedRate(() -> progress.tick(), 0, 120, TimeUnit.MILLISECONDS);
+    try (Statement stmt = conn.createStatement()) {
+      boolean hasResult = stmt.execute(composed);
+      if (!hasResult) {
+        System.out.println("OK");
+        printSqlWarnings(stmt.getWarnings());
+        return;
+      }
+      try (ResultSet rs = stmt.getResultSet()) {
+        OutputFormat format = _overrideFormat != null ? _overrideFormat : resolveOutputFormat();
+        boolean usePager = shouldUsePager();
+        Appendable out;
+        StringBuilder buffer = null;
+        if (usePager) {
+          buffer = new StringBuilder();
+          out = buffer;
+        } else {
+          out = System.out;
+        }
+        int rows = render(rs, format, out, getTerminalWidthIfInteractive());
+        printSqlWarnings(rs.getWarnings());
+        if (usePager && buffer != null) {
+          page(buffer.toString());
+        }
+        if (_debug) {
+          Instant end = Instant.now();
+          Duration d = Duration.between(start, end);
+          System.err.println("[debug] rows=" + rows + ", elapsed=" + d.toMillis() + " ms");
+        }
+      }
+    } finally {
+      spinner.cancel(true);
+      scheduler.shutdownNow();
+      progress.clear();
+      _overrideFormat = null;
+    }
+  }
+
+  private int render(ResultSet rs, OutputFormat format, Appendable out, int terminalWidth)
+      throws SQLException {
+    if (format == OutputFormat.NULL) {
+      int count = 0;
+      while (rs.next()) {
+        count++;
+      }
+      return count;
+    }
+    if (format == OutputFormat.JSON) {
+      return renderJsonLines(rs, out);
+    }
+    if (format == OutputFormat.VERTICAL) {
+      return renderVertical(rs, out);
+    }
+    if (format == OutputFormat.MARKDOWN) {
+      return renderMarkdown(rs, out);
+    }
+    if (format == OutputFormat.CSV
+        || format == OutputFormat.CSV_HEADER
+        || format == OutputFormat.CSV_UNQUOTED
+        || format == OutputFormat.CSV_HEADER_UNQUOTED) {
+      boolean includeHeader = (format == OutputFormat.CSV_HEADER || format == OutputFormat.CSV_HEADER_UNQUOTED);
+      boolean quoted = (format == OutputFormat.CSV || format == OutputFormat.CSV_HEADER);
+      return renderSeparated(rs, out, ',', includeHeader, quoted);
+    }
+    if (format == OutputFormat.TSV || format == OutputFormat.TSV_HEADER) {
+      boolean includeHeader = (format == OutputFormat.TSV_HEADER);
+      // TSV is unquoted
+      return renderSeparated(rs, out, '\t', includeHeader, false);
+    }
+    // ALIGNED or AUTO
+    if (format == OutputFormat.AUTO) {
+      // Decide based on width
+      ResultSetMetaData md = rs.getMetaData();
+      int cols = md.getColumnCount();
+      int[] widths = new int[cols];
+      String[] headers = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        headers[i - 1] = md.getColumnLabel(i);
+        widths[i - 1] = headers[i - 1].length();
+      }
+      List<String[]> rows = new ArrayList<>();
+      while (rs.next()) {
+        String[] row = new String[cols];
+        for (int i = 1; i <= cols; i++) {
+          String v = rs.getString(i);
+          row[i - 1] = v == null ? "NULL" : v;
+          widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+        }
+        rows.add(row);
+      }
+      int requiredWidth = 0;
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          requiredWidth += 3; // separator " | "
+        }
+        requiredWidth += widths[i];
+      }
+      if (terminalWidth > 0 && requiredWidth > terminalWidth) {
+        return renderVertical(headers, rows, out);
+      } else {
+        return renderAligned(headers, widths, rows, out);
+      }
+    }
+    // ALIGNED
+    return renderAlignedFromResultSet(rs, out);
+  }
+
+  private String prefixSessionOptions(String sql) {
+    if (_options.isEmpty()) {
+      return sql;
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : _options.entrySet()) {
+      sb.append("SET ").append(e.getKey()).append("=").append(e.getValue()).append(";\n");
+    }
+    sb.append(sql);
+    return sb.toString();
+  }
+
+  private int renderAlignedFromResultSet(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int[] widths = new int[cols];
+    String[] headers = new String[cols];
+    for (int i = 1; i <= cols; i++) {
+      headers[i - 1] = md.getColumnLabel(i);
+      widths[i - 1] = headers[i - 1].length();
+    }
+    List<String[]> rows = new ArrayList<>();
+    while (rs.next()) {
+      String[] row = new String[cols];
+      for (int i = 1; i <= cols; i++) {
+        String v = rs.getString(i);
+        row[i - 1] = v == null ? "NULL" : v;
+        widths[i - 1] = Math.max(widths[i - 1], row[i - 1].length());
+      }
+      rows.add(row);
+    }
+    return renderAligned(headers, widths, rows, out);
+  }
+
+  private int renderAligned(String[] headers, int[] widths, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int cols = headers.length;
+    // header
+    StringBuilder sep = new StringBuilder();
+    StringBuilder head = new StringBuilder();
+    for (int i = 0; i < cols; i++) {
+      if (i > 0) {
+        sep.append("-+-");
+        head.append(" | ");
+      }
+      sep.append(repeat('-', widths[i]));
+      head.append(pad(headers[i], widths[i]));
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, head.toString());
+    tryAppendLine(out, sep.toString());
+    for (String[] row : rows) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 0; i < cols; i++) {
+        if (i > 0) {
+          line.append(" | ");
+        }
+        line.append(pad(row[i], widths[i]));
+      }
+      tryAppendLine(out, line.toString());
+    }
+    tryAppendLine(out, sep.toString());
+    tryAppendLine(out, rows.size() + " row(s)");
+    return rows.size();
+  }
+
+  private int renderSeparated(ResultSet rs,
+      Appendable out,
+      char delimiter,
+      boolean includeHeader,
+      boolean quoted)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    if (includeHeader) {
+      StringBuilder header = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          header.append(delimiter);
+        }
+        String h = md.getColumnLabel(i);
+        header.append(quoted ? escapeCsv(h) : escapeSeparated(h, delimiter));
+      }
+      tryAppendLine(out, header.toString());
+    }
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(delimiter);
+        }
+        String v = rs.getString(i);
+        if (quoted) {
+          line.append(escapeCsv(v));
+        } else {
+          line.append(escapeSeparated(v, delimiter));
+        }
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderJsonLines(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder obj = new StringBuilder();
+      obj.append("{");
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          obj.append(",");
+        }
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        obj.append("\"" + escapeJson(name) + "\":");
+        if (v == null) {
+          obj.append("null");
+        } else {
+          obj.append("\"" + escapeJson(v) + "\"");
+        }
+      }
+      obj.append("}");
+      tryAppendLine(out, obj.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    int count = 0;
+    while (rs.next()) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 1; i <= cols; i++) {
+        String name = md.getColumnLabel(i);
+        String v = rs.getString(i);
+        tryAppendLine(out, name + " | " + (v == null ? "NULL" : v));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private int renderMarkdown(ResultSet rs, Appendable out)
+      throws SQLException {
+    ResultSetMetaData md = rs.getMetaData();
+    int cols = md.getColumnCount();
+    StringBuilder header = new StringBuilder();
+    StringBuilder sep = new StringBuilder();
+    for (int i = 1; i <= cols; i++) {
+      if (i > 1) {
+        header.append(" | ");
+        sep.append("|");
+      }
+      String h = md.getColumnLabel(i);
+      header.append(h);
+      sep.append(" --- ");
+    }
+    tryAppendLine(out, header.toString());
+    tryAppendLine(out, sep.toString());
+    int count = 0;
+    while (rs.next()) {
+      StringBuilder line = new StringBuilder();
+      for (int i = 1; i <= cols; i++) {
+        if (i > 1) {
+          line.append(" | ");
+        }
+        String v = rs.getString(i);
+        line.append(v == null ? "" : v);
+      }
+      tryAppendLine(out, line.toString());
+      count++;
+    }
+    return count;
+  }
+
+  private int renderVertical(String[] headers, List<String[]> rows, Appendable out)
+      throws SQLException {
+    int count = 0;
+    for (String[] row : rows) {
+      tryAppendLine(out, "-[ RECORD " + (count + 1) + " ]--------");
+      for (int i = 0; i < headers.length; i++) {
+        tryAppendLine(out, headers[i] + " | " + (row[i] == null ? "NULL" : row[i]));
+      }
+      count++;
+    }
+    return count;
+  }
+
+  private void tryAppendLine(Appendable out, String line)
+      throws SQLException {
+    try {
+      out.append(line).append('\n');
+    } catch (IOException ioe) {
+      throw new SQLException("Failed to write output", ioe);
+    }
+  }
+
+  private String escapeSeparated(String s, char delimiter) {
+    if (s == null) {
+      return "";
+    }
+    // For separated formats, normalize newlines to spaces for readability
+    return s.replace("\n", " ").replace("\r", " ");
+  }
+
+  private static String pad(String s, int width) {
+    if (s == null) {
+      s = "";
+    }
+    if (s.length() >= width) {
+      return s;
+    }
+    StringBuilder b = new StringBuilder(s);
+    while (b.length() < width) {
+      b.append(' ');
+    }
+    return b.toString();
+  }
+
+  private static String repeat(char ch, int count) {
+    StringBuilder b = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      b.append(ch);
+    }
+    return b.toString();
+  }
+
+  private static String escapeCsv(String s) {
+    if (s == null) {
+      return "";
+    }
+    boolean needQuotes = s.contains(",") || s.contains("\n") || s.contains("\r") || s.contains("\"");
+    String escaped = s.replace("\"", "\"\"");
+    return needQuotes ? "\"" + escaped + "\"" : escaped;
+  }
+
+  private static String escapeJson(String s) {
+    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
+  }
+
+  public static void main(String[] args) {
+    int code = new CommandLine(new PinotCli()).execute(args);
+    System.exit(code);
+  }
+
+  private OutputFormat resolveOutputFormat() {
+    // Backward compatibility for -o/--output
+    if (_outputFormat == null) {
+      if (_execute != null || _file != null) {
+        // batch default
+        if ("json".equalsIgnoreCase(_output)) {
+          return OutputFormat.JSON;
+        } else if ("csv".equalsIgnoreCase(_output)) {
+          return OutputFormat.CSV;
+        } else {
+          return OutputFormat.ALIGNED;
+        }
+      } else {
+        // interactive default
+        return parseFormat(_outputFormatInteractive);
+      }
+    }
+    return parseFormat(_outputFormat);
+  }
+
+  private OutputFormat parseFormat(String fmt) {
+    if (fmt == null) {
+      return OutputFormat.ALIGNED;
+    }
+    String f = fmt.trim().toUpperCase();
+    try {
+      if ("TABLE".equals(f)) {
+        return OutputFormat.ALIGNED;
+      }
+      return OutputFormat.valueOf(f);
+    } catch (IllegalArgumentException iae) {
+      return OutputFormat.ALIGNED;
+    }
+  }
+
+  private boolean shouldUsePager() {
+    if (_execute != null || _file != null) {
+      return false; // batch mode: no pager
+    }
+    String pager = effectivePager();
+    return pager != null && !pager.isEmpty();
+  }
+
+  private String effectivePager() {
+    if (_pager != null) {
+      return _pager;
+    }
+    String env = System.getenv("TRINO_PAGER");
+    if (env == null || env.isEmpty()) {
+      env = System.getenv("PINOT_PAGER");
+    }
+    return env;
+  }
+
+  private void page(String content)
+      throws SQLException {
+    String pager = effectivePager();
+    if (pager == null || pager.isEmpty()) {
+      System.out.print(content);
+      return;
+    }
+    ProcessBuilder pb = new ProcessBuilder("sh", "-c", pager);

Review Comment:
   Using 'sh -c' with user-configurable pager command creates a command injection vulnerability. The pager value from config/environment is passed unsanitized to shell execution. Consider using ProcessBuilder with split arguments or validating/sanitizing the pager command.
   ```suggestion
       // Basic validation: reject shell metacharacters to avoid command injection
       if (pager.matches(".*[|&;`$><].*")) {
         throw new SQLException("Invalid pager command: shell metacharacters are not allowed");
       }
       // Split pager string into command and arguments
       String[] pagerCmd = pager.trim().split("\\s+");
       ProcessBuilder pb = new ProcessBuilder(pagerCmd);
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org


Re: [PR] Add pinot-cli: interactive and batch command-line client [pinot]

Posted by "xiangfu0 (via GitHub)" <gi...@apache.org>.
xiangfu0 merged PR #17029:
URL: https://github.com/apache/pinot/pull/17029


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org