From 5ac209c70a90a3c044724d4b5d5d36824bc8b9b5 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Sat, 13 Jun 2026 10:28:58 +0200 Subject: [PATCH 1/6] feat: read SQL query from file with -f/--file flag (#157) --- build.zig | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/args.zig | 22 ++++++++++++--- src/main.zig | 7 +++++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/build.zig b/build.zig index d4ae472..61ba95a 100644 --- a/build.zig +++ b/build.zig @@ -1711,6 +1711,82 @@ pub fn build(b: *std.Build) void { test_file_t_conflict.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_file_t_conflict.step); + // ─── -f/--file flag integration tests (issue #157) ──────────────────────── + + // Integration test 157a: -f reads query from file, file arg for data + const test_f_flag_basic = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'name,age\nAlice,30\nBob,25\nCarol,35' > "$dir/data.csv" + \\printf 'SELECT name FROM data WHERE age > 27' > "$dir/query.sql" + \\result=$(./zig-out/bin/sql-pipe -f "$dir/query.sql" "$dir/data.csv") + \\rm -rf "$dir" + \\[ "$result" = "$(printf 'Alice\nCarol')" ] + }); + test_f_flag_basic.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_basic.step); + + // Integration test 157b: -f with stdin input + const test_f_flag_stdin = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'SELECT name FROM t WHERE age > 27' > "$dir/query.sql" + \\result=$(printf 'name,age\nAlice,30\nBob,25\nCarol,35' \ + \\ | ./zig-out/bin/sql-pipe -f "$dir/query.sql") + \\rm -rf "$dir" + \\[ "$result" = "$(printf 'Alice\nCarol')" ] + }); + test_f_flag_stdin.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_stdin.step); + + // Integration test 157c: --file long form + const test_file_flag_long = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'name,age\nAlice,30\nBob,25\nCarol,35' > "$dir/data.csv" + \\printf 'SELECT name FROM data WHERE age > 27' > "$dir/query.sql" + \\result=$(./zig-out/bin/sql-pipe --file "$dir/query.sql" "$dir/data.csv") + \\rm -rf "$dir" + \\[ "$result" = "$(printf 'Alice\nCarol')" ] + }); + test_file_flag_long.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_file_flag_long.step); + + // Integration test 157d: --file= equals form + const test_file_flag_equals = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'name,age\nAlice,30\n' > "$dir/data.csv" + \\printf 'SELECT name FROM data' > "$dir/query.sql" + \\result=$(./zig-out/bin/sql-pipe --file="$dir/query.sql" "$dir/data.csv") + \\rm -rf "$dir" + \\[ "$result" = "Alice" ] + }); + test_file_flag_equals.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_file_flag_equals.step); + + // Integration test 157e: -f= equals form + const test_f_flag_eq = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'name,age\nAlice,30\n' > "$dir/data.csv" + \\printf 'SELECT name FROM data' > "$dir/query.sql" + \\result=$(./zig-out/bin/sql-pipe -f="$dir/query.sql" "$dir/data.csv") + \\rm -rf "$dir" + \\[ "$result" = "Alice" ] + }); + test_f_flag_eq.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_eq.step); + + // Integration test 157f: Error case — file doesn't exist + const test_f_flag_not_found = b.addSystemCommand(&.{ + "bash", "-c", + \\msg=$(./zig-out/bin/sql-pipe -f /tmp/nonexistent_query_file.sql 2>&1 >/dev/null; echo "EXIT:$?") + \\echo "$msg" | grep -q "cannot read query file" && echo "$msg" | grep -q 'EXIT:1' + }); + test_f_flag_not_found.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_not_found.step); + // ─── Table output tests (--table / --no-table) ──────────────────────────── // Integration test 156a: --table produces formatted table output diff --git a/src/args.zig b/src/args.zig index 0c88a28..946e425 100644 --- a/src/args.zig +++ b/src/args.zig @@ -75,6 +75,8 @@ pub const SqlPipeError = error{ pub const ParsedArgs = struct { /// SQL query to execute. query: []const u8, + /// Path to file containing SQL query (when using -f/--file); null = query from positional arg. + query_file: ?[]const u8 = null, /// Input files as positional arguments; empty when reading from stdin only. files: []const FileInput = &.{}, /// True when stdin has piped data (not a TTY). @@ -181,6 +183,7 @@ pub fn printUsage(writer: *std.Io.Writer) !void { try writer.writeAll( \\Usage: sql-pipe [OPTIONS] \\ sql-pipe [OPTIONS] ... + \\ sql-pipe -f [OPTIONS] [...] \\ \\Reads input from stdin and/or file arguments, loads each into an in-memory \\SQLite table, runs , and prints results to stdout. @@ -223,6 +226,7 @@ pub fn printUsage(writer: *std.Io.Writer) !void { \\ Also sets PRAGMA temp_store = FILE for transient structures \\ --table Force pretty-printed table output (auto-detected on TTY) \\ --no-table Force CSV output even when stdout is a TTY + \\ -f, --file Read SQL query from file instead of command line \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit \\ @@ -247,6 +251,8 @@ pub fn printUsage(writer: *std.Io.Writer) !void { \\ cat data.ndjson | sql-pipe -I ndjson -O ndjson 'SELECT name FROM t WHERE age > 18' \\ cat data.xml | sql-pipe -I xml --xml-root channel --xml-row item "SELECT title FROM t" \\ cat data.csv | sql-pipe --sample 5 + \\ sql-pipe -f query.sql data.csv + \\ cat data.csv | sql-pipe -f query.sql \\ ); } @@ -275,6 +281,7 @@ pub fn isValidXmlName(s: []const u8) bool { pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlPipeError || std.mem.Allocator.Error)!ArgsResult { var query: ?[]const u8 = null; + var query_file: ?[]const u8 = null; var type_inference = true; var delimiter: []const u8 = ","; var header = false; @@ -435,6 +442,14 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP table_mode = .always; } else if (std.mem.eql(u8, arg, "--no-table")) { table_mode = .never; + } else if (std.mem.eql(u8, arg, "-f") or std.mem.eql(u8, arg, "--file")) { + i += 1; + if (i >= args.len) return error.MissingQuery; + query_file = args[i]; + } else if (std.mem.startsWith(u8, arg, "--file=")) { + query_file = arg["--file=".len..]; + } else if (std.mem.startsWith(u8, arg, "-f=")) { + query_file = arg["-f=".len..]; } else { try positional_args.append(allocator, arg); } @@ -448,8 +463,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP var files: std.ArrayList(FileInput) = .empty; if (pos.len > 0) { - if (is_special_mode) { - // Special modes: every positional arg is a file input + if (is_special_mode or query_file != null) { + // Special modes or -f: every positional arg is a file input for (pos) |p| { const name = try tableNameFromPath(allocator, p); const fmt = if (input_format_explicit) input_format else (InputFormat.fromExtension(p) orelse input_format); @@ -588,7 +603,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP } }; return .{ .parsed = ParsedArgs{ - .query = query orelse return error.MissingQuery, + .query = query orelse (if (query_file != null) "" else return error.MissingQuery), + .query_file = query_file, .files = files.items, .type_inference = type_inference, .delimiter = delimiter, diff --git a/src/main.zig b/src/main.zig index 6f8498e..6cdea36 100644 --- a/src/main.zig +++ b/src/main.zig @@ -325,6 +325,13 @@ pub fn main(init: std.process.Init.Minimal) void { .parsed => |mut_parsed| { var parsed = mut_parsed; parsed.has_stdin = has_stdin; + // Read query from file if -f/--file was used + if (parsed.query_file) |path| { + const contents = std.Io.Dir.cwd().readFileAlloc(io.io(), path, allocator, .limited(10 * 1024 * 1024)) catch |err| { + fatal("cannot read query file '{s}': {s}", stderr_writer, .usage, .{ path, @errorName(err) }); + }; + parsed.query = contents; + } // Check for file-stdin table name collision (t is reserved for stdin) if (parsed.has_stdin) { for (parsed.files) |f| { From 6eca639eb8d34b43af0d8f4698e59f002859d59a Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Sat, 13 Jun 2026 10:33:01 +0200 Subject: [PATCH 2/6] fix: memory leak, MissingQuery handler, and empty -f path guard (#157) --- src/args.zig | 6 +++++- src/main.zig | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/args.zig b/src/args.zig index 946e425..7097f69 100644 --- a/src/args.zig +++ b/src/args.zig @@ -70,6 +70,7 @@ pub const SqlPipeError = error{ InvalidSampleCount, DuplicateTableName, TableWithNonCsv, + InvalidQueryFile, }; pub const ParsedArgs = struct { @@ -444,12 +445,15 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP table_mode = .never; } else if (std.mem.eql(u8, arg, "-f") or std.mem.eql(u8, arg, "--file")) { i += 1; - if (i >= args.len) return error.MissingQuery; + if (i >= args.len) return error.InvalidQueryFile; + if (args[i].len == 0) return error.InvalidQueryFile; query_file = args[i]; } else if (std.mem.startsWith(u8, arg, "--file=")) { query_file = arg["--file=".len..]; + if (query_file.?.len == 0) return error.InvalidQueryFile; } else if (std.mem.startsWith(u8, arg, "-f=")) { query_file = arg["-f=".len..]; + if (query_file.?.len == 0) return error.InvalidQueryFile; } else { try positional_args.append(allocator, arg); } diff --git a/src/main.zig b/src/main.zig index 6cdea36..fbf6e92 100644 --- a/src/main.zig +++ b/src/main.zig @@ -267,6 +267,11 @@ pub fn main(init: std.process.Init.Minimal) void { error.SampleWithValidate => fatal("--sample cannot be combined with --validate", stderr_writer, .usage, .{}), error.SampleWithOutput => fatal("--sample cannot be combined with --output", stderr_writer, .usage, .{}), error.InvalidSampleCount => fatal("--sample requires a positive integer value", stderr_writer, .usage, .{}), + error.MissingQuery => { + stderr_writer.writeAll("error: no SQL query provided\n") catch |werr| std.log.err("failed to write error: {}", .{werr}); + // Fall through to printUsage + exit below + }, + error.InvalidQueryFile => fatal("-f/--file requires a non-empty file path", stderr_writer, .usage, .{}), error.MissingXmlFlagValue => fatal("--xml-root and --xml-row require a value", stderr_writer, .usage, .{}), error.MissingJsonFlagValue => fatal("--json-path requires a value", stderr_writer, .usage, .{}), error.JsonPathRequiresJson => fatal("--json-path requires -I json", stderr_writer, .usage, .{}), @@ -325,9 +330,10 @@ pub fn main(init: std.process.Init.Minimal) void { .parsed => |mut_parsed| { var parsed = mut_parsed; parsed.has_stdin = has_stdin; - // Read query from file if -f/--file was used + // Read query from file if -f/--file was used. + // Arena-allocated to match the lifetime of parsed args. if (parsed.query_file) |path| { - const contents = std.Io.Dir.cwd().readFileAlloc(io.io(), path, allocator, .limited(10 * 1024 * 1024)) catch |err| { + const contents = std.Io.Dir.cwd().readFileAlloc(io.io(), path, args_arena.allocator(), .limited(10 * 1024 * 1024)) catch |err| { fatal("cannot read query file '{s}': {s}", stderr_writer, .usage, .{ path, @errorName(err) }); }; parsed.query = contents; From 5622636e3efcdd1ef388791c1dab5e8a8d984d5b Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Sat, 13 Jun 2026 10:40:02 +0200 Subject: [PATCH 3/6] fix: guard against empty query file with clear error message (#157) --- build.zig | 12 ++++++++++++ src/main.zig | 3 +++ 2 files changed, 15 insertions(+) diff --git a/build.zig b/build.zig index 61ba95a..71ebd2a 100644 --- a/build.zig +++ b/build.zig @@ -1787,6 +1787,18 @@ pub fn build(b: *std.Build) void { test_f_flag_not_found.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_f_flag_not_found.step); + // Integration test 157g: Error case — empty query file + const test_f_flag_empty = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf '' > "$dir/empty.sql" + \\msg=$(./zig-out/bin/sql-pipe -f "$dir/empty.sql" 2>&1 >/dev/null; echo "EXIT:$?") + \\rm -rf "$dir" + \\echo "$msg" | grep -q "query file.*is empty" && echo "$msg" | grep -q 'EXIT:1' + }); + test_f_flag_empty.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_empty.step); + // ─── Table output tests (--table / --no-table) ──────────────────────────── // Integration test 156a: --table produces formatted table output diff --git a/src/main.zig b/src/main.zig index fbf6e92..9dceddc 100644 --- a/src/main.zig +++ b/src/main.zig @@ -336,6 +336,9 @@ pub fn main(init: std.process.Init.Minimal) void { const contents = std.Io.Dir.cwd().readFileAlloc(io.io(), path, args_arena.allocator(), .limited(10 * 1024 * 1024)) catch |err| { fatal("cannot read query file '{s}': {s}", stderr_writer, .usage, .{ path, @errorName(err) }); }; + if (contents.len == 0) { + fatal("query file '{s}' is empty", stderr_writer, .usage, .{path}); + } parsed.query = contents; } // Check for file-stdin table name collision (t is reserved for stdin) From d9b4327d9f288b23383cd387435cfc59b28f340c Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Sat, 13 Jun 2026 10:44:50 +0200 Subject: [PATCH 4/6] docs: add -f/--file flag to README (#157) --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 4f85a97..7698f78 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,18 @@ $ cat events.csv \ | sql-pipe 'SELECT * FROM t WHERE n > 100' ``` +### Query from file + +For complex queries, store the SQL in a file and pass it with `-f` / `--file`: + +```sh +$ sql-pipe -f analysis.sql orders.csv +$ cat data.csv | sql-pipe -f analysis.sql +$ sql-pipe --file=query.sql orders.csv customers.csv +``` + +When `-f` is used, all positional arguments are treated as data files (no positional query needed). + ### Flags | Flag | Description | @@ -309,6 +321,7 @@ $ cat events.csv \ | `--output ` | Write results to the given file instead of stdout. Creates or overwrites the file. Exits 1 if the file cannot be created. | | `--table` | Force pretty-printed table output (auto-detected when stdout is a TTY). Requires CSV/TSV output format. | | `--no-table` | Force CSV output even when stdout is a TTY | +| `-f`, `--file ` | Read SQL query from file instead of command line | | `-v`, `--verbose` | Print `Loaded rows in s` to stderr after loading (always on TTY; forced with flag) | | `-s`, `--silent` | Suppress `Loaded rows in s` and the progress counter from stderr unconditionally. Cannot be combined with `-v`/`--verbose` | | `-h`, `--help` | Show usage help and exit | From 78db3713fbd87a4319d8d782fd490a6372d62196 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Sat, 13 Jun 2026 10:46:23 +0200 Subject: [PATCH 5/6] docs: add -f/--file flag to man page (#157) --- docs/sql-pipe.1.scd | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/sql-pipe.1.scd b/docs/sql-pipe.1.scd index 6d3c990..60ec6f0 100644 --- a/docs/sql-pipe.1.scd +++ b/docs/sql-pipe.1.scd @@ -6,6 +6,7 @@ NAME SYNOPSIS *sql-pipe* [OPTIONS] [...] *sql-pipe* [OPTIONS] + *sql-pipe* -f [OPTIONS] [...] *sql-pipe* --columns [OPTIONS] [] *sql-pipe* --sample [] [OPTIONS] [] @@ -144,6 +145,13 @@ OPTIONS *--header*, CSV). Exits with code 1 and an error message if the file cannot be created (bad path or insufficient permissions). + *-f, --file* + Read the SQL query from instead of the command line. When + this flag is used, all positional arguments are treated as data + files (no positional query is needed). Exits with code 1 and an + error message if the file does not exist, cannot be read, or is + empty. + *-h, --help* Print the help message and exit with code 0. @@ -187,6 +195,14 @@ EXAMPLES $ cat events.csv | sql-pipe users.csv ++ 'SELECT * FROM t JOIN users ON t.uid = users.id' + Read query from a file: + + $ sql-pipe -f analysis.sql orders.csv + + Combine -f with stdin: + + $ cat data.csv | sql-pipe -f analysis.sql + Group data by region and sum revenue: $ cat sales.csv | sql-pipe 'SELECT region, SUM(revenue) FROM t GROUP BY region' From a5d8f182e2cb2ad7ae1d02b3a858bf8fe922bdcf Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Sat, 13 Jun 2026 10:57:06 +0200 Subject: [PATCH 6/6] fix: conflict checks, multiple -f detection, whitespace trim (#157) - Add conflict checks for -f with --columns/--validate/--sample - Detect multiple -f/--file flags (error: only one allowed) - Trim whitespace from query file before empty check - Add 6 integration tests (157h-157l) for new error paths - Update man page SYNOPSIS with stdin form --- build.zig | 64 +++++++++++++++++++++++++++++++++++++++++++++ docs/sql-pipe.1.scd | 1 + src/args.zig | 20 +++++++++----- src/main.zig | 6 +++-- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/build.zig b/build.zig index 71ebd2a..dcec06f 100644 --- a/build.zig +++ b/build.zig @@ -1799,6 +1799,70 @@ pub fn build(b: *std.Build) void { test_f_flag_empty.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_f_flag_empty.step); + // Integration test 157h: Error case — whitespace-only query file + const test_f_flag_whitespace = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf ' \n\t \n' > "$dir/ws.sql" + \\msg=$(./zig-out/bin/sql-pipe -f "$dir/ws.sql" 2>&1 >/dev/null; echo "EXIT:$?") + \\rm -rf "$dir" + \\echo "$msg" | grep -q "query file.*is empty" && echo "$msg" | grep -q 'EXIT:1' + }); + test_f_flag_whitespace.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_whitespace.step); + + // Integration test 157i: Error case — -f with --columns + const test_f_flag_columns = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'SELECT 1' > "$dir/q.sql" + \\printf 'name,age\nAlice,30' > "$dir/data.csv" + \\msg=$(./zig-out/bin/sql-pipe --columns -f "$dir/q.sql" "$dir/data.csv" 2>&1 >/dev/null; echo "EXIT:$?") + \\rm -rf "$dir" + \\echo "$msg" | grep -q "cannot be combined with" && echo "$msg" | grep -q 'EXIT:1' + }); + test_f_flag_columns.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_columns.step); + + // Integration test 157j: Error case — -f with --validate + const test_f_flag_validate = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'SELECT 1' > "$dir/q.sql" + \\printf 'name,age\nAlice,30' > "$dir/data.csv" + \\msg=$(./zig-out/bin/sql-pipe --validate -f "$dir/q.sql" "$dir/data.csv" 2>&1 >/dev/null; echo "EXIT:$?") + \\rm -rf "$dir" + \\echo "$msg" | grep -q "cannot be combined with" && echo "$msg" | grep -q 'EXIT:1' + }); + test_f_flag_validate.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_validate.step); + + // Integration test 157k: Error case — -f with --sample + const test_f_flag_sample = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'SELECT 1' > "$dir/q.sql" + \\printf 'name,age\nAlice,30' > "$dir/data.csv" + \\msg=$(./zig-out/bin/sql-pipe --sample -f "$dir/q.sql" "$dir/data.csv" 2>&1 >/dev/null; echo "EXIT:$?") + \\rm -rf "$dir" + \\echo "$msg" | grep -q "cannot be combined with" && echo "$msg" | grep -q 'EXIT:1' + }); + test_f_flag_sample.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_sample.step); + + // Integration test 157l: Error case — multiple -f flags + const test_f_flag_multiple = b.addSystemCommand(&.{ + "bash", "-c", + \\dir=$(mktemp -d) + \\printf 'SELECT 1' > "$dir/q1.sql" + \\printf 'SELECT 2' > "$dir/q2.sql" + \\msg=$(./zig-out/bin/sql-pipe -f "$dir/q1.sql" -f "$dir/q2.sql" 2>&1 >/dev/null; echo "EXIT:$?") + \\rm -rf "$dir" + \\echo "$msg" | grep -q "only one -f/--file" && echo "$msg" | grep -q 'EXIT:1' + }); + test_f_flag_multiple.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_f_flag_multiple.step); + // ─── Table output tests (--table / --no-table) ──────────────────────────── // Integration test 156a: --table produces formatted table output diff --git a/docs/sql-pipe.1.scd b/docs/sql-pipe.1.scd index 60ec6f0..214cee3 100644 --- a/docs/sql-pipe.1.scd +++ b/docs/sql-pipe.1.scd @@ -7,6 +7,7 @@ SYNOPSIS *sql-pipe* [OPTIONS] [...] *sql-pipe* [OPTIONS] *sql-pipe* -f [OPTIONS] [...] + *sql-pipe* -f [OPTIONS] *sql-pipe* --columns [OPTIONS] [] *sql-pipe* --sample [] [OPTIONS] [] diff --git a/src/args.zig b/src/args.zig index 7097f69..fc37026 100644 --- a/src/args.zig +++ b/src/args.zig @@ -71,6 +71,7 @@ pub const SqlPipeError = error{ DuplicateTableName, TableWithNonCsv, InvalidQueryFile, + MultipleQueryFiles, }; pub const ParsedArgs = struct { @@ -283,6 +284,7 @@ pub fn isValidXmlName(s: []const u8) bool { pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlPipeError || std.mem.Allocator.Error)!ArgsResult { var query: ?[]const u8 = null; var query_file: ?[]const u8 = null; + var query_file_seen = false; var type_inference = true; var delimiter: []const u8 = ","; var header = false; @@ -447,11 +449,17 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP i += 1; if (i >= args.len) return error.InvalidQueryFile; if (args[i].len == 0) return error.InvalidQueryFile; + if (query_file_seen) return error.MultipleQueryFiles; + query_file_seen = true; query_file = args[i]; } else if (std.mem.startsWith(u8, arg, "--file=")) { + if (query_file_seen) return error.MultipleQueryFiles; + query_file_seen = true; query_file = arg["--file=".len..]; if (query_file.?.len == 0) return error.InvalidQueryFile; } else if (std.mem.startsWith(u8, arg, "-f=")) { + if (query_file_seen) return error.MultipleQueryFiles; + query_file_seen = true; query_file = arg["-f=".len..]; if (query_file.?.len == 0) return error.InvalidQueryFile; } else { @@ -530,16 +538,16 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP if (validate and list_columns) return error.ValidateWithColumns; - // --columns is mutually exclusive with a query argument - if (list_columns and query != null) + // --columns is mutually exclusive with a query argument (positional or -f) + if (list_columns and (query != null or query_file != null)) return error.ColumnsWithQuery; - // --validate is mutually exclusive with a query argument - if (validate and query != null) + // --validate is mutually exclusive with a query argument (positional or -f) + if (validate and (query != null or query_file != null)) return error.ValidateWithQuery; - // --sample is mutually exclusive with a query argument - if (sample_mode and query != null) + // --sample is mutually exclusive with a query argument (positional or -f) + if (sample_mode and (query != null or query_file != null)) return error.SampleWithQuery; // --sample is mutually exclusive with --json / json output format diff --git a/src/main.zig b/src/main.zig index 9dceddc..23275d3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -272,6 +272,7 @@ pub fn main(init: std.process.Init.Minimal) void { // Fall through to printUsage + exit below }, error.InvalidQueryFile => fatal("-f/--file requires a non-empty file path", stderr_writer, .usage, .{}), + error.MultipleQueryFiles => fatal("only one -f/--file flag is allowed", stderr_writer, .usage, .{}), error.MissingXmlFlagValue => fatal("--xml-root and --xml-row require a value", stderr_writer, .usage, .{}), error.MissingJsonFlagValue => fatal("--json-path requires a value", stderr_writer, .usage, .{}), error.JsonPathRequiresJson => fatal("--json-path requires -I json", stderr_writer, .usage, .{}), @@ -336,10 +337,11 @@ pub fn main(init: std.process.Init.Minimal) void { const contents = std.Io.Dir.cwd().readFileAlloc(io.io(), path, args_arena.allocator(), .limited(10 * 1024 * 1024)) catch |err| { fatal("cannot read query file '{s}': {s}", stderr_writer, .usage, .{ path, @errorName(err) }); }; - if (contents.len == 0) { + const trimmed = std.mem.trim(u8, contents, " \t\r\n"); + if (trimmed.len == 0) { fatal("query file '{s}' is empty", stderr_writer, .usage, .{path}); } - parsed.query = contents; + parsed.query = trimmed; } // Check for file-stdin table name collision (t is reserved for stdin) if (parsed.has_stdin) {