From 4314dbacbc9e8c33e002fbdb4f2f6c8778e20be0 Mon Sep 17 00:00:00 2001 From: Yannick Utard Date: Tue, 20 Oct 2026 15:58:57 +0200 Subject: [PATCH 1/2] feat(query): read SQL from a file or stdin with --file --- COMMANDS.md | 6 ++-- cli-reference.json | 15 ++++++-- cli/scripts/command-reference.test.ts | 2 +- cli/src/commands/query/index.test.ts | 43 ++++++++++++++++++++++ cli/src/commands/query/index.ts | 49 ++++++++++++++++++++++---- cli/src/commands/query/lib/args.ts | 6 +++- cli/src/lib/command-descriptor.test.ts | 2 +- cli/src/lib/usage.test.ts | 4 +-- tests/query-file.test.ts | 46 ++++++++++++++++++++++++ 9 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 tests/query-file.test.ts diff --git a/COMMANDS.md b/COMMANDS.md index a3d47ee..239ee1a 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -883,7 +883,7 @@ Run SQL queries against the lakehouse. **Usage** ```bash -altertable query [options] +altertable query [options] [STATEMENT] altertable query show|cancel ``` @@ -891,12 +891,13 @@ altertable query show|cancel | Argument | Description | | --- | --- | -| `` | SQL statement to run Required. | +| `[STATEMENT]` | SQL statement to run | **Options** | Option | Description | | --- | --- | +| `--file ` | Path to a file containing the SQL statement; use - for stdin | | `--format ` | Result format: csv, jsonl, and parquet stream from the API; markdown is rendered by the CLI. Use global --json for structured JSON Values: csv, jsonl, parquet, markdown. | | `--layout ` | Human layout: auto, table, or line Values: auto, table, line. | | `--columns ` | Comma-separated columns to show | @@ -921,6 +922,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 --file report.sql --format csv --output results.csv altertable query show ``` diff --git a/cli-reference.json b/cli-reference.json index 20d685b..a9d4aa5 100644 --- a/cli-reference.json +++ b/cli-reference.json @@ -1525,7 +1525,7 @@ "command": "altertable query", "description": "Run SQL queries against the lakehouse.", "usage": [ - "altertable query [options] ", + "altertable query [options] [STATEMENT]", "altertable query show|cancel" ], "aliases": [], @@ -1535,7 +1535,7 @@ "aliases": [], "type": "positional", "description": "SQL statement to run", - "required": true, + "required": false, "repeatable": false, "scope": "command", "values": [], @@ -1543,6 +1543,16 @@ } ], "options": [ + { + "name": "file", + "aliases": [], + "type": "string", + "description": "Path to a file containing the SQL statement; use - for stdin", + "required": false, + "repeatable": false, + "scope": "command", + "values": [] + }, { "name": "format", "aliases": [], @@ -1684,6 +1694,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 --file report.sql --format csv --output results.csv", "altertable query show " ], "subcommands": [ diff --git a/cli/scripts/command-reference.test.ts b/cli/scripts/command-reference.test.ts index 3f4736c..e3b7346 100644 --- a/cli/scripts/command-reference.test.ts +++ b/cli/scripts/command-reference.test.ts @@ -62,7 +62,7 @@ describe('command reference', () => { const reference = renderCommandReferenceMarkdown(await resolveReference()); expect(reference).toContain( - '```bash\naltertable query [options] \naltertable query show|cancel\n```' + '```bash\naltertable query [options] [STATEMENT]\naltertable query show|cancel\n```' ); expect(reference).toContain('```bash\naltertable profile env [NAME]\n```'); expect(reference).toContain( diff --git a/cli/src/commands/query/index.test.ts b/cli/src/commands/query/index.test.ts index 53d2754..6a7d380 100644 --- a/cli/src/commands/query/index.test.ts +++ b/cli/src/commands/query/index.test.ts @@ -211,6 +211,49 @@ 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', '--file', sqlPath]); + + expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ + statement: 'SELECT id FROM analytics.main.users\n', + compute_size: 'AUTO', + }); + }); + + test('rejects a positional statement together with --file', async () => { + const sqlPath = workspace.writeFile('report.sql', 'SELECT 1'); + expect( + runCommandWithTestRuntime(['query', 'SELECT 1', '--file', sqlPath]) + ).rejects.toThrow('Provide either a SQL statement or --file, not both.'); + }); + + test('requires a statement or --file', async () => { + expect(runCommandWithTestRuntime(['query'])).rejects.toThrow( + 'Provide a SQL statement, e.g. altertable query "SELECT 1" or altertable query --file report.sql.' + ); + }); + + test('rejects a missing --file path', async () => { + expect( + runCommandWithTestRuntime(['query', '--file', '/no/such/report.sql']) + ).rejects.toThrow('File not found: /no/such/report.sql'); + }); + + test('rejects an empty --file', async () => { + const sqlPath = workspace.writeFile('empty.sql', ' \n'); + expect( + runCommandWithTestRuntime(['query', '--file', sqlPath]) + ).rejects.toThrow(`SQL file is empty: ${sqlPath}`); + }); + 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..ebc4099 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,7 +1,9 @@ +import { readFileSync } from 'node:fs'; + import { queryCancelCommand } from '@/commands/query/cancel.ts'; import { queryRunArgs } from '@/commands/query/lib/args.ts'; import { queryShowCommand } from '@/commands/query/show.ts'; -import { optionalStringArg } from '@/lib/args.ts'; +import { optionalStringArg, type ParsedArgsRecord } from '@/lib/args.ts'; import { defineCommand } from '@/lib/command.ts'; import { CliError } from '@/lib/errors.ts'; import { @@ -25,6 +27,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 --file report.sql --format csv --output results.csv', 'altertable query show ', ], }, @@ -34,12 +37,7 @@ export const queryCommand = defineCommand({ cancel: queryCancelCommand, }, async run({ args, rawArgs, execution, sink }) { - const statement = optionalStringArg(args, 'statement'); - if (statement === undefined) { - throw new CliError( - 'Provide a SQL statement, e.g. altertable query "SELECT 1".' - ); - } + const statement = resolveStatement(args); const { format, displayOptions, @@ -85,3 +83,40 @@ export const queryCommand = defineCommand({ ); }, }); + +function resolveStatement(args: ParsedArgsRecord): string { + const statement = optionalStringArg(args, 'statement'); + const filePath = optionalStringArg(args, 'file'); + if (statement !== undefined && filePath !== undefined) { + throw new CliError('Provide either a SQL statement or --file, not both.'); + } + if (statement !== undefined) { + return statement; + } + if (filePath === undefined) { + throw new CliError( + 'Provide a SQL statement, e.g. altertable query "SELECT 1" or altertable query --file report.sql.' + ); + } + + const contents = readSqlFile(filePath); + if (contents.trim() === '') { + throw new CliError( + filePath === '-' + ? 'SQL from stdin is empty.' + : `SQL file is empty: ${filePath}` + ); + } + return contents; +} + +function readSqlFile(filePath: string): string { + if (filePath === '-') { + return readFileSync(0, 'utf8'); + } + try { + return readFileSync(filePath, 'utf8'); + } catch { + throw new CliError(`File not found: ${filePath}`); + } +} diff --git a/cli/src/commands/query/lib/args.ts b/cli/src/commands/query/lib/args.ts index 3254d30..556d130 100644 --- a/cli/src/commands/query/lib/args.ts +++ b/cli/src/commands/query/lib/args.ts @@ -11,7 +11,11 @@ export const queryRunArgs = defineArguments({ type: 'positional', description: 'SQL statement to run', required: false, - directRequired: true, + }, + file: { + type: 'string', + description: 'Path to a file containing the SQL statement; use - for stdin', + completion: 'file', }, ...queryApiResultFormatArgs, ...queryDisplayArgs, diff --git a/cli/src/lib/command-descriptor.test.ts b/cli/src/lib/command-descriptor.test.ts index ad3c3de..2ba0a10 100644 --- a/cli/src/lib/command-descriptor.test.ts +++ b/cli/src/lib/command-descriptor.test.ts @@ -287,7 +287,7 @@ describe('command descriptor', () => { expect.objectContaining({ name: 'statement', parserRequired: false, - required: true, + required: false, }) ); expect(append?.arguments).toEqual( diff --git a/cli/src/lib/usage.test.ts b/cli/src/lib/usage.test.ts index 93106cc..60970ab 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -210,7 +210,7 @@ describe('renderAltertableUsage', () => { expect(help.command).toBe('altertable query'); expect(help.usage).toEqual([ - 'altertable query [flags] ', + 'altertable query [flags] [STATEMENT]', 'altertable query show|cancel', ]); expect(help.arguments).toContainEqual( @@ -248,7 +248,7 @@ describe('renderAltertableUsage', () => { const queryUsage = await renderAltertableUsage(query, queryParent); expect(queryUsage).toContain( - 'Usage\n altertable query [flags] \n altertable query show|cancel' + 'Usage\n altertable query [flags] [STATEMENT]\n altertable query show|cancel' ); expect(queryUsage).not.toContain('[STATEMENT] show|cancel'); diff --git a/tests/query-file.test.ts b/tests/query-file.test.ts new file mode 100644 index 0000000..578eaf4 --- /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 --file", () => { + 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 --file -", { + 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 --file -", { + stdin: " \n", + env: lakehouseEnv, + }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toMatchObject({ + message: "SQL from stdin is empty.", + }); + }); +}); From 96d4474be3eab0a0850e6c122b5391f7134e347c Mon Sep 17 00:00:00 2001 From: Yannick Utard Date: Tue, 20 Oct 2026 16:13:08 +0200 Subject: [PATCH 2/2] feat(query): read SQL from @file or stdin (@-) Replace --file with the @file convention shared with append and api -F. Move @path/@- handling into a shared readFileArgument helper, which also lets append read from stdin with @-. Co-authored-by: Cursor --- COMMANDS.md | 9 ++-- README.md | 4 ++ cli-reference.json | 20 ++------ cli/scripts/command-reference.test.ts | 2 +- cli/src/commands/api/lib/body.ts | 14 +----- cli/src/commands/append/lib/args.ts | 2 +- cli/src/commands/append/lib/data.test.ts | 13 +++++ cli/src/commands/append/lib/data.ts | 13 +---- cli/src/commands/query/index.test.ts | 31 +----------- cli/src/commands/query/index.ts | 52 ++++---------------- cli/src/commands/query/lib/args.ts | 8 +-- cli/src/commands/query/lib/statement.test.ts | 40 +++++++++++++++ cli/src/commands/query/lib/statement.ts | 14 ++++++ cli/src/lib/command-descriptor.test.ts | 2 +- cli/src/lib/file-argument.ts | 18 +++++++ cli/src/lib/usage.test.ts | 4 +- tests/query-file.test.ts | 6 +-- 17 files changed, 124 insertions(+), 128 deletions(-) create mode 100644 cli/src/commands/query/lib/statement.test.ts create mode 100644 cli/src/commands/query/lib/statement.ts create mode 100644 cli/src/lib/file-argument.ts diff --git a/COMMANDS.md b/COMMANDS.md index 239ee1a..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** @@ -883,7 +883,7 @@ Run SQL queries against the lakehouse. **Usage** ```bash -altertable query [options] [STATEMENT] +altertable query [options] altertable query show|cancel ``` @@ -891,13 +891,12 @@ altertable query show|cancel | Argument | Description | | --- | --- | -| `[STATEMENT]` | SQL statement to run | +| `` | SQL statement to run, or @file (@- for stdin) Required. | **Options** | Option | Description | | --- | --- | -| `--file ` | Path to a file containing the SQL statement; use - for stdin | | `--format ` | Result format: csv, jsonl, and parquet stream from the API; markdown is rendered by the CLI. Use global --json for structured JSON Values: csv, jsonl, parquet, markdown. | | `--layout ` | Human layout: auto, table, or line Values: auto, table, line. | | `--columns ` | Comma-separated columns to show | @@ -922,7 +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 --file report.sql --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 a9d4aa5..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", @@ -1525,7 +1525,7 @@ "command": "altertable query", "description": "Run SQL queries against the lakehouse.", "usage": [ - "altertable query [options] [STATEMENT]", + "altertable query [options] ", "altertable query show|cancel" ], "aliases": [], @@ -1534,8 +1534,8 @@ "name": "statement", "aliases": [], "type": "positional", - "description": "SQL statement to run", - "required": false, + "description": "SQL statement to run, or @file (@- for stdin)", + "required": true, "repeatable": false, "scope": "command", "values": [], @@ -1543,16 +1543,6 @@ } ], "options": [ - { - "name": "file", - "aliases": [], - "type": "string", - "description": "Path to a file containing the SQL statement; use - for stdin", - "required": false, - "repeatable": false, - "scope": "command", - "values": [] - }, { "name": "format", "aliases": [], @@ -1694,7 +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 --file report.sql --format csv --output results.csv", + "altertable query @report.sql --format csv --output results.csv", "altertable query show " ], "subcommands": [ diff --git a/cli/scripts/command-reference.test.ts b/cli/scripts/command-reference.test.ts index e3b7346..3f4736c 100644 --- a/cli/scripts/command-reference.test.ts +++ b/cli/scripts/command-reference.test.ts @@ -62,7 +62,7 @@ describe('command reference', () => { const reference = renderCommandReferenceMarkdown(await resolveReference()); expect(reference).toContain( - '```bash\naltertable query [options] [STATEMENT]\naltertable query show|cancel\n```' + '```bash\naltertable query [options] \naltertable query show|cancel\n```' ); expect(reference).toContain('```bash\naltertable profile env [NAME]\n```'); expect(reference).toContain( 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 6a7d380..81c8329 100644 --- a/cli/src/commands/query/index.test.ts +++ b/cli/src/commands/query/index.test.ts @@ -211,7 +211,7 @@ describe('query command', () => { ); }); - test('sends a statement read from --file', async () => { + test('sends a statement read from @file', async () => { const sqlPath = workspace.writeFile( 'report.sql', 'SELECT id FROM analytics.main.users\n' @@ -220,40 +220,13 @@ describe('query command', () => { { urlPattern: '/query', method: 'POST', body: QUERY_RESPONSE }, ]); - await runCommandWithTestRuntime(['query', '--file', sqlPath]); + await runCommandWithTestRuntime(['query', `@${sqlPath}`]); expect(JSON.parse(workspace.readPayloads()[0] ?? '')).toEqual({ statement: 'SELECT id FROM analytics.main.users\n', compute_size: 'AUTO', }); }); - - test('rejects a positional statement together with --file', async () => { - const sqlPath = workspace.writeFile('report.sql', 'SELECT 1'); - expect( - runCommandWithTestRuntime(['query', 'SELECT 1', '--file', sqlPath]) - ).rejects.toThrow('Provide either a SQL statement or --file, not both.'); - }); - - test('requires a statement or --file', async () => { - expect(runCommandWithTestRuntime(['query'])).rejects.toThrow( - 'Provide a SQL statement, e.g. altertable query "SELECT 1" or altertable query --file report.sql.' - ); - }); - - test('rejects a missing --file path', async () => { - expect( - runCommandWithTestRuntime(['query', '--file', '/no/such/report.sql']) - ).rejects.toThrow('File not found: /no/such/report.sql'); - }); - - test('rejects an empty --file', async () => { - const sqlPath = workspace.writeFile('empty.sql', ' \n'); - expect( - runCommandWithTestRuntime(['query', '--file', sqlPath]) - ).rejects.toThrow(`SQL file is empty: ${sqlPath}`); - }); - 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 ebc4099..ec24f01 100644 --- a/cli/src/commands/query/index.ts +++ b/cli/src/commands/query/index.ts @@ -1,9 +1,8 @@ -import { readFileSync } from 'node:fs'; - 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, type ParsedArgsRecord } from '@/lib/args.ts'; +import { optionalStringArg } from '@/lib/args.ts'; import { defineCommand } from '@/lib/command.ts'; import { CliError } from '@/lib/errors.ts'; import { @@ -27,7 +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 --file report.sql --format csv --output results.csv', + 'altertable query @report.sql --format csv --output results.csv', 'altertable query show ', ], }, @@ -37,7 +36,13 @@ export const queryCommand = defineCommand({ cancel: queryCancelCommand, }, async run({ args, rawArgs, execution, sink }) { - const statement = resolveStatement(args); + 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, @@ -83,40 +88,3 @@ export const queryCommand = defineCommand({ ); }, }); - -function resolveStatement(args: ParsedArgsRecord): string { - const statement = optionalStringArg(args, 'statement'); - const filePath = optionalStringArg(args, 'file'); - if (statement !== undefined && filePath !== undefined) { - throw new CliError('Provide either a SQL statement or --file, not both.'); - } - if (statement !== undefined) { - return statement; - } - if (filePath === undefined) { - throw new CliError( - 'Provide a SQL statement, e.g. altertable query "SELECT 1" or altertable query --file report.sql.' - ); - } - - const contents = readSqlFile(filePath); - if (contents.trim() === '') { - throw new CliError( - filePath === '-' - ? 'SQL from stdin is empty.' - : `SQL file is empty: ${filePath}` - ); - } - return contents; -} - -function readSqlFile(filePath: string): string { - if (filePath === '-') { - return readFileSync(0, 'utf8'); - } - try { - return readFileSync(filePath, 'utf8'); - } catch { - throw new CliError(`File not found: ${filePath}`); - } -} diff --git a/cli/src/commands/query/lib/args.ts b/cli/src/commands/query/lib/args.ts index 556d130..99e10ae 100644 --- a/cli/src/commands/query/lib/args.ts +++ b/cli/src/commands/query/lib/args.ts @@ -9,13 +9,9 @@ import { export const queryRunArgs = defineArguments({ statement: { type: 'positional', - description: 'SQL statement to run', + description: 'SQL statement to run, or @file (@- for stdin)', required: false, - }, - file: { - type: 'string', - description: 'Path to a file containing the SQL statement; use - for stdin', - completion: 'file', + directRequired: true, }, ...queryApiResultFormatArgs, ...queryDisplayArgs, 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/command-descriptor.test.ts b/cli/src/lib/command-descriptor.test.ts index 2ba0a10..ad3c3de 100644 --- a/cli/src/lib/command-descriptor.test.ts +++ b/cli/src/lib/command-descriptor.test.ts @@ -287,7 +287,7 @@ describe('command descriptor', () => { expect.objectContaining({ name: 'statement', parserRequired: false, - required: false, + required: true, }) ); expect(append?.arguments).toEqual( 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/cli/src/lib/usage.test.ts b/cli/src/lib/usage.test.ts index 60970ab..93106cc 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -210,7 +210,7 @@ describe('renderAltertableUsage', () => { expect(help.command).toBe('altertable query'); expect(help.usage).toEqual([ - 'altertable query [flags] [STATEMENT]', + 'altertable query [flags] ', 'altertable query show|cancel', ]); expect(help.arguments).toContainEqual( @@ -248,7 +248,7 @@ describe('renderAltertableUsage', () => { const queryUsage = await renderAltertableUsage(query, queryParent); expect(queryUsage).toContain( - 'Usage\n altertable query [flags] [STATEMENT]\n altertable query show|cancel' + 'Usage\n altertable query [flags] \n altertable query show|cancel' ); expect(queryUsage).not.toContain('[STATEMENT] show|cancel'); diff --git a/tests/query-file.test.ts b/tests/query-file.test.ts index 578eaf4..7b5db26 100644 --- a/tests/query-file.test.ts +++ b/tests/query-file.test.ts @@ -8,7 +8,7 @@ const lakehouseEnv = { ALTERTABLE_LAKEHOUSE_PASSWORD: "testpass", } as const; -describe("altertable query --file", () => { +describe("altertable query @-", () => { let workspace: TestWorkspace; beforeAll(async () => { @@ -21,7 +21,7 @@ describe("altertable query --file", () => { { method: "POST", urlPattern: "/query", body: QUERY_RESPONSE }, ]); - const result = await workspace.runCommand("altertable --json query --file -", { + const result = await workspace.runCommand("altertable --json query @-", { stdin: "SELECT 99 FROM dual", env: lakehouseEnv, }); @@ -33,7 +33,7 @@ describe("altertable query --file", () => { }); test("rejects empty stdin", async () => { - const result = await workspace.runCommand("altertable --json query --file -", { + const result = await workspace.runCommand("altertable --json query @-", { stdin: " \n", env: lakehouseEnv, });