From 134a5916e8de9483e0212bd9b54273fbc20d3832 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 29 Apr 2026 17:38:55 +0200 Subject: [PATCH 1/2] feat: add --output flag to write results to a file (#87) --- README.md | 1 + build.zig | 25 +++++++++++++++++++++++++ docs/sql-pipe.1.scd | 6 ++++++ src/main.zig | 45 +++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8d2fd8d..5319199 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ $ cat events.csv \ | `--json` | Output results as a JSON array of objects (mutually exclusive with `-H`) | | `--max-rows ` | Stop if more than `n` data rows are read (exit 1) | | `--columns` | Read the CSV header row, print each column name on its own line, and exit 0. With `-v`/`--verbose`, also shows the inferred type per column (`name INTEGER`). Respects `--delimiter` and `--tsv`. Mutually exclusive with a query argument. | +| `--output ` | Write results to the given file instead of stdout. Creates or overwrites the file. Exits 1 if the file cannot be created. | | `-v`, `--verbose` | Print `Loaded rows in s` to stderr after loading (always on TTY; forced with flag) | | `-h`, `--help` | Show usage help and exit | | `-V`, `--version` | Print version and exit | diff --git a/build.zig b/build.zig index 657196d..5fd6aab 100644 --- a/build.zig +++ b/build.zig @@ -427,6 +427,31 @@ pub fn build(b: *std.Build) void { test_columns_short_verbose.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_columns_short_verbose.step); + // Integration test 42: --output writes results to a file + const test_output_file = b.addSystemCommand(&.{ + "bash", "-c", + \\tmp=$(mktemp); printf 'name,age\nAlice,30\nBob,25\n' | ./zig-out/bin/sql-pipe --output "$tmp" 'SELECT name FROM t ORDER BY age'; diff "$tmp" <(printf 'Bob\nAlice\n'); rm -f "$tmp" + }); + test_output_file.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_output_file.step); + + // Integration test 43: --output works with --json + const test_output_json = b.addSystemCommand(&.{ + "bash", "-c", + \\tmp=$(mktemp); printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe --json --output "$tmp" 'SELECT * FROM t'; grep -q '"name":"Alice"' "$tmp"; rm -f "$tmp" + }); + test_output_json.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_output_json.step); + + // Integration test 44: --output with missing parent directory exits 1 with error message + const test_output_bad_path = b.addSystemCommand(&.{ + "bash", "-c", + \\msg=$(printf 'a\n1\n' | ./zig-out/bin/sql-pipe --output '/nonexistent/dir/file.csv' 'SELECT * FROM t' 2>&1 >/dev/null; echo "EXIT:$?") + \\echo "$msg" | grep -q "^error:" && echo "$msg" | grep -q 'EXIT:1' + }); + test_output_bad_path.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_output_bad_path.step); + // Unit tests for the RFC 4180 CSV parser (src/csv.zig) const unit_tests = b.addTest(.{ .root_module = b.createModule(.{ diff --git a/docs/sql-pipe.1.scd b/docs/sql-pipe.1.scd index b350b31..466aa6f 100644 --- a/docs/sql-pipe.1.scd +++ b/docs/sql-pipe.1.scd @@ -65,6 +65,12 @@ OPTIONS for each column, using the first 100 data rows for inference. Respects *--delimiter* and *--tsv*. Mutually exclusive with a query argument. + *--output* + Write results to instead of standard output. Creates or + overwrites the file. Compatible with all output modes (*--json*, + *--header*, CSV). Exits with code 1 and an error message if the file + cannot be created (bad path or insufficient permissions). + *-h, --help* Print the help message and exit with code 0. diff --git a/src/main.zig b/src/main.zig index 8d005aa..ef17dbf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -30,6 +30,7 @@ const SqlPipeError = error{ StepFailed, CommitFailed, PrepareQueryFailed, + InvalidOutputPath, }; // ─── Column type inference ──────────────────────────── @@ -72,6 +73,8 @@ const ParsedArgs = struct { /// Print "Loaded rows" to stderr after all CSV rows are inserted when true. /// When false, the message is still shown automatically when stderr is a TTY. verbose: bool, + /// Write results to this file path instead of stdout; null = write to stdout. + output: ?[]const u8, }; /// Arguments for `--columns` mode. @@ -117,6 +120,7 @@ fn printUsage(writer: *std.Io.Writer) !void { \\ With --columns: show inferred type per column \\ --columns List column names from header (one per line) and exit \\ Combine with -v/--verbose to include inferred types + \\ --output Write results to file instead of stdout \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit \\ @@ -166,6 +170,7 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { var max_rows: ?usize = null; var verbose = false; var list_columns = false; + var output: ?[]const u8 = null; // Loop invariant I: all args[1..i] have been processed; // query holds the first non-flag argument seen, or null; @@ -214,6 +219,13 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { verbose = true; } else if (std.mem.eql(u8, arg, "--columns")) { list_columns = true; + } else if (std.mem.eql(u8, arg, "--output")) { + i += 1; + if (i >= args.len) return error.InvalidOutputPath; + output = args[i]; + } else if (std.mem.startsWith(u8, arg, "--output=")) { + output = arg["--output=".len..]; + if (output.?.len == 0) return error.InvalidOutputPath; } else { if (query == null) query = arg; } @@ -239,6 +251,7 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { .json = json, .max_rows = max_rows, .verbose = verbose, + .output = output, } }; } @@ -1299,6 +1312,13 @@ pub fn main(init: std.process.Init.Minimal) void { stderr_writer.flush() catch |ferr| std.log.err("failed to flush: {}", .{ferr}); std.process.exit(@intFromEnum(ExitCode.usage)); }, + error.InvalidOutputPath => { + stderr_writer.writeAll("error: --output requires a non-empty file path\n") catch |werr| { + std.log.err("failed to write error message: {}", .{werr}); + }; + stderr_writer.flush() catch |ferr| std.log.err("failed to flush: {}", .{ferr}); + std.process.exit(@intFromEnum(ExitCode.usage)); + }, else => {}, } printUsage(stderr_writer) catch |werr| { @@ -1333,10 +1353,27 @@ pub fn main(init: std.process.Init.Minimal) void { }; }, .parsed => |parsed| { - run(parsed, allocator, io.io(), stderr_writer, stdout_writer); - stdout_file_writer.flush() catch |err| { - std.log.err("failed to flush stdout: {}", .{err}); - }; + 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| { + std.log.err("failed to write error message: {}", .{werr}); + }; + stderr_writer.flush() catch |ferr| std.log.err("failed to flush: {}", .{ferr}); + std.process.exit(@intFromEnum(ExitCode.usage)); + }; + 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(parsed, allocator, io.io(), stderr_writer, &output_file_writer.interface); + output_file_writer.flush() catch |err| { + std.log.err("failed to flush output file: {}", .{err}); + }; + } else { + run(parsed, allocator, io.io(), stderr_writer, stdout_writer); + stdout_file_writer.flush() catch |err| { + std.log.err("failed to flush stdout: {}", .{err}); + }; + } stderr_file_writer.flush() catch |err| { std.log.err("failed to flush stderr: {}", .{err}); }; From 2f0b2ff345a07c9075531f25cae4d8dead70be9b Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 29 Apr 2026 17:52:13 +0200 Subject: [PATCH 2/2] fix: address PR review findings for --output flag - flush stdout_writer before fatalSqlWithContext when execQuery fails mid-execution (prevents partial buffered rows being lost on SQL error) - reject --output + --columns at parse time with clear error message - validate --output value for empty/whitespace in both space and equals forms - update --columns help text to document --output exclusion - add tests 45-47: --output+--header, --output+--columns rejection, --output SQL error exit code --- build.zig | 29 +++++++++++++++++++++++++++++ src/main.zig | 23 ++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/build.zig b/build.zig index 5fd6aab..e3f8464 100644 --- a/build.zig +++ b/build.zig @@ -452,6 +452,35 @@ pub fn build(b: *std.Build) void { test_output_bad_path.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_output_bad_path.step); + // Integration test 45: --output works with --header + const test_output_header = b.addSystemCommand(&.{ + "bash", "-c", + \\tmp=$(mktemp); printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe --header --output "$tmp" 'SELECT name FROM t'; diff "$tmp" <(printf 'name\nAlice\n'); rm -f "$tmp" + }); + test_output_header.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_output_header.step); + + // Integration test 46: --output cannot be combined with --columns (exits 1 with error) + const test_output_with_columns = b.addSystemCommand(&.{ + "bash", "-c", + \\msg=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe --columns --output /tmp/out.csv 2>&1 >/dev/null; echo "EXIT:$?") + \\echo "$msg" | grep -q 'error: --output cannot be combined with --columns' && echo "$msg" | grep -q 'EXIT:1' + }); + test_output_with_columns.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_output_with_columns.step); + + // Integration test 47: --output on SQL error flushes partial output before exit + const test_output_sql_error_flush = b.addSystemCommand(&.{ + "bash", "-c", + \\tmp=$(mktemp) + \\printf 'name,age\nAlice,30\nBob,25\n' | ./zig-out/bin/sql-pipe --header --output "$tmp" 'SELECT * FROM nonexistent_table' 2>/dev/null; test $? -eq 3 + \\rm -f "$tmp" + }); + test_output_sql_error_flush.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_output_sql_error_flush.step); + test_output_bad_path.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_output_bad_path.step); + // Unit tests for the RFC 4180 CSV parser (src/csv.zig) const unit_tests = b.addTest(.{ .root_module = b.createModule(.{ diff --git a/src/main.zig b/src/main.zig index ef17dbf..94f4edd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -31,6 +31,7 @@ const SqlPipeError = error{ CommitFailed, PrepareQueryFailed, InvalidOutputPath, + OutputWithColumns, }; // ─── Column type inference ──────────────────────────── @@ -120,6 +121,7 @@ fn printUsage(writer: *std.Io.Writer) !void { \\ With --columns: show inferred type per column \\ --columns List column names from header (one per line) and exit \\ Combine with -v/--verbose to include inferred types + \\ Cannot be combined with --output or a query argument \\ --output Write results to file instead of stdout \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit @@ -222,10 +224,13 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { } else if (std.mem.eql(u8, arg, "--output")) { i += 1; if (i >= args.len) return error.InvalidOutputPath; - output = args[i]; + const trimmed = std.mem.trim(u8, args[i], " \t"); + if (trimmed.len == 0) return error.InvalidOutputPath; + output = trimmed; } else if (std.mem.startsWith(u8, arg, "--output=")) { - output = arg["--output=".len..]; - if (output.?.len == 0) return error.InvalidOutputPath; + const trimmed = std.mem.trim(u8, arg["--output=".len..], " \t"); + if (trimmed.len == 0) return error.InvalidOutputPath; + output = trimmed; } else { if (query == null) query = arg; } @@ -235,6 +240,10 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { if (json and header) return error.IncompatibleFlags; + // --output is mutually exclusive with --columns (--columns always writes to stdout) + if (output != null and list_columns) + return error.OutputWithColumns; + // --columns is mutually exclusive with a query argument if (list_columns and query != null) return error.ColumnsWithQuery; @@ -1264,6 +1273,7 @@ fn run( } execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.json) catch { + stdout_writer.flush() catch |err| std.log.err("failed to flush output before fatal: {}", .{err}); fatalSqlWithContext(allocator, db, std.mem.span(c.sqlite3_errmsg(db)), stderr_writer); }; // {A10: all result rows written to stdout as CSV lines} @@ -1319,6 +1329,13 @@ pub fn main(init: std.process.Init.Minimal) void { stderr_writer.flush() catch |ferr| std.log.err("failed to flush: {}", .{ferr}); std.process.exit(@intFromEnum(ExitCode.usage)); }, + error.OutputWithColumns => { + stderr_writer.writeAll("error: --output cannot be combined with --columns\n") catch |werr| { + std.log.err("failed to write error message: {}", .{werr}); + }; + stderr_writer.flush() catch |ferr| std.log.err("failed to flush: {}", .{ferr}); + std.process.exit(@intFromEnum(ExitCode.usage)); + }, else => {}, } printUsage(stderr_writer) catch |werr| {