diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 84ebb5831b..e1ad07aa6c 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -143,6 +143,8 @@ import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty"; import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; import { runBatchTableDrop } from "@/lib/table/batchTableDrop"; +import { buildSidebarDdlTemplateSql, sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate"; +import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { copyToClipboard } from "@/lib/common/clipboard"; import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/savedSqlHistory"; @@ -1373,34 +1375,50 @@ async function newDeleteTemplate() { } async function generateDdlTemplate() { - const node = activeNode.value; - if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return; - if (node.type !== "table" && node.type !== "view" && node.type !== "materialized_view") return; + const targets = selectedDdlTargets(); + if (!targets.length) return; + const tabTarget = targets.find((target) => target.id === activeNode.value.id) ?? targets[0]!; try { - await connectionStore.ensureConnected(node.connectionId); - connectionStore.activeConnectionId = node.connectionId; - const schema = node.schema || node.database; - let ddl: string; - if (node.type === "table") { - ddl = await api.getTableDisplayDdl(node.connectionId, node.database, schema, node.label, undefined, node.catalog); - } else if (node.type === "materialized_view") { - ddl = await api.getTableDisplayDdl(node.connectionId, node.database, schema, node.label, "MATERIALIZED_VIEW", node.catalog); - } else { - const result = await api.getObjectSource(node.connectionId, node.database, schema, node.label, "VIEW"); - ddl = await buildViewDdl({ - databaseType: currentDatabaseType(), - schema, - name: node.label, - source: result.source, - }); - } - const formatted = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(currentDatabaseType()), settingsStore.editorSettings.sqlFormatter); - openSqlTemplateTab(node.connectionId, node.database, node.schema, node.catalog, formatted, `DDL - ${node.label}`); + const sql = await buildSidebarDdlTemplateSql( + targets, + async (target) => { + await connectionStore.ensureConnected(target.connectionId); + const schema = target.schema || target.database; + if (target.type === "table") return api.getTableDisplayDdl(target.connectionId, target.database, schema, target.label, undefined, target.catalog); + if (target.type === "materialized_view") return api.getTableDisplayDdl(target.connectionId, target.database, schema, target.label, "MATERIALIZED_VIEW", target.catalog); + const result = await api.getObjectSource(target.connectionId, target.database, schema, target.label, "VIEW"); + return buildViewDdl({ + databaseType: databaseTypeForNode(target), + schema, + name: target.label, + source: result.source, + }); + }, + (ddl, target) => formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseTypeForNode(target)), settingsStore.editorSettings.sqlFormatter), + ); + connectionStore.activeConnectionId = tabTarget.connectionId; + const title = `DDL - ${targets.map((target) => target.label).join(", ")}`; + openSqlTemplateTab(tabTarget.connectionId, tabTarget.database, tabTarget.schema, tabTarget.catalog, sql, title); } catch (e: any) { toast(e?.message || String(e), 5000); } } +function selectedDdlTargets() { + const targets = sidebarStructureExportTargets(activeNode.value, connectionStore.treeNodes, acceptedSelectionIds ?? connectionStore.selectedTreeNodeIds); + return sidebarDdlTargetsForExecutionContext(activeNode.value as TreeNode & { connectionId: string; database: string }, targets); +} + +async function openDdl() { + const targets = selectedDdlTargets(); + if (!targets.length) return; + if (targets.length > 1) { + await exportStructure(); + return; + } + emit("open-ddl", targets[0]!); +} + async function refresh() { const node = activeNode.value; try { @@ -4328,7 +4346,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (node.type === "table") { items.push({ label: t("contextMenu.viewDdl"), - action: () => emit("open-ddl", node), + action: openDdl, icon: FileCode, }); } @@ -4337,7 +4355,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); items.push({ label: t("contextMenu.viewDdl"), - action: () => emit("open-ddl", node), + action: openDdl, icon: FileCode, }); items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 }); diff --git a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts index 3f345498ae..f74a314362 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts @@ -7,6 +7,7 @@ const apiMock = vi.hoisted(() => ({ buildTableSelectSql: vi.fn(async () => 'SELECT * FROM "main"."users" LIMIT 10000'), executeQuery: vi.fn(), exportQueryResultJson: vi.fn(), + getTableDdl: vi.fn(), })); vi.mock("@/lib/backend/api", () => apiMock); @@ -24,10 +25,14 @@ vi.mock("vue-i18n", () => ({ })); import { useSidebarTreeExportRuntime } from "@/composables/useSidebarTreeExportRuntime"; +import { showStructurePreviewDialog, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState"; describe("useSidebarTreeExportRuntime", () => { beforeEach(() => { vi.clearAllMocks(); + showStructurePreviewDialog.value = false; + structurePreviewSql.value = ""; + structurePreviewTitle.value = ""; }); it("translates direct executeQuery errors for sidebar JSON export", async () => { @@ -52,4 +57,33 @@ describe("useSidebarTreeExportRuntime", () => { expect(apiMock.executeQuery).toHaveBeenCalledOnce(); expect(toastMock).toHaveBeenCalledWith("导出失败:上一个 DuckDB 查询仍在停止中,请稍后重试。", 5000); }); + + it("loads and joins every selected DDL in tree order", async () => { + apiMock.getTableDdl.mockResolvedValueOnce("CREATE TABLE one (id INT)").mockResolvedValueOnce("CREATE VIEW two AS SELECT 1;"); + const first = { id: "table-1", type: "table", label: "one", connectionId: "conn-1", database: "db", schema: "main" } as TreeNode; + const second = { id: "view-1", type: "view", label: "two", connectionId: "conn-1", database: "db", schema: "main" } as TreeNode; + const group = { id: "tables", type: "group-tables", label: "Tables", children: [first, second] } as TreeNode; + const activeNode = shallowRef(first); + const connectionStore = { + ensureConnected: vi.fn(), + treeNodes: [group], + selectedTreeNodeIds: [second.id, first.id], + }; + const runtime = useSidebarTreeExportRuntime({ + activeNode, + connectionStore: connectionStore as never, + settingsStore: {} as never, + acceptedSelectionIds: () => null, + }); + + await runtime.exportStructure(); + + expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(1, first.connectionId); + expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(2, second.connectionId); + expect(apiMock.getTableDdl).toHaveBeenNthCalledWith(1, first.connectionId, first.database, first.schema, first.label, undefined, undefined); + expect(apiMock.getTableDdl).toHaveBeenNthCalledWith(2, second.connectionId, second.database, second.schema, second.label, "VIEW", undefined); + expect(structurePreviewSql.value).toBe("CREATE TABLE one (id INT);\n\nCREATE VIEW two AS SELECT 1;\n"); + expect(structurePreviewTitle.value).toBe("contextMenu.exportStructurePreviewTitleMultiple"); + expect(showStructurePreviewDialog.value).toBe(true); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTemplate.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTemplate.spec.ts new file mode 100644 index 0000000000..43cf6d1851 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTemplate.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildSidebarDdlTemplateSql, sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate"; + +describe("sidebar DDL template", () => { + it("preserves the single-target SQL", async () => { + const sql = await buildSidebarDdlTemplateSql( + ["one"], + async () => "CREATE TABLE one (id INT)", + async (ddl) => ddl, + ); + + expect(sql).toBe("CREATE TABLE one (id INT)"); + }); + + it("loads, formats, and joins multiple targets in order", async () => { + const loadDdl = vi.fn(async (target: string) => `CREATE TABLE ${target} (id INT)`); + const formatDdl = vi.fn(async (ddl: string, target: string) => `${ddl} /* ${target} */`); + + const sql = await buildSidebarDdlTemplateSql(["one", "two"], loadDdl, formatDdl); + + expect(loadDdl.mock.calls).toEqual([["one"], ["two"]]); + expect(formatDdl.mock.calls).toEqual([ + ["CREATE TABLE one (id INT)", "one"], + ["CREATE TABLE two (id INT)", "two"], + ]); + expect(sql).toBe("CREATE TABLE one (id INT) /* one */;\n\nCREATE TABLE two (id INT) /* two */;\n"); + }); + + it("keeps DDL targets in the active SQL execution context", () => { + const active = { id: "active", connectionId: "c1", database: "db1", catalog: "catalog1" }; + const sameContext = { id: "same", connectionId: "c1", database: "db1", catalog: "catalog1" }; + const otherDatabase = { id: "database", connectionId: "c1", database: "db2", catalog: "catalog1" }; + const otherConnection = { id: "connection", connectionId: "c2", database: "db1", catalog: "catalog1" }; + const otherCatalog = { id: "catalog", connectionId: "c1", database: "db1", catalog: "catalog2" }; + + expect(sidebarDdlTargetsForExecutionContext(active, [active, otherDatabase, sameContext, otherConnection, otherCatalog])).toEqual([active, sameContext]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts index e55b3c58a9..7107d9ebaf 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarExportRuntime.spec.ts @@ -21,9 +21,10 @@ describe("sidebar export runtime", () => { it("freezes the accepted structure selection before export work starts", () => { const first: TreeNode = { id: "t1", label: "one", type: "table", connectionId: "c1", database: "db" }; const second: TreeNode = { id: "t2", label: "two", type: "view", connectionId: "c1", database: "db" }; - const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [first, second] }; + const third: TreeNode = { id: "t3", label: "three", type: "materialized_view", connectionId: "c1", database: "db" }; + const group: TreeNode = { id: "group", label: "Tables", type: "group-tables", children: [first, second, third] }; - expect(sidebarStructureExportTargets(first, [group], [first.id, second.id])).toEqual([first, second]); + expect(sidebarStructureExportTargets(first, [group], [third.id, first.id, second.id])).toEqual([first, second, third]); expect(sidebarStructureExportTargets(first, [group], [second.id])).toEqual([first]); }); }); diff --git a/apps/desktop/src/lib/sidebar/sidebarDdlTemplate.ts b/apps/desktop/src/lib/sidebar/sidebarDdlTemplate.ts new file mode 100644 index 0000000000..002f763c9a --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarDdlTemplate.ts @@ -0,0 +1,19 @@ +import { joinExportedDdls } from "@/lib/export/ddlExport"; + +interface SidebarDdlExecutionTarget { + connectionId: string; + database: string; + catalog?: string; +} + +export function sidebarDdlTargetsForExecutionContext(activeTarget: SidebarDdlExecutionTarget, targets: readonly T[]): T[] { + return targets.filter((target) => target.connectionId === activeTarget.connectionId && target.database === activeTarget.database && (target.catalog ?? "") === (activeTarget.catalog ?? "")); +} + +export async function buildSidebarDdlTemplateSql(targets: readonly T[], loadDdl: (target: T) => Promise, formatDdl: (ddl: string, target: T) => Promise): Promise { + const parts: string[] = []; + for (const target of targets) { + parts.push(await formatDdl(await loadDdl(target), target)); + } + return parts.length === 1 ? parts[0]! : joinExportedDdls(parts); +}