From 00e1f373d5a4bc6819a17b71b84223773a945dc3 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 29 Apr 2026 16:02:16 +0200 Subject: [PATCH 1/2] feat: print row count after loading CSV data (#83) Add --verbose / -v flag and automatic TTY detection so users can confirm how many rows were ingested. Prints 'Loaded rows' to stderr (never stdout) after the COMMIT, with thousands separators for readability. Output is suppressed automatically in non-interactive pipelines unless --verbose is passed explicitly. --- README.md | 10 ++++++++++ build.zig | 33 +++++++++++++++++++++++++++++++++ src/main.zig | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/README.md b/README.md index aae91b7..557f418 100644 --- a/README.md +++ b/README.md @@ -174,9 +174,19 @@ $ cat events.csv \ | `-H`, `--header` | Print column names as the first output row | | `--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) | +| `-v`, `--verbose` | Print `Loaded rows` to stderr after loading (always on TTY; forced with flag) | | `-h`, `--help` | Show usage help and exit | | `-V`, `--version` | Print version and exit | +After loading, `sql-pipe` prints `Loaded rows` to stderr whenever stderr is a TTY (interactive terminal). The message is suppressed in scripts and pipes to keep them noise-free. Use `-v` / `--verbose` to force it regardless of TTY: + +```sh +$ cat sales.csv | sql-pipe --verbose 'SELECT region, SUM(revenue) FROM t GROUP BY region' +# stderr: Loaded 42,317 rows +``` + +The count uses thousands separators (`42,317` not `42317`). It is always written to stderr so stdout remains clean for piping. + ### Exit Codes | Code | Meaning | diff --git a/build.zig b/build.zig index 240af0d..efceeee 100644 --- a/build.zig +++ b/build.zig @@ -317,6 +317,39 @@ pub fn build(b: *std.Build) void { test_csv_row_number.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_csv_row_number.step); + // Integration test 29: --verbose prints "Loaded rows" to stderr + const test_verbose_count = b.addSystemCommand(&.{ + "bash", "-c", + \\printf 'name,age\nAlice,30\nBob,25\nCarol,35\n' | ./zig-out/bin/sql-pipe --verbose 'SELECT COUNT(*) FROM t' 2>&1 >/dev/null | grep -q 'Loaded 3 rows' + }); + test_verbose_count.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_verbose_count.step); + + // Integration test 30: --verbose formats thousands separator (e.g. 1,000 not 1000) + const test_verbose_thousands = b.addSystemCommand(&.{ + "bash", "-c", + \\{ printf 'n\n'; seq 1 1000; } | ./zig-out/bin/sql-pipe --verbose 'SELECT COUNT(*) FROM t' 2>&1 >/dev/null | grep -q 'Loaded 1,000 rows' + }); + test_verbose_thousands.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_verbose_thousands.step); + + // Integration test 31: without --verbose, row count is NOT printed to stderr (non-TTY) + const test_no_verbose_silent = b.addSystemCommand(&.{ + "bash", "-c", + \\out=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe 'SELECT * FROM t' 2>&1 >/dev/null) + \\test -z "$out" + }); + test_no_verbose_silent.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_no_verbose_silent.step); + + // Integration test 32: -v is an alias for --verbose + const test_verbose_short = b.addSystemCommand(&.{ + "bash", "-c", + \\printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe -v 'SELECT * FROM t' 2>&1 >/dev/null | grep -q 'Loaded 1 rows' + }); + test_verbose_short.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_verbose_short.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 9b11a19..bb61e80 100644 --- a/src/main.zig +++ b/src/main.zig @@ -65,6 +65,9 @@ const ParsedArgs = struct { json: bool, /// Abort with exit 1 when more than this many data rows are read; null = unlimited. max_rows: ?usize, + /// 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, }; /// Result of argument parsing — either parsed arguments or a special action. @@ -96,6 +99,7 @@ fn printUsage(writer: *std.Io.Writer) !void { \\ -H, --header Print column names as the first output row \\ --json Output results as a JSON array of objects \\ --max-rows Stop if more than data rows are read (exit 1) + \\ -v, --verbose Print row count to stderr after loading (always on TTY) \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit \\ @@ -143,6 +147,7 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { var explicit_delimiter = false; var explicit_tsv = false; var max_rows: ?usize = null; + var verbose = false; // Loop invariant I: all args[1..i] have been processed; // query holds the first non-flag argument seen, or null; @@ -187,6 +192,8 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { } else if (std.mem.startsWith(u8, arg, "--max-rows=")) { max_rows = std.fmt.parseUnsigned(usize, arg["--max-rows=".len..], 10) catch return error.InvalidMaxRows; if (max_rows.? == 0) return error.InvalidMaxRows; + } else if (std.mem.eql(u8, arg, "--verbose") or std.mem.eql(u8, arg, "-v")) { + verbose = true; } else { if (query == null) query = arg; } @@ -203,6 +210,7 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { .header = header, .json = json, .max_rows = max_rows, + .verbose = verbose, } }; } @@ -850,6 +858,32 @@ fn printSqlErrorContext( // ─── Entry point ────────────────────────────────────── +/// fmtThousands(buf, n) → []const u8 +/// Pre: buf.len >= 26 (accommodates any usize value with thousands separators) +/// Post: n is formatted as a decimal string with ',' separating each group of +/// three digits from the right (e.g. 42317 → "42,317", 1000 → "1,000") +fn fmtThousands(buf: []u8, n: usize) []const u8 { + var tmp: [20]u8 = undefined; + const digits = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; + const len = digits.len; + const first_group = len % 3; // digits in the leading group (0 means groups of 3 from start) + var out_len: usize = 0; + // Loop invariant I: buf[0..out_len] = formatted prefix of digits[0..i] + // commas inserted before every third digit counted from the right + // Bounding function: len - i + for (digits, 0..) |ch, i| { + if ((i > 0 and i == first_group) or + (i > first_group and (i - first_group) % 3 == 0)) + { + buf[out_len] = ','; + out_len += 1; + } + buf[out_len] = ch; + out_len += 1; + } + return buf[0..out_len]; +} + /// fatal(writer, code, comptime fmt, args) → noreturn /// Pre: writer is stderr, code is non-zero ExitCode /// Post: "error: \n" written to stderr, process exits with code @@ -1114,6 +1148,16 @@ fn run( } // {A9: transaction committed; t holds all input rows, no active transaction} + // Print row count to stderr when stderr is a TTY or --verbose is set. + if (parsed.verbose or (std.Io.File.isTty(std.Io.File.stderr(), io) catch false)) { + var count_buf: [32]u8 = undefined; + const count_str = fmtThousands(&count_buf, rows_inserted); + stderr_writer.print("Loaded {s} rows\n", .{count_str}) catch |err| { + std.log.err("failed to write row count: {}", .{err}); + }; + stderr_writer.flush() catch |err| std.log.err("failed to flush stderr: {}", .{err}); + } + execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.json) catch { fatalSqlWithContext(allocator, db, std.mem.span(c.sqlite3_errmsg(db)), stderr_writer); }; From 214a077f69ee4a0629ccde441cd1430b2060a5ac Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 29 Apr 2026 16:13:10 +0200 Subject: [PATCH 2/2] chore: apply code review fixes from PR #111 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fmtThousands: tmp buf [20]u8 → [32]u8 for safety margin on edge cases - printUsage: clarify --verbose description wording - build.zig test 31: add comment explaining 2>&1 >/dev/null redirect semantics --- build.zig | 3 +++ src/main.zig | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index efceeee..179d80b 100644 --- a/build.zig +++ b/build.zig @@ -334,6 +334,9 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&test_verbose_thousands.step); // Integration test 31: without --verbose, row count is NOT printed to stderr (non-TTY) + // Note: `2>&1 >/dev/null` redirects stderr to the subshell's stdout (captured in $out), + // then redirects stdout to /dev/null. The TTY check suppresses the count in this + // non-interactive context, so $out should be empty. const test_no_verbose_silent = b.addSystemCommand(&.{ "bash", "-c", \\out=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe 'SELECT * FROM t' 2>&1 >/dev/null) diff --git a/src/main.zig b/src/main.zig index bb61e80..7400660 100644 --- a/src/main.zig +++ b/src/main.zig @@ -99,7 +99,7 @@ fn printUsage(writer: *std.Io.Writer) !void { \\ -H, --header Print column names as the first output row \\ --json Output results as a JSON array of objects \\ --max-rows Stop if more than data rows are read (exit 1) - \\ -v, --verbose Print row count to stderr after loading (always on TTY) + \\ -v, --verbose Force row count to stderr (shown automatically on TTY) \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit \\ @@ -863,7 +863,7 @@ fn printSqlErrorContext( /// Post: n is formatted as a decimal string with ',' separating each group of /// three digits from the right (e.g. 42317 → "42,317", 1000 → "1,000") fn fmtThousands(buf: []u8, n: usize) []const u8 { - var tmp: [20]u8 = undefined; + var tmp: [32]u8 = undefined; // 20 digits max (u64) + safety margin const digits = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; const len = digits.len; const first_group = len % 3; // digits in the leading group (0 means groups of 3 from start)