This is an automated email from the ASF dual-hosted git repository.
xiangfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 599b139e78e Add pinot-cli module to provide a terminal cli for pinot
(#17029)
599b139e78e is described below
commit 599b139e78e66e3a2e15d0d9667c1b44af9fb875
Author: Xiang Fu <[email protected]>
AuthorDate: Fri Oct 24 18:09:13 2025 -0700
Add pinot-cli module to provide a terminal cli for pinot (#17029)
---
pinot-clients/pinot-cli/README.md | 171 ++++
pinot-clients/pinot-cli/pom.xml | 111 +++
.../main/java/org/apache/pinot/cli/PinotCli.java | 1048 ++++++++++++++++++++
pinot-clients/pom.xml | 1 +
4 files changed, 1331 insertions(+)
diff --git a/pinot-clients/pinot-cli/README.md
b/pinot-clients/pinot-cli/README.md
new file mode 100644
index 00000000000..9d9ade55dca
--- /dev/null
+++ b/pinot-clients/pinot-cli/README.md
@@ -0,0 +1,171 @@
+<!--
+
+ 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.
+
+## 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-*-executable.jar` (executable,
recommended)
+- `pinot-clients/pinot-cli/target/pinot-cli-1.5.0-SNAPSHOT.jar` (thin)
+
+## Running
+
+### Interactive mode
+
+```bash
+pinot-clients/pinot-cli/target/pinot-cli-*-executable.jar \
+ -u jdbc:pinot://<controller-host>:<port>
+```
+
+- Multi-line SQL is supported; end statements with `;` to execute.
+- Built-in commands: `help`, `clear`, `exit`, `quit`.
+- Default history file: `~/.pinot_history` (customize with `--history-file`).
+- Enable paging with a pager (e.g., `less`) via `--pager` or environment
variables below.
+
+### Batch mode
+
+Execute a single statement:
+
+```bash
+pinot-clients/pinot-cli/target/pinot-cli-*-executable.jar \
+ -u jdbc:pinot://<controller-host>:<port> \
+ --output-format=CSV_HEADER \
+ --execute "SELECT * FROM myTable LIMIT 3;"
+```
+
+Execute statements from a file:
+
+```bash
+pinot-clients/pinot-cli/target/pinot-cli-*-executable.jar \
+ -u jdbc:pinot://<controller-host>:<port> \
+ --output-format=JSON \
+ -f queries.sql
+```
+
+## Options
+
+- `-u, --url` JDBC URL. Example: `jdbc:pinot://controller:9000` or
`jdbc:pinotgrpc://controller:9000` (required)
+- `-n, --user` Username
+- `-p, --password` Password
+- `--header` Extra request header `key=value` (repeatable), e.g., `--header
Authorization=Bearer <token>`
+- `--set` Query/session option `key=value` (repeatable). Forwarded as
connection properties
+- `-e, --execute` Execute SQL and exit
+- `-f, --file` Execute SQL from file and exit
+- `-o, --output` Legacy: `table|csv|json` (backward compatibility). Prefer the
formats below
+- `--output-format` Batch output format
+- `--output-format-interactive` Interactive output format (default: `ALIGNED`)
+- `--pager` Pager program used in interactive mode (e.g., `less -SRFXK`).
Empty disables pagination
+- `--history-file` Path to history file for interactive mode (default:
`~/.pinot_history`)
+- `--config` Path to a properties file with defaults (see Configuration below)
+- `--debug` Print stack traces and timing diagnostics to stderr
+
+### Output formats
+
+Available values for `--output-format` and `--output-format-interactive`
(case-insensitive):
+
+- `CSV`, `CSV_HEADER`, `CSV_UNQUOTED`, `CSV_HEADER_UNQUOTED`
+- `TSV`, `TSV_HEADER`
+- `JSON` (one JSON object per line)
+- `ALIGNED` (ASCII table)
+- `VERTICAL` (record-oriented)
+- `AUTO` (chooses `ALIGNED` if it fits terminal width, otherwise `VERTICAL`)
+- `MARKDOWN` (Markdown table)
+- `NULL` (suppress normal results; useful for timing/error checks)
+
+## Configuration
+
+You can load defaults from a properties file using `--config` or via
environment variables:
+
+- `PINOT_CONFIG` (preferred)
+
+Supported keys in the properties file (CLI flags take precedence):
+
+- `server` (maps to `--url`)
+- `user`, `password`
+- `output-format`, `output-format-interactive`, `output`
+- `pager`, `history-file`, `debug`
+- `headers.*` (e.g., `headers.Authorization=Bearer <token>`) -> becomes extra
headers
+- Any other key is forwarded as a session option (equivalent to `--set
key=value`)
+
+Example `pinot-cli.properties`:
+
+```properties
+server=jdbc:pinot://localhost:9000
+user=alice
+output-format-interactive=AUTO
+pager=less -SRFXK
+history-file=/Users/alice/.pinot_history
+headers.Authorization=Bearer abc123
+debug=true
+timeoutMs=60000
+```
+
+Run with:
+
+```bash
+PINOT_CONFIG=/path/to/pinot-cli.properties \
+pinot-clients/pinot-cli/target/pinot-cli-*-executable.jar
+```
+
+## Environment variables
+
+- `PINOT_CONFIG`: path to a config properties file
+- `PINOT_PAGER`: pager command for interactive mode (e.g., `less -SRFXK`)
+
+## Examples
+
+Interactive with AUTO format and pager:
+
+```bash
+pinot-clients/pinot-cli/target/pinot-cli-*-executable.jar \
+ -u jdbc:pinot://localhost:9000 \
+ --output-format-interactive=AUTO \
+ --pager "less -SRFXK"
+```
+
+Batch to JSON:
+
+```bash
+pinot-clients/pinot-cli/target/pinot-cli-*-executable.jar \
+ -u jdbc:pinot://localhost:9000 \
+ --output-format=JSON \
+ --execute "SELECT col1, col2 FROM myTable LIMIT 3;"
+```
+
+## Notes
+
+- CLI arguments take precedence over config file values.
+- Pager is only used in interactive mode. Batch mode prints directly to stdout.
+
+
diff --git a/pinot-clients/pinot-cli/pom.xml b/pinot-clients/pinot-cli/pom.xml
new file mode 100644
index 00000000000..0cc5cd458ac
--- /dev/null
+++ b/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>
+ <classifier>executable</classifier>
+ </configuration>
+ <executions>
+ <execution>
+ <goals>
+ <goal>really-executable-jar</goal>
+ </goals>
+ <phase>package</phase>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
+
+
diff --git
a/pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java
b/pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java
new file mode 100644
index 00000000000..f0188efe7cc
--- /dev/null
+++ b/pinot-clients/pinot-cli/src/main/java/org/apache/pinot/cli/PinotCli.java
@@ -0,0 +1,1048 @@
+/**
+ * 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
+ 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) {
+ return String.valueOf(ch).repeat(count);
+ }
+
+ 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);
+ }
+ break;
+ }
+ }
+ 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;
+ }
+ return System.getenv("PINOT_PAGER");
+ }
+
+ 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(".*[|&;`$><\\\\].*")) {
+ throw new SQLException("Invalid pager command: shell metacharacters are
not allowed");
+ }
+ String[] pagerCmd = tokenizeCommand(pager);
+ if (pagerCmd.length == 0) {
+ System.out.print(content);
+ return;
+ }
+ ProcessBuilder pb = new ProcessBuilder(pagerCmd);
+ try {
+ Process p = pb.start();
+ try (OutputStream os = p.getOutputStream()) {
+ os.write(content.getBytes(StandardCharsets.UTF_8));
+ 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 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()) {
+ _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.")) {
+ continue;
+ }
+ // Exclude non-session configuration keys from being forwarded as query
options
+ if (name.equals("server")
+ || name.equals("user")
+ || name.equals("password")
+ || name.equals("output")
+ || name.equals("output-format")
+ || name.equals("output-format-interactive")
+ || name.equals("pager")
+ || name.equals("history-file")
+ || name.equals("debug")) {
+ continue;
+ }
+ // don't override explicit --set
+ if (!_options.containsKey(name)) {
+ _options.put(name, p.getProperty(name));
+ }
+ }
+ }
+
+ private void handleSet(String expr) {
+ int idx = expr.indexOf('=');
+ if (idx <= 0) {
+ System.err.println("SET requires key=value");
+ return;
+ }
+ String key = expr.substring(0, idx).trim();
+ String value = expr.substring(idx + 1).trim();
+ if (key.isEmpty()) {
+ System.err.println("Invalid key");
+ return;
+ }
+ _options.put(key, value);
+ System.out.println("Set " + key + "=" + value);
+ }
+
+ private void handleUnset(String key) {
+ key = key.trim();
+ if (key.isEmpty()) {
+ System.err.println("UNSET requires key");
+ return;
+ }
+ if (_options.remove(key) != null) {
+ System.out.println("Unset " + key);
+ } else {
+ System.out.println("No such key: " + key);
+ }
+ }
+
+ private void showSession() {
+ System.out.println("URL: " + _jdbcUrl);
+ System.out.println("User: " + (_user == null ? "" : _user));
+ System.out.println("Path: " + (_path == null ? "" : _path));
+ System.out.println("Roles: " + (_roles.isEmpty() ? "(none)" :
String.join(",", _roles)) + " (client-side)");
+ System.out.println("Output (batch): " + (_outputFormat == null ? _output :
_outputFormat));
+ System.out.println("Output (interactive): " + _outputFormatInteractive);
+ if (_pager != null) {
+ System.out.println("Pager: " + _pager);
+ }
+ if (!_options.isEmpty()) {
+ System.out.println("Session properties:");
+ for (Map.Entry<String, String> e : _options.entrySet()) {
+ System.out.println(" " + e.getKey() + "=" + e.getValue());
+ }
+ }
+ }
+
+ private void printSqlWarnings(java.sql.SQLWarning warn) {
+ if (warn == null) {
+ return;
+ }
+ System.err.println("Warnings:");
+ for (java.sql.SQLWarning w = warn; w != null; w = w.getNextWarning()) {
+ System.err.println(
+ " " + w.getMessage() + (w.getSQLState() != null ? (" [SQLState=" +
w.getSQLState() + "]") : ""));
+ }
+ }
+
+ private static final class Terminator {
+ final boolean _completed;
+ final boolean _vertical;
+
+ Terminator(boolean completed, boolean vertical) {
+ _completed = completed;
+ _vertical = vertical;
+ }
+ }
+
+ private static final class Progress {
+ private final char[] _spin = new char[]{'|', '/', '-', '\\'};
+ private int _idx = 0;
+ private final long _start = System.currentTimeMillis();
+
+ synchronized void tick() {
+ long elapsed = System.currentTimeMillis() - _start;
+ System.err.print("\r[" + _spin[_idx] + "] Executing... " + elapsed + "
ms");
+ System.err.flush();
+ _idx = (_idx + 1) % _spin.length;
+ }
+
+ synchronized void clear() {
+ System.err.print("\r\033[K");
+ System.err.flush();
+ }
+ }
+
+ private enum OutputFormat {
+ CSV,
+ CSV_HEADER,
+ CSV_UNQUOTED,
+ CSV_HEADER_UNQUOTED,
+ TSV,
+ TSV_HEADER,
+ JSON,
+ ALIGNED,
+ VERTICAL,
+ AUTO,
+ MARKDOWN,
+ NULL
+ }
+}
diff --git a/pinot-clients/pom.xml b/pinot-clients/pom.xml
index 844bc680b86..e21a3af51eb 100644
--- a/pinot-clients/pom.xml
+++ b/pinot-clients/pom.xml
@@ -35,6 +35,7 @@
</properties>
<modules>
+ <module>pinot-cli</module>
<module>pinot-java-client</module>
<module>pinot-jdbc-client</module>
</modules>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]