From 815a1e6f02781d9540a4017fcd57944b79f11341 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:13:07 +0100 Subject: [PATCH 01/13] feat(stdin): add readStdinText for raw stdin contents --- src/lib/utils/stdin.test.ts | 48 +++++++++++++++++++++++++------------ src/lib/utils/stdin.ts | 11 +++++++++ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/lib/utils/stdin.test.ts b/src/lib/utils/stdin.test.ts index bffcaa61..025b50c3 100644 --- a/src/lib/utils/stdin.test.ts +++ b/src/lib/utils/stdin.test.ts @@ -1,28 +1,46 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { isStdinPiped, isStdoutPiped, setTTYOverride } from './stdin.js'; +import { PassThrough } from 'node:stream'; +import { setTTYOverride, readStdinText } from './stdin.js'; + +describe('readStdinText', () => { + let originalStdin: typeof process.stdin; -describe('stdin utilities', () => { afterEach(() => { + if (originalStdin) { + Object.defineProperty(process, 'stdin', { + value: originalStdin, + configurable: true, + }); + } setTTYOverride({ stdin: true, stdout: true }); }); - it('should report stdin as TTY when override is set', () => { - setTTYOverride({ stdin: true }); - expect(isStdinPiped()).toBe(false); - }); - - it('should report stdin as piped when override is set', () => { + function replaceStdin(content: string): void { + originalStdin = process.stdin; + const stream = new PassThrough(); + stream.end(content); + Object.defineProperty(process, 'stdin', { + value: stream, + configurable: true, + }); setTTYOverride({ stdin: false }); - expect(isStdinPiped()).toBe(true); + } + + it('returns the full stdin contents verbatim', async () => { + replaceStdin('SELECT 1\n'); + const result = await readStdinText(); + expect(result).toBe('SELECT 1\n'); }); - it('should report stdout as TTY when override is set', () => { - setTTYOverride({ stdout: true }); - expect(isStdoutPiped()).toBe(false); + it('preserves embedded blank lines and trailing whitespace', async () => { + replaceStdin('SELECT a,\n\n b\nFROM t\n'); + const result = await readStdinText(); + expect(result).toBe('SELECT a,\n\n b\nFROM t\n'); }); - it('should report stdout as piped when override is set', () => { - setTTYOverride({ stdout: false }); - expect(isStdoutPiped()).toBe(true); + it('returns an empty string when stdin is empty', async () => { + replaceStdin(''); + const result = await readStdinText(); + expect(result).toBe(''); }); }); diff --git a/src/lib/utils/stdin.ts b/src/lib/utils/stdin.ts index 9f0aadf7..59c60403 100644 --- a/src/lib/utils/stdin.ts +++ b/src/lib/utils/stdin.ts @@ -30,3 +30,14 @@ export async function readLinesFromStdin(): Promise { .map((line) => line.trim()) .filter((line) => line.length > 0); } + +export async function readStdinText(): Promise { + if (!isStdinPiped()) { + console.error('Waiting for input on stdin... (pipe data or press Ctrl+D to end)'); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf-8'); +} From 651133558db7bc540aff77514583e9ee503950f1 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:14:46 +0100 Subject: [PATCH 02/13] test(stdin): restore isStdinPiped/isStdoutPiped tests dropped during task 1 --- src/lib/utils/stdin.test.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/stdin.test.ts b/src/lib/utils/stdin.test.ts index 025b50c3..d72e60bb 100644 --- a/src/lib/utils/stdin.test.ts +++ b/src/lib/utils/stdin.test.ts @@ -1,6 +1,32 @@ import { describe, it, expect, afterEach } from 'vitest'; import { PassThrough } from 'node:stream'; -import { setTTYOverride, readStdinText } from './stdin.js'; +import { isStdinPiped, isStdoutPiped, setTTYOverride, readStdinText } from './stdin.js'; + +describe('stdin utilities', () => { + afterEach(() => { + setTTYOverride({ stdin: true, stdout: true }); + }); + + it('should report stdin as TTY when override is set', () => { + setTTYOverride({ stdin: true }); + expect(isStdinPiped()).toBe(false); + }); + + it('should report stdin as piped when override is set', () => { + setTTYOverride({ stdin: false }); + expect(isStdinPiped()).toBe(true); + }); + + it('should report stdout as TTY when override is set', () => { + setTTYOverride({ stdout: true }); + expect(isStdoutPiped()).toBe(false); + }); + + it('should report stdout as piped when override is set', () => { + setTTYOverride({ stdout: false }); + expect(isStdoutPiped()).toBe(true); + }); +}); describe('readStdinText', () => { let originalStdin: typeof process.stdin; From 113f9d420d64083b3192a2c95f105edb3e622555 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:18:15 +0100 Subject: [PATCH 03/13] refactor(datasources): re-export columnarToRows from core/datasources --- src/commands/datasources/datasources.test.ts | 5 +++++ src/core/datasources/datasources.ts | 2 ++ src/core/datasources/index.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index e22e986a..896148ce 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -212,4 +212,9 @@ describe('datasources command', () => { expect(mockClient.recreateDatasourceJsonLayouts).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('--yes')); }); + + it('re-exports columnarToRows from core/datasources', async () => { + const mod = await import('../../core/datasources/datasources.js'); + expect(typeof (mod as { columnarToRows?: unknown }).columnarToRows).toBe('function'); + }); }); diff --git a/src/core/datasources/datasources.ts b/src/core/datasources/datasources.ts index 2227ef29..48292319 100644 --- a/src/core/datasources/datasources.ts +++ b/src/core/datasources/datasources.ts @@ -176,3 +176,5 @@ export async function previewDatasourceJsonLayouts( const data = await client.previewDatasourceJsonLayouts(params.id); return { data }; } + +export { columnarToRows } from '../events/events.js'; diff --git a/src/core/datasources/index.ts b/src/core/datasources/index.ts index 112484b8..ec62bb94 100644 --- a/src/core/datasources/index.ts +++ b/src/core/datasources/index.ts @@ -10,6 +10,7 @@ export { previewDatasourceQuery, setDefaultDatasource, getDatasourceSchema, + columnarToRows, } from './datasources.js'; export type { GetDatasourceParams, From 3cf6a7e9db3021e7e91b86cd9a885ee04cf799e9 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:21:29 +0100 Subject: [PATCH 04/13] fix(datasources): reshape preview-query response to rows by default, keep --raw for columnar --- src/commands/datasources/datasources.test.ts | 42 ++++++++++++++++++-- src/commands/datasources/index.ts | 6 ++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index 896148ce..ceaf6521 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -31,7 +31,14 @@ describe('datasources command', () => { testDatasource: vi.fn().mockResolvedValue(undefined), introspectDatasource: vi.fn().mockResolvedValue({ schema: [] }), validateDatasourceQuery: vi.fn().mockResolvedValue(undefined), - previewDatasourceQuery: vi.fn().mockResolvedValue({ result: [] }), + previewDatasourceQuery: vi.fn().mockResolvedValue({ + columnNames: ['experiment_id', 'cnt'], + columnTypes: ['INT64', 'INT64'], + rows: [ + [1, 538217], + [5, 250000], + ], + }), setDefaultDatasource: vi.fn().mockResolvedValue(undefined), getDatasourceSchema: vi.fn().mockResolvedValue({ tables: [] }), deleteDatasource: vi.fn().mockResolvedValue(undefined), @@ -153,7 +160,7 @@ describe('datasources command', () => { expect(mockClient.validateDatasourceQuery).toHaveBeenCalledWith({ query: 'SELECT 1' }); }); - it('should preview a datasource query', async () => { + it('should preview a datasource query and reshape columnar response to rows', async () => { await datasourcesCommand.parseAsync([ 'node', 'test', @@ -163,7 +170,36 @@ describe('datasources command', () => { ]); expect(mockClient.previewDatasourceQuery).toHaveBeenCalledWith({ query: 'SELECT 1' }); - expect(printFormatted).toHaveBeenCalled(); + expect(printFormatted).toHaveBeenCalledWith( + [ + { experiment_id: 1, cnt: 538217 }, + { experiment_id: 5, cnt: 250000 }, + ], + expect.anything() + ); + }); + + it('should preview a datasource query with --raw and keep the columnar shape', async () => { + await datasourcesCommand.parseAsync([ + 'node', + 'test', + 'preview-query', + '--json-config', + '{"query":"SELECT 1"}', + '--raw', + ]); + + expect(printFormatted).toHaveBeenCalledWith( + { + columnNames: ['experiment_id', 'cnt'], + columnTypes: ['INT64', 'INT64'], + rows: [ + [1, 538217], + [5, 250000], + ], + }, + expect.anything() + ); }); it('should set default datasource', async () => { diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index ce46cd4b..733e0ffb 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -25,6 +25,7 @@ import { createDatasourceJsonLayouts as coreCreateDatasourceJsonLayouts, recreateDatasourceJsonLayouts as coreRecreateDatasourceJsonLayouts, previewDatasourceJsonLayouts as corePreviewDatasourceJsonLayouts, + columnarToRows, } from '../../core/datasources/datasources.js'; export const datasourcesCommand = new Command('datasources') @@ -150,7 +151,10 @@ const previewQueryCommand = new Command('preview-query') const client = await getAPIClientFromOptions(globalOptions); const config = validateJSON(options.jsonConfig, '--json-config') as Record; const result = await corePreviewDatasourceQuery(client, { config }); - printFormatted(result.data, globalOptions); + printFormatted( + globalOptions.raw ? result.data : columnarToRows(result.data), + globalOptions + ); }) ); From 1392afe5aad4b2403838ea6f4b031844e7157ad3 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:23:57 +0100 Subject: [PATCH 05/13] test(datasources): use mockReturnValueOnce for --raw assertion (matches events test pattern) --- src/commands/datasources/datasources.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index ceaf6521..7a2eec16 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -180,13 +180,14 @@ describe('datasources command', () => { }); it('should preview a datasource query with --raw and keep the columnar shape', async () => { + vi.mocked(getGlobalOptions).mockReturnValueOnce({ output: 'table', raw: true } as any); + await datasourcesCommand.parseAsync([ 'node', 'test', 'preview-query', '--json-config', '{"query":"SELECT 1"}', - '--raw', ]); expect(printFormatted).toHaveBeenCalledWith( From 01507fd8fec7ff97c52b44aaf404ee67c85aa146 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:27:43 +0100 Subject: [PATCH 06/13] feat(datasources): add query subcommand with positional SQL --- src/commands/datasources/datasources.test.ts | 10 +++++++++ src/commands/datasources/index.ts | 23 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index 7a2eec16..051872c6 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -250,6 +250,16 @@ describe('datasources command', () => { expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('--yes')); }); + it('should run query with positional SQL and reshape the response', async () => { + await datasourcesCommand.parseAsync(['node', 'test', 'query', '6', 'SELECT 1 AS one']); + + expect(mockClient.previewDatasourceQuery).toHaveBeenCalledWith({ + datasource_id: 6, + query: 'SELECT 1 AS one', + }); + expect(printFormatted).toHaveBeenCalled(); + }); + it('re-exports columnarToRows from core/datasources', async () => { const mod = await import('../../core/datasources/datasources.js'); expect(typeof (mod as { columnarToRows?: unknown }).columnarToRows).toBe('function'); diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index 733e0ffb..9781a726 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -158,6 +158,28 @@ const previewQueryCommand = new Command('preview-query') }) ); +const queryCommand = new Command('query') + .description('Run a SQL query against a datasource') + .argument('', 'datasource ID', parseDatasourceId) + .argument('[sql]', 'SQL query (positional). Mutually exclusive with --sql.') + .action( + withErrorHandling(async (id: DatasourceId, sql: string | undefined) => { + const globalOptions = getGlobalOptions(queryCommand); + const client = await getAPIClientFromOptions(globalOptions); + + const config: Record = { + datasource_id: id, + query: sql, + }; + + const result = await corePreviewDatasourceQuery(client, { config }); + printFormatted( + globalOptions.raw ? result.data : columnarToRows(result.data), + globalOptions + ); + }) + ); + const setDefaultCommand = new Command('set-default') .description('Set a datasource as the default') .argument('', 'datasource ID', parseDatasourceId) @@ -264,6 +286,7 @@ datasourcesCommand.addCommand(testCommand); datasourcesCommand.addCommand(introspectCommand); datasourcesCommand.addCommand(validateQueryCommand); datasourcesCommand.addCommand(previewQueryCommand); +datasourcesCommand.addCommand(queryCommand); datasourcesCommand.addCommand(setDefaultCommand); datasourcesCommand.addCommand(schemaCommand); datasourcesCommand.addCommand(deleteCommand); From 7b5c8d885596da5136b58e65ad2517a6828df068 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:30:29 +0100 Subject: [PATCH 07/13] feat(datasources): add --sql flag with stdin support to query subcommand --- src/commands/datasources/datasources.test.ts | 103 +++++++++++++++++++ src/commands/datasources/index.ts | 60 ++++++++--- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index 051872c6..90f498ea 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -6,6 +6,8 @@ import { printFormatted, } from '../../lib/utils/api-helper.js'; import { resetCommand } from '../../test/helpers/command-reset.js'; +import { PassThrough } from 'node:stream'; +import { setTTYOverride } from '../../lib/utils/stdin.js'; vi.mock('../../lib/utils/api-helper.js', async (importOriginal) => { const actual = await importOriginal(); @@ -260,8 +262,109 @@ describe('datasources command', () => { expect(printFormatted).toHaveBeenCalled(); }); + it('should run query with --sql flag', async () => { + await datasourcesCommand.parseAsync([ + 'node', + 'test', + 'query', + '6', + '--sql', + 'SELECT 2 AS two', + ]); + + expect(mockClient.previewDatasourceQuery).toHaveBeenCalledWith({ + datasource_id: 6, + query: 'SELECT 2 AS two', + }); + }); + + it('should reject when both positional sql and --sql are provided', async () => { + await expect( + datasourcesCommand.parseAsync([ + 'node', + 'test', + 'query', + '6', + 'SELECT 1', + '--sql', + 'SELECT 2', + ]) + ).rejects.toThrow(/process\.exit: 1/); + + expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('--sql')); + }); + it('re-exports columnarToRows from core/datasources', async () => { const mod = await import('../../core/datasources/datasources.js'); expect(typeof (mod as { columnarToRows?: unknown }).columnarToRows).toBe('function'); }); }); + +describe('datasources query --sql -', () => { + let consoleErrorSpy: ReturnType; + let processExitSpy: ReturnType; + let originalStdin: typeof process.stdin; + + const mockClient = { + previewDatasourceQuery: vi.fn().mockResolvedValue({ + columnNames: ['answer'], + columnTypes: ['INT64'], + rows: [[42]], + }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + resetCommand(datasourcesCommand); + vi.mocked(getAPIClientFromOptions).mockResolvedValue(mockClient as any); + vi.mocked(getGlobalOptions).mockReturnValue({ output: 'table' } as any); + vi.mocked(printFormatted).mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code?) => { + throw new Error(`process.exit: ${code}`); + }); + }); + + afterEach(() => { + if (originalStdin) { + Object.defineProperty(process, 'stdin', { + value: originalStdin, + configurable: true, + }); + } + setTTYOverride({ stdin: true, stdout: true }); + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + function replaceStdin(content: string): void { + originalStdin = process.stdin; + const stream = new PassThrough(); + stream.end(content); + Object.defineProperty(process, 'stdin', { + value: stream, + configurable: true, + }); + setTTYOverride({ stdin: false }); + } + + it('reads SQL from stdin when --sql is "-"', async () => { + replaceStdin('SELECT 42 AS answer\n'); + await datasourcesCommand.parseAsync(['node', 'test', 'query', '6', '--sql', '-']); + + expect(mockClient.previewDatasourceQuery).toHaveBeenCalledWith({ + datasource_id: 6, + query: 'SELECT 42 AS answer\n', + }); + }); + + it('errors when --sql is "-" but stdin is empty', async () => { + replaceStdin(''); + await expect( + datasourcesCommand.parseAsync(['node', 'test', 'query', '6', '--sql', '-']) + ).rejects.toThrow(/process\.exit: 1/); + + expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index 9781a726..2e2faa06 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -8,6 +8,7 @@ import { withErrorHandling, } from '../../lib/utils/api-helper.js'; import { parseDatasourceId, validateJSON } from '../../lib/utils/validators.js'; +import { readStdinText } from '../../lib/utils/stdin.js'; import type { DatasourceId } from '../../lib/api/branded-types.js'; import { listDatasources as coreListDatasources, @@ -162,22 +163,55 @@ const queryCommand = new Command('query') .description('Run a SQL query against a datasource') .argument('', 'datasource ID', parseDatasourceId) .argument('[sql]', 'SQL query (positional). Mutually exclusive with --sql.') + .option( + '--sql ', + 'SQL query. Use "-" to read from stdin. Mutually exclusive with the positional argument.' + ) .action( - withErrorHandling(async (id: DatasourceId, sql: string | undefined) => { - const globalOptions = getGlobalOptions(queryCommand); - const client = await getAPIClientFromOptions(globalOptions); + withErrorHandling( + async ( + id: DatasourceId, + positionalSql: string | undefined, + options: { sql?: string } + ) => { + const globalOptions = getGlobalOptions(queryCommand); - const config: Record = { - datasource_id: id, - query: sql, - }; + if (positionalSql !== undefined && options.sql !== undefined) { + console.error( + chalk.red('✗ Provide SQL via the positional argument OR --sql, not both.') + ); + process.exit(1); + } - const result = await corePreviewDatasourceQuery(client, { config }); - printFormatted( - globalOptions.raw ? result.data : columnarToRows(result.data), - globalOptions - ); - }) + let sql: string | undefined = positionalSql ?? options.sql; + if (sql === '-') { + sql = await readStdinText(); + if (sql.length === 0) { + console.error(chalk.red('✗ --sql - was specified but stdin was empty.')); + process.exit(1); + } + } + + if (sql === undefined) { + console.error( + chalk.red('✗ SQL is required. Provide it positionally or via --sql.') + ); + process.exit(1); + } + + const client = await getAPIClientFromOptions(globalOptions); + const config: Record = { + datasource_id: id, + query: sql, + }; + + const result = await corePreviewDatasourceQuery(client, { config }); + printFormatted( + globalOptions.raw ? result.data : columnarToRows(result.data), + globalOptions + ); + } + ) ); const setDefaultCommand = new Command('set-default') From 9071e2e86ab3d46a85ad8ef3f66864c4e61b8116 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:36:21 +0100 Subject: [PATCH 08/13] feat(datasources): add --limit option to query subcommand --- src/commands/datasources/datasources.test.ts | 25 ++++++++++++++++++++ src/commands/datasources/index.ts | 6 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index 90f498ea..fd307832 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -295,6 +295,31 @@ describe('datasources command', () => { expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('--sql')); }); + it('should pass --limit through to the request body', async () => { + await datasourcesCommand.parseAsync([ + 'node', + 'test', + 'query', + '6', + 'SELECT 1', + '--limit', + '20', + ]); + + expect(mockClient.previewDatasourceQuery).toHaveBeenCalledWith({ + datasource_id: 6, + query: 'SELECT 1', + limit: 20, + }); + }); + + it('should omit limit from the body when --limit is not set', async () => { + await datasourcesCommand.parseAsync(['node', 'test', 'query', '6', 'SELECT 1']); + + const call = mockClient.previewDatasourceQuery.mock.calls[0]?.[0]; + expect(call).not.toHaveProperty('limit'); + }); + it('re-exports columnarToRows from core/datasources', async () => { const mod = await import('../../core/datasources/datasources.js'); expect(typeof (mod as { columnarToRows?: unknown }).columnarToRows).toBe('function'); diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index 2e2faa06..aa288a01 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -167,12 +167,13 @@ const queryCommand = new Command('query') '--sql ', 'SQL query. Use "-" to read from stdin. Mutually exclusive with the positional argument.' ) + .option('--limit ', 'maximum number of rows to return', (value) => Number(value)) .action( withErrorHandling( async ( id: DatasourceId, positionalSql: string | undefined, - options: { sql?: string } + options: { sql?: string; limit?: number } ) => { const globalOptions = getGlobalOptions(queryCommand); @@ -204,6 +205,9 @@ const queryCommand = new Command('query') datasource_id: id, query: sql, }; + if (options.limit !== undefined) { + config.limit = options.limit; + } const result = await corePreviewDatasourceQuery(client, { config }); printFormatted( From 40feba369c5450535a7e2cbea35a0afb04a883a5 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:44:23 +0100 Subject: [PATCH 09/13] feat(datasources): add --json-config escape hatch to query subcommand --- src/commands/datasources/datasources.test.ts | 75 ++++++++++++++++++ src/commands/datasources/index.ts | 83 +++++++++++++------- 2 files changed, 130 insertions(+), 28 deletions(-) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index fd307832..f0fb73e6 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -324,6 +324,81 @@ describe('datasources command', () => { const mod = await import('../../core/datasources/datasources.js'); expect(typeof (mod as { columnarToRows?: unknown }).columnarToRows).toBe('function'); }); + + it('should accept --json-config as the full body when no other inputs are present', async () => { + await datasourcesCommand.parseAsync([ + 'node', + 'test', + 'query', + '6', + '--json-config', + '{"datasource_id":6,"query":"SELECT 1","limit":3}', + ]); + + expect(mockClient.previewDatasourceQuery).toHaveBeenCalledWith({ + datasource_id: 6, + query: 'SELECT 1', + limit: 3, + }); + }); + + it('should reject --json-config combined with positional sql', async () => { + await expect( + datasourcesCommand.parseAsync([ + 'node', + 'test', + 'query', + '6', + 'SELECT 1', + '--json-config', + '{"query":"SELECT 1"}', + ]) + ).rejects.toThrow(/process\.exit: 1/); + + expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); + }); + + it('should reject --json-config combined with --sql', async () => { + await expect( + datasourcesCommand.parseAsync([ + 'node', + 'test', + 'query', + '6', + '--sql', + 'SELECT 1', + '--json-config', + '{"query":"SELECT 1"}', + ]) + ).rejects.toThrow(/process\.exit: 1/); + + expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); + }); + + it('should reject --json-config combined with --limit', async () => { + await expect( + datasourcesCommand.parseAsync([ + 'node', + 'test', + 'query', + '6', + '--limit', + '5', + '--json-config', + '{"query":"SELECT 1"}', + ]) + ).rejects.toThrow(/process\.exit: 1/); + + expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); + }); + + it('should reject when no SQL source is provided', async () => { + await expect( + datasourcesCommand.parseAsync(['node', 'test', 'query', '6']) + ).rejects.toThrow(/process\.exit: 1/); + + expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); + }); }); describe('datasources query --sql -', () => { diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index aa288a01..a14ffaff 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -162,51 +162,78 @@ const previewQueryCommand = new Command('preview-query') const queryCommand = new Command('query') .description('Run a SQL query against a datasource') .argument('', 'datasource ID', parseDatasourceId) - .argument('[sql]', 'SQL query (positional). Mutually exclusive with --sql.') + .argument('[sql]', 'SQL query (positional). Mutually exclusive with --sql and --json-config.') .option( '--sql ', - 'SQL query. Use "-" to read from stdin. Mutually exclusive with the positional argument.' + 'SQL query. Use "-" to read from stdin. Mutually exclusive with the positional argument and --json-config.' ) .option('--limit ', 'maximum number of rows to return', (value) => Number(value)) + .option( + '--json-config ', + 'full request body as JSON. Overrides all other inputs and rejects them if set.' + ) .action( withErrorHandling( async ( id: DatasourceId, positionalSql: string | undefined, - options: { sql?: string; limit?: number } + options: { sql?: string; limit?: number; jsonConfig?: string } ) => { const globalOptions = getGlobalOptions(queryCommand); + const client = await getAPIClientFromOptions(globalOptions); - if (positionalSql !== undefined && options.sql !== undefined) { - console.error( - chalk.red('✗ Provide SQL via the positional argument OR --sql, not both.') - ); - process.exit(1); - } + let config: Record; - let sql: string | undefined = positionalSql ?? options.sql; - if (sql === '-') { - sql = await readStdinText(); - if (sql.length === 0) { - console.error(chalk.red('✗ --sql - was specified but stdin was empty.')); + if (options.jsonConfig !== undefined) { + if ( + positionalSql !== undefined || + options.sql !== undefined || + options.limit !== undefined + ) { + console.error( + chalk.red( + '✗ --json-config cannot be combined with positional SQL, --sql, or --limit.' + ) + ); + process.exit(1); + } + config = validateJSON(options.jsonConfig, '--json-config') as Record< + string, + unknown + >; + } else { + if (positionalSql !== undefined && options.sql !== undefined) { + console.error( + chalk.red('✗ Provide SQL via the positional argument OR --sql, not both.') + ); process.exit(1); } - } - if (sql === undefined) { - console.error( - chalk.red('✗ SQL is required. Provide it positionally or via --sql.') - ); - process.exit(1); - } + let sql: string | undefined = positionalSql ?? options.sql; + if (sql === '-') { + sql = await readStdinText(); + if (sql.length === 0) { + console.error( + chalk.red('✗ --sql - was specified but stdin was empty.') + ); + process.exit(1); + } + } - const client = await getAPIClientFromOptions(globalOptions); - const config: Record = { - datasource_id: id, - query: sql, - }; - if (options.limit !== undefined) { - config.limit = options.limit; + if (sql === undefined) { + console.error( + chalk.red('✗ SQL is required. Provide it positionally or via --sql.') + ); + process.exit(1); + } + + config = { + datasource_id: id, + query: sql, + }; + if (options.limit !== undefined) { + config.limit = options.limit; + } } const result = await corePreviewDatasourceQuery(client, { config }); From a0507b59893c35f0d238f395b0f4335df0504018 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:48:15 +0100 Subject: [PATCH 10/13] refactor(datasources): defer client construction until after query validation --- src/commands/datasources/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index a14ffaff..b7326a2f 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -180,7 +180,6 @@ const queryCommand = new Command('query') options: { sql?: string; limit?: number; jsonConfig?: string } ) => { const globalOptions = getGlobalOptions(queryCommand); - const client = await getAPIClientFromOptions(globalOptions); let config: Record; @@ -236,6 +235,7 @@ const queryCommand = new Command('query') } } + const client = await getAPIClientFromOptions(globalOptions); const result = await corePreviewDatasourceQuery(client, { config }); printFormatted( globalOptions.raw ? result.data : columnarToRows(result.data), From 90c833147cec36d71b5b82cefb0f82485fd562a3 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:50:14 +0100 Subject: [PATCH 11/13] test(datasources): cover query reshape and --raw behavior --- src/commands/datasources/datasources.test.ts | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index f0fb73e6..1c1c34a2 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -325,6 +325,36 @@ describe('datasources command', () => { expect(typeof (mod as { columnarToRows?: unknown }).columnarToRows).toBe('function'); }); + it('query reshapes columnar response to rows by default', async () => { + await datasourcesCommand.parseAsync(['node', 'test', 'query', '6', 'SELECT 1']); + + expect(printFormatted).toHaveBeenCalledWith( + [ + { experiment_id: 1, cnt: 538217 }, + { experiment_id: 5, cnt: 250000 }, + ], + expect.anything() + ); + }); + + it('query --raw preserves the columnar response', async () => { + vi.mocked(getGlobalOptions).mockReturnValueOnce({ output: 'table', raw: true } as any); + + await datasourcesCommand.parseAsync(['node', 'test', 'query', '6', 'SELECT 1']); + + expect(printFormatted).toHaveBeenCalledWith( + { + columnNames: ['experiment_id', 'cnt'], + columnTypes: ['INT64', 'INT64'], + rows: [ + [1, 538217], + [5, 250000], + ], + }, + expect.anything() + ); + }); + it('should accept --json-config as the full body when no other inputs are present', async () => { await datasourcesCommand.parseAsync([ 'node', From 04e1940657c11703357859c8ad4c9f98939772b9 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 26 May 2026 18:50:54 +0100 Subject: [PATCH 12/13] docs(datasources): document query subcommand and --raw reshape behavior --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9e4fbd9f..6d46a28f 100644 --- a/README.md +++ b/README.md @@ -1299,10 +1299,14 @@ abs datasources test --json-config '{"type": "postgres", ...}' abs datasources introspect --json-config '{"type": "postgres", ...}' abs datasources validate-query --json-config '{"query": "SELECT ..."}' abs datasources preview-query --json-config '{"query": "SELECT ..."}' +abs datasources query 6 "SELECT experiment_id, COUNT(*) FROM exposures GROUP BY 1" --limit 20 +abs datasources query 6 --sql - < my_query.sql abs datasources set-default 1 abs datasources schema 1 ``` +By default, `query` and `preview-query` reshape the columnar API response (`columnNames`, `columnTypes`, `rows`) into row-objects so `-o table`, `-o vertical`, `-o json`, etc. render naturally. Pass `--raw` to get the original columnar payload. + ### Export configurations Manage scheduled data export configurations. From a3187e8f666abfe7330c5b215c539acef737821a Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 28 May 2026 13:51:03 +0100 Subject: [PATCH 13/13] style(datasources): apply prettier formatting --- src/commands/datasources/datasources.test.ts | 25 ++++---------------- src/commands/datasources/index.ts | 18 ++++---------- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index 1c1c34a2..0466e3a2 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -263,14 +263,7 @@ describe('datasources command', () => { }); it('should run query with --sql flag', async () => { - await datasourcesCommand.parseAsync([ - 'node', - 'test', - 'query', - '6', - '--sql', - 'SELECT 2 AS two', - ]); + await datasourcesCommand.parseAsync(['node', 'test', 'query', '6', '--sql', 'SELECT 2 AS two']); expect(mockClient.previewDatasourceQuery).toHaveBeenCalledWith({ datasource_id: 6, @@ -280,15 +273,7 @@ describe('datasources command', () => { it('should reject when both positional sql and --sql are provided', async () => { await expect( - datasourcesCommand.parseAsync([ - 'node', - 'test', - 'query', - '6', - 'SELECT 1', - '--sql', - 'SELECT 2', - ]) + datasourcesCommand.parseAsync(['node', 'test', 'query', '6', 'SELECT 1', '--sql', 'SELECT 2']) ).rejects.toThrow(/process\.exit: 1/); expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); @@ -423,9 +408,9 @@ describe('datasources command', () => { }); it('should reject when no SQL source is provided', async () => { - await expect( - datasourcesCommand.parseAsync(['node', 'test', 'query', '6']) - ).rejects.toThrow(/process\.exit: 1/); + await expect(datasourcesCommand.parseAsync(['node', 'test', 'query', '6'])).rejects.toThrow( + /process\.exit: 1/ + ); expect(mockClient.previewDatasourceQuery).not.toHaveBeenCalled(); }); diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index b7326a2f..14fdbfe0 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -152,10 +152,7 @@ const previewQueryCommand = new Command('preview-query') const client = await getAPIClientFromOptions(globalOptions); const config = validateJSON(options.jsonConfig, '--json-config') as Record; const result = await corePreviewDatasourceQuery(client, { config }); - printFormatted( - globalOptions.raw ? result.data : columnarToRows(result.data), - globalOptions - ); + printFormatted(globalOptions.raw ? result.data : columnarToRows(result.data), globalOptions); }) ); @@ -196,10 +193,7 @@ const queryCommand = new Command('query') ); process.exit(1); } - config = validateJSON(options.jsonConfig, '--json-config') as Record< - string, - unknown - >; + config = validateJSON(options.jsonConfig, '--json-config') as Record; } else { if (positionalSql !== undefined && options.sql !== undefined) { console.error( @@ -212,17 +206,13 @@ const queryCommand = new Command('query') if (sql === '-') { sql = await readStdinText(); if (sql.length === 0) { - console.error( - chalk.red('✗ --sql - was specified but stdin was empty.') - ); + console.error(chalk.red('✗ --sql - was specified but stdin was empty.')); process.exit(1); } } if (sql === undefined) { - console.error( - chalk.red('✗ SQL is required. Provide it positionally or via --sql.') - ); + console.error(chalk.red('✗ SQL is required. Provide it positionally or via --sql.')); process.exit(1); }