Skip to content
Open
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
8 changes: 7 additions & 1 deletion COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,10 +641,15 @@ altertable query show|cancel

| Option | Description |
| --- | --- |
| `--format <CSV\|MARKDOWN>` | Serialized output format; use global --json for JSON Values: csv, markdown. |
| `--format <CSV\|JSONL\|PARQUET\|MARKDOWN>` | 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 <AUTO\|TABLE\|LINE>` | Human layout: auto, table, or line Values: auto, table, line. |
| `--columns <COLUMNS>` | Comma-separated columns to show |
| `--max-width <MAX-WIDTH>` | Maximum display width for table columns Default: "32". |
| `--compute-size <XS\|S\|M\|L\|XL\|AUTO>` | Compute size for the query (AUTO cannot be combined with --session-id) Values: XS, S, M, L, XL, AUTO. Default: "AUTO". |
| `--dialect <DIALECT>` | Source SQL dialect to transpile from (server default: DuckDB) |
| `--catalog <CATALOG>` | Catalog name (optional; can also come from the session) |
| `--schema <SCHEMA>` | Schema name (optional; can also come from the session) |
| `--output <OUTPUT>` | Write result bytes/text to this path instead of stdout |
| `--query-id <QUERY-ID>` | Optional stable query id |
| `--session-id <SESSION-ID>` | Optional session id |
| `--pager <AUTO\|ALWAYS\|NEVER>` | Pager mode for human output: auto, always, or never Values: auto, always, never. Default: "auto". |
Expand All @@ -659,6 +664,7 @@ altertable query show|cancel
```bash
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 show <query-id>
```

Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,16 @@ altertable query "SELECT event, user_id, timestamp FROM analytics.main.events OR
altertable query "SELECT * FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10" --columns event,user_id,timestamp
altertable query "SELECT * FROM analytics.main.events ORDER BY timestamp DESC LIMIT 10" --max-width 24

# Serialized output
# Serialized / API-native output
altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 100" --format csv
altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 100" --json
altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 100" --format jsonl --output events.jsonl
altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 100" --format parquet --output users.parquet
altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 100" --format markdown
altertable query "SELECT id, email, plan FROM analytics.main.users LIMIT 100" --json

# Request options
altertable query "SELECT 1" --compute-size M
altertable query "SELECT * FROM users" --catalog analytics --schema main --dialect snowflake

# Long results — pipe through a pager
altertable query "SELECT * FROM analytics.main.orders ORDER BY created_at DESC" --pager always
Expand All @@ -347,7 +353,7 @@ altertable --json query "SELECT 1"
altertable --agent query "SELECT 1"
```

Human output is the default and respects `--layout auto|table|line`, `--columns`, `--max-width`, and `--pager auto|always|never`. Use `--format csv|markdown` for serialized text, or the global `--json`/`--agent` flags for structured JSON. Serialized output skips pager and layout controls.
Human output is the default and respects `--layout auto|table|line`, `--columns`, `--max-width`, and `--pager auto|always|never`. Use `--format csv|jsonl|parquet` to stream those encodings from the lakehouse API, `--format markdown` for CLI-rendered markdown, or the global `--json`/`--agent` flags for structured JSON. API-native formats cannot be combined with `--json`/`--agent` or layout/pager flags. `--output` writes to a file (stdout by default). `--compute-size` defaults to `AUTO` (omit with `--session-id` unless you set `XS|S|M|L|XL`).

Set display defaults in `~/.config/altertable/config`:

Expand Down
26 changes: 15 additions & 11 deletions cli/src/commands/completion/lib/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,10 @@ describe("buildCompletionSpec", () => {
]);
});

