feat(export): add DB2 comments to XLSX headers
This commit is contained in:
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"] })]));
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1337,6 +1337,10 @@ export default withEnglishFallback({
|
||||
numericColumnAlign: "数値列の配置",
|
||||
numericColumnAlignLeft: "左揃え",
|
||||
numericColumnAlignRight: "右揃え",
|
||||
xlsxHeaderTitle: "ヘッダー形式",
|
||||
xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:",
|
||||
xlsxHeaderOriginal: "ヘッダーにフィールド名を使用",
|
||||
xlsxHeaderComment: "ヘッダーにコメントを使用",
|
||||
},
|
||||
exportProgress: {
|
||||
xlsxRowLimit: "XLSX は最大 {limit} 行のデータに対応しています。完全な結果を得るには CSV エクスポートを使用してください。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1093,6 +1093,10 @@ export default withEnglishFallback({
|
||||
exportSelectedRowsTxt: "导出选中行为 TXT",
|
||||
exported: "导出完成",
|
||||
exportFailed: "导出失败:{message}",
|
||||
xlsxHeaderTitle: "表头格式",
|
||||
xlsxHeaderPrompt: "请选择导出 Excel 时使用的表头格式:",
|
||||
xlsxHeaderOriginal: "表头使用字段名称",
|
||||
xlsxHeaderComment: "表头使用注释",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败:{message}",
|
||||
previewSqlEmpty: "暂无待预览的 SQL 更改",
|
||||
|
||||
@@ -1337,6 +1337,10 @@ export default withEnglishFallback({
|
||||
numericColumnAlign: "數值列對齊",
|
||||
numericColumnAlignLeft: "左對齊",
|
||||
numericColumnAlignRight: "右對齊",
|
||||
xlsxHeaderTitle: "表頭格式",
|
||||
xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:",
|
||||
xlsxHeaderOriginal: "表頭使用欄位名稱",
|
||||
xlsxHeaderComment: "表頭使用註解",
|
||||
},
|
||||
exportProgress: {
|
||||
xlsxRowLimit: "XLSX 最多支援 {limit} 列資料,請改用 CSV 匯出完整結果。",
|
||||
|
||||
@@ -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;
|
||||
}[],
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user