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
58 changes: 49 additions & 9 deletions cli/cli/src/cmd/view/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ Query a defined view, outputting matching records.
Options:
--limit <n> Maximum records to return
--count Only output the count of matching records
--collect Collect all results into a JSON array
--filter <expr> Filter expression (e.g., "name=lodash", "versions|length>10")
--json Output as ndjson (default)
--format <fmt> Output format: ndjson (default), jsonl, lines, json
- ndjson/jsonl: One JSON object per line (streaming)
- lines: Plain text, one value per line
- json: Complete JSON array

Examples:
_all_docs view query npm-packages
_all_docs view query npm-versions --limit 100
_all_docs view query npm-packages --count
_all_docs view query npm-packages --filter "name=lodash"
_all_docs view query npm-versions --collect > all-versions.json
_all_docs view query npm-packages --format json > packages.json
_all_docs view query npm-packages --select 'name' --format lines | wc -l
`;

export const command = async (cli) => {
Expand Down Expand Up @@ -62,15 +65,52 @@ export const command = async (cli) => {
return;
}

if (cli.values.collect) {
const results = await collectView(view, cache, options);
console.log(JSON.stringify(results, null, 2));
return;
// Determine format (--collect is alias for --format json for backwards compat)
const format = cli.values.collect ? 'json' : (cli.values.format || 'ndjson');

// Normalize format (ndjson is alias for jsonl)
const normalizedFormat = format === 'ndjson' ? 'jsonl' : format;

// Validate format
if (!['jsonl', 'lines', 'json'].includes(normalizedFormat)) {
console.error(`Unknown format: ${format}`);
console.error('Valid formats: ndjson, jsonl, lines, json');
process.exit(1);
}

// Stream ndjson output
// Collect results for json format
const results = [];

for await (const record of queryView(view, cache, options)) {
console.log(JSON.stringify(record));
switch (normalizedFormat) {
case 'jsonl':
console.log(JSON.stringify(record));
break;

case 'lines': {
const values = Object.values(record);
if (values.length === 1) {
// Single field: output as-is (string) or JSON (other types)
const val = values[0];
console.log(typeof val === 'string' ? val : JSON.stringify(val));
} else {
// Multiple fields: tab-separated
console.log(values.map(v =>
typeof v === 'string' ? v : JSON.stringify(v)
).join('\t'));
}
break;
}

case 'json':
results.push(record);
break;
}
}

// Output collected results for json format
if (normalizedFormat === 'json') {
console.log(JSON.stringify(results, null, 2));
}
} catch (err) {
console.error(`Error querying view: ${err.message}`);
Expand Down
194 changes: 194 additions & 0 deletions cli/cli/src/cmd/view/query.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, it } from 'node:test';
import { strict as assert } from 'node:assert';

/**
* Test the format output logic for view query --format option
*/

/**
* Format a record for 'lines' output mode
* @param {object} record - The record to format
* @returns {string} The formatted line
*/
function formatLinesOutput(record) {
const values = Object.values(record);
if (values.length === 1) {
// Single field: output as-is (string) or JSON (other types)
const val = values[0];
return typeof val === 'string' ? val : JSON.stringify(val);
} else {
// Multiple fields: tab-separated
return values.map(v =>
typeof v === 'string' ? v : JSON.stringify(v)
).join('\t');
}
}

describe('view query --format', () => {
describe('ndjson format', () => {
it('outputs valid JSON per line', () => {
const record = { name: 'lodash', count: 114 };
const output = JSON.stringify(record);

assert.equal(output, '{"name":"lodash","count":114}');
assert.doesNotThrow(() => JSON.parse(output));
});

it('handles nested objects', () => {
const record = { name: 'react', meta: { versions: ['18.0.0', '18.1.0'] } };
const output = JSON.stringify(record);

const parsed = JSON.parse(output);
assert.deepEqual(parsed.meta.versions, ['18.0.0', '18.1.0']);
});
});

describe('lines format', () => {
it('outputs plain string for single string field', () => {
const record = { name: 'lodash' };
const output = formatLinesOutput(record);

assert.equal(output, 'lodash');
// Should NOT be quoted
assert.ok(!output.startsWith('"'));
assert.ok(!output.startsWith('{'));
});

it('outputs JSON for single non-string field (array)', () => {
const record = { versions: ['1.0.0', '2.0.0'] };
const output = formatLinesOutput(record);

assert.equal(output, '["1.0.0","2.0.0"]');
assert.doesNotThrow(() => JSON.parse(output));
});

it('outputs JSON for single non-string field (number)', () => {
const record = { count: 42 };
const output = formatLinesOutput(record);

assert.equal(output, '42');
});

it('outputs tab-separated for multiple fields', () => {
const record = { name: 'lodash', count: 114 };
const output = formatLinesOutput(record);

assert.equal(output, 'lodash\t114');
assert.ok(output.includes('\t'));
});

it('handles mixed string and non-string in multi-field', () => {
const record = { name: 'react', versions: ['18.0.0', '18.1.0'], count: 2 };
const output = formatLinesOutput(record);

const parts = output.split('\t');
assert.equal(parts.length, 3);
assert.equal(parts[0], 'react');
assert.equal(parts[1], '["18.0.0","18.1.0"]');
assert.equal(parts[2], '2');
});

it('handles empty string values', () => {
const record = { name: '' };
const output = formatLinesOutput(record);

assert.equal(output, '');
});

it('handles null values in multi-field', () => {
const record = { name: 'test', value: null };
const output = formatLinesOutput(record);

assert.equal(output, 'test\tnull');
});
});

describe('json format', () => {
it('outputs valid JSON array', () => {
const results = [
{ name: 'lodash', count: 114 },
{ name: 'express', count: 50 }
];
const output = JSON.stringify(results, null, 2);

const parsed = JSON.parse(output);
assert.ok(Array.isArray(parsed));
assert.equal(parsed.length, 2);
});

it('handles empty results', () => {
const results = [];
const output = JSON.stringify(results, null, 2);

const parsed = JSON.parse(output);
assert.deepEqual(parsed, []);
});

it('handles single result', () => {
const results = [{ name: 'lodash' }];
const output = JSON.stringify(results, null, 2);

const parsed = JSON.parse(output);
assert.equal(parsed.length, 1);
assert.equal(parsed[0].name, 'lodash');
});
});

describe('format validation', () => {
it('recognizes valid formats', () => {
const validFormats = ['ndjson', 'jsonl', 'lines', 'json'];

for (const format of validFormats) {
const normalized = format === 'ndjson' ? 'jsonl' : format;
assert.ok(
['jsonl', 'lines', 'json'].includes(normalized),
`${format} should be valid`
);
}
});

it('normalizes ndjson to jsonl', () => {
const format = 'ndjson';
const normalized = format === 'ndjson' ? 'jsonl' : format;
assert.equal(normalized, 'jsonl');
});

it('rejects invalid formats', () => {
const invalidFormats = ['csv', 'xml', 'yaml', 'tsv', ''];

for (const format of invalidFormats) {
const normalized = format === 'ndjson' ? 'jsonl' : format;
assert.ok(
!['jsonl', 'lines', 'json'].includes(normalized),
`${format} should be invalid`
);
}
});
});

describe('backwards compatibility', () => {
it('--collect maps to json format', () => {
const collect = true;
const explicitFormat = undefined;

const format = collect ? 'json' : (explicitFormat || 'ndjson');
assert.equal(format, 'json');
});

it('defaults to ndjson when no flags set', () => {
const collect = false;
const explicitFormat = undefined;

const format = collect ? 'json' : (explicitFormat || 'ndjson');
assert.equal(format, 'ndjson');
});

it('explicit --format overrides when --collect not set', () => {
const collect = false;
const explicitFormat = 'lines';

const format = collect ? 'json' : (explicitFormat || 'ndjson');
assert.equal(format, 'lines');
});
});
});
9 changes: 9 additions & 0 deletions cli/cli/src/jack.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,15 @@ const cli = ack
limit: {
hint: 'n',
description: `Maximum records to return`
},
format: {
hint: 'fmt',
description: `Output format: ndjson (default), jsonl, lines, json

- ndjson/jsonl: One JSON object per line (streaming)
- lines: Plain text values (for shell piping)
- json: Complete JSON array
`
}
})

Expand Down
Loading