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
18 changes: 3 additions & 15 deletions cli/src/commands/catalogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
77 changes: 60 additions & 17 deletions cli/src/commands/duckdb.ts
Original file line number Diff line number Diff line change
@@ -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.";

Expand All @@ -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<void> {
// 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<void> {
if (!Bun.which("duckdb")) {
throw new ConfigurationError(
"duckdb is not installed. Install it from https://duckdb.org/install/ and try again.",
Expand All @@ -46,7 +81,10 @@ async function runDuckdb(input: DuckdbInput): Promise<void> {
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}`);
Expand All @@ -56,16 +94,21 @@ async function runDuckdb(input: DuckdbInput): Promise<void> {
export const duckdbCommand = defineLocalCommand<DuckdbInput>({
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),
});
17 changes: 17 additions & 0 deletions cli/src/lib/management-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CatalogRow[]> {
const databasesResponse = await runOperationEffect(
managementCatalogDatabasesOperation.effect(env, context),
context,
);
const connectionsResponse = await runOperationEffect(
managementCatalogConnectionsOperation.effect(env, context),
context,
);
return buildManagementCatalogRows([databasesResponse, connectionsResponse]);
}
52 changes: 47 additions & 5 deletions cli/tests/commands-duckdb.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading