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;
+
+
[email protected](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;
+
+
[email protected](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;
+
+
[email protected](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;
+
+
[email protected](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: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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


Reply via email to