diff --git a/COMMANDS.md b/COMMANDS.md index a3d47ee..79990e3 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -777,7 +777,7 @@ altertable append status | Argument | Description | | --- | --- | -| `` | JSON object, array, or @file Required. | +| `` | JSON object, array, or @file (@- for stdin) Required. | **Options** @@ -891,7 +891,7 @@ altertable query show|cancel | Argument | Description | | --- | --- | -| `` | SQL statement to run Required. | +| `` | SQL statement to run, or @file (@- for stdin) Required. | **Options** @@ -921,6 +921,7 @@ altertable query show|cancel altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 10" altertable query "SELECT event, timestamp FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10" --json altertable query "SELECT 1" --format csv --output results.csv +altertable query @report.sql --format csv --output results.csv altertable query show ``` diff --git a/README.md b/README.md index 469fe1d..aeb7d39 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,10 @@ You can also override endpoints per-command with flags: ```bash altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 10" +# Read SQL from a file, or from stdin with @- +altertable query @report.sql +cat report.sql | altertable query @- + # Human layout and script-friendly formats altertable query "SELECT event, user_id, timestamp FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10" altertable query "SELECT event, user_id, timestamp FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10" --layout auto diff --git a/cli-reference.json b/cli-reference.json index 20d685b..b16fbb7 100644 --- a/cli-reference.json +++ b/cli-reference.json @@ -1322,7 +1322,7 @@ "name": "data", "aliases": [], "type": "positional", - "description": "JSON object, array, or @file", + "description": "JSON object, array, or @file (@- for stdin)", "required": true, "repeatable": false, "scope": "command", @@ -1534,7 +1534,7 @@ "name": "statement", "aliases": [], "type": "positional", - "description": "SQL statement to run", + "description": "SQL statement to run, or @file (@- for stdin)", "required": true, "repeatable": false, "scope": "command", @@ -1684,6 +1684,7 @@ "altertable query \"SELECT id, email, plan FROM analytics.main.users LIMIT 10\"", "altertable query \"SELECT event, timestamp FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10\" --json", "altertable query \"SELECT 1\" --format csv --output results.csv", + "altertable query @report.sql --format csv --output results.csv", "altertable query show " ], "subcommands": [ diff --git a/cli/src/commands/api/lib/body.ts b/cli/src/commands/api/lib/body.ts index 105f6d3..17f2867 100644 --- a/cli/src/commands/api/lib/body.ts +++ b/cli/src/commands/api/lib/body.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { CliError, ParseError } from '@/lib/errors.ts'; +import { readFileArgument } from '@/lib/file-argument.ts'; const BODY_METHOD_NAMES = ['POST', 'PATCH', 'PUT'] as const; const BODY_METHODS = new Set(BODY_METHOD_NAMES); @@ -105,18 +106,7 @@ function parseTypedFieldValue(value: string): ApiFieldValue { ? parsed : { kind: 'exact_json_integer', json }; } - if (value === '@-') { - return readFileSync(0, 'utf8'); - } - if (value.startsWith('@')) { - const filePath = value.slice(1); - try { - return readFileSync(filePath, 'utf8'); - } catch { - throw new CliError(`File not found: ${filePath}`); - } - } - return value; + return readFileArgument(value); } function parseRawFields( diff --git a/cli/src/commands/append/lib/args.ts b/cli/src/commands/append/lib/args.ts index 9f0c65e..7799c9f 100644 --- a/cli/src/commands/append/lib/args.ts +++ b/cli/src/commands/append/lib/args.ts @@ -3,7 +3,7 @@ import { defineArguments } from '@/lib/command.ts'; export const appendRunArgs = defineArguments({ data: { type: 'positional', - description: 'JSON object, array, or @file', + description: 'JSON object, array, or @file (@- for stdin)', required: true, }, to: { diff --git a/cli/src/commands/append/lib/data.test.ts b/cli/src/commands/append/lib/data.test.ts index e0e8219..e43a7a2 100644 --- a/cli/src/commands/append/lib/data.test.ts +++ b/cli/src/commands/append/lib/data.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from 'bun:test'; +import { rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { parseAppendJsonContent } from '@/commands/append/lib/data.ts'; import { CliError } from '@/lib/errors.ts'; @@ -13,6 +16,16 @@ describe('parseAppendJsonContent', () => { expect(() => parseAppendJsonContent('not-json')).toThrow(CliError); }); + test('reads JSON from @file', () => { + const path = join(tmpdir(), `altertable-append-${process.pid}.json`); + writeFileSync(path, '[{"id":1}]'); + try { + expect(parseAppendJsonContent(`@${path}`)).toBe('[{"id":1}]'); + } finally { + rmSync(path, { force: true }); + } + }); + test('reports a missing input file', () => { expect(() => parseAppendJsonContent('@/no/such/missing.json')).toThrow( 'File not found: /no/such/missing.json' diff --git a/cli/src/commands/append/lib/data.ts b/cli/src/commands/append/lib/data.ts index ebca05e..e0f9211 100644 --- a/cli/src/commands/append/lib/data.ts +++ b/cli/src/commands/append/lib/data.ts @@ -1,17 +1,8 @@ -import { readFileSync } from 'node:fs'; - import { CliError } from '@/lib/errors.ts'; +import { readFileArgument } from '@/lib/file-argument.ts'; export function parseAppendJsonContent(dataArg: string): string { - let jsonContent = dataArg; - if (jsonContent.startsWith('@')) { - const filePath = jsonContent.slice(1); - try { - jsonContent = readFileSync(filePath, 'utf8'); - } catch { - throw new CliError(`File not found: ${filePath}`); - } - } + const jsonContent = readFileArgument(dataArg); const firstCharacter = jsonContent.trimStart()[0]; if (firstCharacter !== '{' && firstCharacter !== '[') { diff --git a/cli/src/commands/query/index.test.ts b/cli/src/commands/query/index.test.ts index 53d2754..81c8329 100644 --- a/cli/src/commands/query/index.test.ts +++ b/cli/src/commands/query/index.test.ts @@ -211,6 +211,22 @@ describe('query command', () => { ); }); + test('sends a statement read from @file', async () => { + const sqlPath = workspace.writeFile( + 'report.sql', + 'SELECT id FROM analytics.main.users\n' + ); + workspace.writeMocks([ + { urlPattern: '/query', method: 'POST', body: QUERY_RESPONSE }, + ]); + + await runCommandWithTestRuntime(['query', `@${sqlPath}`]); + + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ + statement: 'SELECT id FROM analytics.main.users\n', + compute_size: 'AUTO', + }); + }); test('URL-encodes query identifiers', async () => { const queryId = 'query/id+special'; const encodedQueryId = encodeURIComponent(queryId); diff --git a/cli/src/commands/query/index.ts b/cli/src/commands/query/index.ts index b11d302..ec24f01 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,5 +1,6 @@ import { queryCancelCommand } from '@/commands/query/cancel.ts'; import { queryRunArgs } from '@/commands/query/lib/args.ts'; +import { parseQueryStatement } from '@/commands/query/lib/statement.ts'; import { queryShowCommand } from '@/commands/query/show.ts'; import { optionalStringArg } from '@/lib/args.ts'; import { defineCommand } from '@/lib/command.ts'; @@ -25,6 +26,7 @@ export const queryCommand = defineCommand({ 'altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 10"', 'altertable query "SELECT event, timestamp FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10" --json', 'altertable query "SELECT 1" --format csv --output results.csv', + 'altertable query @report.sql --format csv --output results.csv', 'altertable query show ', ], }, @@ -34,12 +36,13 @@ export const queryCommand = defineCommand({ cancel: queryCancelCommand, }, async run({ args, rawArgs, execution, sink }) { - const statement = optionalStringArg(args, 'statement'); - if (statement === undefined) { + const statementArg = optionalStringArg(args, 'statement'); + if (statementArg === undefined) { throw new CliError( 'Provide a SQL statement, e.g. altertable query "SELECT 1".' ); } + const statement = parseQueryStatement(statementArg); const { format, displayOptions, diff --git a/cli/src/commands/query/lib/args.ts b/cli/src/commands/query/lib/args.ts index 3254d30..99e10ae 100644 --- a/cli/src/commands/query/lib/args.ts +++ b/cli/src/commands/query/lib/args.ts @@ -9,7 +9,7 @@ import { export const queryRunArgs = defineArguments({ statement: { type: 'positional', - description: 'SQL statement to run', + description: 'SQL statement to run, or @file (@- for stdin)', required: false, directRequired: true, }, diff --git a/cli/src/commands/query/lib/statement.test.ts b/cli/src/commands/query/lib/statement.test.ts new file mode 100644 index 0000000..223e31e --- /dev/null +++ b/cli/src/commands/query/lib/statement.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { parseQueryStatement } from '@/commands/query/lib/statement.ts'; + +let directory: string; + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'altertable-query-statement-')); +}); + +afterEach(() => rmSync(directory, { recursive: true, force: true })); + +describe('parseQueryStatement', () => { + test('passes inline SQL through unchanged', () => { + expect(parseQueryStatement(' SELECT 1 ')).toBe(' SELECT 1 '); + }); + + test('reads SQL from @file without trimming', () => { + const path = join(directory, 'report.sql'); + writeFileSync(path, 'SELECT 1;\n'); + expect(parseQueryStatement(`@${path}`)).toBe('SELECT 1;\n'); + }); + + test('reports a missing input file', () => { + expect(() => parseQueryStatement('@/no/such/report.sql')).toThrow( + 'File not found: /no/such/report.sql' + ); + }); + + test('rejects an empty input file', () => { + const path = join(directory, 'empty.sql'); + writeFileSync(path, ' \n'); + expect(() => parseQueryStatement(`@${path}`)).toThrow( + `SQL file is empty: ${path}` + ); + }); +}); diff --git a/cli/src/commands/query/lib/statement.ts b/cli/src/commands/query/lib/statement.ts new file mode 100644 index 0000000..db3a9a8 --- /dev/null +++ b/cli/src/commands/query/lib/statement.ts @@ -0,0 +1,14 @@ +import { CliError } from '@/lib/errors.ts'; +import { readFileArgument } from '@/lib/file-argument.ts'; + +export function parseQueryStatement(statementArg: string): string { + const statement = readFileArgument(statementArg); + if (statementArg.startsWith('@') && statement.trim() === '') { + throw new CliError( + statementArg === '@-' + ? 'SQL from stdin is empty.' + : `SQL file is empty: ${statementArg.slice(1)}` + ); + } + return statement; +} diff --git a/cli/src/lib/file-argument.ts b/cli/src/lib/file-argument.ts new file mode 100644 index 0000000..e8939fc --- /dev/null +++ b/cli/src/lib/file-argument.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; + +import { CliError } from '@/lib/errors.ts'; + +export function readFileArgument(value: string): string { + if (value === '@-') { + return readFileSync(0, 'utf8'); + } + if (!value.startsWith('@')) { + return value; + } + const filePath = value.slice(1); + try { + return readFileSync(filePath, 'utf8'); + } catch { + throw new CliError(`File not found: ${filePath}`); + } +} diff --git a/tests/query-file.test.ts b/tests/query-file.test.ts new file mode 100644 index 0000000..7b5db26 --- /dev/null +++ b/tests/query-file.test.ts @@ -0,0 +1,46 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; + +const QUERY_RESPONSE = ['{"statement":"SELECT 1"}', '["id"]', '[1]'].join("\n"); +const lakehouseEnv = { + ALTERTABLE_API_BASE: "https://lakehouse.example.test", + ALTERTABLE_LAKEHOUSE_USERNAME: "testuser", + ALTERTABLE_LAKEHOUSE_PASSWORD: "testpass", +} as const; + +describe("altertable query @-", () => { + let workspace: TestWorkspace; + + beforeAll(async () => { + workspace = await createTestWorkspace(); + }); + + test("reads the SQL statement from stdin", async () => { + await workspace.setupHttpLog(); + await workspace.setupMockHttp([ + { method: "POST", urlPattern: "/query", body: QUERY_RESPONSE }, + ]); + + const result = await workspace.runCommand("altertable --json query @-", { + stdin: "SELECT 99 FROM dual", + env: lakehouseEnv, + }); + + expect(result.exitCode).toBe(0); + expect(await workspace.httpLogJsonValue("PAYLOAD")).toMatchObject({ + statement: "SELECT 99 FROM dual", + }); + }); + + test("rejects empty stdin", async () => { + const result = await workspace.runCommand("altertable --json query @-", { + stdin: " \n", + env: lakehouseEnv, + }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toMatchObject({ + message: "SQL from stdin is empty.", + }); + }); +});