Skip to content

Commit 582e357

Browse files
indexzeroclaude
andauthored
feat(cli) add --format option to view query command (#24)
Add support for multiple output formats in view query: - ndjson: One JSON object per line (default, streaming) - lines: Plain text values for shell piping - json: Complete JSON array for programmatic use The lines format outputs strings as-is and other types as JSON, with tab-separated values for multi-field records. This enables shell pipeline integration like sort, uniq, wc, and xargs. * feat(cli) add jsonl format alias for view query command Add jsonl as an accepted format for --format option, normalized internally to jsonl as the canonical format with ndjson as alias. Both formats produce identical output (one JSON object per line). --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9fcff66 commit 582e357

4 files changed

Lines changed: 325 additions & 10 deletions

File tree

cli/cli/src/cmd/view/query.js

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,19 @@ Query a defined view, outputting matching records.
88
Options:
99
--limit <n> Maximum records to return
1010
--count Only output the count of matching records
11-
--collect Collect all results into a JSON array
1211
--filter <expr> Filter expression (e.g., "name=lodash", "versions|length>10")
13-
--json Output as ndjson (default)
12+
--format <fmt> Output format: ndjson (default), jsonl, lines, json
13+
- ndjson/jsonl: One JSON object per line (streaming)
14+
- lines: Plain text, one value per line
15+
- json: Complete JSON array
1416
1517
Examples:
1618
_all_docs view query npm-packages
1719
_all_docs view query npm-versions --limit 100
1820
_all_docs view query npm-packages --count
1921
_all_docs view query npm-packages --filter "name=lodash"
20-
_all_docs view query npm-versions --collect > all-versions.json
22+
_all_docs view query npm-packages --format json > packages.json
23+
_all_docs view query npm-packages --select 'name' --format lines | wc -l
2124
`;
2225

2326
export const command = async (cli) => {
@@ -62,15 +65,52 @@ export const command = async (cli) => {
6265
return;
6366
}
6467

65-
if (cli.values.collect) {
66-
const results = await collectView(view, cache, options);
67-
console.log(JSON.stringify(results, null, 2));
68-
return;
68+
// Determine format (--collect is alias for --format json for backwards compat)
69+
const format = cli.values.collect ? 'json' : (cli.values.format || 'ndjson');
70+
71+
// Normalize format (ndjson is alias for jsonl)
72+
const normalizedFormat = format === 'ndjson' ? 'jsonl' : format;
73+
74+
// Validate format
75+
if (!['jsonl', 'lines', 'json'].includes(normalizedFormat)) {
76+
console.error(`Unknown format: ${format}`);
77+
console.error('Valid formats: ndjson, jsonl, lines, json');
78+
process.exit(1);
6979
}
7080

71-
// Stream ndjson output
81+
// Collect results for json format
82+
const results = [];
83+
7284
for await (const record of queryView(view, cache, options)) {
73-
console.log(JSON.stringify(record));
85+
switch (normalizedFormat) {
86+
case 'jsonl':
87+
console.log(JSON.stringify(record));
88+
break;
89+
90+
case 'lines': {
91+
const values = Object.values(record);
92+
if (values.length === 1) {
93+
// Single field: output as-is (string) or JSON (other types)
94+
const val = values[0];
95+
console.log(typeof val === 'string' ? val : JSON.stringify(val));
96+
} else {
97+
// Multiple fields: tab-separated
98+
console.log(values.map(v =>
99+
typeof v === 'string' ? v : JSON.stringify(v)
100+
).join('\t'));
101+
}
102+
break;
103+
}
104+
105+
case 'json':
106+
results.push(record);
107+
break;
108+
}
109+
}
110+
111+
// Output collected results for json format
112+
if (normalizedFormat === 'json') {
113+
console.log(JSON.stringify(results, null, 2));
74114
}
75115
} catch (err) {
76116
console.error(`Error querying view: ${err.message}`);

cli/cli/src/cmd/view/query.test.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, it } from 'node:test';
2+
import { strict as assert } from 'node:assert';
3+
4+
/**
5+
* Test the format output logic for view query --format option
6+
*/
7+
8+
/**
9+
* Format a record for 'lines' output mode
10+
* @param {object} record - The record to format
11+
* @returns {string} The formatted line
12+
*/
13+
function formatLinesOutput(record) {
14+
const values = Object.values(record);
15+
if (values.length === 1) {
16+
// Single field: output as-is (string) or JSON (other types)
17+
const val = values[0];
18+
return typeof val === 'string' ? val : JSON.stringify(val);
19+
} else {
20+
// Multiple fields: tab-separated
21+
return values.map(v =>
22+
typeof v === 'string' ? v : JSON.stringify(v)
23+
).join('\t');
24+
}
25+
}
26+
27+
describe('view query --format', () => {
28+
describe('ndjson format', () => {
29+
it('outputs valid JSON per line', () => {
30+
const record = { name: 'lodash', count: 114 };
31+
const output = JSON.stringify(record);
32+
33+
assert.equal(output, '{"name":"lodash","count":114}');
34+
assert.doesNotThrow(() => JSON.parse(output));
35+
});
36+
37+
it('handles nested objects', () => {
38+
const record = { name: 'react', meta: { versions: ['18.0.0', '18.1.0'] } };
39+
const output = JSON.stringify(record);
40+
41+
const parsed = JSON.parse(output);
42+
assert.deepEqual(parsed.meta.versions, ['18.0.0', '18.1.0']);
43+
});
44+
});
45+
46+
describe('lines format', () => {
47+
it('outputs plain string for single string field', () => {
48+
const record = { name: 'lodash' };
49+
const output = formatLinesOutput(record);
50+
51+
assert.equal(output, 'lodash');
52+
// Should NOT be quoted
53+
assert.ok(!output.startsWith('"'));
54+
assert.ok(!output.startsWith('{'));
55+
});
56+
57+
it('outputs JSON for single non-string field (array)', () => {
58+
const record = { versions: ['1.0.0', '2.0.0'] };
59+
const output = formatLinesOutput(record);
60+
61+
assert.equal(output, '["1.0.0","2.0.0"]');
62+
assert.doesNotThrow(() => JSON.parse(output));
63+
});
64+
65+
it('outputs JSON for single non-string field (number)', () => {
66+
const record = { count: 42 };
67+
const output = formatLinesOutput(record);
68+
69+
assert.equal(output, '42');
70+
});
71+
72+
it('outputs tab-separated for multiple fields', () => {
73+
const record = { name: 'lodash', count: 114 };
74+
const output = formatLinesOutput(record);
75+
76+
assert.equal(output, 'lodash\t114');
77+
assert.ok(output.includes('\t'));
78+
});
79+
80+
it('handles mixed string and non-string in multi-field', () => {
81+
const record = { name: 'react', versions: ['18.0.0', '18.1.0'], count: 2 };
82+
const output = formatLinesOutput(record);
83+
84+
const parts = output.split('\t');
85+
assert.equal(parts.length, 3);
86+
assert.equal(parts[0], 'react');
87+
assert.equal(parts[1], '["18.0.0","18.1.0"]');
88+
assert.equal(parts[2], '2');
89+
});
90+
91+
it('handles empty string values', () => {
92+
const record = { name: '' };
93+
const output = formatLinesOutput(record);
94+
95+
assert.equal(output, '');
96+
});
97+
98+
it('handles null values in multi-field', () => {
99+
const record = { name: 'test', value: null };
100+
const output = formatLinesOutput(record);
101+
102+
assert.equal(output, 'test\tnull');
103+
});
104+
});
105+
106+
describe('json format', () => {
107+
it('outputs valid JSON array', () => {
108+
const results = [
109+
{ name: 'lodash', count: 114 },
110+
{ name: 'express', count: 50 }
111+
];
112+
const output = JSON.stringify(results, null, 2);
113+
114+
const parsed = JSON.parse(output);
115+
assert.ok(Array.isArray(parsed));
116+
assert.equal(parsed.length, 2);
117+
});
118+
119+
it('handles empty results', () => {
120+
const results = [];
121+
const output = JSON.stringify(results, null, 2);
122+
123+
const parsed = JSON.parse(output);
124+
assert.deepEqual(parsed, []);
125+
});
126+
127+
it('handles single result', () => {
128+
const results = [{ name: 'lodash' }];
129+
const output = JSON.stringify(results, null, 2);
130+
131+
const parsed = JSON.parse(output);
132+
assert.equal(parsed.length, 1);
133+
assert.equal(parsed[0].name, 'lodash');
134+
});
135+
});
136+
137+
describe('format validation', () => {
138+
it('recognizes valid formats', () => {
139+
const validFormats = ['ndjson', 'jsonl', 'lines', 'json'];
140+
141+
for (const format of validFormats) {
142+
const normalized = format === 'ndjson' ? 'jsonl' : format;
143+
assert.ok(
144+
['jsonl', 'lines', 'json'].includes(normalized),
145+
`${format} should be valid`
146+
);
147+
}
148+
});
149+
150+
it('normalizes ndjson to jsonl', () => {
151+
const format = 'ndjson';
152+
const normalized = format === 'ndjson' ? 'jsonl' : format;
153+
assert.equal(normalized, 'jsonl');
154+
});
155+
156+
it('rejects invalid formats', () => {
157+
const invalidFormats = ['csv', 'xml', 'yaml', 'tsv', ''];
158+
159+
for (const format of invalidFormats) {
160+
const normalized = format === 'ndjson' ? 'jsonl' : format;
161+
assert.ok(
162+
!['jsonl', 'lines', 'json'].includes(normalized),
163+
`${format} should be invalid`
164+
);
165+
}
166+
});
167+
});
168+
169+
describe('backwards compatibility', () => {
170+
it('--collect maps to json format', () => {
171+
const collect = true;
172+
const explicitFormat = undefined;
173+
174+
const format = collect ? 'json' : (explicitFormat || 'ndjson');
175+
assert.equal(format, 'json');
176+
});
177+
178+
it('defaults to ndjson when no flags set', () => {
179+
const collect = false;
180+
const explicitFormat = undefined;
181+
182+
const format = collect ? 'json' : (explicitFormat || 'ndjson');
183+
assert.equal(format, 'ndjson');
184+
});
185+
186+
it('explicit --format overrides when --collect not set', () => {
187+
const collect = false;
188+
const explicitFormat = 'lines';
189+
190+
const format = collect ? 'json' : (explicitFormat || 'ndjson');
191+
assert.equal(format, 'lines');
192+
});
193+
});
194+
});

cli/cli/src/jack.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,15 @@ const cli = ack
270270
limit: {
271271
hint: 'n',
272272
description: `Maximum records to return`
273+
},
274+
format: {
275+
hint: 'fmt',
276+
description: `Output format: ndjson (default), jsonl, lines, json
277+
278+
- ndjson/jsonl: One JSON object per line (streaming)
279+
- lines: Plain text values (for shell piping)
280+
- json: Complete JSON array
281+
`
273282
}
274283
})
275284

0 commit comments

Comments
 (0)