feat(export): support XLSX comment headers

This commit is contained in:
二丫讲梵
2026-08-25 22:00:26 +08:00
committed by GitHub
parent 86f36e51ad
commit 46111850db
4 changed files with 311 additions and 28 deletions
@@ -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);
@@ -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)');
});
});
@@ -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<string, unknown>) => {
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<ColumnInfo[]>();
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),
);
});
});
@@ -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<typeof effectiveDatabaseTypeForConnection>;
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<XlsxHeaderMode | null> {
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 {