From 46111850dbd96d39755bccc160ae69c4038bb11f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Tue, 25 Aug 2026 22:00:26 +0800 Subject: [PATCH] feat(export): support XLSX comment headers --- .../src/components/objects/ObjectBrowser.vue | 6 +- .../__tests__/ObjectBrowserExport.spec.ts | 35 ++++ .../useSidebarTreeExportRuntime.spec.ts | 164 +++++++++++++++++- .../useSidebarTreeExportRuntime.ts | 134 +++++++++++--- 4 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index f1294969f7..20b6f30ac4 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -2012,13 +2012,11 @@ async function exportDataXlsx(row: ObjectBrowserRow) { try { columnInfos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog); - const hasComments = hasXlsxHeaderComments(columnInfos.map((column) => column.comment)); - const result = await showObjectBrowserXlsxHeaderDialog(hasComments); + const result = await showObjectBrowserXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos.map((column) => column.comment))); if (result === null) return; headerMode = result; } catch { - // Column fetch failed, fallback to export without comments - columnInfos = undefined; + // Export still works with field-name headers when column metadata is unavailable. } await exportTableData(row, "xlsx", columnInfos, headerMode); diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts new file mode 100644 index 0000000000..4368508393 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowserExport.spec.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8"); + +function functionBody(name: string): string { + const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source); + if (!signature) throw new Error(`Missing function ${name}`); + const bodyStart = signature.index + signature[0].length; + let depth = 1; + for (let index = bodyStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") depth -= 1; + if (depth === 0) return source.slice(bodyStart, index); + } + throw new Error(`Unclosed function ${name}`); +} + +describe("ObjectBrowser XLSX export", () => { + it("asks for a header mode before opening the save dialog when comments exist", () => { + const dialog = functionBody("showObjectBrowserXlsxHeaderDialog"); + const exportDataXlsx = functionBody("exportDataXlsx"); + + expect(dialog).toContain('if (!hasComments) return Promise.resolve("name")'); + expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos.map((column) => column.comment))"); + expect(exportDataXlsx.indexOf("await showObjectBrowserXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(row, "xlsx", columnInfos, headerMode)')); + }); + + it("falls back to field-name headers when column metadata is unavailable", () => { + const exportDataXlsx = functionBody("exportDataXlsx"); + + expect(exportDataXlsx).toContain("catch {\n // Export still works with field-name headers when column metadata is unavailable.\n }"); + expect(exportDataXlsx).toContain('await exportTableData(row, "xlsx", columnInfos, headerMode)'); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts index 3669c6d7b6..7023266d36 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarTreeExportRuntime.spec.ts @@ -1,19 +1,28 @@ +import { readFileSync } from "node:fs"; import { shallowRef } from "vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { TreeNode } from "@/types/database"; +import type { ColumnInfo, TreeNode } from "@/types/database"; +const source = readFileSync(new URL("../useSidebarTreeExportRuntime.ts", import.meta.url), "utf8"); const toastMock = vi.hoisted(() => vi.fn()); +const addExportTaskMock = vi.hoisted(() => vi.fn()); +const updateTableExportTaskMock = vi.hoisted(() => vi.fn()); const apiMock = vi.hoisted(() => ({ buildTableSelectSql: vi.fn(async () => 'SELECT * FROM "main"."users" LIMIT 10000'), executeQuery: vi.fn(), + exportQueryResultCsv: vi.fn(), exportQueryResultJson: vi.fn(), + exportQueryResultXlsx: vi.fn(), + getColumns: vi.fn(), getTableDdl: vi.fn(), + startTableExport: vi.fn(), })); vi.mock("@/lib/backend/api", () => apiMock); vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: toastMock }) })); -vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => ({ addTask: vi.fn() }) })); +vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => ({ addTask: addExportTaskMock, updateTableExportTask: updateTableExportTaskMock }) })); +vi.mock("@/i18n", () => ({ default: { install() {} } })); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, params?: Record) => { @@ -27,9 +36,64 @@ vi.mock("vue-i18n", () => ({ import { useSidebarTreeExportRuntime } from "@/composables/useSidebarTreeExportRuntime"; import { showStructurePreviewDialog, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState"; +function functionBody(name: string): string { + const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source); + if (!signature) throw new Error(`Missing function ${name}`); + const bodyStart = signature.index + signature[0].length; + let depth = 1; + for (let index = bodyStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") depth -= 1; + if (depth === 0) return source.slice(bodyStart, index); + } + throw new Error(`Unclosed function ${name}`); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function column(name: string, isPrimaryKey = false): ColumnInfo { + return { + name, + data_type: "text", + is_nullable: !isPrimaryKey, + column_default: null, + is_primary_key: isPrimaryKey, + extra: null, + comment: null, + }; +} + +function exportSettings() { + return { + editorSettings: { + exportBatchSize: 128, + exportRowLimit: 500, + exportRowLimitEnabled: true, + }, + }; +} + describe("useSidebarTreeExportRuntime", () => { beforeEach(() => { vi.clearAllMocks(); + addExportTaskMock.mockImplementation((tableName: string, format: string, filePath: string) => ({ + exportId: "export-1", + tableName, + format, + filePath, + status: "Running", + rowsExported: 0, + totalRows: null, + })); + apiMock.startTableExport.mockResolvedValue({}); showStructurePreviewDialog.value = false; structurePreviewSql.value = ""; structurePreviewTitle.value = ""; @@ -86,4 +150,100 @@ describe("useSidebarTreeExportRuntime", () => { expect(structurePreviewTitle.value).toBe("contextMenu.exportStructurePreviewTitleMultiple"); expect(showStructurePreviewDialog.value).toBe(true); }); + + it("prompts for the header mode before it opens the save dialog when comments exist", () => { + const exportDataXlsx = functionBody("exportDataXlsx"); + + expect(source).toContain('import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue"'); + expect(exportDataXlsx).toContain("await api.getColumns("); + expect(exportDataXlsx).toContain("hasXlsxHeaderComments(columnInfos?.map((column) => column.comment))"); + expect(exportDataXlsx.indexOf("await showSidebarTreeXlsxHeaderDialog(")).toBeLessThan(exportDataXlsx.indexOf('await exportTableData(target, "xlsx", columnInfos, headerMode)')); + }); + + it("falls back to field-name headers when column metadata is unavailable", () => { + const exportDataXlsx = functionBody("exportDataXlsx"); + + expect(exportDataXlsx).toContain("catch {\n // Export still works with field-name headers when column metadata is unavailable.\n }"); + expect(exportDataXlsx).toContain('await exportTableData(target, "xlsx", columnInfos, headerMode)'); + }); + + it("sends the selected mode's header overrides to both XLSX export paths", () => { + const exportTableData = functionBody("exportTableData"); + + expect(exportTableData).toContain("buildXlsxHeaderOverrides(result.columns, comments, headerMode)"); + expect(exportTableData).toContain("columnComments,"); + }); + + it("keeps the original table and export options when selection changes during XLSX preparation", async () => { + const metadata = deferred(); + apiMock.getColumns.mockReturnValueOnce(metadata.promise); + const originalNode = { id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db-a", schema: "public", catalog: "catalog-a", children: [] } as TreeNode; + const activeNode = shallowRef(originalNode); + const settingsStore = exportSettings(); + const connectionStore = { + ensureConnected: vi.fn(), + getConfig: vi.fn(() => ({ db_type: "postgres" })), + connectionIdentifierQuote: vi.fn((connectionId: string) => (connectionId === "conn-1" ? '"' : "`")), + treeNodes: [], + selectedTreeNodeIds: [], + }; + const runtime = useSidebarTreeExportRuntime({ + activeNode, + connectionStore: connectionStore as never, + settingsStore: settingsStore as never, + acceptedSelectionIds: () => null, + }); + + const exportPromise = runtime.exportDataXlsx(); + await vi.waitFor(() => expect(apiMock.getColumns).toHaveBeenCalledWith("conn-1", "db-a", "public", "users", "catalog-a")); + activeNode.value = { id: "table-2", type: "table", label: "orders", connectionId: "conn-2", database: "db-b", schema: "sales", children: [] } as TreeNode; + settingsStore.editorSettings.exportBatchSize = 16; + settingsStore.editorSettings.exportRowLimit = 10; + metadata.resolve([column("name")]); + + await exportPromise; + + expect(apiMock.startTableExport).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: "conn-1", + database: "db-a", + schema: "public", + identifierQuote: '"', + tableName: "users", + filePath: "users.xlsx", + columns: ["name"], + batchSize: 128, + rowLimit: 500, + }), + expect.any(Function), + ); + }); + + it("keeps explicit column order and primary keys for keyset XLSX export", async () => { + apiMock.getColumns.mockResolvedValueOnce([column("display_name"), column("id", true), column("created_at")]); + const activeNode = shallowRef({ id: "table-1", type: "table", label: "users", connectionId: "conn-1", database: "db", schema: "public", children: [] } as TreeNode); + const connectionStore = { + ensureConnected: vi.fn(), + getConfig: vi.fn(() => ({ db_type: "postgres" })), + connectionIdentifierQuote: vi.fn(() => '"'), + treeNodes: [], + selectedTreeNodeIds: [], + }; + const runtime = useSidebarTreeExportRuntime({ + activeNode, + connectionStore: connectionStore as never, + settingsStore: exportSettings() as never, + acceptedSelectionIds: () => null, + }); + + await runtime.exportDataXlsx(); + + expect(apiMock.startTableExport).toHaveBeenCalledWith( + expect.objectContaining({ + columns: ["display_name", "id", "created_at"], + primaryKeys: ["id"], + }), + expect.any(Function), + ); + }); }); diff --git a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts index 0196691f46..e598c6b712 100644 --- a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts @@ -1,5 +1,6 @@ -import type { ShallowRef } from "vue"; +import { createApp, type ShallowRef } from "vue"; import { useI18n } from "vue-i18n"; +import i18n from "@/i18n"; import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { useToast } from "@/composables/useToast"; import type { useConnectionStore } from "@/stores/connectionStore"; @@ -14,6 +15,8 @@ import { joinExportedDdls } from "@/lib/export/ddlExport"; import { translateBackendError } from "@/i18n/backend-errors"; import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime"; import { fetchTableDataForExport } from "@/lib/table/tableDataExport"; +import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue"; +import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxHeaderMode } from "@/lib/export/xlsxHeader"; import { isLoadingStructurePreview, showStructureDocCopyDialog, showStructurePreviewDialog, structureDocCopyText, structureDocCopyTitle, structurePreviewDefaultFileName, structurePreviewError, structurePreviewSql, structurePreviewTitle } from "@/components/sidebar/sidebarTreeDialogState"; type StructureCopyFormat = "tsv" | "markdown"; @@ -25,6 +28,21 @@ interface SidebarTreeExportRuntimeOptions { acceptedSelectionIds: () => readonly string[] | null; } +interface SidebarTableExportTarget { + connectionId: string; + database: string; + schema?: string; + metadataSchema: string; + catalog?: string; + tableName: string; + tableType?: string; + databaseType: ReturnType; + loadColumnMetadata: boolean; + identifierQuote?: string; + batchSize: number; + rowLimit: number | null; +} + export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOptions) { const { t } = useI18n(); const { toast } = useToast(); @@ -237,18 +255,62 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt } } - async function exportTableData(format: "csv" | "xlsx" | "sql") { + function showSidebarTreeXlsxHeaderDialog(hasComments: boolean): Promise { + if (!hasComments) return Promise.resolve("name"); + + return new Promise((resolve) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const app = createApp(XlsxHeaderDialog, { + open: true, + onConfirm: (mode: XlsxHeaderMode) => { + resolve(mode); + app.unmount(); + document.body.removeChild(container); + }, + onCancel: () => { + resolve(null); + app.unmount(); + document.body.removeChild(container); + }, + }); + app.use(i18n); + app.mount(container); + }); + } + + function currentTableExportTarget(): SidebarTableExportTarget | null { const node = activeNode.value; - if (!node.connectionId || !node.database) return; + if (!node.connectionId || !node.database) return null; const connectionId = node.connectionId; const database = node.database; const config = connectionStore.getConfig(connectionId); - if (!config) return; + if (!config) return null; + const editorSettings = settingsStore.editorSettings; + + return { + connectionId, + database, + schema: node.schema || undefined, + metadataSchema: node.schema || database, + catalog: node.catalog, + tableName: node.label, + tableType: node.tableType, + databaseType: effectiveDatabaseTypeForConnection(config), + loadColumnMetadata: config.db_type === "neo4j", + identifierQuote: connectionStore.connectionIdentifierQuote(connectionId), + batchSize: editorSettings.exportBatchSize, + rowLimit: editorSettings.exportRowLimitEnabled ? editorSettings.exportRowLimit : null, + }; + } + + async function exportTableData(target: SidebarTableExportTarget, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name") { + const { connectionId, database } = target; let task: ExportTask | null = null; try { // Choose the destination before registering a background task so cancellation creates no orphan tracker entry. - let outputPath = `${node.label}.${format}`; + let outputPath = `${target.tableName}.${format}`; if (isTauriRuntime()) { const { save } = await import("@tauri-apps/plugin-dialog"); const filterName = format === "csv" ? "CSV" : format === "xlsx" ? "Excel" : "SQL"; @@ -261,21 +323,25 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt } await connectionStore.ensureConnected(connectionId); - task = addExportTask(node.label, format, outputPath); + task = addExportTask(target.tableName, format, outputPath); const currentTask = task; - const effectiveDbType = effectiveDatabaseTypeForConnection(config); - if (effectiveDbType === "victoriametrics") { + const exportColumnInfos = columnInfos ?? (target.loadColumnMetadata ? await api.getColumns(connectionId, database, target.metadataSchema, target.tableName, target.catalog) : undefined); + const queryColumns = exportColumnInfos?.map((column) => column.name); + const primaryKeys = exportColumnInfos?.filter((column) => column.is_primary_key).map((column) => column.name); + if (target.databaseType === "victoriametrics") { const result = await fetchTableDataForExport({ - databaseType: effectiveDbType, - schema: node.schema, - tableName: node.label, - tableType: node.tableType, + databaseType: target.databaseType, + schema: target.schema, + tableName: target.tableName, + tableType: target.tableType, executePage: (sql) => api.executeQuery(connectionId, database, sql), }); if (format === "csv") { await api.exportQueryResultCsv(outputPath, result.columns, result.rows); } else { - await api.exportQueryResultXlsx(outputPath, node.label, result.columns, result.column_types ?? result.columns.map(() => ""), undefined, result.rows); + const comments = result.columns.map((name) => exportColumnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment); + const headerOverrides = buildXlsxHeaderOverrides(result.columns, comments, headerMode); + await api.exportQueryResultXlsx(outputPath, target.tableName, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows); } currentTask.status = "Done"; currentTask.rowsExported = result.rows.length; @@ -283,21 +349,29 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt toast(t("grid.exported")); return; } - const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((column) => column.name) : undefined; - const rowLimit = settingsStore.editorSettings.exportRowLimitEnabled ? settingsStore.editorSettings.exportRowLimit : null; + const columnComments = + format === "xlsx" && exportColumnInfos + ? buildXlsxHeaderOverrides( + exportColumnInfos.map((column) => column.name), + exportColumnInfos.map((column) => column.comment), + headerMode, + ) + : undefined; const request: api.TableExportRequest = { exportId: currentTask.exportId, connectionId, database, - schema: node.schema || undefined, - identifierQuote: connectionStore.connectionIdentifierQuote(connectionId), - tableName: node.label, + schema: target.schema, + identifierQuote: target.identifierQuote, + tableName: target.tableName, filePath: outputPath, format, columns: queryColumns, - batchSize: settingsStore.editorSettings.exportBatchSize, + columnComments, + primaryKeys, + batchSize: target.batchSize, skipCount: format === "sql", - rowLimit, + rowLimit: target.rowLimit, }; await api.startTableExport(request, (progress) => { @@ -316,11 +390,27 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt async function exportData(format: "csv" | "json" | "sql") { if (format === "json") await exportDataLegacy(format); - else await exportTableData(format); + else { + const target = currentTableExportTarget(); + if (target) await exportTableData(target, format); + } } async function exportDataXlsx() { - await exportTableData("xlsx"); + const target = currentTableExportTarget(); + if (!target) return; + + let columnInfos: ColumnInfo[] | undefined; + try { + await connectionStore.ensureConnected(target.connectionId); + columnInfos = await api.getColumns(target.connectionId, target.database, target.metadataSchema, target.tableName, target.catalog); + } catch { + // Export still works with field-name headers when column metadata is unavailable. + } + + const headerMode = await showSidebarTreeXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos?.map((column) => column.comment))); + if (headerMode === null) return; + await exportTableData(target, "xlsx", columnInfos, headerMode); } return {