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

@@ -143,7 +143,7 @@ const emit = defineEmits<{
formatError: [message: string];
execute: [source: SqlExecutionOverride];
executeInNewResultTab: [source: SqlExecutionOverride];
exportQuery: [payload: { sql: string; format: "csv" | "xlsx" | "txt" }];
exportQuery: [payload: { sql: string; format: "csv" | "xlsx" | "txt"; columnComments?: (string | null)[] }];
save: [];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [target: SqlObjectNavigationTarget];
@@ -1016,7 +1016,7 @@ function executeInNewResultTabFromContextMenu() {
function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
const sql = executableSql.value;
if (!sql.trim()) return;
emit("exportQuery", { sql, format });
emit("exportQuery", { sql, format, columnComments: undefined });
}
async function copySelectedSqlFromContextMenu() {
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const selected = ref<"original" | "comment">("original");
const emit = defineEmits<{
confirm: [useCommentHeader: boolean];
cancel: [];
}>();
function onConfirm() {
open.value = false;
emit("confirm", selected.value === "comment");
}
function onCancel() {
open.value = false;
emit("cancel");
}
function onOpenChange(value: boolean) {
if (!value) onCancel();
}
</script>
<template>
<Dialog v-model:open="open" @update:open="onOpenChange">
<DialogContent class="sm:max-w-sm" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ t("grid.xlsxHeaderTitle") }}</DialogTitle>
</DialogHeader>
<div class="py-2">
<p class="text-sm text-muted-foreground mb-3">{{ t("grid.xlsxHeaderPrompt") }}</p>
<div class="flex flex-col gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="original" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderOriginal") }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="selected" value="comment" class="h-4 w-4" />
<span class="text-sm">{{ t("grid.xlsxHeaderComment") }}</span>
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="onCancel">{{ t("common.cancel") }}</Button>
<Button @click="onConfirm">{{ t("common.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import i18n from "@/i18n";
import XlsxHeaderDialog from "../XlsxHeaderDialog.vue";
describe("XlsxHeaderDialog", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("emits cancel when the built-in close button dismisses the dialog", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
let cancelCount = 0;
const app = createApp(XlsxHeaderDialog, {
open: true,
onCancel: () => {
cancelCount += 1;
},
});
app.use(i18n);
app.mount(container);
await nextTick();
document.querySelector<HTMLElement>('[data-slot="dialog-close"]')!.click();
await nextTick();
expect(cancelCount).toBe(1);
app.unmount();
});
});
@@ -867,7 +867,7 @@ function requestQueryEditorExecuteInNewResultTab() {
return queryEditorRef.value?.requestExecuteInNewResultTab();
}
async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx" | "txt" }) {
async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx" | "txt"; columnComments?: (string | null)[] }) {
const tab = props.activeTab;
if (!tab || tab.mode !== "query") return;
let filePath = `query-result.${payload.format}`;
@@ -878,7 +878,7 @@ async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx"
if (!picked) return;
filePath = picked as string;
}
await queryStore.exportQuerySqlDirect(tab.id, payload.sql, payload.format, filePath);
await queryStore.exportQuerySqlDirect(tab.id, payload.sql, payload.format, filePath, payload.columnComments);
}
function pasteClipboardAsSqlInCondition() {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, ref, watch, type Component } from "vue";
import { computed, createApp, nextTick, onActivated, onBeforeUnmount, ref, watch, type Component } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import {
@@ -46,6 +46,7 @@ import {
X,
} from "@lucide/vue";
import { useI18n } from "vue-i18n";
import i18n from "@/i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -53,6 +54,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
@@ -1782,11 +1784,50 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql")
await exportDataLegacy(row, format);
}
async function exportDataXlsx(row: ObjectBrowserRow) {
await exportTableData(row, "xlsx");
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<boolean | null> {
if (!hasComments) 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);
});
}
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
async function exportDataXlsx(row: ObjectBrowserRow) {
const schema = row.schema || selectedSchema.value;
let useCommentHeader = false;
let columnInfos: ColumnInfo[] | undefined;
try {
columnInfos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog);
const hasComments = columnInfos.some((col) => col.comment && col.comment.trim().length > 0);
const result = await showObjectBrowserXlsxHeaderDialog(hasComments);
if (result === null) return;
useCommentHeader = result;
} catch {
// Column fetch failed, fallback to export without comments
columnInfos = undefined;
}
await exportTableData(row, "xlsx", columnInfos, useCommentHeader);
}
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx", columnInfos?: ColumnInfo[], useCommentHeader = false) {
const schema = row.schema || selectedSchema.value;
// Save dialog first
@@ -1814,7 +1855,18 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
let task: ExportTask | null = null;
try {
const queryColumns = props.connection.db_type === "neo4j" ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog)).map((column) => column.name) : undefined;
let columns: string[] | undefined;
let columnComments: (string | null)[] | undefined;
if (columnInfos) {
columns = columnInfos.map((c) => c.name);
if (format === "xlsx" && useCommentHeader) {
columnComments = columnInfos.map((c) => c.comment ?? null);
}
} else if (props.connection.db_type === "neo4j") {
const infos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog);
columns = infos.map((c) => c.name);
}
task = addExportTask(row.name, format, filePath);
const currentTask = task;
@@ -1827,7 +1879,8 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
tableName: row.name,
filePath,
format,
columns: queryColumns,
columns,
columnComments: format === "xlsx" ? columnComments : undefined,
batchSize: settingsStore.editorSettings.exportBatchSize,
rowLimit,
};
@@ -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) {
+4
View File
@@ -1092,6 +1092,10 @@ export default {
exportSelectedRowsTxt: "Export Selected Rows as TXT",
exported: "Exported",
exportFailed: "Export failed: {message}",
xlsxHeaderTitle: "Header Format",
xlsxHeaderPrompt: "Choose the header format for the exported Excel file:",
xlsxHeaderOriginal: "Use column names as headers",
xlsxHeaderComment: "Use column comments as headers",
copied: "Copied",
copyFailed: "Copy failed: {message}",
previewSqlEmpty: "No pending SQL changes to preview",
+4
View File
@@ -1338,6 +1338,10 @@ export default withEnglishFallback({
numericColumnAlign: "Alineación de columna numérica",
numericColumnAlignLeft: "Alineación izquierda",
numericColumnAlignRight: "Alineación derecha",
xlsxHeaderTitle: "Formato de encabezado",
xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:",
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
xlsxHeaderComment: "Encabezado usando comentarios",
},
exportProgress: {
xlsxRowLimit: "XLSX admite como máximo {limit} filas de datos. Use la exportación CSV para obtener el resultado completo.",
+4
View File
@@ -1336,6 +1336,10 @@ export default withEnglishFallback({
numericColumnAlign: "Allineamento colonna numerica",
numericColumnAlignLeft: "Allineamento a sinistra",
numericColumnAlignRight: "Allineamento a destra",
xlsxHeaderTitle: "Formato intestazione",
xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:",
xlsxHeaderOriginal: "Intestazione con nome campo",
xlsxHeaderComment: "Intestazione con commento",
},
exportProgress: {
xlsxRowLimit: "XLSX supporta al massimo {limit} righe di dati. Usa l'esportazione CSV per il risultato completo.",
+4
View File
@@ -1337,6 +1337,10 @@ export default withEnglishFallback({
numericColumnAlign: "数値列の配置",
numericColumnAlignLeft: "左揃え",
numericColumnAlignRight: "右揃え",
xlsxHeaderTitle: "ヘッダー形式",
xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:",
xlsxHeaderOriginal: "ヘッダーにフィールド名を使用",
xlsxHeaderComment: "ヘッダーにコメントを使用",
},
exportProgress: {
xlsxRowLimit: "XLSX は最大 {limit} 行のデータに対応しています。完全な結果を得るには CSV エクスポートを使用してください。",
+4
View File
@@ -1338,6 +1338,10 @@ export default withEnglishFallback({
numericColumnAlign: "Alinhamento de colunas numéricas",
numericColumnAlignLeft: "Alinhamento à esquerda",
numericColumnAlignRight: "Alinhamento à direita",
xlsxHeaderTitle: "Formato do cabeçalho",
xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:",
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
xlsxHeaderComment: "Cabeçalho usa comentário",
},
exportProgress: {
xlsxRowLimit: "O XLSX suporta no máximo {limit} linhas de dados. Use a exportação CSV para obter o resultado completo.",
+4
View File
@@ -1093,6 +1093,10 @@ export default withEnglishFallback({
exportSelectedRowsTxt: "导出选中行为 TXT",
exported: "导出完成",
exportFailed: "导出失败:{message}",
xlsxHeaderTitle: "表头格式",
xlsxHeaderPrompt: "请选择导出 Excel 时使用的表头格式:",
xlsxHeaderOriginal: "表头使用字段名称",
xlsxHeaderComment: "表头使用注释",
copied: "已复制",
copyFailed: "复制失败:{message}",
previewSqlEmpty: "暂无待预览的 SQL 更改",
+4
View File
@@ -1337,6 +1337,10 @@ export default withEnglishFallback({
numericColumnAlign: "數值列對齊",
numericColumnAlignLeft: "左對齊",
numericColumnAlignRight: "右對齊",
xlsxHeaderTitle: "表頭格式",
xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:",
xlsxHeaderOriginal: "表頭使用欄位名稱",
xlsxHeaderComment: "表頭使用註解",
},
exportProgress: {
xlsxRowLimit: "XLSX 最多支援 {limit} 列資料,請改用 CSV 匯出完整結果。",
+3 -1
View File
@@ -2135,12 +2135,13 @@ function downloadTextFile(filePath: string, fallbackFileName: string, content: s
URL.revokeObjectURL(url);
}
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], columnComments: readonly (string | null)[] | undefined, rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
const { buildXlsxWorkbook } = await import("@/lib/export/xlsxExport");
const workbook = buildXlsxWorkbook({
sheetName: sheetName || "Export",
columns,
columnTypes,
columnComments,
rows,
numericColumnRightAlign,
});
@@ -2162,6 +2163,7 @@ export async function exportQueryResultsXlsx(
sheetName?: string;
columns: readonly string[];
columnTypes?: readonly string[];
columnComments?: readonly (string | null)[];
rows: readonly (readonly XlsxCellValue[])[];
numericColumnRightAlign?: boolean;
}[],
+5 -1
View File
@@ -3427,6 +3427,7 @@ export interface TableExportRequest {
format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt";
columns?: string[];
columnTypes?: Array<string | null | undefined>;
columnComments?: Array<string | null> | null;
primaryKeys?: string[];
whereInput?: string;
orderBy?: string;
@@ -3481,6 +3482,7 @@ export interface QueryResultExportRequest {
exportTableName?: string;
exportColumnTypes?: Array<string | null | undefined>;
numericColumnRightAlign?: boolean;
columnComments?: Array<string | null> | null;
}
export async function startTableExport(request: TableExportRequest, onProgress: (progress: TableExportProgress) => void): Promise<TableExportProgress> {
@@ -3615,13 +3617,14 @@ export async function exportTableDataCsv(options: TableCsvExportOptions): Promis
return invoke("export_table_data_csv", { request: options });
}
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
export async function exportQueryResultXlsx(filePath: string, sheetName: string | undefined, columns: string[], columnTypes: string[], columnComments: readonly (string | null)[] | undefined, rows: readonly (readonly XlsxCellValue[])[], numericColumnRightAlign?: boolean): Promise<void> {
return invoke("export_query_result_xlsx", {
request: {
filePath,
sheetName,
columns,
columnTypes,
columnComments,
rows,
numericColumnRightAlign,
},
@@ -3634,6 +3637,7 @@ export async function exportQueryResultsXlsx(
sheetName?: string;
columns: readonly string[];
columnTypes?: readonly string[];
columnComments?: readonly (string | null)[];
rows: readonly (readonly XlsxCellValue[])[];
numericColumnRightAlign?: boolean;
}[],
@@ -19,6 +19,7 @@ export interface DataGridColumnInfo {
is_primary_key?: boolean;
column_default?: string | null;
extra?: string | null;
comment?: string | null;
}
export interface DataGridSaveStatementOptions {
+6 -4
View File
@@ -6,6 +6,7 @@ export interface XlsxWorksheetData {
sheetName?: string;
columns: readonly string[];
columnTypes?: readonly string[];
columnComments?: readonly (string | null)[] | undefined;
rows: readonly (readonly XlsxCellValue[])[];
numericColumnRightAlign?: boolean;
}
@@ -99,10 +100,11 @@ function normalizeUniqueSheetNames(sheets: readonly XlsxWorksheetData[]): string
return names;
}
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[]): number[] {
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[], columnComments?: readonly (string | null)[]): number[] {
return columns.map((column, colIndex) => {
const headerText = columnComments?.[colIndex] || column;
const values = rows.slice(0, 100).map((row) => row[colIndex]);
const maxLen = [column, ...values.map((value) => (value == null ? "" : String(value)))].map((value) => Math.min(value.length, 60)).reduce((max, length) => Math.max(max, length), 8);
const maxLen = [headerText, ...values.map((value) => (value == null ? "" : String(value)))].map((value) => Math.min(value.length, 60)).reduce((max, length) => Math.max(max, length), 8);
return Math.max(10, Math.min(60, maxLen + 2));
});
}
@@ -146,10 +148,10 @@ function worksheetXml(data: XlsxWorksheetData): string {
const rows = data.rows;
const totalRows = rows.length + 1;
const range = sheetRange(columns.length, totalRows);
const widths = estimateColumnWidths(columns, rows);
const widths = estimateColumnWidths(columns, rows, data.columnComments);
const rightAlignEnabled = data.numericColumnRightAlign !== false;
const colsXml = widths.map((width, index) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`).join("");
const headerXml = `<row r="1">${columns.map((column, index) => cellXml(column, 0, index, 1)).join("")}</row>`;
const headerXml = `<row r="1">${columns.map((column, index) => cellXml(data.columnComments?.[index] || column, 0, index, 1)).join("")}</row>`;
const bodyXml = rows
.map((row, rowIndex) => {
const excelRowIndex = rowIndex + 2;
+2 -1
View File
@@ -4834,7 +4834,7 @@ export const useQueryStore = defineStore("query", () => {
};
}
async function exportQuerySqlDirect(id: string, sql: string, format: "csv" | "xlsx" | "txt", filePath: string) {
async function exportQuerySqlDirect(id: string, sql: string, format: "csv" | "xlsx" | "txt", filePath: string, columnComments?: (string | null)[]) {
const tab = tabs.value.find((item) => item.id === id);
if (!tab || tab.mode !== "query" || !sql.trim()) return;
@@ -4865,6 +4865,7 @@ export const useQueryStore = defineStore("query", () => {
clientSessionId: `${tabClientSessionId(tab, "export")}:${exportId}`,
executionId: uuid(),
numericColumnRightAlign: settings.numericColumnRightAlign,
columnComments,
};
const tracker = useExportTracker();