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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ When `-f` is used, all positional arguments are treated as data files (no positi
| `-d`, `--delimiter <char>` | Input field delimiter (single character, default `,`) |
| `--tsv` | Alias for `--delimiter '\t'` |
| `-I`, `--input-format <fmt>` | Input format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`. Overrides file extension auto-detection. |
| `-O`, `--output-format <fmt>` | Output format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`, `markdown` (alias: `md`) |
| `-O`, `--output-format <fmt>` | Output format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`, `markdown` (alias: `md`), `sql` |
| `--sql-table <name>` | Target table name for `-O sql` INSERT output (default: `t`) |
| `--no-type-inference` | Treat all columns as TEXT (skip auto-detection) |
| `-H`, `--header` | Print column names as the first output row |
| `--json` | Alias for `--output-format json` (mutually exclusive with `-H`) |
Expand Down
85 changes: 85 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,91 @@ pub fn build(b: *std.Build) void {
test_markdown_empty.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_markdown_empty.step);

// ─── SQL INSERT output integration tests (issue #174) ─────────────────────

// Integration test 174a: Basic SQL INSERT output
const test_sql_basic = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,25\n' | ./zig-out/bin/sql-pipe -O sql 'SELECT * FROM t ORDER BY name')
\\echo "$result" | grep -Fq 'INSERT INTO "t" ("name", "age") VALUES ('"'"'Alice'"'"', 30);' && \
\\echo "$result" | grep -Fq 'INSERT INTO "t" ("name", "age") VALUES ('"'"'Bob'"'"', 25);'
});
test_sql_basic.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_basic.step);

// Integration test 174b: --sql-table flag with custom table name
const test_sql_custom_table = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name\nAlice\n' | ./zig-out/bin/sql-pipe -O sql --sql-table users 'SELECT * FROM t')
\\echo "$result" | grep -Fq 'INSERT INTO "users"'
});
test_sql_custom_table.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_custom_table.step);

// Integration test 174c: String escaping (single quotes doubled)
const test_sql_escaping = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name\nO'"'"'Brien\nIt'"'"'s great\n' | ./zig-out/bin/sql-pipe -O sql 'SELECT * FROM t')
\\echo "$result" | grep -Fq "VALUES ('O''Brien');" && \
\\echo "$result" | grep -Fq "VALUES ('It''s great');"
});
test_sql_escaping.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_escaping.step);

// Integration test 174d: NULL values rendered as NULL (unquoted)
const test_sql_null = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,\n' | ./zig-out/bin/sql-pipe -O sql 'SELECT * FROM t ORDER BY name')
\\echo "$result" | grep -Fq 'NULL' && echo "$result" | grep -vq "'NULL'"
});
test_sql_null.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_null.step);

// Integration test 174e: Numeric values unquoted
const test_sql_numeric = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'n\n42\n-7\n' | ./zig-out/bin/sql-pipe -O sql 'SELECT * FROM t')
\\echo "$result" | grep -Fq "(42);" && echo "$result" | grep -Fq "(-7);"
});
test_sql_numeric.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_numeric.step);

// Integration test 174f: Float value (non-trunc → with decimal, trunc → integer)
const test_sql_float = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,score\nAlice,3.14\nBob,30.0\n' | ./zig-out/bin/sql-pipe -O sql 'SELECT * FROM t ORDER BY name')
\\echo "$result" | grep -Fq "3.14)" && echo "$result" | grep -Fq " 30);" && echo "$result" | grep -vq "30.0"
});
test_sql_float.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_float.step);

// Integration test 174g: Empty result set
const test_sql_empty = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe -O sql 'SELECT * FROM t WHERE age > 100')
\\test -z "$result"
});
test_sql_empty.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_empty.step);

// Integration test 174h: Column name with special characters (quoted identifier)
const test_sql_quoted_col = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'a,b\n1,2\n' | ./zig-out/bin/sql-pipe -O sql 'SELECT 1 AS "my col", 2 AS "order"')
\\echo "$result" | grep -Fq '"my col"' && echo "$result" | grep -Fq '"order"'
});
test_sql_quoted_col.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_quoted_col.step);

// Integration test 174i: Table name with spaces (output should quote it)
const test_sql_quoted_table = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name\nAlice\n' | ./zig-out/bin/sql-pipe -O sql --sql-table 'my table' 'SELECT * FROM t')
\\echo "$result" | grep -Fq '"my table"'
});
test_sql_quoted_table.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_sql_quoted_table.step);

// ─── Fixture-based integration tests ─────────────────────────────────────
// These tests use sample files committed in tests/fixtures/ to exercise
// the binary end-to-end with realistic data across all supported formats.
Expand Down
5 changes: 4 additions & 1 deletion docs/sql-pipe.1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ OPTIONS

*-O, --output-format* <fmt>
Set the output format: *csv* (default), *tsv*, *json*, *ndjson*,
*xml*, or *markdown* (alias: *md*).
*xml*, *markdown* (alias: *md*), or *sql*.

*--sql-table* <name>
Target table name for *-O sql* INSERT output (default: *t*).

*--no-type-inference*
Treat all columns as TEXT. Skips automatic type detection and uses plain
Expand Down
18 changes: 15 additions & 3 deletions src/args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub const SqlPipeError = error{
TableWithNonCsv,
InvalidQueryFile,
MultipleQueryFiles,
MissingSqlTableValue,
ExplainWithFlags,
MissingNullValue,
};
Expand Down Expand Up @@ -123,6 +124,8 @@ pub const ParsedArgs = struct {
explain: bool = false,
/// Pretty-printed table output mode (default: auto — TTY detection).
table_mode: TableMode = .auto,
/// Target table name for SQL INSERT output (default: "t").
sql_table: []const u8 = "t",
/// Custom string for NULL values in output (null = format default: "NULL" for CSV/TSV/table, "" for markdown).
null_value: ?[]const u8 = null,
};
Expand Down Expand Up @@ -233,9 +236,10 @@ pub fn printUsage(writer: *std.Io.Writer) !void {
\\ --tsv Alias for --delimiter '\t'
\\ -I, --input-format <fmt> Input format: csv (default), tsv, json, ndjson, xml
\\ Overrides file extension auto-detection; stdin always uses this value
\\ -O, --output-format <fmt> Output format: csv (default), tsv, json, ndjson, xml, markdown (alias: md)
\\ --json Alias for --output-format json
\\ --no-type-inference Treat all columns as TEXT (CSV input only)
\\ -O, --output-format <fmt> Output format: csv (default), tsv, json, ndjson, xml, markdown (alias: md), sql
\\ --json Alias for --output-format json
\\ --sql-table <name> Target table name for -O sql INSERT output (default: t)
\\ --no-type-inference Treat all columns as TEXT (CSV input only)
\\ -H, --header Print column names as the first output row (CSV/TSV output only)
\\ --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)
Expand Down Expand Up @@ -353,6 +357,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
var disk = false;
var explain = false;
var table_mode: TableMode = .auto;
var sql_table: []const u8 = "t";
var seen_dashdash = false;
var positional_args: std.ArrayList([]const u8) = .empty;
defer positional_args.deinit(allocator);
Expand Down Expand Up @@ -403,6 +408,12 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
output_format = OutputFormat.parse(arg["--output-format=".len..]) catch return error.InvalidOutputFormat;
} else if (std.mem.startsWith(u8, arg, "-O=")) {
output_format = OutputFormat.parse(arg["-O=".len..]) catch return error.InvalidOutputFormat;
} else if (std.mem.eql(u8, arg, "--sql-table")) {
i += 1;
if (i >= args.len) return error.MissingSqlTableValue;
sql_table = args[i];
} else if (std.mem.startsWith(u8, arg, "--sql-table=")) {
sql_table = arg["--sql-table=".len..];
} else if (std.mem.eql(u8, arg, "--max-rows")) {
i += 1;
if (i >= args.len) return error.InvalidMaxRows;
Expand Down Expand Up @@ -710,6 +721,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
.disk = disk,
.explain = explain,
.table_mode = table_mode,
.sql_table = sql_table,
.null_value = null_value,
} };
}
Expand Down
67 changes: 66 additions & 1 deletion src/format.zig
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub const OutputFormat = enum {
ndjson,
xml,
markdown,
sql,

/// Parse a format name string.
/// Returns error.InvalidOutputFormat when the value is unrecognised.
Expand All @@ -68,6 +69,8 @@ pub const WriteOpts = struct {
xml_root: []const u8 = "results",
/// Row element name for XML output.
xml_row: []const u8 = "row",
/// Target table name for SQL INSERT output (default: "t").
sql_table: []const u8 = "t",
/// Custom NULL representation (null = format default).
null_value: ?[]const u8 = null,
};
Expand Down Expand Up @@ -133,7 +136,7 @@ pub const OutputWriter = struct {

// Collect column-name pointers for formats that need them per row.
switch (self.format) {
.json, .ndjson, .xml => {
.json, .ndjson, .xml, .sql => {
const names = try allocator.alloc([*:0]const u8, @intCast(col_count));
var i: c_int = 0;
while (i < col_count) : (i += 1) {
Expand Down Expand Up @@ -180,6 +183,7 @@ pub const OutputWriter = struct {
self.opts.xml_row,
self.opts.null_value,
),
.sql => try self.writeSqlRow(stmt, writer),
.markdown => unreachable, // handled before OutputWriter in execQuery
}
}
Expand All @@ -199,8 +203,69 @@ pub const OutputWriter = struct {
fn csvDelimiter(self: OutputWriter) []const u8 {
return if (self.format == .tsv) "\t" else ",";
}

/// Write one SQL INSERT row from the current SQLITE_ROW.
fn writeSqlRow(self: *OutputWriter, stmt: *c.sqlite3_stmt, writer: *std.Io.Writer) !void {
// INSERT INTO "table" (col1, col2) VALUES (val1, val2);
try writer.writeAll("INSERT INTO ");
try writeSqlId(writer, self.opts.sql_table);
try writer.writeAll(" (");
const col_count: usize = @intCast(self.col_count);
for (0..col_count) |j| {
if (j > 0) try writer.writeAll(", ");
try writeSqlId(writer, std.mem.span(self.col_names[j]));
}
try writer.writeAll(") VALUES (");
for (0..col_count) |j| {
const i: c_int = @intCast(j);
if (j > 0) try writer.writeAll(", ");
switch (c.sqlite3_column_type(stmt, i)) {
c.SQLITE_NULL => try writer.writeAll("NULL"),
c.SQLITE_INTEGER => try writer.print("{d}", .{c.sqlite3_column_int64(stmt, i)}),
c.SQLITE_FLOAT => {
const f = c.sqlite3_column_double(stmt, i);
if (f == @trunc(f) and !std.math.isInf(f) and !std.math.isNan(f)) {
try writer.print("{d}", .{@as(i64, @intFromFloat(f))});
} else {
try writer.print("{d}", .{f});
}
},
else => {
// ponytail: BLOB truncated at first NUL, same as CSV/JSON; X'...' hex if needed
if (sqlite_mod.columnText(stmt, i)) |text| {
try writeSqlStringLiteral(writer, text);
} else {
try writer.writeAll("NULL");
}
},
}
}
try writer.writeAll(");\n");
}
};

// ── SQL output helpers ─────────────────────────────────────────────────────────

/// Write a double-quoted SQL identifier, escaping embedded double quotes.
fn writeSqlId(writer: *std.Io.Writer, name: []const u8) !void {
try writer.writeByte('"');
for (name) |ch| {
if (ch == '"') try writer.writeByte('"');
try writer.writeByte(ch);
}
try writer.writeByte('"');
}

/// Write a single-quoted SQL string literal, escaping embedded single quotes.
fn writeSqlStringLiteral(writer: *std.Io.Writer, value: []const u8) !void {
try writer.writeByte('\'');
for (value) |ch| {
if (ch == '\'') try writer.writeByte('\'');
try writer.writeByte(ch);
}
try writer.writeByte('\'');
}

// ── CSV output helpers ─────────────────────────────────────────────────────────

/// Write a single CSV/TSV field with RFC 4180 quoting when necessary.
Expand Down
2 changes: 1 addition & 1 deletion src/json.zig
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ pub fn loadJsonArray(
stderr_writer: *std.Io.Writer,
) usize {
// Read all input into a buffer using block reads instead of byte-by-byte takeByte()
const buf = sqlite_helpers.readAllInput(reader, allocator, stderr_writer, "JSON input");
const buf = sqlite_helpers.readAllInput(allocator, reader, stderr_writer, "JSON input");
defer allocator.free(buf);

if (buf.len == 0) return 0; // Empty input - return 0 rows gracefully
Expand Down
7 changes: 5 additions & 2 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ fn execQuery(
output_format: OutputFormat,
xml_root: []const u8,
xml_row: []const u8,
sql_table: []const u8,
null_value: ?[]const u8,
use_table: bool,
) (SqlPipeError || std.mem.Allocator.Error || error{WriteFailed, StepFailed})!void {
Expand Down Expand Up @@ -83,6 +84,7 @@ fn execQuery(
.header = header,
.xml_root = xml_root,
.xml_row = xml_row,
.sql_table = sql_table,
.null_value = null_value,
});
defer out_writer.deinit(allocator);
Expand Down Expand Up @@ -231,7 +233,7 @@ fn run(
printQueryPlan(allocator, db, query, main_table, stderr_writer);
}

execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.output_format, parsed.xml_root, parsed.xml_row, parsed.null_value, use_table) catch {
execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.output_format, parsed.xml_root, parsed.xml_row, parsed.sql_table, parsed.null_value, use_table) catch {
stdout_writer.flush() catch |err| std.log.err("failed to flush output before fatal: {}", .{err});
sqlite_mod.fatalSqlWithContext(allocator, db, main_table, std.mem.span(c.sqlite3_errmsg(db)), stderr_writer);
};
Expand Down Expand Up @@ -269,7 +271,7 @@ pub fn main(init: std.process.Init.Minimal) void {
error.SilentVerboseConflict => fatal("--silent cannot be combined with --verbose", stderr_writer, .usage, .{}),
error.InvalidMaxRows => fatal("--max-rows must be a positive integer", stderr_writer, .usage, .{}),
error.InvalidInputFormat => fatal("unknown input format; supported: csv, tsv, json, ndjson, xml", stderr_writer, .usage, .{}),
error.InvalidOutputFormat => fatal("unknown output format; supported: csv, tsv, json, ndjson, xml, markdown (md)", stderr_writer, .usage, .{}),
error.InvalidOutputFormat => fatal("unknown output format; supported: csv, tsv, json, ndjson, xml, markdown (md), sql", stderr_writer, .usage, .{}),
error.ColumnsWithQuery => fatal("--columns cannot be combined with a query argument", stderr_writer, .usage, .{}),
error.ValidateWithQuery => fatal("--validate cannot be combined with a query argument", stderr_writer, .usage, .{}),
error.InvalidOutputPath => fatal("--output requires a non-empty file path", stderr_writer, .usage, .{}),
Expand All @@ -290,6 +292,7 @@ pub fn main(init: std.process.Init.Minimal) void {
},
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.MissingSqlTableValue => fatal("--sql-table requires a value", 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
10 changes: 5 additions & 5 deletions src/modes/columns.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub fn runColumns(
.csv, .tsv => {
const col_delim: []const u8 = if (args.input_format == .tsv) "\t" else args.delimiter;
var read_buf: [4096]u8 = undefined;
const opened = source.openInput(input_source, io, stderr_writer);
const opened = source.openInput(io, input_source, stderr_writer);
defer opened.deinit(io);
var source_reader = std.Io.File.reader(opened.file, io, &read_buf);
var csv_reader = csv_mod.csvReaderWithDelimiter(allocator, &source_reader.interface, col_delim);
Expand Down Expand Up @@ -95,11 +95,11 @@ pub fn runColumns(
},
.json => {
var read_buf: [4096]u8 = undefined;
const opened = source.openInput(input_source, io, stderr_writer);
const opened = source.openInput(io, input_source, stderr_writer);
defer opened.deinit(io);
var source_reader = std.Io.File.reader(opened.file, io, &read_buf);

const input = readAllInput(&source_reader.interface, allocator, stderr_writer, "JSON input");
const input = readAllInput(allocator, &source_reader.interface, stderr_writer, "JSON input");
defer allocator.free(input);
if (input.len == 0) fatal("empty input", stderr_writer, .csv_error, .{});

Expand All @@ -125,7 +125,7 @@ pub fn runColumns(
},
.ndjson => {
var read_buf: [4096]u8 = undefined;
const opened = source.openInput(input_source, io, stderr_writer);
const opened = source.openInput(io, input_source, stderr_writer);
defer opened.deinit(io);
var source_reader = std.Io.File.reader(opened.file, io, &read_buf);

Expand Down Expand Up @@ -171,7 +171,7 @@ pub fn runColumns(
},
.xml => {
var read_buf: [4096]u8 = undefined;
const opened = source.openInput(input_source, io, stderr_writer);
const opened = source.openInput(io, input_source, stderr_writer);
defer opened.deinit(io);
var source_reader = std.Io.File.reader(opened.file, io, &read_buf);

Expand Down
2 changes: 1 addition & 1 deletion src/modes/sample.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub fn runSample(
.csv, .tsv => {
const col_delim: []const u8 = if (args.input_format == .tsv) "\t" else args.delimiter;
var read_buf: [4096]u8 = undefined;
const opened = source.openInput(input_source, io, stderr_writer);
const opened = source.openInput(io, input_source, stderr_writer);
defer opened.deinit(io);
var source_reader = std.Io.File.reader(opened.file, io, &read_buf);
var csv_reader = csv_mod.csvReaderWithDelimiter(allocator, &source_reader.interface, col_delim);
Expand Down
Loading
Loading