test("extracts intentional direct and subcommand operand collisions", async () => {
test("exposes empty soleDirectOperands when query has no operand collisions", async () => {
const spec = await buildCompletionSpec(buildMainCommand());

expect(findNode(spec, "query")?.soleDirectOperands).toEqual(["show"]);
expect(findNode(spec, "query")?.soleDirectOperands).toEqual([]);
});

test("extracts finite shell positional values from completion commands", async () => {
Expand Down Expand Up @@ -339,16 +339,16 @@ describe("normalized completion argv", () => {
path: ["query", "show"],
positionals: ["qry_123"],
},
{
argv: ["query", "show", "--layout", "table"],
path: ["query"],
positionals: ["show"],
},
{
argv: ["query", "show", "--help"],
path: ["query", "show"],
positionals: [],
},
{
argv: ["query", "SELECT 1", "--layout", "table"],
path: ["query"],
positionals: ["SELECT 1"],
},
{
argv: ["append", '{"event":"checkout"}', "--sync"],
path: ["append"],
Expand Down Expand Up @@ -536,17 +536,21 @@ describe("executable shell completion contract", () => {
expect(candidates).not.toContain("--layout");
});

shellTest(`${shell} preserves intentional direct operands with trailing flags`, async () => {
const direct = await runCompletion([
shellTest(`${shell} treats query show as a subcommand, not a SQL direct operand`, async () => {
const showLeaf = await runCompletion([
"altertable",
"query",
"show",
"--layout",
"table",
"--",
]);
expect(direct).toContain("--columns");
expect(direct).toContain("--layout");
expect(showLeaf).not.toContain("--columns");
expect(showLeaf).not.toContain("--layout");

const directSql = await runCompletion(["altertable", "query", "SELECT 1", "--"]);
expect(directSql).toContain("--columns");
expect(directSql).toContain("--layout");

const nested = await runCompletion(["altertable", "query", "show", "qry_1", "--"]);
expect(nested).not.toContain("--columns");
Expand Down
1 change: 1 addition & 0 deletions cli/src/commands/doctor/lib/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function createDoctorContext(offline = false): DoctorCheckContext {
writeStderr() {},
writeJson() {},
writeRaw() {},
writeBytes() {},
writeHuman() {},
writeMetadata() {},
},
Expand Down
129 changes: 118 additions & 11 deletions cli/src/commands/query/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCommandWithTestRuntime } from "@/test-utils/cli.ts";
import {
createLakehouseTestWorkspace,
Expand All @@ -15,25 +18,133 @@ beforeEach(() => {
afterEach(() => workspace.cleanup());

describe("query command", () => {
test("sends the statement and optional identifiers", async () => {
test("sends statement identifiers and default compute size", async () => {
workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }]);

await runCommandWithTestRuntime([
"query",
"SELECT 1",
"--query-id",
"query-1",
"--session-id",
"session-1",
"--dialect",
"snowflake",
"--catalog",
"analytics",
"--schema",
"main",
]);

expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({
statement: "SELECT 1",
query_id: "query-1",
compute_size: "AUTO",
dialect: "snowflake",
catalog: "analytics",
schema: "main",
});
});

test("omits compute size with session unless an explicit size is set", async () => {
workspace.writeMocks([
{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE },
{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE },
]);

await runCommandWithTestRuntime(["query", "SELECT 1", "--session-id", "session-1"]);
expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({
statement: "SELECT 1",
session_id: "session-1",
});

await runCommandWithTestRuntime([
"query",
"SELECT 1",
"--session-id",
"session-1",
"--compute-size",
"S",
]);
expect(JSON.parse(workspace.readPayloads()[1] ?? "")).toEqual({
statement: "SELECT 1",
session_id: "session-1",
compute_size: "S",
});
});

test("rejects explicit AUTO compute size with a session", async () => {
expect(
runCommandWithTestRuntime([
"query",
"SELECT 1",
"--session-id",
"session-1",
"--compute-size",
"AUTO",
]),
).rejects.toThrow("--compute-size AUTO cannot be combined with --session-id.");
});

test("streams API-native csv bytes instead of client-rendered CSV", async () => {
const apiCsv = "id,name\n9,from-api\n";
workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: apiCsv }]);

const harness = await runCommandWithTestRuntime(["query", "SELECT 1", "--format", "csv"], {
debug: false,
json: false,
agent: false,
});

expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({
statement: "SELECT 1",
compute_size: "AUTO",
format: "csv",
});
expect(harness.stdout.join("")).toContain("9,from-api");
});

test("writes API-native output to --output", async () => {
const outputDir = mkdtempSync(join(tmpdir(), "altertable-query-output-"));
const outputPath = join(outputDir, "result.csv");
try {
workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: "id\n42\n" }]);

await runCommandWithTestRuntime(
["query", "SELECT 1", "--format", "csv", "--output", outputPath],
{ debug: false, json: false, agent: false },
);

expect(readFileSync(outputPath, "utf8")).toContain("42");
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
});

test("keeps markdown as client-rendered NDJSON output", async () => {
workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }]);

const harness = await runCommandWithTestRuntime(["query", "SELECT 1", "--format", "markdown"], {
debug: false,
json: false,
agent: false,
});

expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({
statement: "SELECT 1",
compute_size: "AUTO",
});
expect(harness.stdout.join("")).toContain("|");
});

test("rejects API-native format with --json", async () => {
expect(
runCommandWithTestRuntime(["query", "SELECT 1", "--format", "parquet"], {
debug: false,
json: true,
agent: false,
}),
).rejects.toThrow("cannot be combined with --json or --agent");
});

test("dispatches show and cancel without run arguments", async () => {
const queryId = "11111111-2222-3333-4444-555555555555";
workspace.writeMocks([
Expand All @@ -51,14 +162,10 @@ describe("query command", () => {
);
});

test("executes a bare lowercase show statement as SQL", async () => {
workspace.writeMocks([{ urlPattern: "/query", method: "POST", body: QUERY_RESPONSE }]);

await runCommandWithTestRuntime(["query", "show"]);

expect(JSON.parse(workspace.readPayloads()[0] ?? "")).toEqual({
statement: "show",
});
test("requires a query id for show instead of running bare show as SQL", async () => {
expect(runCommandWithTestRuntime(["query", "show"])).rejects.toThrow(
"Missing required argument: query-id.",
);
});

test("URL-encodes query identifiers", async () => {
Expand Down
39 changes: 33 additions & 6 deletions cli/src/commands/query/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import { queryShowCommand } from "@/commands/query/show.ts";
import { queryCancelCommand } from "@/commands/query/cancel.ts";
import { CliError } from "@/lib/errors.ts";
import { optionalStringArg } from "@/lib/args.ts";
import { writeQueryOutput } from "@/lib/query-output.ts";
import { writeQueryDestination } from "@/lib/query-destination.ts";
import { isApiNativeQueryFormat, writeQueryOutput } from "@/lib/query-output.ts";
import { parseQueryOutputOptions } from "@/lib/query-output-args.ts";
import { executeLakehouseQuery } from "@/lib/lakehouse/query.ts";
import {
executeLakehouseQuery,
executeLakehouseQueryBytes,
type LakehouseQueryInput,
} from "@/lib/lakehouse/query.ts";

export const queryCommand = defineCommand({
metadata: {
Expand All @@ -16,11 +21,11 @@ export const queryCommand = defineCommand({
examples: [
'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 show <query-id>",
],
},
args: queryRunArgs,
soleDirectOperands: ["show"],
subcommands: {
show: queryShowCommand,
cancel: queryCancelCommand,
Expand All @@ -30,16 +35,38 @@ export const queryCommand = defineCommand({
if (statement === undefined) {
throw new CliError('Provide a SQL statement, e.g. altertable query "SELECT 1".');
}
const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, {
const {
format,
displayOptions,
pagerOptions,
outputPath,
computeSize,
dialect,
catalog,
schema,
} = parseQueryOutputOptions(args, {
agent: execution.cli.agent,
json: sink.json,
rawArgs,
});
const input = {

const input: LakehouseQueryInput = {
statement,
queryId: optionalStringArg(args, "query-id"),
sessionId: optionalStringArg(args, "session-id"),
computeSize,
dialect,
catalog,
schema,
};

if (isApiNativeQueryFormat(format)) {
const stream = await executeLakehouseQueryBytes({ ...input, format }, execution);
await writeQueryDestination(stream, { outputPath, sink });
return;
}

const result = await executeLakehouseQuery(input, execution, !sink.json);
await writeQueryOutput(result, format, sink, displayOptions, pagerOptions);
await writeQueryOutput(result, format, sink, displayOptions, pagerOptions, outputPath);
},
});
6 changes: 4 additions & 2 deletions cli/src/commands/query/lib/args.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { defineArguments } from "@/lib/command.ts";
import {
queryApiResultFormatArgs,
queryDisplayArgs,
queryPagerArgs,
queryResultFormatArgs,
queryRequestArgs,
} from "@/lib/query-output-args.ts";

export const queryRunArgs = defineArguments({
Expand All @@ -12,8 +13,9 @@ export const queryRunArgs = defineArguments({
required: false,
directRequired: true,
},
...queryResultFormatArgs,
...queryApiResultFormatArgs,
...queryDisplayArgs,
...queryRequestArgs,
"query-id": { type: "string", description: "Optional stable query id" },
"session-id": { type: "string", description: "Optional session id" },
...queryPagerArgs,
Expand Down
1 change: 1 addition & 0 deletions cli/src/commands/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const schemaCommand = defineCommand({
const catalog = stringArg(args, "catalog");
const { format, displayOptions, pagerOptions } = parseQueryOutputOptions(args, {
agent: execution.cli.agent,
json: sink.json,
rawArgs,
});
const queryInput = {
Expand Down
Loading