Skip to content

Commit 85f92b5

Browse files
authored
feat: add YAML input support (-I yaml) with bundled libyaml 0.2.5 (#191)
1 parent 9778d6e commit 85f92b5

17 files changed

Lines changed: 553 additions & 29 deletions

File tree

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ jobs:
5454
print('SQLite amalgamation ready')
5555
"
5656
57+
- name: Download libyaml amalgamation
58+
shell: bash
59+
run: |
60+
mkdir -p lib/yaml
61+
curl -fsSL "https://github.com/yaml/libyaml/archive/refs/tags/0.2.5.tar.gz" -o /tmp/yaml-0.2.5.tar.gz
62+
tar -xzf /tmp/yaml-0.2.5.tar.gz -C /tmp
63+
cp /tmp/libyaml-0.2.5/include/yaml.h lib/yaml/yaml.h
64+
cp /tmp/libyaml-0.2.5/src/yaml_private.h lib/yaml/yaml_private.h
65+
cp /tmp/libyaml-0.2.5/src/api.c lib/yaml/api.c
66+
cp /tmp/libyaml-0.2.5/src/parser.c lib/yaml/parser.c
67+
cp /tmp/libyaml-0.2.5/src/scanner.c lib/yaml/scanner.c
68+
cp /tmp/libyaml-0.2.5/src/reader.c lib/yaml/reader.c
69+
cp /tmp/libyaml-0.2.5/src/writer.c lib/yaml/writer.c
70+
cp /tmp/libyaml-0.2.5/src/loader.c lib/yaml/loader.c
71+
echo "libyaml 0.2.5 ready"
72+
5773
- name: Build
5874
run: zig build -Dbundle-sqlite=true -Doptimize=ReleaseSafe
5975

.github/workflows/release.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@ jobs:
8686
unzip -j sqlite.zip "*/sqlite3.c" "*/sqlite3.h" -d lib/
8787
echo "SQLite $(grep -m1 SQLITE_VERSION lib/sqlite3.h | awk '{print $3}')"
8888
89+
- name: Download libyaml amalgamation
90+
run: |
91+
mkdir -p lib/yaml
92+
curl -fsSL "https://github.com/yaml/libyaml/archive/refs/tags/0.2.5.tar.gz" -o /tmp/yaml-0.2.5.tar.gz
93+
tar -xzf /tmp/yaml-0.2.5.tar.gz -C /tmp
94+
cp /tmp/libyaml-0.2.5/include/yaml.h lib/yaml/yaml.h
95+
cp /tmp/libyaml-0.2.5/src/yaml_private.h lib/yaml/yaml_private.h
96+
cp /tmp/libyaml-0.2.5/src/api.c lib/yaml/api.c
97+
cp /tmp/libyaml-0.2.5/src/parser.c lib/yaml/parser.c
98+
cp /tmp/libyaml-0.2.5/src/scanner.c lib/yaml/scanner.c
99+
cp /tmp/libyaml-0.2.5/src/reader.c lib/yaml/reader.c
100+
cp /tmp/libyaml-0.2.5/src/writer.c lib/yaml/writer.c
101+
cp /tmp/libyaml-0.2.5/src/loader.c lib/yaml/loader.c
102+
echo "libyaml 0.2.5 ready"
103+
89104
- name: Build
90105
run: |
91106
VERSION="${GITHUB_REF_NAME#v}"

README.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![Release](https://img.shields.io/github/v/release/vmvarela/sql-pipe)](https://github.com/vmvarela/sql-pipe/releases/latest)
55
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
66

7-
`sql-pipe` reads CSV, JSON, or NDJSON from stdin or file arguments, loads it into an in-memory SQLite database, runs a SQL query, and prints the results. No server, no schema files, no setup.
7+
`sql-pipe` reads CSV, JSON, NDJSON, YAML, or XML from stdin or file arguments, loads it into an in-memory SQLite database, runs a SQL query, and prints the results. No server, no schema files, no setup.
88

99
It exists because `awk` is cryptic, spinning up a Python interpreter for a one-liner feels wrong, and `sqlite3 :memory:` takes four commands before you can query anything. If you know SQL and work with CSV in the terminal, this is the tool you've been reaching for.
1010

@@ -196,6 +196,24 @@ $ printf '[{"name":"Alice","score":95},{"name":"Bob","score":72}]' \
196196
Alice,95
197197
```
198198

199+
For YAML input, pass `-I yaml` (reads a top-level sequence of mappings). Both `.yaml` and `.yml` extensions are auto-detected. All values are loaded as `TEXT`:
200+
201+
```sh
202+
$ cat users.yaml
203+
- name: Alice
204+
country: US
205+
- name: Bob
206+
country: UK
207+
- name: Carol
208+
country: DE
209+
210+
$ cat users.yaml | sql-pipe -I yaml 'SELECT name FROM t WHERE country != "US" ORDER BY name'
211+
Bob
212+
Carol
213+
```
214+
215+
YAML is auto-detected from the `.yaml` and `.yml` extensions, or use `-I yaml` explicitly. Comments (`#`), flow-style mappings (`[{name: Alice}]`), and standard scalar types (strings, numbers, booleans) work transparently. Multi-document streams and anchors/aliases are rejected with a clear error.
216+
199217
Columns are auto-detected as `INTEGER`, `REAL`, `DATE`, `DATETIME`, or `TEXT` based on the first 100 rows. Date and datetime values are normalized to ISO 8601 on insert, so SQLite date functions (`date()`, `strftime()`, `julianday()`) work immediately. Use `--no-type-inference` to force all columns to `TEXT`:
200218

201219
```sh
@@ -258,7 +276,7 @@ $ cat events.xml | sql-pipe -I xml --xml-root events --xml-row event \
258276

259277
### File arguments
260278

261-
Pass files as positional arguments instead of piping through stdin. Each file becomes a table named after its basename (without extension). The input format is auto-detected from the file extension (`.csv`, `.tsv`, `.json`, `.ndjson`, `.xml`):
279+
Pass files as positional arguments instead of piping through stdin. Each file becomes a table named after its basename (without extension). The input format is auto-detected from the file extension (`.csv`, `.tsv`, `.json`, `.ndjson`, `.yaml`, `.yml`, `.xml`):
262280

263281
```sh
264282
# Single file — no more cat
@@ -267,6 +285,9 @@ $ sql-pipe orders.csv 'SELECT * FROM orders WHERE amount > 100'
267285
# JSON file — extension tells sql-pipe the format, no -I needed
268286
$ sql-pipe data.json 'SELECT * FROM data WHERE score > 80'
269287

288+
# YAML file — .yaml and .yml both auto-detect
289+
$ sql-pipe users.yaml 'SELECT name FROM users WHERE country = "US"'
290+
270291
# Multi-file join — the #1 reason people reach for DuckDB
271292
$ sql-pipe orders.csv customers.csv \
272293
'SELECT c.name, SUM(o.amount) FROM orders o
@@ -321,15 +342,15 @@ When `-f` is used, all positional arguments are treated as data files (no positi
321342
|------|-------------|
322343
| `-d`, `--delimiter <char>` | Input field delimiter (single character, default `,`) |
323344
| `--tsv` | Alias for `--delimiter '\t'` |
324-
| `-I`, `--input-format <fmt>` | Input format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`. Overrides file extension auto-detection. |
345+
| `-I`, `--input-format <fmt>` | Input format: `csv` (default), `tsv`, `json`, `ndjson`, `yaml`, `xml`. Overrides file extension auto-detection. Both `.yaml` and `.yml` file extensions auto-detect to `yaml`. |
325346
| `-O`, `--output-format <fmt>` | Output format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`, `markdown` (alias: `md`), `html`, `sql` |
326347
| `--sql-table <name>` | Target table name for `-O sql` INSERT output (default: `t`) |
327348
| `--no-type-inference` | Treat all columns as TEXT (skip auto-detection) |
328349
| `-H`, `--header` | Print column names as the first output row (CSV/TSV/HTML) |
329350
| `--json` | Alias for `--output-format json` (mutually exclusive with `-H`) |
330351
| `--max-rows <n>` | Stop if more than `n` data rows are read (exit 1) |
331-
| `--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. |
332-
| `--columns` | Read the input header, print each column name on its own line, and exit 0. Supports CSV, TSV, JSON, NDJSON, and XML input. With `-v`/`--verbose`, also shows the inferred type per column (`name INTEGER`). Respects `--delimiter` and `--tsv`. Mutually exclusive with a query argument. |
352+
| `--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, yaml, xml). JSON/NDJSON/YAML/XML columns are reported as TEXT. |
353+
| `--columns` | Read the input header, print each column name on its own line, and exit 0. Supports CSV, TSV, JSON, NDJSON, YAML, and XML input. With `-v`/`--verbose`, also shows the inferred type per column (`name INTEGER`). Respects `--delimiter` and `--tsv`. Mutually exclusive with a query argument. |
333354
| `--sample [<n>]` | Print a schema comment block to stderr and the first `<n>` data rows to stdout as CSV (default: `n=10`). The schema block lists each column name and its inferred type, prefixed with `#`. Implies `--header`. Compatible with `--delimiter` and `--tsv`. Mutually exclusive with `--json` and a query argument. No query required. |
334355
| `--stats` | Load input and print per-column statistics (column name, type, non-null count, min, max, mean) as a formatted table. Mean is blank for non-numeric columns. Compatible with `--delimiter`, `--tsv`, `--no-type-inference`, `-I`/`--input-format`. Mutually exclusive with a query, `--columns`, `--validate`, `--sample`, and `--output`. |
335356
| `--profile` | Alias for `--stats` |
@@ -643,10 +664,10 @@ The database never touches disk and vanishes when the process exits. No state, n
643664

644665
## Limitations
645666

646-
- **File format auto-detection** is based on file extension. Files without a recognized extension (`.csv`, `.tsv`, `.json`, `.ndjson`, `.xml`) default to CSV. Use `-I` to override.
667+
- **File format auto-detection** is based on file extension. Files without a recognized extension (`.csv`, `.tsv`, `.json`, `.ndjson`, `.yaml`, `.yml`, `.xml`) default to CSV. Use `-I` to override.
647668

648669
## Related
649670

650671
- **[q](https://harelba.github.io/q/)** — Python-based SQL on tabular data. Similar concept, but requires Python runtime. Better if you're already in a Python environment or need Python-specific integrations.
651-
- **[trdsql](https://github.com/noborus/trdsql)** — Go alternative with broader format support (LTSV, TBLN) and more output options. Better if you need formats beyond CSV/JSON/NDJSON/XML or want more output formatting choices.
672+
- **[trdsql](https://github.com/noborus/trdsql)** — Go alternative with broader format support (LTSV, TBLN) and more output options. Better if you need formats beyond CSV/JSON/NDJSON/YAML/XML or want more output formatting choices.
652673
- **[sqlite-utils](https://sqlite-utils.datasette.io/)** — better if you need persistent databases, schema management, or Python scripting. sql-pipe is designed for one-shot queries on ephemeral in-memory data.

build.zig

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ pub fn build(b: *std.Build) void {
4444
});
4545
exe.root_module.addImport("c", translate_c.createModule());
4646

47+
// Translate yaml.h to Zig declarations, exposed as @import("yaml").
48+
// libyaml is always bundled (no --bundle-yaml option) — it's tiny and
49+
// not always available as a system library.
50+
const translate_yaml = b.addTranslateC(.{
51+
.root_source_file = b.path("lib/yaml/yaml_translate.h"),
52+
.target = target,
53+
.optimize = optimize,
54+
});
55+
exe.root_module.addImport("yaml", translate_yaml.createModule());
56+
4757
if (bundle_sqlite) {
4858
exe.root_module.addIncludePath(b.path("lib"));
4959
exe.root_module.addCSourceFile(.{
@@ -54,6 +64,22 @@ pub fn build(b: *std.Build) void {
5464
exe.root_module.linkSystemLibrary("sqlite3", .{});
5565
}
5666

67+
// Bundle libyaml — only used for loading, so compile api.c, parser.c,
68+
// scanner.c, reader.c, writer.c, loader.c. Skip dumper.c, emitter.c
69+
// (we don't emit YAML).
70+
exe.root_module.addIncludePath(b.path("lib/yaml"));
71+
exe.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/api.c"), .flags = &.{
72+
"-DYAML_VERSION_MAJOR=0",
73+
"-DYAML_VERSION_MINOR=2",
74+
"-DYAML_VERSION_PATCH=5",
75+
"-DYAML_VERSION_STRING=\"0.2.5\"",
76+
} });
77+
exe.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/parser.c"), .flags = &.{} });
78+
exe.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/scanner.c"), .flags = &.{} });
79+
exe.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/reader.c"), .flags = &.{} });
80+
exe.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/writer.c"), .flags = &.{} });
81+
exe.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/loader.c"), .flags = &.{} });
82+
5783
b.installArtifact(exe);
5884

5985
// Generate man page from scdoc source if scdoc (and gzip) are available (optional dependencies)
@@ -2643,6 +2669,77 @@ pub fn build(b: *std.Build) void {
26432669
});
26442670
fixture_test_step.dependOn(&fixture_sample_file.step);
26452671

2672+
// Fixture test 168a: YAML file argument — auto-detected from .yaml extension
2673+
// Note: YAML preserves original number formatting (e.g., 25.00 stays 25.00)
2674+
const fixture_yaml_file = b.addSystemCommand(&.{
2675+
"bash", "-c",
2676+
\\result=$(./zig-out/bin/sql-pipe tests/fixtures/users.yaml 'SELECT name, price FROM users ORDER BY CAST(price AS REAL) DESC')
2677+
\\expected=$(printf 'Thingamajig,60.00\nDoohickey,50.00\nGadget,40.25\nWidget,25.00')
2678+
\\[ "$result" = "$expected" ]
2679+
});
2680+
fixture_test_step.dependOn(&fixture_yaml_file.step);
2681+
2682+
// Fixture test 168b: YAML file argument — .yml extension alias
2683+
const fixture_yaml_yml_alias = b.addSystemCommand(&.{
2684+
"bash", "-c",
2685+
\\result=$(./zig-out/bin/sql-pipe tests/fixtures/users.yml 'SELECT name FROM users WHERE category = "hardware" ORDER BY name')
2686+
\\expected=$(printf 'Doohickey\nWidget')
2687+
\\[ "$result" = "$expected" ]
2688+
});
2689+
fixture_test_step.dependOn(&fixture_yaml_yml_alias.step);
2690+
2691+
// Fixture test 168c: YAML file via stdin with -I yaml
2692+
const fixture_yaml_stdin = b.addSystemCommand(&.{
2693+
"bash", "-c",
2694+
\\result=$(cat tests/fixtures/users.yaml | ./zig-out/bin/sql-pipe -I yaml 'SELECT name FROM t WHERE CAST(stock AS INTEGER) > 0 ORDER BY name')
2695+
\\expected=$(printf 'Doohickey\nGadget\nWidget')
2696+
\\[ "$result" = "$expected" ]
2697+
});
2698+
fixture_test_step.dependOn(&fixture_yaml_stdin.step);
2699+
2700+
// Fixture test 168d: YAML file — filter and aggregate
2701+
const fixture_yaml_aggregate = b.addSystemCommand(&.{
2702+
"bash", "-c",
2703+
\\result=$(./zig-out/bin/sql-pipe tests/fixtures/users.yaml 'SELECT category, COUNT(*) FROM users GROUP BY category ORDER BY category')
2704+
\\expected=$(printf 'electronics,2\nhardware,2')
2705+
\\[ "$result" = "$expected" ]
2706+
});
2707+
fixture_test_step.dependOn(&fixture_yaml_aggregate.step);
2708+
2709+
// Fixture test 168e: YAML comments are transparent
2710+
const fixture_yaml_comments = b.addSystemCommand(&.{
2711+
"bash", "-c",
2712+
\\result=$(printf -- '# This is a comment\n# Another comment\n- name: Alice\n age: 30\n# Mid comment\n- name: Bob\n age: 25\n' \
2713+
\\ | ./zig-out/bin/sql-pipe -I yaml 'SELECT name FROM t ORDER BY name')
2714+
\\expected=$(printf 'Alice\nBob')
2715+
\\[ "$result" = "$expected" ]
2716+
});
2717+
fixture_test_step.dependOn(&fixture_yaml_comments.step);
2718+
2719+
// Fixture test 168f: Malformed YAML — error exit 2 with line number
2720+
const fixture_yaml_malformed = b.addSystemCommand(&.{
2721+
"bash", "-c",
2722+
\\msg=$(printf -- '- name: Alice\n\tbad: indent\n' | ./zig-out/bin/sql-pipe -I yaml 'SELECT 1' 2>&1 >/dev/null; echo "EXIT:$?")
2723+
\\echo "$msg" | grep -q 'YAML parse error at line' && echo "$msg" | grep -q 'EXIT:2'
2724+
});
2725+
fixture_test_step.dependOn(&fixture_yaml_malformed.step);
2726+
2727+
// Fixture test 168g: Empty YAML input — graceful (returns 0, no crash)
2728+
const fixture_yaml_empty = b.addSystemCommand(&.{
2729+
"bash", "-c",
2730+
\\result=$(./zig-out/bin/sql-pipe -I yaml 'SELECT 1 AS one' < /dev/null 2>/dev/null)
2731+
\\[ "$result" = "1" ]
2732+
});
2733+
fixture_test_step.dependOn(&fixture_yaml_empty.step);
2734+
2735+
// Fixture test 168h: Multi-document YAML stream — error exit 2
2736+
const fixture_yaml_multidoc = b.addSystemCommand(&.{
2737+
"bash", "-c",
2738+
\\msg=$(printf -- '- name: Alice\n---\n- name: Bob\n' | ./zig-out/bin/sql-pipe -I yaml 'SELECT 1' 2>&1 >/dev/null; echo "EXIT:$?")
2739+
\\echo "$msg" | grep -q 'multiple documents not supported' && echo "$msg" | grep -q 'EXIT:2'
2740+
});
2741+
fixture_test_step.dependOn(&fixture_yaml_multidoc.step);
2742+
26462743
const unit_tests = b.addTest(.{
26472744
.root_module = b.createModule(.{
26482745
.root_source_file = b.path("src/csv.zig"),
@@ -2664,6 +2761,19 @@ pub fn build(b: *std.Build) void {
26642761
}),
26652762
});
26662763
xml_unit_tests.root_module.addImport("c", translate_c.createModule());
2764+
xml_unit_tests.root_module.addImport("yaml", translate_yaml.createModule());
2765+
xml_unit_tests.root_module.addIncludePath(b.path("lib/yaml"));
2766+
xml_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/api.c"), .flags = &.{
2767+
"-DYAML_VERSION_MAJOR=0",
2768+
"-DYAML_VERSION_MINOR=2",
2769+
"-DYAML_VERSION_PATCH=5",
2770+
"-DYAML_VERSION_STRING=\"0.2.5\"",
2771+
} });
2772+
xml_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/parser.c"), .flags = &.{} });
2773+
xml_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/scanner.c"), .flags = &.{} });
2774+
xml_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/reader.c"), .flags = &.{} });
2775+
xml_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/writer.c"), .flags = &.{} });
2776+
xml_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/loader.c"), .flags = &.{} });
26672777
if (bundle_sqlite) {
26682778
xml_unit_tests.root_module.addIncludePath(b.path("lib"));
26692779
xml_unit_tests.root_module.addCSourceFile(.{
@@ -2687,6 +2797,19 @@ pub fn build(b: *std.Build) void {
26872797
}),
26882798
});
26892799
loader_unit_tests.root_module.addImport("c", translate_c.createModule());
2800+
loader_unit_tests.root_module.addImport("yaml", translate_yaml.createModule());
2801+
loader_unit_tests.root_module.addIncludePath(b.path("lib/yaml"));
2802+
loader_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/api.c"), .flags = &.{
2803+
"-DYAML_VERSION_MAJOR=0",
2804+
"-DYAML_VERSION_MINOR=2",
2805+
"-DYAML_VERSION_PATCH=5",
2806+
"-DYAML_VERSION_STRING=\"0.2.5\"",
2807+
} });
2808+
loader_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/parser.c"), .flags = &.{} });
2809+
loader_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/scanner.c"), .flags = &.{} });
2810+
loader_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/reader.c"), .flags = &.{} });
2811+
loader_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/writer.c"), .flags = &.{} });
2812+
loader_unit_tests.root_module.addCSourceFile(.{ .file = b.path("lib/yaml/loader.c"), .flags = &.{} });
26902813
if (bundle_sqlite) {
26912814
loader_unit_tests.root_module.addIncludePath(b.path("lib"));
26922815
loader_unit_tests.root_module.addCSourceFile(.{

0 commit comments

Comments
 (0)