feat(export): add DB2 comments to XLSX headers
This commit is contained in:
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"] })]));
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user