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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>` | Stop if more than `n` data rows are read (exit 1) |
| `-v`, `--verbose` | Print `Loaded <n> 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 <n> 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 |
Expand Down
36 changes: 36 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,42 @@ 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 <n> 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)
// 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)
\\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(.{
Expand Down
44 changes: 44 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n> 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.
Expand Down Expand Up @@ -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 <n> Stop if more than <n> data rows are read (exit 1)
\\ -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
\\
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -203,6 +210,7 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult {
.header = header,
.json = json,
.max_rows = max_rows,
.verbose = verbose,
} };
}

Expand Down Expand Up @@ -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: [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)
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: <message>\n" written to stderr, process exits with code
Expand Down Expand Up @@ -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);
};
Expand Down
Loading