feat(export): add DB2 comments to XLSX headers

This commit is contained in:
大峰 authored and GitHub committed 2026-07-29 22:37:05 +08:00
1 parent 51b5c8ae95
commit 843f945581
35 files changed
+640 -87

No files matched your search

@@ -15,6 +15,10 @@ vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string, params?: { message?: string }) => (params?.message ? `${key}: ${params.message}` : key) }),
}));
vi.mock("@/i18n", () => ({
default: { install() {} },
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast }),
}));
@@ -0,0 +1,128 @@
// @vitest-environment happy-dom
import { computed, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
exportQueryResultsXlsx: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({
default: {
emits: ["confirm"],
mounted(this: { $emit: (event: string, value: boolean) => void }) {
queueMicrotask(() => this.$emit("confirm", true));
},
render() {
return null;
},
},
}));
vi.mock("@/i18n", () => ({
default: { install() {} },
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/composables/useDataGridExtractor", () => ({
useDataGridExtractor: () => ({
copyWithExtractor: vi.fn(),
previewWithExtractor: vi.fn(),
canCopyWithExtractor: vi.fn(),
}),
}));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({
addTask: vi.fn(),
updateTableExportTask: vi.fn(),
registerTaskCancelHandler: vi.fn(),
unregisterTaskCancelHandler: vi.fn(),
removeTask: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
globalDateTimeExportFormat: "",
numericColumnRightAlign: true,
},
}),
}));
vi.mock("@/lib/backend/api", () => ({
exportQueryResultsXlsx: mocks.exportQueryResultsXlsx,
}));
function queryResult(columns: string[], rows: QueryResult["rows"]): QueryResult {
return {
columns,
column_types: columns.map(() => "varchar"),
rows,
affected_rows: 0,
execution_time_ms: 0,
};
}
function createOptions(): UseDataGridExportOptions {
return {
columns: computed(() => ["id"]),
displayItems: computed(() => []),
sql: computed(() => undefined),
tableMeta: computed(() => ({
tableName: "users",
primaryKeys: [],
columns: [
{ name: "id", data_type: "int", is_nullable: false, comment: "Identifier" },
{ name: "name", data_type: "varchar", is_nullable: false, comment: "Display name" },
],
})),
databaseType: computed(() => "mysql"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
context: computed(() => "results"),
sourceColumns: computed(() => ["id"]),
columnTypes: computed(() => ["int"]),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedCellMatrix: computed(() => null),
selectedRange: computed(() => null),
contextCell: ref(null),
contextSelectionIsSynthetic: ref(false),
getRowItem: () => undefined,
selectedRowIds: ref(new Set()),
hasRowSelection: computed(() => false),
allExportResults: computed(() => [
{ sheetName: "Ids", result: queryResult(["id"], [[1]]) },
{ sheetName: "Names", result: queryResult(["name"], [["Ada"]]) },
]),
};
}
describe("useDataGridExport XLSX headers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("maps comments independently for every result sheet", async () => {
const state = useDataGridExport(createOptions());
await state.exportAllResultsXlsx();
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["Identifier"] }), expect.objectContaining({ sheetName: "Names", columnComments: ["Display name"] })]));
});
});
+107 -30
View File
@@ -1,4 +1,4 @@
import { computed, type ComputedRef, type Ref } from "vue";
import { computed, type ComputedRef, type Ref, createApp } from "vue";
import { useI18n } from "vue-i18n";
import { useDataGridExtractor } from "@/composables/useDataGridExtractor";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
@@ -24,6 +24,8 @@ import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { buildXlsxSqlWorksheet } from "@/lib/export/xlsxSqlSheet";
import { formatTemporalRowsForExport } from "@/lib/dataGrid/columnFormatter";
import { translateBackendError } from "@/i18n/backend-errors";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import i18n from "@/i18n";
/**
* Format metadata for backend table exports. Each entry maps a format key
@@ -223,6 +225,47 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return pattern ? { ...result, rows: formatTemporalRowsForExport(result.rows, result.columnTypes, pattern) } : result;
}
function buildColumnComments(columns: string[]): (string | null)[] | undefined {
const meta = tableMeta.value;
if (!meta?.columns) return undefined;
const commentMap = new Map<string, string>();
for (const col of meta.columns) {
if (col.comment) commentMap.set(col.name, col.comment);
}
const result = columns.map((col) => commentMap.get(col) ?? null);
return result.some((c) => c !== null) ? result : undefined;
}
function hasTableComments(): boolean {
const meta = tableMeta.value;
if (!meta?.columns) return false;
return meta.columns.some((col) => col.comment && col.comment.trim().length > 0);
}
function showXlsxHeaderDialog(): Promise<boolean | null> {
if (!hasTableComments()) return Promise.resolve(false);
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(XlsxHeaderDialog, {
open: true,
onConfirm: (useCommentHeader: boolean) => {
resolve(useCommentHeader);
app.unmount();
document.body.removeChild(container);
},
onCancel: () => {
resolve(null);
app.unmount();
document.body.removeChild(container);
},
});
app.use(i18n);
app.mount(container);
});
}
function normalizeCompleteLocalResult(result: QueryResult): { columns: string[]; columnTypes: string[]; rows: CellValue[][] } {
const hiddenColumnIndexes = new Set(result.hidden_column_indexes ?? []);
const exportedColumnIndexes = result.columns.map((_, index) => index).filter((index) => !hiddenColumnIndexes.has(index));
@@ -239,10 +282,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
};
}
async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true, formatDateTime = true): Promise<{ columns: string[]; columnTypes: string[]; rows: CellValue[][] }> {
async function resultToExport(
rowIds?: number[],
onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void,
useFullExport = true,
formatDateTime = true,
useCommentHeader = false,
): Promise<{ columns: string[]; columnTypes: string[]; columnComments?: (string | null)[]; rows: CellValue[][] }> {
if (useFullExport && rowIds === undefined && fullExportResult && !hasCompleteLocalResult?.value) {
const result = await fullExportResult(onProgress);
if (result) return applyGlobalDateTimeExportFormat({ columns: result.columns, columnTypes: result.column_types ?? [], rows: result.rows }, formatDateTime);
if (result) {
const columnComments = useCommentHeader ? buildColumnComments(result.columns) : undefined;
return { ...applyGlobalDateTimeExportFormat({ columns: result.columns, columnTypes: result.column_types ?? [], rows: result.rows }, formatDateTime), columnComments };
}
}
// The full result is already in memory — export the raw QueryResult (all
// rows, all columns, committed values) so "export all data" matches the
@@ -250,16 +302,22 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
// and reflects client-side filters/search and unsaved edits, which would
// silently change what the export contains.
if (useFullExport && rowIds === undefined && hasCompleteLocalResult?.value && completeLocalResult?.value) {
return applyGlobalDateTimeExportFormat(normalizeCompleteLocalResult(completeLocalResult.value), formatDateTime);
const normalized = normalizeCompleteLocalResult(completeLocalResult.value);
const columnComments = useCommentHeader ? buildColumnComments(normalized.columns) : undefined;
return { ...applyGlobalDateTimeExportFormat(normalized, formatDateTime), columnComments };
}
return applyGlobalDateTimeExportFormat(
{
columns: columns.value,
columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
rows: rowsToExport(rowIds).map((item) => item.data),
},
formatDateTime,
);
const commentHeader = useCommentHeader ? buildColumnComments(columns.value) : undefined;
return {
...applyGlobalDateTimeExportFormat(
{
columns: columns.value,
columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
rows: rowsToExport(rowIds).map((item) => item.data),
},
formatDateTime,
),
columnComments: commentHeader,
};
}
function currentXlsxSheetName(): string {
@@ -270,11 +328,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return resultExportSql?.value || sql.value;
}
async function writeXlsxResult(outputPath: string, result: { columns: string[]; columnTypes: string[]; rows: CellValue[][] }, includeSqlSheet: boolean) {
async function writeXlsxResult(outputPath: string, result: { columns: string[]; columnTypes: string[]; columnComments?: (string | null)[]; rows: CellValue[][] }, includeSqlSheet: boolean) {
const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet([{ sql: currentExportSql() || "" }]) : undefined;
const rightAlign = useSettingsStore().editorSettings.numericColumnRightAlign;
if (!sqlWorksheet) {
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.rows, rightAlign);
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.columnTypes, result.columnComments, result.rows, rightAlign);
return;
}
await api.exportQueryResultsXlsx(outputPath, [
@@ -282,6 +340,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
sheetName: currentXlsxSheetName(),
columns: result.columns,
columnTypes: result.columnTypes,
columnComments: result.columnComments,
rows: result.rows,
numericColumnRightAlign: rightAlign,
},
@@ -723,10 +782,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportXlsxResult(rowIds: number[] | undefined, includeSqlSheet: boolean) {
const useCommentHeader = await showXlsxHeaderDialog();
if (useCommentHeader === null) return;
await runExclusiveExport(async () => {
try {
if (await exportQueryResultViaBackend("xlsx", rowIds, includeSqlSheet)) return;
if (await exportFullTableDataViaBackend("xlsx", rowIds)) return;
if (await exportQueryResultViaBackend("xlsx", rowIds, includeSqlSheet, useCommentHeader)) return;
if (await exportFullTableDataViaBackend("xlsx", rowIds, useCommentHeader)) return;
let outputPath = exportFileName("export", "xlsx");
if (isTauriRuntime()) {
@@ -752,16 +814,22 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
};
exportProgressDialog.value = true;
}
const result = await resultToExport(rowIds, (info) => {
if (needsFullExport && exportProgressState && exportProgressState.value.status === "Running") {
const adjustedTotal = info.totalRows !== null && info.rowsExported > info.totalRows ? info.rowsExported : info.totalRows;
exportProgressState.value = {
...exportProgressState.value,
rowsExported: info.rowsExported,
totalRows: adjustedTotal,
};
}
});
const result = await resultToExport(
rowIds,
(info) => {
if (needsFullExport && exportProgressState && exportProgressState.value.status === "Running") {
const adjustedTotal = info.totalRows !== null && info.rowsExported > info.totalRows ? info.rowsExported : info.totalRows;
exportProgressState.value = {
...exportProgressState.value,
rowsExported: info.rowsExported,
totalRows: adjustedTotal,
};
}
},
true,
true,
useCommentHeader,
);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
@@ -802,6 +870,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportCurrentPageXlsxResult(includeSqlSheet: boolean) {
const useCommentHeader = await showXlsxHeaderDialog();
if (useCommentHeader === null) return;
await runExclusiveExport(async () => {
try {
let outputPath = exportFileName("export-page", "xlsx", { page: true });
@@ -814,7 +885,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (!path) return;
outputPath = path as string;
}
const result = await resultToExport(undefined, undefined, false);
const result = await resultToExport(undefined, undefined, false, true, useCommentHeader);
await writeXlsxResult(outputPath, result, includeSqlSheet);
toast(t("grid.exported"));
} catch (e: any) {
@@ -832,6 +903,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportAllResultsXlsxResult(includeSqlSheet: boolean) {
const useCommentHeader = await showXlsxHeaderDialog();
if (useCommentHeader === null) return;
await runExclusiveExport(async () => {
try {
const sheets = (allExportResults?.value ?? []).filter((sheet) => sheet.result.columns.length > 0);
@@ -854,6 +928,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
sheetName: sheet.sheetName,
columns: sheet.result.columns,
columnTypes: sheet.result.column_types ?? [],
columnComments: useCommentHeader ? buildColumnComments(sheet.result.columns) : undefined,
rows: formatTemporalRowsForExport(sheet.result.rows, sheet.result.column_types ?? [], exportPattern),
numericColumnRightAlign: rightAlign,
}));
@@ -874,7 +949,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
await exportAllResultsXlsxResult(true);
}
async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[]): Promise<boolean> {
async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], useCommentHeader = false): Promise<boolean> {
const meta = tableMeta.value;
// The backend table exporter currently builds two-part table names. External
// Doris/StarRocks catalogs need the data-tab paginator's three-part SQL.
@@ -932,6 +1007,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
format,
columns: columns.value,
columnTypes: columnTypes.value,
columnComments: useCommentHeader ? buildColumnComments(columns.value) : undefined,
primaryKeys: meta.primaryKeys,
whereInput: whereInput.value,
orderBy: orderBy.value,
@@ -966,7 +1042,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return true;
}
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt", rowIds?: number[], includeSqlSheet = false): Promise<boolean> {
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt", rowIds?: number[], includeSqlSheet = false, useCommentHeader = false): Promise<boolean> {
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) {
return false;
}
@@ -990,7 +1066,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
const exportId = uuid();
const baseRequest = await queryResultExportRequest({ exportId, filePath: outputPath, format, includeSqlSheet });
const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true } : undefined;
const columnComments = useCommentHeader ? buildColumnComments(columns.value) : undefined;
const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true, columnComments } : undefined;
if (!request) throw new Error("Unable to build query result export request");
if (exportProgressState) {