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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -309,6 +321,7 @@ $ cat events.csv \
| `--output <file>` | 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 <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` |
| `-h`, `--help` | Show usage help and exit |
Expand Down
152 changes: 152 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,158 @@ 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);

// 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);

// 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
Expand Down
17 changes: 17 additions & 0 deletions docs/sql-pipe.1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ NAME
SYNOPSIS
*sql-pipe* [OPTIONS] [<file>...] <query>
*sql-pipe* [OPTIONS] <query>
*sql-pipe* -f <file> [OPTIONS] [<file>...]
*sql-pipe* -f <file> [OPTIONS]
*sql-pipe* --columns [OPTIONS] [<file>]
*sql-pipe* --sample [<n>] [OPTIONS] [<file>]

Expand Down Expand Up @@ -144,6 +146,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* <file>
Read the SQL query from <file> 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.

Expand Down Expand Up @@ -187,6 +196,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'
Expand Down
46 changes: 37 additions & 9 deletions src/args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,15 @@ pub const SqlPipeError = error{
InvalidSampleCount,
DuplicateTableName,
TableWithNonCsv,
InvalidQueryFile,
MultipleQueryFiles,
};

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).
Expand Down Expand Up @@ -181,6 +185,7 @@ pub fn printUsage(writer: *std.Io.Writer) !void {
try writer.writeAll(
\\Usage: sql-pipe [OPTIONS] <query>
\\ sql-pipe [OPTIONS] <file>... <query>
\\ sql-pipe -f <file> [OPTIONS] [<file>...]
\\
\\Reads input from stdin and/or file arguments, loads each into an in-memory
\\SQLite table, runs <query>, and prints results to stdout.
Expand Down Expand Up @@ -223,6 +228,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 <file> Read SQL query from file instead of command line
\\ -h, --help Show this help message and exit
\\ -V, --version Show version and exit
\\
Expand All @@ -247,6 +253,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
\\
);
}
Expand Down Expand Up @@ -275,6 +283,8 @@ 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;
Expand Down Expand Up @@ -435,6 +445,23 @@ 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.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 {
try positional_args.append(allocator, arg);
}
Expand All @@ -448,8 +475,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);
Expand Down Expand Up @@ -511,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
Expand Down Expand Up @@ -588,7 +615,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,
Expand Down
18 changes: 18 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ 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.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, .{}),
Expand Down Expand Up @@ -325,6 +331,18 @@ 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.
// Arena-allocated to match the lifetime of parsed args.
if (parsed.query_file) |path| {
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) });
};
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 = trimmed;
}
// Check for file-stdin table name collision (t is reserved for stdin)
if (parsed.has_stdin) {
for (parsed.files) |f| {
Expand Down
Loading