Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `vortex sql <file.vortex> [more...]` — interactive SQL shell over Vortex files, backed by Calcite. Multiline editor (psql-style: queries run on `;`), a scrollable result grid (`Tab` to focus, arrows/PgUp/PgDn to scroll), and `Up`/`Down` recall of the last 100 queries persisted at `~/.vortex/sql_history`.
- `DType.isUnsigned()` — `true` for the unsigned integer primitives (`U8`–`U64`), `false` otherwise. ([#159](https://github.com/dfa1/vortex-java/issues/159))

### Fixed
Expand Down
6 changes: 6 additions & 0 deletions cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
<groupId>io.github.dfa1.vortex</groupId>
<artifactId>vortex-inspector</artifactId>
</dependency>
<dependency>
<!-- Backs the `sql` subcommand: Calcite JDBC over Vortex files. Pulls calcite-core
and its transitives (avatica, guava, …) into the shaded uber-jar. -->
<groupId>io.github.dfa1.vortex</groupId>
<artifactId>vortex-calcite</artifactId>
</dependency>
<dependency>
<groupId>io.airlift</groupId>
<artifactId>aircompressor-v3</artifactId>
Expand Down
60 changes: 60 additions & 0 deletions cli/src/main/java/io/github/dfa1/vortex/cli/SqlCommand.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.github.dfa1.vortex.cli;

import io.github.dfa1.vortex.cli.tui.VortexSqlTui;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;

/// `sql FILE.vortex [MORE.vortex...]` - opens the interactive SQL shell.
///
/// Each file is registered as a table named after its filename stem (`ohlc.vortex` becomes table
/// `ohlc`, queried as `vtx.ohlc`). Duplicate table names are rejected. Only local `.vortex` files
/// are supported - Calcite scans them directly, so there is no http(s) handle to round-trip.
@SuppressWarnings("java:S106") // CLI command: stdout/stderr are the intended channels
final class SqlCommand {

private SqlCommand() {
}

static int run(String[] args) {
if (args.length < 2) {
System.err.println("usage: sql <file.vortex> [more.vortex...]");
return ExitStatus.USAGE_ERROR;
}
Map<String, Path> tables = new LinkedHashMap<>();
for (int i = 1; i < args.length; i++) {
Path path = Path.of(args[i]);
if (!Files.exists(path)) {
System.err.println("file not found: " + path);
return ExitStatus.FILE_NOT_FOUND;
}
String name = tableName(path);
if (tables.containsKey(name)) {
System.err.println("duplicate table name '" + name + "' from " + path
+ " (rename one of the files)");
return ExitStatus.USAGE_ERROR;
}
tables.put(name, path);
}
try {
VortexSqlTui.show(tables);
return ExitStatus.OK;
} catch (IOException | RuntimeException e) {
System.err.println("error: " + CliHandles.describe(e));
return ExitStatus.ERROR;
}
}

/// Derives the SQL table name from a file path: the filename with its extension stripped.
///
/// @param path the `.vortex` file path
/// @return the table name
static String tableName(Path path) {
String file = path.getFileName().toString();
int dot = file.lastIndexOf('.');
return dot > 0 ? file.substring(0, dot) : file;
}
}
2 changes: 2 additions & 0 deletions cli/src/main/java/io/github/dfa1/vortex/cli/VortexCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static void main(String[] args) {
case "select" -> SelectCommand.run(args);
case "stats" -> StatsCommand.run(args);
case "filter" -> FilterCommand.run(args);
case "sql" -> SqlCommand.run(args);
default -> {
System.err.println("unknown subcommand: " + args[0]);
printUsage(System.err);
Expand All @@ -54,5 +55,6 @@ static void printUsage(PrintStream out) {
out.println(" select <file.vortex> <col> [...] project columns to CSV on stdout");
out.println(" stats <file.vortex> print per-column min/max statistics");
out.println(" filter <file.vortex> <expr> filter rows to CSV (e.g. \"price >= 100\")");
out.println(" sql <file.vortex> [more...] open interactive SQL shell (Calcite)");
}
}
134 changes: 134 additions & 0 deletions cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package io.github.dfa1.vortex.cli.tui;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/// Bounded, persistent recall buffer for the SQL shell: the last [#MAX_ENTRIES] queries the
/// user ran, oldest first.
///
/// Entries are stored one per file line with newlines and backslashes escaped (`\n`, `\\`), so a
/// multiline query survives a round-trip through the flat history file. Consecutive duplicates are
/// collapsed - re-running the same query does not grow the list. The buffer is rewritten on every
/// [#add(String)] so an interrupted session still keeps everything up to the last run query.
///
/// Persistence is best-effort: like [#load(Path)], a failed write is swallowed so history never
/// aborts the running shell - the in-memory recall buffer stays usable for the rest of the session.
public final class QueryHistory {

/// Maximum number of queries retained; older entries are dropped on overflow.
public static final int MAX_ENTRIES = 100;

private final Path file;
private final List<String> entries;

private QueryHistory(Path file, List<String> entries) {
this.file = file;
this.entries = entries;
}

/// Loads history from the default location, `~/.vortex/sql_history`.
///
/// A missing or unreadable file yields an empty history rather than failing - history is a
/// convenience, never a hard dependency.
///
/// @return the loaded history, possibly empty
public static QueryHistory loadDefault() {
return load(defaultFile());
}

/// Loads history from `file`, treating a missing or unreadable file as empty.
///
/// @param file the history file to read and later persist to
/// @return the loaded history, possibly empty
public static QueryHistory load(Path file) {
List<String> entries = new ArrayList<>();
try {
if (Files.exists(file)) {
for (String line : Files.readAllLines(file)) {
if (!line.isEmpty()) {
entries.add(unescape(line));
}
}
}
} catch (IOException _) {
entries.clear();
}
return new QueryHistory(file, entries);
}

/// Appends `query` (collapsing a consecutive duplicate), enforces the size cap, and rewrites
/// the history file. Blank queries are ignored.
///
/// @param query the query text to record
public void add(String query) {
String trimmed = query.strip();
if (trimmed.isEmpty()) {
return;
}
if (!entries.isEmpty() && entries.getLast().equals(trimmed)) {
return;
}
entries.add(trimmed);
while (entries.size() > MAX_ENTRIES) {
entries.removeFirst();
}
persist();
}

/// All retained queries, oldest first. The returned list is a copy.
///
/// @return the history entries
public List<String> entries() {
return List.copyOf(entries);
}

/// Number of retained queries.
///
/// @return the entry count
public int size() {
return entries.size();
}

private void persist() {
try {
Path parent = file.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
List<String> lines = new ArrayList<>(entries.size());
for (String e : entries) {
lines.add(escape(e));
}
Files.write(file, lines);
} catch (IOException _) {
// History is best-effort: a failed write (read-only home, full disk) must never abort
// the running shell. The in-memory entries are already updated, so recall still works
// this session; only cross-session persistence is lost. Mirrors the swallow in load.
}
}

private static Path defaultFile() {
return Path.of(System.getProperty("user.home", "."), ".vortex", "sql_history");
}

private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\n", "\\n");
}

private static String unescape(String s) {
StringBuilder out = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\' && i + 1 < s.length()) {
char next = s.charAt(++i);
out.append(next == 'n' ? '\n' : next);
} else {
out.append(c);
}
}
return out.toString();
}
}
85 changes: 85 additions & 0 deletions cli/src/main/java/io/github/dfa1/vortex/cli/tui/ResultGrid.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package io.github.dfa1.vortex.cli.tui;

