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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ When `-f` is used, all positional arguments are treated as data files (no positi
| `--sample [<n>]` | Print a schema comment block to stderr and the first `<n>` data rows to stdout as CSV (default: `n=10`). The schema block lists each column name and its inferred type, prefixed with `#`. Implies `--header`. Compatible with `--delimiter` and `--tsv`. Mutually exclusive with `--json` and a query argument. No query required. |
| `--stats` | Load input and print per-column statistics (column name, type, non-null count, min, max, mean) as a formatted table. Mean is blank for non-numeric columns. Compatible with `--delimiter`, `--tsv`, `--no-type-inference`, `-I`/`--input-format`. Mutually exclusive with a query, `--columns`, `--validate`, `--sample`, and `--output`. |
| `--profile` | Alias for `--stats` |
| `--explain` | Print the SQLite query plan to stderr before executing the query. Each line is prefixed with `QUERY PLAN: `. The actual query still runs and results go to stdout. Mutually exclusive with `--columns`, `--validate`, `--sample`, `--stats`, and `--output`. |
| `--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. |
Expand Down Expand Up @@ -437,6 +438,28 @@ $ printf 'name,age\nAlice,30\nBob,25\nCarol,35\n' | sql-pipe --stats

Same as running `SELECT MIN(col), MAX(col), AVG(col), COUNT(*)` per column — but in one command.

### Debug query performance with --explain

```sh
$ printf 'region,amount\nEast,100\nWest,200\nNorth,300\n' | sql-pipe --explain 'SELECT region, SUM(amount) FROM t GROUP BY region'
```

Prints the SQLite execution plan to stderr (prefixed with `QUERY PLAN:`) before running the query:

```
QUERY PLAN: SCAN t
```

The actual query results still go to stdout:

```
East,100
North,300
West,200
```

Useful for understanding how SQLite handles complex JOINs, aggregations, and subqueries — plan goes to stderr so stdout stays machine-parseable.

## Real-world examples

These run against live public URLs — no local files needed.
Expand Down
115 changes: 115 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2475,4 +2475,119 @@ pub fn build(b: *std.Build) void {
});
test_stats_delimiter.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_stats_delimiter.step);

// ─── --explain integration tests ─────────────────────────────────────

// Integration test: --explain basic — prints plan to stderr, results to stdout, exit 0
const test_explain_basic = b.addSystemCommand(&.{
"bash", "-c",
\\data='a,b\n1,2\n3,4\n'
\\stderr=$(printf "$data" | ./zig-out/bin/sql-pipe --explain 'SELECT * FROM t' 2>&1 >/dev/null)
\\echo "$stderr" | grep -q 'QUERY PLAN:' || exit 1
\\stdout=$(printf "$data" | ./zig-out/bin/sql-pipe --explain 'SELECT * FROM t' 2>/dev/null)
\\[ "$stdout" = "$(printf '1,2\n3,4')" ] || exit 1
});
test_explain_basic.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_basic.step);

// Integration test: --explain stdout not contaminated with "QUERY PLAN:"
const test_explain_stdout_clean = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain 'SELECT * FROM t' 2>/dev/null | grep -c 'QUERY' || true)
\\[ "$result" = "0" ]
});
test_explain_stdout_clean.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_stdout_clean.step);

// Integration test: --explain exit code 0 on success
const test_explain_exit_0 = b.addSystemCommand(&.{
"bash", "-c",
\\printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain 'SELECT * FROM t' >/dev/null 2>&1
\\[ $? -eq 0 ]
});
test_explain_exit_0.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_exit_0.step);

// Integration test: --explain + --columns → error exit 1
const test_explain_with_columns = b.addSystemCommand(&.{
"bash", "-c",
\\msg=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain --columns 2>&1 >/dev/null; echo "EXIT:$?")
\\echo "$msg" | grep -q 'error: --explain cannot be combined' && echo "$msg" | grep -q 'EXIT:1'
});
test_explain_with_columns.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_with_columns.step);

// Integration test: --explain + --validate → error exit 1
const test_explain_with_validate = b.addSystemCommand(&.{
"bash", "-c",
\\msg=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain --validate 2>&1 >/dev/null; echo "EXIT:$?")
\\echo "$msg" | grep -q 'error: --explain cannot be combined' && echo "$msg" | grep -q 'EXIT:1'
});
test_explain_with_validate.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_with_validate.step);

// Integration test: --explain + --sample → error exit 1
const test_explain_with_sample = b.addSystemCommand(&.{
"bash", "-c",
\\msg=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain --sample 2>&1 >/dev/null; echo "EXIT:$?")
\\echo "$msg" | grep -q 'error: --explain cannot be combined' && echo "$msg" | grep -q 'EXIT:1'
});
test_explain_with_sample.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_with_sample.step);

// Integration test: --explain + --stats → error exit 1
const test_explain_with_stats = b.addSystemCommand(&.{
"bash", "-c",
\\msg=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain --stats 2>&1 >/dev/null; echo "EXIT:$?")
\\echo "$msg" | grep -q 'error: --explain cannot be combined' && echo "$msg" | grep -q 'EXIT:1'
});
test_explain_with_stats.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_with_stats.step);

// Integration test: --explain + --output → error exit 1
const test_explain_with_output = b.addSystemCommand(&.{
"bash", "-c",
\\msg=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain --output /tmp/sp_test_out.csv 2>&1 >/dev/null; echo "EXIT:$?")
\\echo "$msg" | grep -q 'error: --explain cannot be combined' && echo "$msg" | grep -q 'EXIT:1'
});
test_explain_with_output.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_with_output.step);

// Integration test: --help contains --explain
const test_explain_help = b.addSystemCommand(&.{
"bash", "-c",
\\./zig-out/bin/sql-pipe --help 2>&1 >/dev/null | grep -q -- '--explain'
});
test_explain_help.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_help.step);

// Integration test: --explain with nonexistent table → exit 3
const test_explain_nonexistent = b.addSystemCommand(&.{
"bash", "-c",
\\msg=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --explain 'SELECT * FROM nonexistent' 2>&1 >/dev/null; echo "EXIT:$?")
\\echo "$msg" | grep -q 'error: no such table' && echo "$msg" | grep -q 'EXIT:3'
});
test_explain_nonexistent.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_nonexistent.step);

// Integration test: --explain with -f/--file query
const test_explain_query_file = b.addSystemCommand(&.{
"bash", "-c",
\\tmp=$(mktemp)
\\printf 'SELECT * FROM t' > "$tmp"
\\result=$(printf 'x\n1\n2\n' | ./zig-out/bin/sql-pipe -f "$tmp" --explain 2>/dev/null)
\\rm -f "$tmp"
\\[ "$result" = "$(printf '1\n2')" ]
});
test_explain_query_file.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_query_file.step);

// Integration test: --explain with empty stdin (< /dev/null) — no crash
const test_explain_empty_stdin = b.addSystemCommand(&.{
"bash", "-c",
\\./zig-out/bin/sql-pipe --explain 'SELECT 1' < /dev/null >/dev/null 2>&1
\\[ $? -eq 0 ]
});
test_explain_empty_stdin.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_explain_empty_stdin.step);
}
16 changes: 16 additions & 0 deletions docs/sql-pipe.1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ SYNOPSIS
*sql-pipe* --columns [OPTIONS] [<file>]
*sql-pipe* --sample [<n>] [OPTIONS] [<file>]
*sql-pipe* --stats [OPTIONS] [<file>]
*sql-pipe* --explain [OPTIONS] [<file>] <query>

DESCRIPTION
sql-pipe reads CSV, JSON, NDJSON, or XML data from standard input and/or
Expand Down Expand Up @@ -156,6 +157,13 @@ OPTIONS
*--header*, CSV). Exits with code 1 and an error message if the file
cannot be created (bad path or insufficient permissions).

*--explain*
Before executing the SQL query, run *EXPLAIN QUERY PLAN* and print
the execution plan to standard error. Each line is prefixed with
*QUERY PLAN:* so stdout remains clean for piping. The actual query
still executes normally and results go to stdout. Mutually exclusive
with *--columns*, *--validate*, *--sample*, *--stats*, and *--output*.

*-f, --file* <file>
Read the SQL query from <file> instead of the command line. When
this flag is used, all positional arguments are treated as data
Expand Down Expand Up @@ -301,6 +309,14 @@ EXAMPLES
2,South,875.50++
3,East,2100.75

Inspect query execution plan before running (plan goes to stderr,
results to stdout):

$ printf 'region,amount\nEast,100\nWest,200\n' | sql-pipe --explain 'SELECT region, SUM(amount) FROM t GROUP BY region'
QUERY PLAN: SCAN t
East,100
West,200

EXIT CODES
*0*
Success: query executed successfully and results were output.
Expand Down
12 changes: 12 additions & 0 deletions src/args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub const SqlPipeError = error{
TableWithNonCsv,
InvalidQueryFile,
MultipleQueryFiles,
ExplainWithFlags,
};

pub const ParsedArgs = struct {
Expand Down Expand Up @@ -116,6 +117,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,
/// Print EXPLAIN QUERY PLAN to stderr before executing the query.
explain: bool = false,
/// Pretty-printed table output mode (default: auto — TTY detection).
table_mode: TableMode = .auto,
};
Expand Down Expand Up @@ -242,6 +245,7 @@ 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
\\ --explain Print SQLite query plan to stderr before executing
\\ --table Force pretty-printed table output (auto-detected on TTY)
\\ --no-table Force CSV output even when stdout is a TTY
\\ -f, --file <file> Read SQL query from file instead of command line
Expand Down Expand Up @@ -323,6 +327,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
var sample_n: usize = 10;
var stats_mode = false;
var disk = false;
var explain = false;
var table_mode: TableMode = .auto;
var seen_dashdash = false;
var positional_args: std.ArrayList([]const u8) = .empty;
Expand Down Expand Up @@ -447,6 +452,8 @@ 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, "--explain")) {
explain = true;
} else if (std.mem.eql(u8, arg, "--table")) {
table_mode = .always;
} else if (std.mem.eql(u8, arg, "--no-table")) {
Expand Down Expand Up @@ -572,6 +579,10 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
if (stats_mode and (list_columns or validate or sample_mode or query != null or query_file != null or output != null))
return error.StatsWithFlags;

// --explain is mutually exclusive with mode flags and --output
if (explain and (list_columns or validate or sample_mode or stats_mode or output != null))
return error.ExplainWithFlags;

// --silent and --verbose are mutually exclusive
if (silent and verbose)
return error.SilentVerboseConflict;
Expand Down Expand Up @@ -652,6 +663,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,
.explain = explain,
.table_mode = table_mode,
} };
}
Expand Down
45 changes: 45 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,45 @@ fn loadInput(
};
}

/// printQueryPlan(allocator, db, query, main_table, stderr_writer) → void
/// Pre: db is open with tables populated; query is a valid SQL string
/// Post: EXPLAIN QUERY PLAN has been written to stderr, one line per plan row,
/// prefixed with "QUERY PLAN: ". Stderr is flushed after writing.
/// On SQL error, exits with sql_error via fatalSqlWithContext.
fn printQueryPlan(
allocator: std.mem.Allocator,
db: *c.sqlite3,
query: []const u8,
main_table: []const u8,
stderr_writer: *std.Io.Writer,
) void {
// Build null-terminated "EXPLAIN QUERY PLAN <query>" using ArrayList (no allocPrintZ in 0.16).
var eqp_buf: std.ArrayList(u8) = .empty;
defer eqp_buf.deinit(allocator);
eqp_buf.appendSlice(allocator, "EXPLAIN QUERY PLAN ") catch
sqlite_mod.fatal("out of memory", stderr_writer, .csv_error, .{});
eqp_buf.appendSlice(allocator, query) catch
sqlite_mod.fatal("out of memory", stderr_writer, .csv_error, .{});
eqp_buf.append(allocator, 0) catch
sqlite_mod.fatal("out of memory", stderr_writer, .csv_error, .{});
const eqp_query: [*:0]const u8 = @ptrCast(eqp_buf.items.ptr);

var stmt: ?*c.sqlite3_stmt = null;
if (c.sqlite3_prepare_v2(db, eqp_query, -1, &stmt, null) != c.SQLITE_OK) {
sqlite_mod.fatalSqlWithContext(allocator, db, main_table, std.mem.span(c.sqlite3_errmsg(db)), stderr_writer);
}
defer _ = c.sqlite3_finalize(stmt);

while (c.sqlite3_step(stmt) == c.SQLITE_ROW) {
// EXPLAIN QUERY PLAN columns: id(0), parent(1), notused(2), detail(3)
const detail = std.mem.span(c.sqlite3_column_text(stmt, 3));
stderr_writer.print("QUERY PLAN: {s}\n", .{detail}) catch |err| {
std.log.err("failed to write query plan: {}", .{err});
};
}
stderr_writer.flush() catch |err| std.log.err("failed to flush stderr after query plan: {}", .{err});
}

/// 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
Expand Down Expand Up @@ -184,6 +223,11 @@ 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";

// Print query plan to stderr when --explain is set
if (parsed.explain) {
printQueryPlan(allocator, db, query, main_table, stderr_writer);
}

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 @@ -248,6 +292,7 @@ pub fn main(init: std.process.Init.Minimal) void {
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, .{}),
error.ExplainWithFlags => fatal("--explain cannot be combined with --columns, --validate, --sample, --stats, or --output", stderr_writer, .usage, .{}),
else => {},
}
printUsage(stderr_writer) catch |werr| std.log.err("failed to write usage: {}", .{werr});
Expand Down
Loading