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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,10 @@ 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`), `sql` |
| `-O`, `--output-format <fmt>` | Output format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`, `markdown` (alias: `md`), `html`, `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 |
| `-H`, `--header` | Print column names as the first output row (CSV/TSV/HTML) |
| `--json` | Alias for `--output-format json` (mutually exclusive with `-H`) |
| `--max-rows <n>` | Stop if more than `n` data rows are read (exit 1) |
| `--validate` | Parse the entire input and print a summary (`OK: <n> rows, <m> columns (col TYPE, ...)`) to stdout. Exit 0 on success, exit 2 on parse error. No query required. Compatible with `--delimiter`, `--tsv`, `--no-type-inference`, `-I`/`--input-format` (csv, tsv, json, ndjson, xml). JSON/NDJSON/XML columns are reported as TEXT. |
Expand All @@ -341,6 +341,7 @@ When `-f` is used, all positional arguments are treated as data files (no positi
| `--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 |
| `--null-value <string>` | Custom NULL representation in CSV/TSV/table output (default: `NULL`). JSON always uses native `null`. |
| `--html-class <class>` | CSS class name for the HTML `<table>` element (`-O html` only) |
| `-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` |
Expand Down
73 changes: 73 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,79 @@ pub fn build(b: *std.Build) void {
test_markdown_empty.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_markdown_empty.step);

// ─── HTML output integration tests (issue #173) ──────────────────────────────

// Integration test 173a: Basic HTML output
const test_html_basic = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,25\n' | ./zig-out/bin/sql-pipe -O html 'SELECT * FROM t ORDER BY name')
\\echo "$result" | grep -Fq '<table>' && echo "$result" | grep -Fq '<tbody>' && echo "$result" | grep -Fq '<tr><td>Alice</td><td>30</td></tr>' && echo "$result" | grep -Fq '</table>'
});
test_html_basic.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_basic.step);

// Integration test 173b: HTML output with --header produces <thead>
const test_html_header = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe -O html --header 'SELECT * FROM t')
\\echo "$result" | grep -Fq '<thead><tr><th>name</th><th>age</th></tr></thead>'
});
test_html_header.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_header.step);

// Integration test 173c: HTML output with --html-class
const test_html_class = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe -O html --html-class 'my-table' 'SELECT * FROM t')
\\echo "$result" | grep -Fq '<table class="my-table">'
});
test_html_class.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_class.step);

// Integration test 173d: NULL renders as empty cell in HTML
const test_html_null = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,\n' | ./zig-out/bin/sql-pipe -O html 'SELECT * FROM t ORDER BY name')
\\echo "$result" | grep -Fq '<td></td>'
});
test_html_null.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_null.step);

// Integration test 173e: HTML escaping of special characters
const test_html_escape = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,data\nAlice,"<script>x</script>"\n' | ./zig-out/bin/sql-pipe -O html 'SELECT * FROM t')
\\echo "$result" | grep -Fq '&lt;script&gt;'
});
test_html_escape.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_escape.step);

// Integration test 173f: --null-value works with HTML
const test_html_null_value = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name,age\nAlice,30\nBob,\n' | ./zig-out/bin/sql-pipe -O html --null-value 'N/A' 'SELECT * FROM t ORDER BY name')
\\echo "$result" | grep -Fq '<td>N/A</td>'
});
test_html_null_value.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_null_value.step);

// Integration test 173g: --html-class missing value exits with error
const test_html_class_missing = b.addSystemCommand(&.{
"bash", "-c",
\\./zig-out/bin/sql-pipe -O html --html-class 2>&1 | grep -q 'html-class requires a value'
});
test_html_class_missing.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_class_missing.step);

// Integration test 173h: --html-class attribute escaping
const test_html_class_escape = b.addSystemCommand(&.{
"bash", "-c",
\\result=$(printf 'name\nAlice\n' | ./zig-out/bin/sql-pipe -O html --html-class 'a"class' 'SELECT * FROM t')
\\echo "$result" | grep -Fq 'class="a&quot;class"'
});
test_html_class_escape.step.dependOn(b.getInstallStep());
test_step.dependOn(&test_html_class_escape.step);

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

// Integration test 174a: Basic SQL INSERT output
Expand Down
48 changes: 42 additions & 6 deletions docs/sql-pipe.1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ OPTIONS

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

