diff --git a/cli/src/commands/catalogs.ts b/cli/src/commands/catalogs.ts index 561cc17..6ac917b 100644 --- a/cli/src/commands/catalogs.ts +++ b/cli/src/commands/catalogs.ts @@ -4,14 +4,12 @@ import { buildCreateCatalogBody } from "@/lib/management-payloads.ts"; import { parseApiJson } from "@/lib/parse-api-json.ts"; import { defineOperationCommand } from "@/lib/operation-command.ts"; import { defineGroupCommand, defineHttpCommand } from "@/lib/operation-command-builders.ts"; -import { localPlan, runOperationEffect } from "@/lib/operation-effect.ts"; +import { localPlan } from "@/lib/operation-effect.ts"; import { formatCatalogsSummary, formatCatalogsTable } from "@/features/management/render.ts"; import { terminalMetadata } from "@/ui/terminal/styles.ts"; import { - buildManagementCatalogRows, - managementCatalogConnectionsOperation, + fetchManagementCatalogRows, managementCatalogCreateOperation, - managementCatalogDatabasesOperation, type ManagementCatalogCreateInput, type ManagementCatalogCreateResult, } from "@/lib/management-operations.ts"; @@ -80,17 +78,7 @@ const catalogsListCommand = defineOperationCommand({ }, run(_input, context) { const env = requireManagementEnv(); - return localPlan(async () => { - const databasesResponse = await runOperationEffect( - managementCatalogDatabasesOperation.effect(env, context), - context, - ); - const connectionsResponse = await runOperationEffect( - managementCatalogConnectionsOperation.effect(env, context), - context, - ); - return buildManagementCatalogRows([databasesResponse, connectionsResponse]); - }); + return localPlan(() => fetchManagementCatalogRows(env, context)); }, present(rows) { const summary = formatCatalogsSummary(rows); diff --git a/cli/src/commands/duckdb.ts b/cli/src/commands/duckdb.ts index 595a7f6..1de43c5 100644 --- a/cli/src/commands/duckdb.ts +++ b/cli/src/commands/duckdb.ts @@ -1,9 +1,16 @@ import { spawnSync } from "node:child_process"; -import { getLoginLakehouseCredentials, type LakehouseCredentials } from "@/lib/auth.ts"; +import { + getLoginLakehouseCredentials, + requireManagementEnv, + type LakehouseCredentials, +} from "@/lib/auth.ts"; import { configureVerify } from "@/lib/profile-status.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { defineLocalCommand } from "@/lib/operation-command-builders.ts"; -import { stringArg } from "@/lib/operation-codec.ts"; +import { optionalStringArg } from "@/lib/operation-codec.ts"; +import { fetchManagementCatalogRows } from "@/lib/management-operations.ts"; +import type { CatalogRow } from "@/features/management/model.ts"; +import type { OperationContext } from "@/lib/operation-command.ts"; const LOGIN_PROMPT = "Log in with 'altertable login' to use altertable duckdb."; @@ -15,21 +22,49 @@ function quoteIdentifier(value: string): string { return `"${value.replaceAll('"', '""')}"`; } -export function buildDuckdbAttachSnippet( - credentials: LakehouseCredentials, - catalog: string, -): string { +function attachStatement(credentials: LakehouseCredentials, catalog: string): string { const connection = `user=${escapeSql(credentials.user)} password=${escapeSql(credentials.password)} catalog=${escapeSql(catalog)}`; - return `INSTALL altertable FROM community; -LOAD altertable; -ATTACH + return `ATTACH '${connection}' AS ${quoteIdentifier(catalog)} (TYPE ALTERTABLE);`; } -type DuckdbInput = { catalog: string }; +export function buildDuckdbAttachSnippet( + credentials: LakehouseCredentials, + catalogs: string[], +): string { + return [ + "INSTALL altertable FROM community;", + "LOAD altertable;", + ...catalogs.map((catalog) => attachStatement(credentials, catalog)), + ].join("\n"); +} -async function runDuckdb(input: DuckdbInput): Promise { +// Attach the requested catalog (verified against the environment) or every available one. +export function selectCatalogsToAttach( + rows: CatalogRow[], + requested: string | undefined, +): string[] { + const available = [ + ...new Set(rows.map((row) => row.catalog).filter((catalog) => catalog.length > 0)), + ]; + if (requested !== undefined) { + if (!available.includes(requested)) { + throw new ConfigurationError( + `Catalog "${requested}" not found. Available catalogs: ${available.join(", ") || "none"}.`, + ); + } + return [requested]; + } + if (available.length === 0) { + throw new ConfigurationError("No catalogs found in this environment."); + } + return available; +} + +type DuckdbInput = { catalog: string | undefined }; + +async function runDuckdb(input: DuckdbInput, context: OperationContext): Promise { if (!Bun.which("duckdb")) { throw new ConfigurationError( "duckdb is not installed. Install it from https://duckdb.org/install/ and try again.", @@ -46,7 +81,10 @@ async function runDuckdb(input: DuckdbInput): Promise { throw new ConfigurationError(LOGIN_PROMPT); } - const snippet = buildDuckdbAttachSnippet(credentials, input.catalog); + const rows = await fetchManagementCatalogRows(requireManagementEnv(), context); + const catalogs = selectCatalogsToAttach(rows, input.catalog); + + const snippet = buildDuckdbAttachSnippet(credentials, catalogs); const result = spawnSync("duckdb", ["-cmd", snippet], { stdio: "inherit" }); if (result.error) { throw new ConfigurationError(`Failed to launch duckdb: ${result.error.message}`); @@ -56,16 +94,21 @@ async function runDuckdb(input: DuckdbInput): Promise { export const duckdbCommand = defineLocalCommand({ id: "duckdb", output: "none", + capabilities: ["management-http"], meta: { name: "duckdb", - description: "Open a DuckDB shell attached to a lakehouse catalog.", - examples: ["altertable duckdb my_catalog"], + description: "Open a DuckDB shell attached to lakehouse catalogs (all of them by default).", + examples: ["altertable duckdb", "altertable duckdb my_catalog"], }, args: { - catalog: { type: "positional", description: "Catalog to attach", required: true }, + catalog: { + type: "positional", + description: "Catalog to attach (defaults to all catalogs)", + required: false, + }, }, parse({ args }) { - return { catalog: stringArg(args, "catalog") }; + return { catalog: optionalStringArg(args, "catalog") }; }, - local: (input) => runDuckdb(input), + local: (input, context) => runDuckdb(input, context), }); diff --git a/cli/src/lib/management-operations.ts b/cli/src/lib/management-operations.ts index 3ac52d4..7038203 100644 --- a/cli/src/lib/management-operations.ts +++ b/cli/src/lib/management-operations.ts @@ -4,6 +4,8 @@ import { parseApiJson } from "@/lib/parse-api-json.ts"; import { type WhoamiResponse } from "@/features/management/model.ts"; import { type ActiveContext, withAuthenticatedIdentity } from "@/features/profile/model.ts"; import type { CatalogRow } from "@/features/management/model.ts"; +import type { OperationContext } from "@/lib/operation-command.ts"; +import { runOperationEffect } from "@/lib/operation-effect.ts"; export type ManagementCatalogCreateInput = { env: string; @@ -69,3 +71,18 @@ export function buildManagementCatalogRows(responses: unknown[]): CatalogRow[] { const [databasesResponse, connectionsResponse] = responses; return buildCatalogRowsFromResponses(String(databasesResponse), String(connectionsResponse)); } + +export async function fetchManagementCatalogRows( + env: string, + context: OperationContext, +): Promise { + const databasesResponse = await runOperationEffect( + managementCatalogDatabasesOperation.effect(env, context), + context, + ); + const connectionsResponse = await runOperationEffect( + managementCatalogConnectionsOperation.effect(env, context), + context, + ); + return buildManagementCatalogRows([databasesResponse, connectionsResponse]); +} diff --git a/cli/tests/commands-duckdb.test.ts b/cli/tests/commands-duckdb.test.ts index f99c77b..bd9c4e3 100644 --- a/cli/tests/commands-duckdb.test.ts +++ b/cli/tests/commands-duckdb.test.ts @@ -1,27 +1,69 @@ import { describe, expect, test } from "bun:test"; -import { buildDuckdbAttachSnippet } from "@/commands/duckdb.ts"; +import { buildDuckdbAttachSnippet, selectCatalogsToAttach } from "@/commands/duckdb.ts"; +import type { CatalogRow } from "@/features/management/model.ts"; + +function catalogRow(catalog: string): CatalogRow { + return { type: "database", name: catalog, slug: catalog, engine: "altertable", catalog }; +} describe("buildDuckdbAttachSnippet", () => { test("embeds credentials and catalog into the ATTACH connection string", () => { - const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, "sales"); + const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, ["sales"]); expect(snippet).toContain("INSTALL altertable FROM community;"); expect(snippet).toContain("LOAD altertable;"); expect(snippet).toContain("'user=alice password=s3cret catalog=sales'"); expect(snippet).toContain('AS "sales" (TYPE ALTERTABLE);'); }); + test("emits INSTALL/LOAD once and one ATTACH per catalog", () => { + const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, [ + "sales", + "ops", + ]); + expect(snippet.match(/INSTALL altertable FROM community;/g)).toHaveLength(1); + expect(snippet.match(/ATTACH/g)).toHaveLength(2); + expect(snippet).toContain('AS "sales" (TYPE ALTERTABLE);'); + expect(snippet).toContain('AS "ops" (TYPE ALTERTABLE);'); + }); + test("quotes the catalog identifier so a hyphen is not parsed as subtraction", () => { - const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, "my-catalog"); + const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, ["my-catalog"]); expect(snippet).toContain('AS "my-catalog" (TYPE ALTERTABLE);'); }); test("escapes single quotes so a value cannot break out of the SQL string", () => { - const snippet = buildDuckdbAttachSnippet({ user: "a'b", password: "p'w" }, "c'at"); + const snippet = buildDuckdbAttachSnippet({ user: "a'b", password: "p'w" }, ["c'at"]); expect(snippet).toContain("'user=a''b password=p''w catalog=c''at'"); }); test("escapes double quotes in the catalog identifier", () => { - const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, 'we"ird'); + const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, ['we"ird']); expect(snippet).toContain('AS "we""ird" (TYPE ALTERTABLE);'); }); }); + +describe("selectCatalogsToAttach", () => { + const rows = [catalogRow("sales"), catalogRow("ops"), catalogRow("")]; + + test("returns every non-empty catalog when none is requested", () => { + expect(selectCatalogsToAttach(rows, undefined)).toEqual(["sales", "ops"]); + }); + + test("deduplicates repeated catalog values", () => { + expect(selectCatalogsToAttach([catalogRow("sales"), catalogRow("sales")], undefined)).toEqual([ + "sales", + ]); + }); + + test("returns only the requested catalog when it exists", () => { + expect(selectCatalogsToAttach(rows, "ops")).toEqual(["ops"]); + }); + + test("throws when the requested catalog is not available", () => { + expect(() => selectCatalogsToAttach(rows, "missing")).toThrow(/not found/); + }); + + test("throws when there are no catalogs to attach", () => { + expect(() => selectCatalogsToAttach([], undefined)).toThrow(/No catalogs found/); + }); +});