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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ When `-f` is used, all positional arguments are treated as data files (no positi
| `--no-table` | Force CSV output even when stdout is a TTY |
| `--null-value <string>` | Custom NULL representation in CSV/TSV/table output (default: `NULL`). JSON always uses native `null`. |
| `--html-class <class>` | CSS class name for the HTML `<table>` element (`-O html` only) |
| `--checksum` | Compute the SHA-256 hash of the result set and print it to stderr as `checksum: <hash>`. The hash covers only stdout output (the result set), not stderr messages. Works with all output formats, `--output`, `--disk`, `--save`, `--repl`, `--explain`, and `--verbose`. Skipped in inspect modes (`--columns`, `--validate`, `--sample`, `--stats`, `--schema`) since they don't produce result sets. |
| `-f`, `--file <file>` | Read SQL query from file instead of command line |
| `-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` |
Expand Down Expand Up @@ -588,6 +589,19 @@ West,200

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

### Verify result integrity with --checksum

```sh
$ printf 'name,age\nAlice,30\nBob,25\n' | sql-pipe --checksum 'SELECT name FROM t ORDER BY age'
checksum: 081a774cb12f7bd5ea746c3b516da7b5bb8d6e7f62a30c6416f1e79c8958aef7
Bob
Alice
```

The SHA-256 hash of the result set is printed to stderr as `checksum: <hash>`. The hash covers only stdout output (the result set), so it works correctly with `--output`, `--verbose`, `--explain`, and other flags that write to stderr. Skipped in inspect modes (`--columns`, `--validate`, `--sample`, `--stats`, `--schema`).

> **Note:** The entire result set is buffered in memory to compute the checksum. For very large result sets, this may consume significant RAM. Add a `LIMIT` clause to your query to bound the result size. `--max-rows` caps input rows, not output rows. Note: `--checksum` defeats `--disk` — the result set always buffers in RAM regardless of database backing.

## Real-world examples

These run against live public URLs — no local files needed.
Expand Down
124 changes: 122 additions & 2 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3740,6 +3740,126 @@ pub fn build(b: *std.Build) void {
\\rm -f /tmp/fuzz_empty.parquet
\\echo "$msg" | grep -q 'EXIT:[1-9]'
});
test_parquet_fuzz_empty.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_parquet_fuzz_empty.step);
test_parquet_fuzz_empty.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_parquet_fuzz_empty.step);