*--sql-table* <name>
Target table name for *-O sql* INSERT output (default: *t*).
Expand All @@ -81,7 +81,9 @@ OPTIONS
Print a header row with column names as the first line of output. Column
names are taken from the SQL column alias or original name. The output
remains valid CSV that can be piped back into sql-pipe. Header output is
off by default. Cannot be combined with *--json*.
off by default. Cannot be combined with *--json*. Works with CSV, TSV,
and HTML output formats. For *-O html*, controls whether a *<thead>*
section is emitted.

*--json*
Output results as a JSON array of objects instead of CSV. Each row
Expand Down Expand Up @@ -178,10 +180,17 @@ OPTIONS
with *--columns*, *--validate*, *--sample*, *--stats*, *--schema*, and *--output*.

*--null-value* <string>
Custom representation for SQL NULL values in CSV, TSV, and table
output. By default, NULL values are printed as the string *NULL*.
Pass an empty string (*--null-value ''*), *N/A*, or any other label.
Has no effect on JSON output (JSON always outputs native *null*).
Custom representation for SQL NULL values in CSV, TSV, table, and
HTML output. By default, NULL values are printed as the string *NULL*
in CSV/TSV/table, or as an empty cell in HTML/markdown. Pass an empty
string (*--null-value ''*), *N/A*, or any other label. Has no effect on
JSON output (JSON always outputs native *null*).

*--html-class* <class>
When using *-O html*, set a CSS class attribute on the *<table>*
element. The value is escaped for safe insertion into the HTML
attribute. Example: *--html-class 'data-table sortable'* produces
*<table class="data-table sortable">*.

*-f, --file* <file>
Read the SQL query from <file> instead of the command line. When
Expand Down Expand Up @@ -313,6 +322,33 @@ EXAMPLES
| Bob | 25 |++
| Carol | 35 |

Output results as an HTML table:

$ printf 'name,age\nAlice,30\nBob,25' \
| sql-pipe -O html 'SELECT * FROM t'

Output:++
<table>++
<tbody>++
<tr><td>Alice</td><td>30</td></tr>++
<tr><td>Bob</td><td>25</td></tr>++
</tbody>++
</table>

Output with column headers and a CSS class:

$ printf 'name,age\nAlice,30\nBob,25' \
| sql-pipe -O html --header --html-class 'results' 'SELECT * FROM t'

Output:++
<table class="results">++
<thead><tr><th>name</th><th>age</th></tr></thead>++
<tbody>++
<tr><td>Alice</td><td>30</td></tr>++
<tr><td>Bob</td><td>25</td></tr>++
</tbody>++
</table>

Custom NULL representation (replace NULL with empty string):

$ printf 'name,email\nAlice,a@b.com\nBob,\nCarol,c@d.com' \
Expand Down
22 changes: 17 additions & 5 deletions src/args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ pub const SqlPipeError = error{
MissingSqlTableValue,
ExplainWithFlags,
MissingNullValue,
MissingHtmlClassValue,
};

pub const ParsedArgs = struct {
Expand Down Expand Up @@ -126,7 +127,9 @@ pub const ParsedArgs = struct {
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).
/// CSS class name for the HTML <table> element (default: "" = no class).
html_class: []const u8 = "",
/// Custom string for NULL values in output (default: "NULL" for CSV/TSV/table, "" for markdown).
null_value: ?[]const u8 = null,
};

Expand Down Expand Up @@ -236,11 +239,11 @@ 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), sql
\\ -O, --output-format <fmt> Output format: csv (default), tsv, json, ndjson, xml, markdown (alias: md), html, 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)
\\ -H, --header Print column names as the first output row (CSV/TSV/HTML)
\\ --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)
\\ With --columns: show inferred type per column
Expand Down Expand Up @@ -274,6 +277,7 @@ pub fn printUsage(writer: *std.Io.Writer) !void {
\\ --table Force pretty-printed table output (auto-detected on TTY)
\\ --no-table Force CSV output even when stdout is a TTY
\\ --null-value <string> Custom NULL representation in output (default: "NULL" for CSV/TSV/table)
\\ --html-class <class> CSS class name for the HTML <table> element (-O html only)
\\ -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 Down Expand Up @@ -358,6 +362,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
var explain = false;
var table_mode: TableMode = .auto;
var sql_table: []const u8 = "t";
var html_class: []const u8 = "";
var seen_dashdash = false;
var positional_args: std.ArrayList([]const u8) = .empty;
defer positional_args.deinit(allocator);
Expand Down Expand Up @@ -499,6 +504,12 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
null_value = args[i];
} else if (std.mem.startsWith(u8, arg, "--null-value=")) {
null_value = arg["--null-value=".len..];
} else if (std.mem.eql(u8, arg, "--html-class")) {
i += 1;
if (i >= args.len) return error.MissingHtmlClassValue;
html_class = args[i];
} else if (std.mem.startsWith(u8, arg, "--html-class=")) {
html_class = arg["--html-class=".len..];
} 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")) {
Expand Down Expand Up @@ -574,8 +585,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
}
}

