Skip to content
Merged
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
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ wget https://github.com/vmvarela/sql-pipe/releases/latest/download/sql-pipe_VERS
sudo dpkg -i sql-pipe_VERSION_linux_amd64.deb
```

Replace `VERSION` with the release version (e.g. `0.9.0`) and `amd64` with your architecture (`arm64`, `arm7`, or `386`).
Replace `VERSION` with the release version (e.g. `0.12.0`) and `amd64` with your architecture (`arm64`, `arm7`, or `386`).

**Fedora / RHEL / openSUSE (RPM repository):**

Expand All @@ -71,7 +71,7 @@ Or install a single release asset directly:
sudo rpm -i https://github.com/vmvarela/sql-pipe/releases/latest/download/sql-pipe_VERSION_linux_amd64.rpm
```

Replace `VERSION` with the release version (e.g. `0.9.0`) and `amd64` with your architecture (`arm64`).
Replace `VERSION` with the release version (e.g. `0.12.0`) and `amd64` with your architecture (`arm64`).

**Alpine Linux (APK repository):**

Expand All @@ -89,7 +89,7 @@ wget https://github.com/vmvarela/sql-pipe/releases/latest/download/sql-pipe_VERS
sudo apk add --allow-untrusted sql-pipe_VERSION_linux_amd64.apk
```

Replace `VERSION` with the release version (e.g. `0.9.0`) and `amd64` with your architecture (`arm64`).
Replace `VERSION` with the release version (e.g. `0.12.0`) and `amd64` with your architecture (`arm64`).

**Arch Linux (AUR):** install with your preferred AUR helper:

Expand Down Expand Up @@ -159,6 +159,21 @@ Bob,25
Carol,35
```

When stdout is a terminal (not piped), results are automatically formatted as an aligned table:

```sh
$ printf 'name,age\nAlice,30\nBob,25\nCarol,35' | sql-pipe 'SELECT * FROM t'
┌───────┬─────┐
│ name │ age │
├───────┼─────┤
│ Alice │ 30 │
│ Bob │ 25 │
│ Carol │ 35 │
└───────┴─────┘
```

Numeric columns are right-aligned, text columns left-aligned. Pipe the output and it stays CSV — no behavior change for scripts. Use `--table` to force table output or `--no-table` to force CSV.

For JSON and NDJSON input, pass `-I json` (reads an array of objects) or `-I ndjson` (one object per line). Column names are taken from the keys of the first object:

```sh
Expand Down Expand Up @@ -283,6 +298,8 @@ $ cat events.csv \
| `--xml-root <name>` | Root element name for XML I/O (default: `results`) |
| `--xml-row <name>` | Row element name for XML I/O (default: `row`) |
| `--output <file>` | Write results to the given file instead of stdout. Creates or overwrites the file. Exits 1 if the file cannot be created. |
| `--table` | Force pretty-printed table output (auto-detected when stdout is a TTY). Requires CSV/TSV output format. |
| `--no-table` | Force CSV output even when stdout is a TTY |
| `-v`, `--verbose` | Print `Loaded <n> rows in <t>s` to stderr after loading (always on TTY; forced with flag) |
| `-s`, `--silent` | Suppress `Loaded <n> rows in <t>s` and the progress counter from stderr unconditionally. Cannot be combined with `-v`/`--verbose` |
| `-h`, `--help` | Show usage help and exit |
Expand Down Expand Up @@ -550,6 +567,6 @@ The database never touches disk and vanishes when the process exits. No state, n

## Related

- **[q](https://harelba.github.io/q/)** — similar concept in Python; handles quoted CSV fields and more formats. Better if you're already in a Python environment.
- **[trdsql](https://github.com/noborus/trdsql)** — Go alternative with multi-format support (JSON, LTSV) and output formatting. Better if you need non-CSV inputs.
- **[sqlite-utils](https://sqlite-utils.datasette.io/)** — better if you need persistent databases, schema management, or Python scripting.
- **[q](https://harelba.github.io/q/)** — Python-based SQL on tabular data. Similar concept, but requires Python runtime. Better if you're already in a Python environment or need Python-specific integrations.
- **[trdsql](https://github.com/noborus/trdsql)** — Go alternative with broader format support (LTSV, TBLN) and more output options. Better if you need formats beyond CSV/JSON/NDJSON/XML or want more output formatting choices.
- **[sqlite-utils](https://sqlite-utils.datasette.io/)** — better if you need persistent databases, schema management, or Python scripting. sql-pipe is designed for one-shot queries on ephemeral in-memory data.
71 changes: 71 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,77 @@ pub fn build(b: *std.Build) void {
test_file_t_conflict.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_file_t_conflict.step);

// ─── Table output tests (--table / --no-table) ────────────────────────────

// Integration test 156a: --table produces formatted table output
const test_table_basic = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,25' | ./zig-out/bin/sql-pipe --table 'SELECT * FROM t')
\\echo "$result" | grep -q '┌' && echo "$result" | grep -q '│ name' && echo "$result" | grep -q '│ Alice' && echo "$result" | grep -q '└'
});
test_table_basic.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_table_basic.step);

// Integration test 156b: --no-table forces CSV output
const test_no_table = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,25' | ./zig-out/bin/sql-pipe --no-table 'SELECT * FROM t')
\\expected=$(printf 'Alice,30\nBob,25')
\\[ "$result" = "$expected" ]
});
test_no_table.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_no_table.step);

// Integration test 156c: --table with --json produces error
const test_table_json_error = b.addSystemCommand(&.{
"bash", "-c",
\\msg=$(printf 'name,age\nAlice,30' | ./zig-out/bin/sql-pipe --table --json 'SELECT * FROM t' 2>&1; echo "EXIT:$?")
\\echo "$msg" | grep -q '\-\-table requires CSV or TSV' && echo "$msg" | grep -q 'EXIT:1'
});
test_table_json_error.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_table_json_error.step);

// Integration test 156d: piped output (no --table) stays CSV
const test_piped_csv = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,25' | ./zig-out/bin/sql-pipe 'SELECT * FROM t')
\\expected=$(printf 'Alice,30\nBob,25')
\\[ "$result" = "$expected" ]
});
test_piped_csv.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_piped_csv.step);

// Integration test 156e: table output right-aligns numeric columns
const test_table_numeric_align = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,score\nAlice,100\nBob,5' | ./zig-out/bin/sql-pipe --table 'SELECT * FROM t')
\\echo "$result" | grep -q '100' && echo "$result" | grep -q '5' && echo "$result" | grep -q '│'
});
test_table_numeric_align.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_table_numeric_align.step);

// Integration test 156f: empty result shows headers only
const test_table_empty = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30' | ./zig-out/bin/sql-pipe --table 'SELECT * FROM t WHERE age > 100')
\\echo "$result" | grep -q '┌' && echo "$result" | grep -q '│ name' && echo "$result" | grep -q '└' && ! echo "$result" | grep -q 'Alice'
});
test_table_empty.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_table_empty.step);

// Integration test 156g: --output writes CSV even with --table
const test_table_output_file = b.addSystemCommand(&.{
"bash", "-c",
\\tmp=$(mktemp)
\\printf 'name,age\nAlice,30\nBob,25' | ./zig-out/bin/sql-pipe --table --output "$tmp" 'SELECT * FROM t'
\\result=$(cat "$tmp")
\\rm -f "$tmp"
\\expected=$(printf 'Alice,30\nBob,25')
\\[ "$result" = "$expected" ]
});
test_table_output_file.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_table_output_file.step);

// ─── Fixture-based integration tests ─────────────────────────────────────
// These tests use sample files committed in tests/fixtures/ to exercise
// the binary end-to-end with realistic data across all supported formats.
Expand Down
25 changes: 25 additions & 0 deletions src/args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ pub const ExitCode = enum(u8) {
sql_error = 3,
};

/// Controls pretty-printed table output.
/// auto — show table when stdout is a TTY, CSV when piped (default)
/// always — force table output regardless of TTY
/// never — force CSV output regardless of TTY
pub const TableMode = enum {
auto,
always,
never,
};

pub const FileInput = struct {
path: []const u8,
table_name: []const u8,
Expand Down Expand Up @@ -59,6 +69,7 @@ pub const SqlPipeError = error{
SampleWithOutput,
InvalidSampleCount,
DuplicateTableName,
TableWithNonCsv,
};

pub const ParsedArgs = struct {
Expand Down Expand Up @@ -100,6 +111,8 @@ pub const ParsedArgs = struct {
/// Use a file-backed temporary SQLite database instead of :memory: when true.
/// Enables processing datasets larger than available RAM; also sets PRAGMA temp_store = FILE.
disk: bool,
/// Pretty-printed table output mode (default: auto — TTY detection).
table_mode: TableMode = .auto,
};

pub const ColumnsArgs = struct {
Expand Down Expand Up @@ -207,6 +220,8 @@ pub fn printUsage(writer: *std.Io.Writer) !void {
\\ --disk Use a file-backed temp database instead of :memory:
\\ Enables processing datasets larger than available RAM
\\ Also sets PRAGMA temp_store = FILE for transient structures
\\ --table Force pretty-printed table output (auto-detected on TTY)
\\ --no-table Force CSV output even when stdout is a TTY
\\ -h, --help Show this help message and exit
\\ -V, --version Show version and exit
\\
Expand Down Expand Up @@ -277,6 +292,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
var sample_mode = false;
var sample_n: usize = 10;
var disk = false;
var table_mode: TableMode = .auto;
var seen_dashdash = false;
var positional_args: std.ArrayList([]const u8) = .empty;
defer positional_args.deinit(allocator);
Expand Down Expand Up @@ -408,6 +424,10 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
json_path = arg["--json-path=".len..];
} else if (std.mem.eql(u8, arg, "--disk")) {
disk = true;
} else if (std.mem.eql(u8, arg, "--table")) {
table_mode = .always;
} else if (std.mem.eql(u8, arg, "--no-table")) {
table_mode = .never;
} else {
try positional_args.append(allocator, arg);
}
Expand Down Expand Up @@ -515,6 +535,10 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
if (json_path != null and input_format != .json)
return error.JsonPathRequiresJson;

// --table requires CSV or TSV output format (table formatting is visual only)
if (table_mode == .always and output_format != .csv and output_format != .tsv)
return error.TableWithNonCsv;

// --columns mode: list headers and exit
if (list_columns)
return .{ .columns = ColumnsArgs{
Expand Down Expand Up @@ -567,6 +591,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
.xml_row_input = xml_row_input,
.json_path = json_path,
.disk = disk,
.table_mode = table_mode,
} };
}

Expand Down
37 changes: 30 additions & 7 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const c = @import("c");
const json = @import("json.zig");
const xml = @import("xml.zig");
const format = @import("format.zig");
const table = @import("table.zig");
const build_options = @import("build_options");
const args_mod = @import("args.zig");
const sqlite_mod = @import("sqlite.zig");
Expand All @@ -17,6 +18,7 @@ const VERSION: []const u8 = build_options.version;
const SqlPipeError = args_mod.SqlPipeError;
const ParsedArgs = args_mod.ParsedArgs;
const ExitCode = args_mod.ExitCode;
const TableMode = args_mod.TableMode;
const parseArgs = args_mod.parseArgs;
const printUsage = args_mod.printUsage;

Expand All @@ -31,12 +33,13 @@ const InputFormat = format.InputFormat;
/// Supported output formats (canonical definition lives in format.zig).
const OutputFormat = format.OutputFormat;

/// execQuery(db, query, allocator, writer, header, output_format) → !void
/// execQuery(db, query, allocator, writer, header, output_format, use_table) → !void
/// Pre: db is open with tables populated
/// query is a valid SQL string (not null-terminated)
/// allocator is valid
/// when output_format = .json or .ndjson, header must not be set (caller's responsibility)
/// Post: results are written to writer in the requested output format
/// when use_table = true, output_format must be .csv or .tsv (caller's responsibility)
/// Post: results are written to writer in the requested output format (or as a pretty table)
/// error.PrepareQueryFailed when sqlite3_prepare_v2 returns non-SQLITE_OK
/// propagates any writer I/O error
fn execQuery(
Expand All @@ -48,7 +51,8 @@ fn execQuery(
output_format: OutputFormat,
xml_root: []const u8,
xml_row: []const u8,
) (SqlPipeError || std.mem.Allocator.Error || error{WriteFailed})!void {
use_table: bool,
) (SqlPipeError || std.mem.Allocator.Error || error{WriteFailed, StepFailed})!void {
const query_z = try allocator.dupeZ(u8, query);
defer allocator.free(query_z);

Expand All @@ -59,6 +63,12 @@ fn execQuery(

const col_count = c.sqlite3_column_count(stmt);

// Table mode: buffer all rows and print a formatted table
if (use_table) {
try table.writeTable(allocator, writer, stmt.?, col_count);
return;
}

var out_writer = format.OutputWriter.init(output_format, .{
.header = header,
.xml_root = xml_root,
Expand All @@ -75,8 +85,9 @@ fn execQuery(
try out_writer.end(writer);
}

/// run(allocator, io, parsed, stderr_writer, stdout_writer) → void
/// run(allocator, io, parsed, stderr_writer, stdout_writer, use_table) → void
/// Pre: parsed contains a valid query; allocator and writers are valid
/// use_table is true when output should be formatted as a pretty table
/// Post: input from stdin has been loaded (dispatched on parsed.input_format),
/// query executed, results written to stdout in parsed.output_format
/// On error, an "error: ..." message is written to stderr and process
Expand All @@ -87,6 +98,7 @@ fn run(
parsed: ParsedArgs,
stderr_writer: *std.Io.Writer,
stdout_writer: *std.Io.Writer,
use_table: bool,
) void {
const query = parsed.query;

Expand Down Expand Up @@ -204,7 +216,7 @@ fn run(
// Determine which table to show column context for on error
const main_table: []const u8 = if (parsed.files.len > 0) parsed.files[0].table_name else "t";

execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.output_format, parsed.xml_root, parsed.xml_row) catch {
execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.output_format, parsed.xml_root, parsed.xml_row, use_table) catch {
stdout_writer.flush() catch |err| std.log.err("failed to flush output before fatal: {}", .{err});
sqlite_mod.fatalSqlWithContext(allocator, db, main_table, std.mem.span(c.sqlite3_errmsg(db)), stderr_writer);
};
Expand Down Expand Up @@ -260,6 +272,7 @@ pub fn main(init: std.process.Init.Minimal) void {
error.JsonPathRequiresJson => fatal("--json-path requires -I json", stderr_writer, .usage, .{}),
error.InvalidXmlName => fatal("--xml-root and --xml-row must be valid XML element names (letter/underscore first, then letters/digits/-/._/:)", stderr_writer, .usage, .{}),
error.DuplicateTableName => fatal("duplicate table name — file arguments must have unique basenames", stderr_writer, .usage, .{}),
error.TableWithNonCsv => fatal("--table requires CSV or TSV output format (not compatible with --json, -O json, etc.)", stderr_writer, .usage, .{}),
else => {},
}
printUsage(stderr_writer) catch |werr| std.log.err("failed to write usage: {}", .{werr});
Expand Down Expand Up @@ -320,6 +333,15 @@ pub fn main(init: std.process.Init.Minimal) void {
}
}
}
// Resolve table mode: auto-detect from stdout TTY when not explicitly set.
// Table output only applies when writing to stdout (not --output to a file)
// and only for CSV/TSV output formats (not JSON/XML).
const stdout_is_tty = std.Io.File.isTty(std.Io.File.stdout(), io.io()) catch false;
const use_table_stdout = switch (parsed.table_mode) {
.always => true,
.never => false,
.auto => stdout_is_tty and (parsed.output_format == .csv or parsed.output_format == .tsv),
};
if (parsed.output) |output_path| {
const output_file = std.Io.Dir.createFile(std.Io.Dir.cwd(), io.io(), output_path, .{}) catch |err| {
stderr_writer.print("error: cannot create output file '{s}': {s}\n", .{ output_path, @errorName(err) }) catch |werr| {
Expand All @@ -331,12 +353,13 @@ pub fn main(init: std.process.Init.Minimal) void {
defer std.Io.File.close(output_file, io.io());
var output_buf: [4096]u8 = undefined;
var output_file_writer = std.Io.File.writer(output_file, io.io(), &output_buf);
run(allocator, io.io(), parsed, stderr_writer, &output_file_writer.interface);
// Table mode is disabled when writing to a file
run(allocator, io.io(), parsed, stderr_writer, &output_file_writer.interface, false);
output_file_writer.flush() catch |err| {
std.log.err("failed to flush output file: {}", .{err});
};
} else {
run(allocator, io.io(), parsed, stderr_writer, stdout_writer);
run(allocator, io.io(), parsed, stderr_writer, stdout_writer, use_table_stdout);
stdout_file_writer.flush() catch |err| {
std.log.err("failed to flush stdout: {}", .{err});
};
Expand Down
Loading
Loading