From 6f2ad774a1182dfe96723c4a245f759ceab342d4 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Thu, 25 Jun 2026 20:09:47 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20vortex=20sql=20=E2=80=94=20int?= =?UTF-8?q?eractive=20Calcite-backed=20SQL=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `sql FILE.vortex [MORE...]`, an interactive SQL REPL over Vortex files using the calcite module. Split-pane TUI: a multiline editor on top and a scrollable result grid below. - Editor follows psql convention: Enter inserts a newline, a query runs only when the buffer ends with `;`. Tab toggles focus editor<->grid; Up/Down at the editor edge recall previous queries; Esc clears; Ctrl-D quits. - QueryHistory persists the last 100 queries to ~/.vortex/sql_history, newline-escaped and consecutive-dup collapsed. - ResultGrid snapshots a ResultSet (capped at 10k rows) for the grid. - Reuses the existing Terminal/Ansi/Key TUI stack; adds Key.Backspace (KeyDecoder maps 0x7F/0x08) for line editing. Queries execute synchronously on the render thread — VortexTable opens its own reader per scan, so no separate I/O thread is needed for arena confinement. Note: vortex-calcite (calcite-core + transitives) now shades into the cli uber-jar, growing its size. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + cli/pom.xml | 6 + .../io/github/dfa1/vortex/cli/SqlCommand.java | 60 ++ .../io/github/dfa1/vortex/cli/VortexCli.java | 2 + .../dfa1/vortex/cli/tui/QueryHistory.java | 130 ++++ .../dfa1/vortex/cli/tui/ResultGrid.java | 85 +++ .../dfa1/vortex/cli/tui/VortexSqlTui.java | 614 ++++++++++++++++++ .../github/dfa1/vortex/cli/tui/term/Key.java | 6 + .../dfa1/vortex/cli/tui/term/KeyDecoder.java | 5 + .../dfa1/vortex/cli/SqlCommandTest.java | 61 ++ .../dfa1/vortex/cli/tui/QueryHistoryTest.java | 104 +++ .../dfa1/vortex/cli/tui/VortexSqlTuiTest.java | 166 +++++ .../vortex/cli/tui/term/KeyDecoderTest.java | 7 + .../dfa1/vortex/cli/tui/term/KeyTest.java | 1 + pom.xml | 5 + 15 files changed, 1253 insertions(+) create mode 100644 cli/src/main/java/io/github/dfa1/vortex/cli/SqlCommand.java create mode 100644 cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java create mode 100644 cli/src/main/java/io/github/dfa1/vortex/cli/tui/ResultGrid.java create mode 100644 cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java create mode 100644 cli/src/test/java/io/github/dfa1/vortex/cli/SqlCommandTest.java create mode 100644 cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java create mode 100644 cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 593fcc243..583707758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `vortex sql [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 diff --git a/cli/pom.xml b/cli/pom.xml index 1eefe1624..504fa223c 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -33,6 +33,12 @@ io.github.dfa1.vortex vortex-inspector + + + io.github.dfa1.vortex + vortex-calcite + io.airlift aircompressor-v3 diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/SqlCommand.java b/cli/src/main/java/io/github/dfa1/vortex/cli/SqlCommand.java new file mode 100644 index 000000000..94d51dc51 --- /dev/null +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/SqlCommand.java @@ -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 [more.vortex...]"); + return ExitStatus.USAGE_ERROR; + } + Map 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; + } +} diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/VortexCli.java b/cli/src/main/java/io/github/dfa1/vortex/cli/VortexCli.java index 0f5ada1f1..021283489 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/VortexCli.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/VortexCli.java @@ -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); @@ -54,5 +55,6 @@ static void printUsage(PrintStream out) { out.println(" select [...] project columns to CSV on stdout"); out.println(" stats print per-column min/max statistics"); out.println(" filter filter rows to CSV (e.g. \"price >= 100\")"); + out.println(" sql [more...] open interactive SQL shell (Calcite)"); } } diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java new file mode 100644 index 000000000..9a1ae1af4 --- /dev/null +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java @@ -0,0 +1,130 @@ +package io.github.dfa1.vortex.cli.tui; + +import java.io.IOException; +import java.io.UncheckedIOException; +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. +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 entries; + + private QueryHistory(Path file, List 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 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 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 lines = new ArrayList<>(entries.size()); + for (String e : entries) { + lines.add(escape(e)); + } + Files.write(file, lines); + } catch (IOException e) { + throw new UncheckedIOException("cannot write history file " + file, e); + } + } + + 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(); + } +} diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/ResultGrid.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/ResultGrid.java new file mode 100644 index 000000000..8a92e3966 --- /dev/null +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/ResultGrid.java @@ -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 columns; + private final List rows; + private final boolean truncated; + + private ResultGrid(List columns, List 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 columns = new ArrayList<>(colCount); + for (int c = 1; c <= colCount; c++) { + columns.add(meta.getColumnLabel(c)); + } + List 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 columns() { + return columns; + } + + /// The retained rows, each a per-column array of display strings. + /// + /// @return an unmodifiable list of rows + public List 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; + } +} diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java new file mode 100644 index 000000000..807534275 --- /dev/null +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java @@ -0,0 +1,614 @@ +package io.github.dfa1.vortex.cli.tui; + +import io.github.dfa1.vortex.calcite.VortexSchema; +import io.github.dfa1.vortex.cli.tui.term.Ansi; +import io.github.dfa1.vortex.cli.tui.term.Key; +import io.github.dfa1.vortex.cli.tui.term.Terminal; + +import org.apache.calcite.jdbc.CalciteConnection; + +import java.io.IOException; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/// Interactive SQL shell over one or more Vortex files, backed by Calcite. +/// +/// The screen is split into a multiline editor (top) and a scrollable result grid (bottom). The +/// editor follows the psql convention: `Enter` inserts a newline and a query runs only when the +/// accumulated text ends with `;`. `Tab` moves focus between the editor and the result grid; +/// `Up`/`Down` at the editor's top/bottom edge recall previous queries from [QueryHistory]. +/// `Ctrl-D` quits. +/// +/// Queries execute synchronously on the render thread - the UI briefly shows `running…` and then +/// repaints with the result. Vortex readers open and close inside the scan, so no separate I/O +/// thread is needed for arena confinement. +public final class VortexSqlTui { + + /// Calcite schema name the Vortex tables are registered under; queries read `vtx.tableName`. + static final String SCHEMA = "vtx"; + + /// Maximum rows materialised from a result set into the grid; excess rows are dropped and the + /// status bar flags the truncation. + static final int MAX_RESULT_ROWS = 10_000; + + private VortexSqlTui() { + } + + /// Opens the shell over `tables`, loading and persisting history at the default location. + /// + /// @param tables map from SQL table name to backing `.vortex` file + /// @throws IOException if Calcite setup or terminal initialisation fails + public static void show(Map tables) throws IOException { + Properties info = new Properties(); + info.setProperty("lex", "JAVA"); + try (Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) { + conn.unwrap(CalciteConnection.class).getRootSchema().add(SCHEMA, new VortexSchema(tables)); + try (Terminal term = Terminal.open()) { + run(term, conn, tables.keySet(), QueryHistory.loadDefault()); + } + } catch (SQLException e) { + throw new IOException("cannot initialise Calcite SQL shell", e); + } + } + + /// Runs the shell loop against a caller-supplied terminal and connection. Package-private seam + /// for tests that drive a scripted [FakeTerminal] over an in-memory Calcite connection. + /// + /// @param term terminal to render to and read keys from + /// @param conn Calcite connection with the Vortex schema already registered + /// @param tables table names, shown in the title bar + /// @param history recall buffer, updated as queries run + /// @throws IOException if a terminal write fails + static void run(Terminal term, Connection conn, Iterable tables, QueryHistory history) + throws IOException { + new Loop(term, conn, tableLabel(tables), history).run(); + } + + private static String tableLabel(Iterable tables) { + StringBuilder sb = new StringBuilder(); + for (String t : tables) { + if (!sb.isEmpty()) { + sb.append(", "); + } + sb.append(SCHEMA).append('.').append(t); + } + return sb.toString(); + } + + private enum Focus { EDITOR, RESULTS } + + private static final class Loop { + + private static final int CTRL_D = 4; + private static final int GUTTER = 5; + private static final int MIN_COL_WIDTH = 4; + private static final int MAX_COL_WIDTH = 32; + + private final Terminal term; + private final Connection conn; + private final String tableLabel; + private final QueryHistory history; + + // Editor buffer: always at least one line. Cursor is (line, column). + private final List lines = new ArrayList<>(List.of(new StringBuilder())); + private int cursorLine; + private int cursorCol; + private int editorOffset; + + // History recall: index into history.entries(), or -1 when editing a fresh buffer. + private int historyIndex = -1; + + private Focus focus = Focus.EDITOR; + private ResultGrid result; + private long resultRowOffset; + private int resultColOffset; + private String status = "Enter SQL; end with ';' to run. Tab: focus Ctrl-D: quit"; + private boolean dirty = true; + + Loop(Terminal term, Connection conn, String tableLabel, QueryHistory history) { + this.term = term; + this.conn = conn; + this.tableLabel = tableLabel; + this.history = history; + } + + void run() throws IOException { + term.write(Ansi.ENTER_ALT_SCREEN); + term.write(Ansi.CLEAR_SCREEN); + try { + while (true) { + if (dirty) { + render(); + dirty = false; + } + Key key = term.readKey(); + if (!handle(key)) { + return; + } + } + } finally { + term.write(Ansi.SHOW_CURSOR); + term.write(Ansi.EXIT_ALT_SCREEN); + term.flush(); + } + } + + private boolean handle(Key key) throws IOException { + if (key instanceof Key.Eof) { + return false; + } + if (key instanceof Key.Char c && c.value() == CTRL_D) { + return false; + } + if (focus == Focus.RESULTS) { + handleResults(key); + return true; + } + return handleEditor(key); + } + + private boolean handleEditor(Key key) throws IOException { + switch (key) { + case Key.Char c when c.value() == '\t' -> focusResults(); + case Key.Char c -> insertChar(c.value()); + case Key.Enter _ -> onEnter(); + case Key.Backspace _ -> backspace(); + case Key.ArrowLeft _ -> moveLeft(); + case Key.ArrowRight _ -> moveRight(); + case Key.ArrowUp _ -> upOrHistory(); + case Key.ArrowDown _ -> downOrHistory(); + case Key.Escape _ -> clearBuffer(); + default -> { /* ignore other keys in the editor */ } + } + return true; + } + + private void handleResults(Key key) { + if (result == null) { + focus = Focus.EDITOR; + dirty = true; + return; + } + switch (key) { + case Key.ArrowUp _ -> scrollResult(-1); + case Key.ArrowDown _ -> scrollResult(1); + case Key.PageUp _ -> scrollResult(-pageRows()); + case Key.PageDown _ -> scrollResult(pageRows()); + case Key.ArrowLeft _ -> scrollResultCol(-1); + case Key.ArrowRight _ -> scrollResultCol(1); + case Key.Char c when c.value() == '\t' -> { + focus = Focus.EDITOR; + dirty = true; + } + case Key.Escape _ -> { + focus = Focus.EDITOR; + dirty = true; + } + default -> { /* ignore */ } + } + } + + // ---- editor editing --------------------------------------------------------------- + + private void insertChar(char c) { + lines.get(cursorLine).insert(cursorCol, c); + cursorCol++; + historyIndex = -1; + dirty = true; + } + + private void onEnter() throws IOException { + if (bufferText().strip().endsWith(";")) { + runQuery(); + return; + } + StringBuilder cur = lines.get(cursorLine); + String tail = cur.substring(cursorCol); + cur.setLength(cursorCol); + lines.add(cursorLine + 1, new StringBuilder(tail)); + cursorLine++; + cursorCol = 0; + historyIndex = -1; + dirty = true; + } + + private void backspace() { + StringBuilder cur = lines.get(cursorLine); + if (cursorCol > 0) { + cur.deleteCharAt(cursorCol - 1); + cursorCol--; + } else if (cursorLine > 0) { + StringBuilder prev = lines.get(cursorLine - 1); + cursorCol = prev.length(); + prev.append(cur); + lines.remove(cursorLine); + cursorLine--; + } + historyIndex = -1; + dirty = true; + } + + private void moveLeft() { + if (cursorCol > 0) { + cursorCol--; + } else if (cursorLine > 0) { + cursorLine--; + cursorCol = lines.get(cursorLine).length(); + } + dirty = true; + } + + private void moveRight() { + if (cursorCol < lines.get(cursorLine).length()) { + cursorCol++; + } else if (cursorLine < lines.size() - 1) { + cursorLine++; + cursorCol = 0; + } + dirty = true; + } + + private void upOrHistory() { + if (cursorLine > 0) { + cursorLine--; + cursorCol = Math.min(cursorCol, lines.get(cursorLine).length()); + dirty = true; + } else { + recallOlder(); + } + } + + private void downOrHistory() { + if (cursorLine < lines.size() - 1) { + cursorLine++; + cursorCol = Math.min(cursorCol, lines.get(cursorLine).length()); + dirty = true; + } else { + recallNewer(); + } + } + + private void clearBuffer() { + lines.clear(); + lines.add(new StringBuilder()); + cursorLine = 0; + cursorCol = 0; + editorOffset = 0; + historyIndex = -1; + dirty = true; + } + + // ---- history recall --------------------------------------------------------------- + + private void recallOlder() { + List entries = history.entries(); + if (entries.isEmpty()) { + return; + } + int next = historyIndex < 0 ? entries.size() - 1 : historyIndex - 1; + if (next < 0) { + return; + } + historyIndex = next; + loadIntoBuffer(entries.get(next)); + } + + private void recallNewer() { + List entries = history.entries(); + if (historyIndex < 0) { + return; + } + int next = historyIndex + 1; + if (next >= entries.size()) { + historyIndex = -1; + clearBuffer(); + return; + } + historyIndex = next; + loadIntoBuffer(entries.get(next)); + } + + private void loadIntoBuffer(String text) { + lines.clear(); + for (String line : text.split("\n", -1)) { + lines.add(new StringBuilder(line)); + } + cursorLine = lines.size() - 1; + cursorCol = lines.get(cursorLine).length(); + editorOffset = 0; + dirty = true; + } + + // ---- query execution -------------------------------------------------------------- + + private void runQuery() throws IOException { + String sql = stripTrailingSemicolon(bufferText().strip()); + if (sql.isEmpty()) { + status = "empty query"; + dirty = true; + return; + } + history.add(bufferText().strip()); + historyIndex = -1; + status = "running…"; + render(); + long start = System.nanoTime(); + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + result = ResultGrid.from(rs, MAX_RESULT_ROWS); + resultRowOffset = 0; + resultColOffset = 0; + long ms = (System.nanoTime() - start) / 1_000_000; + status = result.rowCount() + " row(s) in " + ms + " ms" + + (result.truncated() ? " (showing first " + MAX_RESULT_ROWS + ")" : ""); + clearBuffer(); + } catch (SQLException | RuntimeException e) { + // Calcite surfaces parse/validation failures as SQLException, but planning and + // scan errors can bubble up as unchecked exceptions; keep the buffer so the user + // can fix the query rather than losing it. + status = "ERROR: " + rootMessage(e); + } + dirty = true; + } + + private static String stripTrailingSemicolon(String s) { + String t = s; + while (t.endsWith(";")) { + t = t.substring(0, t.length() - 1).stripTrailing(); + } + return t; + } + + private static String rootMessage(Throwable t) { + Throwable cur = t; + while (cur.getCause() != null && cur.getCause() != cur) { + cur = cur.getCause(); + } + String msg = cur.getMessage(); + return msg != null ? msg.split("\n", 2)[0] : cur.getClass().getSimpleName(); + } + + // ---- result scrolling ------------------------------------------------------------- + + private void scrollResult(long delta) { + long max = Math.max(0, (long) result.rowCount() - 1); + resultRowOffset = clamp(resultRowOffset + delta, 0, max); + dirty = true; + } + + private void scrollResultCol(int delta) { + int max = Math.max(0, result.columns().size() - 1); + resultColOffset = clamp(resultColOffset + delta, 0, max); + dirty = true; + } + + private void focusResults() { + if (result != null) { + focus = Focus.RESULTS; + dirty = true; + } + } + + private long pageRows() { + return Math.max(1, term.size().rows() / 2); + } + + // ---- rendering -------------------------------------------------------------------- + + private String bufferText() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(lines.get(i)); + } + return sb.toString(); + } + + private void render() throws IOException { + Terminal.Size size = term.size(); + int rows = size.rows(); + int cols = size.cols(); + if (cols < 20 || rows < 8) { + term.write(Ansi.CLEAR_SCREEN); + term.write(Ansi.CURSOR_HOME); + term.write("terminal too small"); + term.flush(); + return; + } + int editorRows = clamp(lines.size(), 1, Math.max(1, rows - 7)); + ensureEditorVisible(editorRows); + int resultRows = Math.max(0, rows - editorRows - 4); + + StringBuilder out = new StringBuilder(cols * rows); + out.append(Ansi.HIDE_CURSOR).append(Ansi.CURSOR_HOME); + renderTitle(out, cols); + renderEditor(out, editorRows, cols); + renderDivider(out, cols); + renderResults(out, resultRows, cols); + renderStatus(out, cols); + + term.write(out.toString()); + if (focus == Focus.EDITOR) { + int screenRow = 2 + (cursorLine - editorOffset); + int screenCol = GUTTER + cursorCol + 1; + term.write(Ansi.moveTo(screenRow, screenCol)); + term.write(Ansi.SHOW_CURSOR); + } + term.flush(); + } + + private void ensureEditorVisible(int editorRows) { + if (cursorLine < editorOffset) { + editorOffset = cursorLine; + } else if (cursorLine >= editorOffset + editorRows) { + editorOffset = cursorLine - editorRows + 1; + } + } + + private void renderTitle(StringBuilder out, int cols) { + out.append(Ansi.bg(44)).append(Ansi.fg(97)); + appendPadded(out, " Vortex SQL " + tableLabel, cols); + out.append(Ansi.RESET).append("\r\n"); + } + + private void renderEditor(StringBuilder out, int editorRows, int cols) { + for (int r = 0; r < editorRows; r++) { + int absLine = editorOffset + r; + String gutter = absLine == 0 ? "sql> " : " | "; + String text = absLine < lines.size() ? lines.get(absLine).toString() : ""; + out.append(Ansi.fg(90)).append(gutter).append(Ansi.RESET); + appendPadded(out, truncate(text, cols - GUTTER), cols - GUTTER); + out.append("\r\n"); + } + } + + private void renderDivider(StringBuilder out, int cols) { + String focusLabel = focus == Focus.EDITOR ? " [editor] " : " [results] "; + out.append(Ansi.bg(100)).append(Ansi.fg(97)); + appendPadded(out, focusLabel + status, cols); + out.append(Ansi.RESET).append("\r\n"); + } + + private void renderResults(StringBuilder out, int resultRows, int cols) { + if (resultRows <= 0) { + return; + } + if (result == null) { + for (int r = 0; r < resultRows; r++) { + appendPadded(out, r == 0 ? " (no results yet)" : "", cols); + out.append("\r\n"); + } + return; + } + int[] widths = colWidths(cols); + renderResultHeader(out, widths, cols); + int dataRows = resultRows - 1; + for (int r = 0; r < dataRows; r++) { + long abs = resultRowOffset + r; + if (abs >= result.rowCount()) { + appendPadded(out, "", cols); + out.append("\r\n"); + continue; + } + renderResultRow(out, result.rows().get((int) abs), widths, cols); + out.append("\r\n"); + } + } + + private void renderResultHeader(StringBuilder out, int[] widths, int cols) { + StringBuilder line = new StringBuilder(); + List columns = result.columns(); + int width = 0; + for (int c = resultColOffset; c < columns.size() && width + widths[c] + 1 <= cols; c++) { + line.append(padRight(truncate(columns.get(c), widths[c]), widths[c])).append(' '); + width += widths[c] + 1; + } + out.append(Ansi.bg(238)).append(Ansi.fg(97)); + appendPadded(out, line.toString(), cols); + out.append(Ansi.RESET).append("\r\n"); + } + + private void renderResultRow(StringBuilder out, String[] row, int[] widths, int cols) { + StringBuilder line = new StringBuilder(); + int width = 0; + for (int c = resultColOffset; c < widths.length && width + widths[c] + 1 <= cols; c++) { + String cell = c < row.length ? row[c] : ""; + line.append(padRight(truncate(cell, widths[c]), widths[c])).append(' '); + width += widths[c] + 1; + } + appendPadded(out, line.toString(), cols); + } + + private void renderStatus(StringBuilder out, int cols) { + String hint = focus == Focus.EDITOR + ? " Enter: newline ;Enter: run ↑↓: history Tab: results Ctrl-D: quit " + : " ↑↓←→/PgUp/PgDn: scroll Tab/Esc: editor Ctrl-D: quit "; + out.append(Ansi.bg(44)).append(Ansi.fg(97)); + appendPadded(out, hint, cols); + out.append(Ansi.RESET); + } + + private int[] colWidths(int cols) { + List columns = result.columns(); + int n = columns.size(); + int[] widths = new int[n]; + int sample = Math.min(result.rowCount(), 50); + for (int c = 0; c < n; c++) { + int w = columns.get(c).length(); + for (int r = 0; r < sample; r++) { + String[] row = result.rows().get(r); + if (c < row.length && row[c] != null) { + w = Math.max(w, row[c].length()); + } + } + widths[c] = Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.min(w, cols))); + } + return widths; + } + + // ---- small helpers ---------------------------------------------------------------- + + private static long clamp(long v, long lo, long hi) { + if (v < lo) { + return lo; + } + return Math.min(v, hi); + } + + private static int clamp(int v, int lo, int hi) { + if (v < lo) { + return lo; + } + return Math.min(v, hi); + } + + private static String truncate(String s, int width) { + if (s == null) { + return ""; + } + if (width <= 0) { + return ""; + } + if (s.length() <= width) { + return s; + } + if (width == 1) { + return "…"; + } + return s.substring(0, width - 1) + "…"; + } + + private static String padRight(String s, int width) { + if (s.length() >= width) { + return s; + } + StringBuilder sb = new StringBuilder(width); + sb.append(s); + while (sb.length() < width) { + sb.append(' '); + } + return sb.toString(); + } + + private static void appendPadded(StringBuilder out, String s, int width) { + if (width <= 0) { + return; + } + int written = Math.min(s.length(), width); + out.append(s, 0, written); + for (int i = written; i < width; i++) { + out.append(' '); + } + } + } +} diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/Key.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/Key.java index 70f4c4819..86ee02d2b 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/Key.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/Key.java @@ -57,6 +57,12 @@ enum Enter implements Key { INSTANCE } + /// Backspace / Delete (`0x7F` DEL or `0x08` BS) - erases the character before the cursor. + enum Backspace implements Key { + /// Singleton instance. + INSTANCE + } + /// Bare Escape key press (no CSI sequence followed). enum Escape implements Key { /// Singleton instance. diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoder.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoder.java index 1de4d8efe..c01173768 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoder.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoder.java @@ -57,6 +57,11 @@ public static Key next(InputStream in) throws IOException { if (b == '\r' || b == '\n') { return Key.Enter.INSTANCE; } + // DEL (0x7F) is what most terminals send for the Backspace key; 0x08 (BS) is the + // legacy code some emit. Treat both as one editing key so the line editor can erase. + if (b == 0x7F || b == 0x08) { + return Key.Backspace.INSTANCE; + } return new Key.Char((char) b); } diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/SqlCommandTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/SqlCommandTest.java new file mode 100644 index 000000000..38b4b60c7 --- /dev/null +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/SqlCommandTest.java @@ -0,0 +1,61 @@ +package io.github.dfa1.vortex.cli; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SqlCommandTest { + + @Test + void run_noFileArgument_isUsageError() { + // Given / When + CliTestSupport.Captured result = CliTestSupport.capture(() -> SqlCommand.run(new String[]{"sql"})); + + // Then + assertThat(result.status()).isEqualTo(ExitStatus.USAGE_ERROR); + assertThat(result.stderr()).contains("usage: sql"); + } + + @Test + void run_missingFile_isFileNotFound(@TempDir Path dir) { + // Given — a path that does not exist + String missing = dir.resolve("nope.vortex").toString(); + + // When + CliTestSupport.Captured result = + CliTestSupport.capture(() -> SqlCommand.run(new String[]{"sql", missing})); + + // Then + assertThat(result.status()).isEqualTo(ExitStatus.FILE_NOT_FOUND); + assertThat(result.stderr()).contains("file not found"); + } + + @Test + void run_duplicateTableName_isUsageError(@TempDir Path dir) throws IOException { + // Given — two real files whose filename stems collide (same name, different directories), + // so both would map to the same SQL table. + Path a = CliTestSupport.writeSmallVortex(Files.createDirectory(dir.resolve("a")), "t.vortex"); + Path b = CliTestSupport.writeSmallVortex(Files.createDirectory(dir.resolve("b")), "t.vortex"); + + // When + CliTestSupport.Captured result = CliTestSupport.capture( + () -> SqlCommand.run(new String[]{"sql", a.toString(), b.toString()})); + + // Then + assertThat(result.status()).isEqualTo(ExitStatus.USAGE_ERROR); + assertThat(result.stderr()).contains("duplicate table name 't'"); + } + + @Test + void tableName_stripsDirectoryAndExtension() { + // Given / When / Then + assertThat(SqlCommand.tableName(Path.of("/data/ohlc.vortex"))).isEqualTo("ohlc"); + assertThat(SqlCommand.tableName(Path.of("trades.v2.vortex"))).isEqualTo("trades.v2"); + assertThat(SqlCommand.tableName(Path.of("noext"))).isEqualTo("noext"); + } +} diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java new file mode 100644 index 000000000..13a51d950 --- /dev/null +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java @@ -0,0 +1,104 @@ +package io.github.dfa1.vortex.cli.tui; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class QueryHistoryTest { + + @Test + void add_appendsAndPersistsAcrossReload(@TempDir Path dir) { + // Given + Path file = dir.resolve("hist"); + QueryHistory sut = QueryHistory.load(file); + + // When + sut.add("select 1"); + sut.add("select 2"); + + // Then — a fresh load sees the same entries, oldest first. + QueryHistory reloaded = QueryHistory.load(file); + assertThat(reloaded.entries()).containsExactly("select 1", "select 2"); + } + + @Test + void add_collapsesConsecutiveDuplicates(@TempDir Path dir) { + // Given + QueryHistory sut = QueryHistory.load(dir.resolve("hist")); + + // When — same query run twice in a row should not duplicate. + sut.add("select 1"); + sut.add("select 1"); + sut.add("select 2"); + sut.add("select 1"); + + // Then — the second consecutive "select 1" collapses; the later non-consecutive one stays. + assertThat(sut.entries()).containsExactly("select 1", "select 2", "select 1"); + } + + @Test + void add_blankQueryIsIgnored(@TempDir Path dir) { + // Given + QueryHistory sut = QueryHistory.load(dir.resolve("hist")); + + // When + sut.add(" \n "); + + // Then + assertThat(sut.size()).isZero(); + } + + @Test + void add_stripsSurroundingWhitespace(@TempDir Path dir) { + // Given + QueryHistory sut = QueryHistory.load(dir.resolve("hist")); + + // When + sut.add(" select 1 "); + + // Then + assertThat(sut.entries()).containsExactly("select 1"); + } + + @Test + void add_enforcesMaxEntriesCapDroppingOldest(@TempDir Path dir) { + // Given + QueryHistory sut = QueryHistory.load(dir.resolve("hist")); + + // When — push one past the cap; the very first entry must be evicted. + for (int i = 0; i < QueryHistory.MAX_ENTRIES + 1; i++) { + sut.add("q" + i); + } + + // Then + assertThat(sut.size()).isEqualTo(QueryHistory.MAX_ENTRIES); + assertThat(sut.entries().getFirst()).isEqualTo("q1"); + assertThat(sut.entries().getLast()).isEqualTo("q" + QueryHistory.MAX_ENTRIES); + } + + @Test + void load_multilineQuery_survivesRoundTrip(@TempDir Path dir) { + // Given — a query with embedded newlines must not split into separate history lines. + Path file = dir.resolve("hist"); + QueryHistory sut = QueryHistory.load(file); + String multiline = "select id,\n name\nfrom vtx.t"; + + // When + sut.add(multiline); + + // Then + assertThat(QueryHistory.load(file).entries()).containsExactly(multiline); + } + + @Test + void load_missingFile_yieldsEmptyHistory(@TempDir Path dir) { + // Given / When + QueryHistory sut = QueryHistory.load(dir.resolve("does-not-exist")); + + // Then + assertThat(sut.entries()).isEmpty(); + } +} diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java new file mode 100644 index 000000000..6a9dfa1d9 --- /dev/null +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java @@ -0,0 +1,166 @@ +package io.github.dfa1.vortex.cli.tui; + +import io.github.dfa1.vortex.calcite.VortexSchema; +import io.github.dfa1.vortex.cli.tui.term.Key; +import io.github.dfa1.vortex.cli.tui.term.Terminal; +import io.github.dfa1.vortex.core.model.DType; +import io.github.dfa1.vortex.writer.VortexWriter; +import io.github.dfa1.vortex.writer.WriteOptions; + +import org.apache.calcite.jdbc.CalciteConnection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Drives the SQL shell loop with a scripted [FakeTerminal] over a real in-memory Calcite +/// connection backed by a tiny Vortex fixture. Assertions are made on the captured terminal +/// output (data values, column labels) and on the recall buffer, since the loop has no other +/// observable surface. +class VortexSqlTuiTest { + + private static final Terminal.Size SIZE = new Terminal.Size(24, 80); + + @Test + void run_queryEndingInSemicolon_executesAndRendersResults(@TempDir Path dir) throws Exception { + // Given — a fixture table `t` and a query typed in full, terminated by ';'. + Path file = writeFixture(dir); + QueryHistory history = QueryHistory.load(dir.resolve("hist")); + FakeTerminal term = new FakeTerminal(SIZE, type("select name from vtx.t;\n")); + + // When + runWith(term, file, history); + + // Then — the result grid shows the column header and every row value. + assertThat(term.output()).contains("name").contains("alice", "bob", "carol"); + // And the query is recorded in history. + assertThat(history.entries()).containsExactly("select name from vtx.t;"); + } + + @Test + void run_backspaceCorrectsTypoBeforeRunning(@TempDir Path dir) throws Exception { + // Given — a typo "namx", one Backspace, then the correct suffix and terminator. + Path file = writeFixture(dir); + List script = new ArrayList<>(type("select namx")); + script.add(Key.Backspace.INSTANCE); + script.addAll(type("e from vtx.t;\n")); + FakeTerminal term = new FakeTerminal(SIZE, script); + + // When + runWith(term, file, QueryHistory.load(dir.resolve("hist"))); + + // Then — the corrected query runs and produces the expected rows. + assertThat(term.output()).contains("alice"); + } + + @Test + void run_enterInsertsNewlineUntilSemicolonTerminates(@TempDir Path dir) throws Exception { + // Given — Enter on a line not ending in ';' must add a newline, not run. + Path file = writeFixture(dir); + List script = new ArrayList<>(type("select name")); + script.add(Key.Enter.INSTANCE); // newline, no run + script.addAll(type("from vtx.t;\n")); // now ends with ';' -> run + FakeTerminal term = new FakeTerminal(SIZE, script); + + // When + runWith(term, file, QueryHistory.load(dir.resolve("hist"))); + + // Then — the continuation gutter is rendered and the multiline query runs. + assertThat(term.output()).contains(" | ").contains("alice"); + } + + @Test + void run_arrowUpRecallsPreviousQueryFromHistory(@TempDir Path dir) throws Exception { + // Given — history seeded with a query (no terminator); ArrowUp recalls it. + Path file = writeFixture(dir); + QueryHistory history = QueryHistory.load(dir.resolve("hist")); + history.add("select name from vtx.t"); + List script = new ArrayList<>(); + script.add(Key.ArrowUp.INSTANCE); // recall into buffer + script.addAll(type(";\n")); // terminate -> run + FakeTerminal term = new FakeTerminal(SIZE, script); + + // When + runWith(term, file, history); + + // Then — the recalled query executes. + assertThat(term.output()).contains("alice"); + } + + @Test + void run_tabFocusesResultGridAfterQuery(@TempDir Path dir) throws Exception { + // Given — run a query, then Tab to move focus into the result grid. + Path file = writeFixture(dir); + List script = new ArrayList<>(type("select name from vtx.t;\n")); + script.add(new Key.Char('\t')); + FakeTerminal term = new FakeTerminal(SIZE, script); + + // When + runWith(term, file, QueryHistory.load(dir.resolve("hist"))); + + // Then — the divider reflects the results focus. + assertThat(term.output()).contains("[results]"); + } + + @Test + void run_invalidSql_reportsErrorWithoutCrashing(@TempDir Path dir) throws Exception { + // Given — a query against a non-existent column. + Path file = writeFixture(dir); + FakeTerminal term = new FakeTerminal(SIZE, type("select nope from vtx.t;\n")); + + // When + runWith(term, file, QueryHistory.load(dir.resolve("hist"))); + + // Then — the status line shows an error and the loop exits cleanly. + assertThat(term.output()).contains("ERROR:"); + assertThat(term.isClosed()).isFalse(); // run() does not close the caller's terminal + } + + // ---- helpers ---------------------------------------------------------------------------- + + private static void runWith(FakeTerminal term, Path file, QueryHistory history) throws Exception { + try (Connection conn = connect(file)) { + VortexSqlTui.run(term, conn, List.of("t"), history); + } + } + + private static Connection connect(Path file) throws SQLException { + Properties info = new Properties(); + info.setProperty("lex", "JAVA"); + Connection conn = DriverManager.getConnection("jdbc:calcite:", info); + conn.unwrap(CalciteConnection.class).getRootSchema() + .add("vtx", new VortexSchema(Map.of("t", file))); + return conn; + } + + private static List type(String text) { + List keys = new ArrayList<>(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + keys.add(c == '\n' ? Key.Enter.INSTANCE : new Key.Char(c)); + } + return keys; + } + + private static Path writeFixture(Path dir) throws IOException { + Path file = dir.resolve("t.vortex"); + DType.Struct schema = new DType.Struct(List.of("name"), List.of(DType.UTF8), false); + try (FileChannel ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + VortexWriter writer = VortexWriter.create(ch, schema, WriteOptions.defaults())) { + writer.writeChunk(Map.of("name", new String[]{"alice", "bob", "carol"})); + } + return file; + } +} diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoderTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoderTest.java index 764fa7096..3ec70b9fe 100644 --- a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoderTest.java +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyDecoderTest.java @@ -69,6 +69,13 @@ void next_enterFromCrAndLf_bothDecodeToEnter() throws IOException { assertThat(KeyDecoder.next(bytes('\n'))).isEqualTo(Key.Enter.INSTANCE); } + @Test + void next_delAndBs_bothDecodeToBackspace() throws IOException { + // Given / When / Then — DEL (0x7F) is the common Backspace code, 0x08 the legacy BS. + assertThat(KeyDecoder.next(bytes(0x7F))).isEqualTo(Key.Backspace.INSTANCE); + assertThat(KeyDecoder.next(bytes(0x08))).isEqualTo(Key.Backspace.INSTANCE); + } + @Test void next_printableChar_returnsChar() throws IOException { // Given diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyTest.java index f127eeb6d..17c9466da 100644 --- a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyTest.java +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/term/KeyTest.java @@ -67,6 +67,7 @@ private static String label(Key key) { case Key.Home _ -> "home"; case Key.End _ -> "end"; case Key.Enter _ -> "enter"; + case Key.Backspace _ -> "backspace"; case Key.Escape _ -> "esc"; case Key.Eof _ -> "eof"; case Key.Char(var c) -> "char:" + c; diff --git a/pom.xml b/pom.xml index fe735a46b..267e43c03 100644 --- a/pom.xml +++ b/pom.xml @@ -179,6 +179,11 @@ vortex-inspector ${project.version} + + io.github.dfa1.vortex + vortex-calcite + ${project.version} + de.siegmar fastcsv From 3b54ed1c41352df8d934661617d67060f90d655f Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Thu, 25 Jun 2026 20:34:38 +0200 Subject: [PATCH 2/2] fix(cli): harden SQL shell history + unify Esc-to-quit across TUIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for the SQL shell, plus a keybinding cleanup: - QueryHistory.persist() no longer throws on a failed write (read-only home, full disk). It is best-effort, mirroring the swallow in load(), so a write failure can never abort the running shell. - The editor drops non-printable control bytes (Ctrl-A, Ctrl-U, …) instead of inserting them as invisible literal SQL text. - Esc is now the universal quit key in every TUI (SQL shell, grid, inspector). The grid and inspector already quit on Esc; the SQL shell now does too, and all three advertise it in their status hints. Tab still toggles the SQL panes; Ctrl-D still quits. Co-Authored-By: Claude Opus 4.8 --- .../dfa1/vortex/cli/tui/QueryHistory.java | 10 ++++-- .../dfa1/vortex/cli/tui/VortexGridTui.java | 2 +- .../vortex/cli/tui/VortexInspectorTui.java | 2 +- .../dfa1/vortex/cli/tui/VortexSqlTui.java | 20 +++++------ .../dfa1/vortex/cli/tui/QueryHistoryTest.java | 16 +++++++++ .../dfa1/vortex/cli/tui/VortexSqlTuiTest.java | 34 +++++++++++++++++++ 6 files changed, 68 insertions(+), 16 deletions(-) diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java index 9a1ae1af4..61ea1f53e 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/QueryHistory.java @@ -1,7 +1,6 @@ package io.github.dfa1.vortex.cli.tui; import java.io.IOException; -import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -14,6 +13,9 @@ /// 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. @@ -101,8 +103,10 @@ private void persist() { lines.add(escape(e)); } Files.write(file, lines); - } catch (IOException e) { - throw new UncheckedIOException("cannot write history file " + file, e); + } 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. } } diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexGridTui.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexGridTui.java index d11374e4c..8eae59fc0 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexGridTui.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexGridTui.java @@ -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) diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java index 51c4b3ccb..bb3ca502a 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java @@ -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); } diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java index 807534275..9a57f5f07 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexSqlTui.java @@ -25,7 +25,7 @@ /// editor follows the psql convention: `Enter` inserts a newline and a query runs only when the /// accumulated text ends with `;`. `Tab` moves focus between the editor and the result grid; /// `Up`/`Down` at the editor's top/bottom edge recall previous queries from [QueryHistory]. -/// `Ctrl-D` quits. +/// `Esc` or `Ctrl-D` quits from either pane, matching the inspector and grid TUIs. /// /// Queries execute synchronously on the render thread - the UI briefly shows `running…` and then /// repaints with the result. Vortex readers open and close inside the scan, so no separate I/O @@ -110,7 +110,7 @@ private static final class Loop { private ResultGrid result; private long resultRowOffset; private int resultColOffset; - private String status = "Enter SQL; end with ';' to run. Tab: focus Ctrl-D: quit"; + private String status = "Enter SQL; end with ';' to run. Tab: focus Esc/Ctrl-D: quit"; private boolean dirty = true; Loop(Terminal term, Connection conn, String tableLabel, QueryHistory history) { @@ -142,7 +142,7 @@ void run() throws IOException { } private boolean handle(Key key) throws IOException { - if (key instanceof Key.Eof) { + if (key instanceof Key.Eof || key instanceof Key.Escape) { return false; } if (key instanceof Key.Char c && c.value() == CTRL_D) { @@ -158,14 +158,16 @@ private boolean handle(Key key) throws IOException { private boolean handleEditor(Key key) throws IOException { switch (key) { case Key.Char c when c.value() == '\t' -> focusResults(); - case Key.Char c -> insertChar(c.value()); + // Drop other control bytes (Ctrl-A, Ctrl-U, …) - they would otherwise land in the + // SQL buffer as literal, invisible characters. Enter/Backspace/Tab/Ctrl-D are their + // own keys and handled above or in handle(). + case Key.Char c when c.value() >= ' ' -> insertChar(c.value()); case Key.Enter _ -> onEnter(); case Key.Backspace _ -> backspace(); case Key.ArrowLeft _ -> moveLeft(); case Key.ArrowRight _ -> moveRight(); case Key.ArrowUp _ -> upOrHistory(); case Key.ArrowDown _ -> downOrHistory(); - case Key.Escape _ -> clearBuffer(); default -> { /* ignore other keys in the editor */ } } return true; @@ -188,10 +190,6 @@ private void handleResults(Key key) { focus = Focus.EDITOR; dirty = true; } - case Key.Escape _ -> { - focus = Focus.EDITOR; - dirty = true; - } default -> { /* ignore */ } } } @@ -531,8 +529,8 @@ private void renderResultRow(StringBuilder out, String[] row, int[] widths, int private void renderStatus(StringBuilder out, int cols) { String hint = focus == Focus.EDITOR - ? " Enter: newline ;Enter: run ↑↓: history Tab: results Ctrl-D: quit " - : " ↑↓←→/PgUp/PgDn: scroll Tab/Esc: editor Ctrl-D: quit "; + ? " Enter: newline ;Enter: run ↑↓: history Tab: results Esc/Ctrl-D: quit " + : " ↑↓←→/PgUp/PgDn: scroll Tab: editor Esc/Ctrl-D: quit "; out.append(Ansi.bg(44)).append(Ansi.fg(97)); appendPadded(out, hint, cols); out.append(Ansi.RESET); diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java index 13a51d950..e1103840d 100644 --- a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/QueryHistoryTest.java @@ -3,9 +3,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; class QueryHistoryTest { @@ -93,6 +96,19 @@ void load_multilineQuery_survivesRoundTrip(@TempDir Path dir) { assertThat(QueryHistory.load(file).entries()).containsExactly(multiline); } + @Test + void add_unwritableFile_doesNotThrowAndKeepsInMemoryEntry(@TempDir Path dir) throws IOException { + // Given — a history path whose parent is a regular file, so createDirectories/write fail. + // History is best-effort: a write failure must never abort the running shell. + Path blocker = dir.resolve("blocker"); + Files.createFile(blocker); + QueryHistory sut = QueryHistory.load(blocker.resolve("hist")); + + // When / Then — add swallows the I/O failure but still records the query in memory. + assertThatCode(() -> sut.add("select 1")).doesNotThrowAnyException(); + assertThat(sut.entries()).containsExactly("select 1"); + } + @Test void load_missingFile_yieldsEmptyHistory(@TempDir Path dir) { // Given / When diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java index 6a9dfa1d9..e46ff634c 100644 --- a/cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/tui/VortexSqlTuiTest.java @@ -114,6 +114,40 @@ void run_tabFocusesResultGridAfterQuery(@TempDir Path dir) throws Exception { assertThat(term.output()).contains("[results]"); } + @Test + void run_escapeQuitsImmediately(@TempDir Path dir) throws Exception { + // Given — Esc is the universal quit key (as in the inspector/grid TUIs). A query scripted + // after the Esc must never run, proving the loop exits on Esc rather than processing on. + Path file = writeFixture(dir); + List script = new ArrayList<>(); + script.add(Key.Escape.INSTANCE); + script.addAll(type("select name from vtx.t;\n")); + FakeTerminal term = new FakeTerminal(SIZE, script); + + // When + runWith(term, file, QueryHistory.load(dir.resolve("hist"))); + + // Then — nothing after Esc executed, so no result rows were rendered. + assertThat(term.output()).doesNotContain("alice"); + } + + @Test + void run_controlCharInEditorIsDroppedNotInsertedAsText(@TempDir Path dir) throws Exception { + // Given — a stray control byte (Ctrl-A) typed mid-query. If inserted literally it would + // corrupt the SQL into a parse error; the editor must drop non-printable input instead. + Path file = writeFixture(dir); + List script = new ArrayList<>(type("select name")); + script.add(new Key.Char((char) 1)); // Ctrl-A: must be ignored + script.addAll(type(" from vtx.t;\n")); + FakeTerminal term = new FakeTerminal(SIZE, script); + + // When + runWith(term, file, QueryHistory.load(dir.resolve("hist"))); + + // Then — the query parses and runs; no error is reported. + assertThat(term.output()).contains("alice").doesNotContain("ERROR:"); + } + @Test void run_invalidSql_reportsErrorWithoutCrashing(@TempDir Path dir) throws Exception { // Given — a query against a non-existent column.