// Non-CSV/TSV output format is mutually exclusive with --header
if (output_format != .csv and output_format != .tsv and header)
// Non-CSV/TSV/HTML output format is mutually exclusive with --header
if (output_format != .csv and output_format != .tsv and output_format != .html and header)
return error.IncompatibleFlags;

// --output is mutually exclusive with --columns (--columns always writes to stdout)
Expand Down Expand Up @@ -722,6 +733,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP
.explain = explain,
.table_mode = table_mode,
.sql_table = sql_table,
.html_class = html_class,
.null_value = null_value,
} };
}
Expand Down
53 changes: 52 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,
html,
sql,

/// Parse a format name string.
Expand All @@ -71,6 +72,8 @@ pub const WriteOpts = struct {
xml_row: []const u8 = "row",
/// Target table name for SQL INSERT output (default: "t").
sql_table: []const u8 = "t",
/// CSS class name for the HTML <table> element (default: "" = no class).
html_class: []const u8 = "",
/// Custom NULL representation (null = format default).
null_value: ?[]const u8 = null,
};
Expand Down Expand Up @@ -136,7 +139,7 @@ pub const OutputWriter = struct {

// Collect column-name pointers for formats that need them per row.
switch (self.format) {
.json, .ndjson, .xml, .sql => {
.json, .ndjson, .xml, .html, .sql => {
const names = try allocator.alloc([*:0]const u8, @intCast(col_count));
var i: c_int = 0;
while (i < col_count) : (i += 1) {
Expand All @@ -156,6 +159,26 @@ pub const OutputWriter = struct {
switch (self.format) {
.json => try writer.writeByte('['),
.xml => try xml_mod.writeXmlHeader(writer, self.opts.xml_root),
.html => {
try writer.writeAll("<table");
if (self.opts.html_class.len > 0) {
try writer.writeAll(" class=\"");
try xml_mod.writeXmlEscaped(writer, self.opts.html_class);
try writer.writeByte('"');
}
try writer.writeAll(">\n");
if (self.opts.header) {
try writer.writeAll("<thead><tr>");
var i: c_int = 0;
while (i < col_count) : (i += 1) {
try writer.writeAll("<th>");
try xml_mod.writeXmlEscaped(writer, std.mem.span(self.col_names[@intCast(i)]));
try writer.writeAll("</th>");
}
try writer.writeAll("</tr></thead>\n");
}
try writer.writeAll("<tbody>\n");
},
else => {},
}
}
Expand Down Expand Up @@ -184,6 +207,7 @@ pub const OutputWriter = struct {
self.opts.null_value,
),
.sql => try self.writeSqlRow(stmt, writer),
.html => try writeHtmlRow(stmt, self.col_count, writer, self.opts.null_value),
.markdown => unreachable, // handled before OutputWriter in execQuery
}
}
Expand All @@ -196,6 +220,7 @@ pub const OutputWriter = struct {
switch (self.format) {
.json => try writer.writeAll("]\n"),
.xml => try xml_mod.writeXmlFooter(writer, self.opts.xml_root),
.html => try writer.writeAll("</tbody>\n</table>\n"),
else => {},
}
}
Expand Down Expand Up @@ -329,3 +354,29 @@ fn csvPrintHeaderRow(
}
try writer.writeByte('\n');
}

// ── HTML output helpers ─────────────────────────────────────────────────────

/// Write one HTML table data row from the current SQLITE_ROW.
fn writeHtmlRow(
stmt: *c.sqlite3_stmt,
col_count: c_int,
writer: *std.Io.Writer,
null_value: ?[]const u8,
) !void {
try writer.writeAll("<tr>");
var i: c_int = 0;
while (i < col_count) : (i += 1) {
try writer.writeAll("<td>");
if (c.sqlite3_column_type(stmt, i) == c.SQLITE_NULL) {
const text = null_value orelse "";
try xml_mod.writeXmlEscaped(writer, text);
} else {
if (sqlite_mod.columnText(stmt, i)) |text| {
try xml_mod.writeXmlEscaped(writer, text);
}
}
try writer.writeAll("</td>");
}
try writer.writeAll("</tr>\n");
}
Loading
Loading