// ─── --checksum integration tests (issue #204) ──────────────────────────────
// 25 data-driven cases (204a-204y). Scripts run with `set -euo pipefail` so a
// failed assertion actually fails the step (a bare `[` + trailing `rm -f`
// would silently mask failures).
const ChecksumTestType = enum {
checksum_match,
checksum_present,
checksum_absent,
help_flag,
completions_flag,
};
const ChecksumTest = struct {
name: []const u8, // test identifier, e.g. "basic", "json"
args: []const u8, // sql-pipe CLI args (may contain "$tmp" for --output/--save)
input: ?[]const u8, // stdin data; null = no stdin pipe
expected_output: ?[]const u8, // checksum_match: literal expected stdout; null = hash captured stdout
check_type: ChecksumTestType,
extra_check: ?[]const u8, // extra bash assertion line (checksum_present only)
use_temp_file: bool, // wrap "$tmp" in mktemp + cleanup (--output/--save cases)
};
const checksum_tests = [_]ChecksumTest{
.{ .name = "basic", .args = "--checksum 'SELECT name FROM t ORDER BY age'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = "Bob\nAlice\n", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204a
.{ .name = "json", .args = "--checksum --json 'SELECT name, age FROM t ORDER BY age'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = "[{\"name\":\"Bob\",\"age\":25},{\"name\":\"Alice\",\"age\":30}]\n", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204b
.{ .name = "tsv", .args = "--checksum -O tsv 'SELECT name, age FROM t ORDER BY age'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = "Bob\t25\nAlice\t30\n", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204c
.{ .name = "table", .args = "--checksum --table 'SELECT * FROM t'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204d
.{ .name = "markdown", .args = "--checksum -O markdown 'SELECT * FROM t ORDER BY name'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204e
.{ .name = "output_file", .args = "--checksum --output \"$tmp\" 'SELECT name FROM t ORDER BY age'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_match, .extra_check = null, .use_temp_file = true }, // 204f
.{ .name = "header", .args = "--checksum --header 'SELECT name, age FROM t ORDER BY age'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = "name,age\nBob,25\nAlice,30\n", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204g
.{ .name = "sql", .args = "--checksum -O sql 'SELECT * FROM t ORDER BY name'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204h
.{ .name = "html", .args = "--checksum -O html 'SELECT * FROM t ORDER BY name'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204i
.{ .name = "xml", .args = "--checksum -O xml 'SELECT * FROM t ORDER BY name'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204j
.{ .name = "ndjson", .args = "--checksum -O ndjson 'SELECT name, age FROM t ORDER BY age'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = "{\"name\":\"Bob\",\"age\":25}\n{\"name\":\"Alice\",\"age\":30}\n", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204k
.{ .name = "empty", .args = "--checksum 'SELECT name FROM t WHERE age > 100'", .input = "name,age\nAlice,30\n", .expected_output = "", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204l
.{ .name = "disk", .args = "--checksum --disk 'SELECT name FROM t WHERE age > 27'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = "Alice\n", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204m
.{ .name = "null_value", .args = "--checksum --null-value 'N/A' 'SELECT name, score FROM t ORDER BY name'", .input = "name,score\nAlice,30\nBob,\n", .expected_output = null, .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204n
.{ .name = "verbose", .args = "--checksum --verbose 'SELECT name FROM t ORDER BY name'", .input = "name,age\nAlice,30\nBob,25\nCarol,35\n", .expected_output = null, .check_type = .checksum_present, .extra_check = "grep -q 'Loaded 3 rows' \"$err_file\"", .use_temp_file = false }, // 204o
.{ .name = "explain", .args = "--checksum --explain 'SELECT name FROM t ORDER BY name'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_present, .extra_check = "grep -q 'QUERY PLAN:' \"$err_file\"", .use_temp_file = false }, // 204p
.{ .name = "save", .args = "--checksum --save \"$tmp\" 'SELECT name FROM t ORDER BY name'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = null, .check_type = .checksum_present, .extra_check = "[ \"$(head -c 15 \"$tmp\")\" = \"SQLite format 3\" ]", .use_temp_file = true }, // 204q
.{ .name = "repl", .args = "--checksum --repl --no-stdin", .input = "SELECT 1 as one;\n.exit\n", .expected_output = null, .check_type = .checksum_present, .extra_check = null, .use_temp_file = false }, // 204r
.{ .name = "silent", .args = "--checksum --silent 'SELECT name FROM t'", .input = "name,age\nAlice,30\nBob,25\n", .expected_output = "Alice\nBob\n", .check_type = .checksum_match, .extra_check = null, .use_temp_file = false }, // 204z
.{ .name = "repl_multi", .args = "--checksum --repl --no-stdin", .input = "SELECT 1 as one;\nSELECT 2 as two;\nSELECT 3 as three;\n.exit\n", .expected_output = null, .check_type = .checksum_present, .extra_check = "[ \"$(grep -c 'checksum:' \"$err_file\")\" = \"3\" ]", .use_temp_file = false }, // 204aa
.{ .name = "columns", .args = "--checksum --columns", .input = "name,age\nAlice,30\n", .expected_output = null, .check_type = .checksum_absent, .extra_check = null, .use_temp_file = false }, // 204s
.{ .name = "validate", .args = "--checksum --validate", .input = "name,age\nAlice,30\n", .expected_output = null, .check_type = .checksum_absent, .extra_check = null, .use_temp_file = false }, // 204t
.{ .name = "sample", .args = "--checksum --sample 1", .input = "name,age\nAlice,30\n", .expected_output = null, .check_type = .checksum_absent, .extra_check = null, .use_temp_file = false }, // 204u
.{ .name = "stats", .args = "--checksum --stats", .input = "name,age\nAlice,30\n", .expected_output = null, .check_type = .checksum_absent, .extra_check = null, .use_temp_file = false }, // 204v
.{ .name = "schema", .args = "--checksum --schema", .input = "name,age\nAlice,30\n", .expected_output = null, .check_type = .checksum_absent, .extra_check = null, .use_temp_file = false }, // 204w
.{ .name = "help", .args = "--help", .input = null, .expected_output = null, .check_type = .help_flag, .extra_check = null, .use_temp_file = false }, // 204x
.{ .name = "completions", .args = "--completions bash", .input = null, .expected_output = null, .check_type = .completions_flag, .extra_check = null, .use_temp_file = false }, // 204y
};

for (checksum_tests) |t| {
const script = switch (t.check_type) {
.help_flag => b.allocator.dupe(u8, "./zig-out/bin/sql-pipe --help 2>&1 >/dev/null | grep -q -- '--checksum'") catch unreachable,
.completions_flag => b.allocator.dupe(u8, "./zig-out/bin/sql-pipe --completions bash | grep -q -- '--checksum'") catch unreachable,
.checksum_absent => std.fmt.allocPrint(b.allocator,
\\set -euo pipefail
\\err_file=$(mktemp)
\\stdout=$(printf '{s}' | ./zig-out/bin/sql-pipe {s} 2>"$err_file")
\\! grep -q 'checksum:' "$err_file"
\\rm -f "$err_file"
, .{ t.input.?, t.args }) catch unreachable,
.checksum_present => if (t.use_temp_file)
std.fmt.allocPrint(b.allocator,
\\set -euo pipefail
\\tmp=$(mktemp)
\\err_file=$(mktemp)
\\stdout=$(printf '{s}' | ./zig-out/bin/sql-pipe {s} 2>"$err_file")
\\grep -q 'checksum:' "$err_file"
\\{s}
\\rm -f "$tmp" "$err_file"
, .{ t.input.?, t.args, t.extra_check.? }) catch unreachable
else
std.fmt.allocPrint(b.allocator,
\\set -euo pipefail
\\err_file=$(mktemp)
\\stdout=$(printf '{s}' | ./zig-out/bin/sql-pipe {s} 2>"$err_file")
\\grep -q 'checksum:' "$err_file"
\\{s}
\\rm -f "$err_file"
, .{ t.input.?, t.args, t.extra_check orelse "" }) catch unreachable,
.checksum_match => if (t.use_temp_file)
std.fmt.allocPrint(b.allocator,
\\set -euo pipefail
\\tmp=$(mktemp)
\\err_file=$(mktemp)
\\stdout=$(printf '{s}' | ./zig-out/bin/sql-pipe {s} 2>"$err_file")
\\checksum=$(grep 'checksum:' "$err_file" | sed 's/.*checksum: //')
\\expected=$(cat "$tmp" | sha256sum | awk '{{print $1}}')
\\[ "$checksum" = "$expected" ]
\\rm -f "$tmp" "$err_file"
, .{ t.input.?, t.args }) catch unreachable
else if (t.expected_output) |expected|
std.fmt.allocPrint(b.allocator,
\\set -euo pipefail
\\err_file=$(mktemp)
\\stdout=$(printf '{s}' | ./zig-out/bin/sql-pipe {s} 2>"$err_file")
\\checksum=$(grep 'checksum:' "$err_file" | sed 's/.*checksum: //')
\\expected=$(printf '{s}' | sha256sum | awk '{{print $1}}')
\\[ "$checksum" = "$expected" ]
\\rm -f "$err_file"
, .{ t.input.?, t.args, expected }) catch unreachable
else
// Hash redirected stdout: $( ) capture strips trailing newlines,
// which would break the checksum comparison.
std.fmt.allocPrint(b.allocator,
\\set -euo pipefail
\\tmp=$(mktemp)
\\err_file=$(mktemp)
\\printf '{s}' | ./zig-out/bin/sql-pipe {s} > "$tmp" 2>"$err_file"
\\checksum=$(grep 'checksum:' "$err_file" | sed 's/.*checksum: //')
\\expected=$(sha256sum "$tmp" | awk '{{print $1}}')
\\[ "$checksum" = "$expected" ]
\\rm -f "$tmp" "$err_file"
, .{ t.input.?, t.args }) catch unreachable,
};
const test_checksum = b.addSystemCommand(&.{ "bash", "-c", script });
test_checksum.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_checksum.step);
}
}
23 changes: 23 additions & 0 deletions docs/sql-pipe.1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,22 @@ OPTIONS
attribute. Example: *--html-class 'data-table sortable'* produces
*<table class="data-table sortable">*.

*--checksum*
Compute the SHA-256 hash of the result set and print it to standard
error as *checksum: <hash>*. The hash covers only stdout output (the
result set), not stderr messages. Works with all output formats,
*--output*, *--disk*, *--save*, *--repl*, *--explain*, and
*--verbose*. Skipped in inspect modes (*--columns*, *--validate*,
*--sample*, *--stats*, *--schema*) since they do not produce result
sets.

The entire result set is buffered in memory to compute the checksum.
For very large result sets, this may consume significant RAM.
Add a *LIMIT* clause to your query to bound the result size.
*--max-rows* caps input rows, not output rows. Note:
*--checksum* defeats *--disk* — the result set always buffers in
RAM regardless of database backing.

*-r, --repl*
Enter an interactive REPL (read-eval-print loop) after loading
input data. All input files are loaded into SQLite tables once at startup,
Expand Down Expand Up @@ -471,6 +487,13 @@ EXAMPLES
East,100
West,200

Compute a SHA-256 checksum of the result set (hash goes to stderr):

$ printf 'name,age\nAlice,30\nBob,25\n' | sql-pipe --checksum 'SELECT name FROM t ORDER BY age'
checksum: 081a774cb12f7bd5ea746c3b516da7b5bb8d6e7f62a30c6416f1e79c8958aef7
Bob
Alice

Interactive REPL mode (explore data iteratively):

$ sql-pipe --repl sales.csv
Expand Down
11 changes: 9 additions & 2 deletions src/args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ pub const ParsedArgs = struct {
html_class: []const u8 = "",
/// Custom string for NULL values in output (default: "NULL" for CSV/TSV/table).
null_value: ?[]const u8 = null,
/// Emit SHA-256 checksum of result set to stderr when true.
checksum: bool = false,
/// Maximum response body size in bytes for --url (default: 100MB).
max_body_size: usize = 100 * 1024 * 1024,
/// When set, run in --inspect mode instead of normal query mode.
Expand Down Expand Up @@ -279,8 +281,9 @@ pub fn printUsage(writer: *std.Io.Writer) !void {
\\ --table Force pretty-printed table output (auto-detected on TTY)
\\ --no-table Force CSV output even when stdout is a TTY
\\ --null-value <string> Custom NULL representation in output (default: "NULL" for CSV/TSV/table)
\\ --html-class <class> CSS class name for the HTML <table> element (-O html only)
\\ -f, --file <file> Read SQL query from file instead of command line
\\ --html-class <class> CSS class name for the HTML <table> element (-O html only)
\\ --checksum Emit SHA-256 hash of result set to stderr
\\ -f, --file <file> Read SQL query from file instead of command line
\\ --completions <shell> Generate shell completion script (bash, zsh, fish)
\\ -h, --help Show this help message and exit
\\ -V, --version Show version and exit
Expand Down Expand Up @@ -392,6 +395,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
var xml_root_input: ?[]const u8 = null;
var xml_row_input: ?[]const u8 = null;
var null_value: ?[]const u8 = null;
var checksum = false;
var json_path: ?[]const u8 = null;
var inspect_mode: ?InspectMode = null;
var inspect_sample_n: usize = 10;
Expand Down Expand Up @@ -575,6 +579,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
html_class = args[i];
} else if (std.mem.startsWith(u8, arg, "--html-class=")) {
html_class = arg["--html-class=".len..];
} else if (std.mem.eql(u8, arg, "--checksum")) {
checksum = true;
} else if (std.mem.eql(u8, arg, "--no-table")) {
table_mode = .never;
} else if (std.mem.eql(u8, arg, "--completions")) {
Expand Down Expand Up @@ -980,6 +986,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
.sql_table = sql_table,
.html_class = html_class,
.null_value = null_value,
.checksum = checksum,
.max_body_size = max_body_size,
};

Expand Down
9 changes: 6 additions & 3 deletions src/completions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ fn generateBash(writer: *std.Io.Writer) !void {
\\ --explain
\\ --repl -r
\\ --table --no-table
\\ --null-value
\\ --html-class
\\ --completions
\\ --null-value
\\ --html-class
\\ --checksum
\\ --completions
\\ --columns
\\ --file -f
\\ --help -h
Expand Down Expand Up @@ -144,6 +145,7 @@ fn generateZsh(writer: *std.Io.Writer) !void {
\\ '--no-table[Force CSV output]'
\\ '--null-value=[Custom NULL representation]:string:'
\\ '--html-class=[HTML table CSS class]:class:'
\\ '--checksum[Compute SHA-256 checksum of result set to stderr]'
\\ '--completions=[Generate shell completions]:shell:(bash zsh fish)'
\\ '(-f --file)'{-f+,--file=}'[Read SQL query from file]:file:_files'
\\ '(-h --help)'{-h,--help}'[Show help message]'
Expand Down Expand Up @@ -201,6 +203,7 @@ fn generateFish(writer: *std.Io.Writer) !void {
\\complete -c sql-pipe -l no-table -d "Force CSV output"
\\complete -c sql-pipe -l null-value -r -d "Custom NULL representation"
\\complete -c sql-pipe -l html-class -r -d "CSS class for HTML table"
\\complete -c sql-pipe -l checksum -d "Compute SHA-256 checksum of result set to stderr"
\\
\\# Meta options
\\complete -c sql-pipe -l completions -r -f -a "bash zsh fish" -d "Generate shell completions"
Expand Down
Loading
Loading