import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

/// An in-memory snapshot of a SQL [ResultSet]: column names plus up to a caller-supplied cap of
/// already-stringified cells, ready to feed the scrollable grid renderer.
///
/// Results are materialised eagerly (Vortex readers are confined to the scanning thread and closed
/// when the query enumerator finishes, so the rows must be copied out before the result set is
/// drained). When the underlying result exceeds the cap, [#truncated()] is `true` and only the
/// first `maxRows` rows are kept.
public final class ResultGrid {

private final List<String> columns;
private final List<String[]> rows;
private final boolean truncated;

private ResultGrid(List<String> columns, List<String[]> rows, boolean truncated) {
this.columns = columns;
this.rows = rows;
this.truncated = truncated;
}

/// Drains `rs` into a snapshot, keeping at most `maxRows` rows.
///
/// @param rs the result set to read (left at end-of-cursor or at the first dropped row)
/// @param maxRows the maximum number of rows to retain
/// @return the materialised grid
/// @throws SQLException if reading column metadata or a cell fails
public static ResultGrid from(ResultSet rs, int maxRows) throws SQLException {
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
List<String> columns = new ArrayList<>(colCount);
for (int c = 1; c <= colCount; c++) {
columns.add(meta.getColumnLabel(c));
}
List<String[]> rows = new ArrayList<>();
boolean truncated = false;
while (rs.next()) {
if (rows.size() >= maxRows) {
truncated = true;
break;
}
String[] row = new String[colCount];
for (int c = 1; c <= colCount; c++) {
Object v = rs.getObject(c);
row[c - 1] = v == null ? "" : v.toString();
}
rows.add(row);
}
return new ResultGrid(List.copyOf(columns), List.copyOf(rows), truncated);
}

/// Column labels, left to right.
///
/// @return an unmodifiable list of column names
public List<String> columns() {
return columns;
}

/// The retained rows, each a per-column array of display strings.
///
/// @return an unmodifiable list of rows
public List<String[]> rows() {
return rows;
}

/// Number of retained rows.
///
/// @return the row count (at most the cap)
public int rowCount() {
return rows.size();
}

/// Whether the original result had more rows than were retained.
///
/// @return true if rows were dropped at the cap
public boolean truncated() {
return truncated;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ private void renderDataRow(StringBuilder out, long absRow, String[] row, int ter
private void renderStatus(StringBuilder out, String[][] window, int viewportRows, int termCols) {
String col = cursorCol < totalCols ? data.columns().get(cursorCol) : "-";
String cellValue = currentCellValue(window, viewportRows);
String right = " arrows/PgUp/PgDn move g/G top/bot q quit ";
String right = " arrows/PgUp/PgDn move g/G top/bot Esc/q quit ";
int rightRoom = right.length() < termCols ? right.length() : 0;
int leftBudget = termCols - rightRoom;
String leftFull = " R" + (cursorRow + 1) + " C" + (cursorCol + 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ private void drawHeader(StringBuilder buf, int width) {
private void drawFooter(StringBuilder buf, int width, int height) {
buf.append(Ansi.moveTo(height, 1));
buf.append(Ansi.bg(47)).append(Ansi.fg(30));
buf.append(InspectorRender.pad(" ↑↓ nav →/Enter expand ← collapse q quit ", width));
buf.append(InspectorRender.pad(" ↑↓ nav →/Enter expand ← collapse Esc/q quit ", width));
buf.append(Ansi.RESET);
}

Expand Down
Loading
Loading