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
5 changes: 3 additions & 2 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ altertable append status

| Argument | Description |
| --- | --- |
| `<DATA>` | JSON object, array, or @file Required. |
| `<DATA>` | JSON object, array, or @file (@- for stdin) Required. |

**Options**

Expand Down Expand Up @@ -891,7 +891,7 @@ altertable query show|cancel

| Argument | Description |
| --- | --- |
| `<STATEMENT>` | SQL statement to run Required. |
| `<STATEMENT>` | SQL statement to run, or @file (@- for stdin) Required. |

**Options**

Expand Down Expand Up @@ -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 <query-id>
```

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions cli-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <query-id>"
],
"subcommands": [
Expand Down
14 changes: 2 additions & 12 deletions cli/src/commands/api/lib/body.ts
Original file line number Diff line number Diff line change
@@ -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<string>(BODY_METHOD_NAMES);
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/append/lib/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
13 changes: 13 additions & 0 deletions cli/src/commands/append/lib/data.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'
Expand Down
13 changes: 2 additions & 11 deletions cli/src/commands/append/lib/data.ts
Original file line number Diff line number Diff line change
@@ -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 !== '[') {
Expand Down
16 changes: 16 additions & 0 deletions cli/src/commands/query/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions cli/src/commands/query/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 <query-id>',
],
},
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/query/lib/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
40 changes: 40 additions & 0 deletions cli/src/commands/query/lib/statement.test.ts
Original file line number Diff line number Diff line change
@@ -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}`
);
});
});
14 changes: 14 additions & 0 deletions cli/src/commands/query/lib/statement.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions cli/src/lib/file-argument.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
46 changes: 46 additions & 0 deletions tests/query-file.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
});
});
});
Loading