feat(export): add DB2 comments to XLSX headers

This commit is contained in:
大峰 2026-07-29 22:37:05 +08:00 committed by GitHub
parent 51b5c8ae95
commit 843f945581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 640 additions and 87 deletions

View File

@ -97,7 +97,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
String sql = "SELECT TABNAME, TYPE FROM SYSCAT.TABLES WHERE TABSCHEMA = ? AND TYPE IN ('T','V') ORDER BY TABNAME";
String sql = "SELECT TABNAME, TYPE, REMARKS FROM SYSCAT.TABLES WHERE TABSCHEMA = ? AND TYPE IN ('T','V') ORDER BY TABNAME";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
try (ResultSet rs = stmt.executeQuery()) {
@ -108,7 +108,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
case "V" -> "VIEW";
default -> db2Type;
};
result.add(new TableInfo(rs.getString(1).trim(), type, null));
result.add(new TableInfo(rs.getString(1).trim(), type, rs.getString(3)));
}
}
}
@ -136,7 +136,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT TABNAME, TYPE FROM SYSCAT.TABLES WHERE TABSCHEMA = ?");
StringBuilder sql = new StringBuilder("SELECT TABNAME, TYPE, REMARKS FROM SYSCAT.TABLES WHERE TABSCHEMA = ?");
args.add(schema);
appendDb2TableTypePredicate(sql, args, constraints);
MetadataSqlSupport.appendNameFilter(sql, args, "TABNAME", constraints);
@ -146,7 +146,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new TableInfo(rs.getString(1).trim(), db2TableType(rs.getString(2)), null));
result.add(new TableInfo(rs.getString(1).trim(), db2TableType(rs.getString(2)), rs.getString(3)));
}
}
}
@ -198,7 +198,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
List<Object> args = new ArrayList<>();
if (constraints.includesTableLikeTypes()) {
StringBuilder tableSql = new StringBuilder(
"SELECT TABNAME AS OBJECT_NAME, CASE TYPE WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE TYPE END AS OBJECT_TYPE FROM SYSCAT.TABLES WHERE TABSCHEMA = ?"
"SELECT TABNAME AS OBJECT_NAME, CASE TYPE WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE TYPE END AS OBJECT_TYPE, REMARKS AS OBJECT_COMMENT FROM SYSCAT.TABLES WHERE TABSCHEMA = ?"
);
args.add(schema);
appendDb2TableTypePredicate(tableSql, args, constraints);
@ -207,7 +207,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
}
if (constraints.objectTypeAllowed("PROCEDURE")) {
StringBuilder procedureSql = new StringBuilder(
"SELECT PROCNAME AS OBJECT_NAME, 'PROCEDURE' AS OBJECT_TYPE FROM SYSCAT.PROCEDURES WHERE PROCSCHEMA = ?"
"SELECT PROCNAME AS OBJECT_NAME, 'PROCEDURE' AS OBJECT_TYPE, CAST(NULL AS VARCHAR(254)) AS OBJECT_COMMENT FROM SYSCAT.PROCEDURES WHERE PROCSCHEMA = ?"
);
args.add(schema);
MetadataSqlSupport.appendNameFilter(procedureSql, args, "PROCNAME", constraints);
@ -216,7 +216,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
if (branches.isEmpty()) {
return List.of();
}
StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE FROM (")
StringBuilder sql = new StringBuilder("SELECT OBJECT_NAME, OBJECT_TYPE, OBJECT_COMMENT FROM (")
.append(String.join(" UNION ALL ", branches))
.append(") metadata_objects ORDER BY CASE OBJECT_TYPE WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'PROCEDURE' THEN 2 ELSE 9 END, OBJECT_NAME");
MetadataSqlSupport.appendLiteralOffsetFetch(sql, constraints);
@ -224,7 +224,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new ObjectInfo(rs.getString(1).trim(), rs.getString(2), schema, null));
result.add(new ObjectInfo(rs.getString(1).trim(), rs.getString(2), schema, rs.getString(3)));
}
}
}
@ -269,7 +269,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
List<ColumnInfo> result = new ArrayList<>();
String colSql = """
SELECT COLNAME, TYPENAME, NULLS, DEFAULT, LENGTH, SCALE
SELECT COLNAME, TYPENAME, NULLS, DEFAULT, LENGTH, SCALE, REMARKS
FROM SYSCAT.COLUMNS
WHERE TABSCHEMA = ? AND TABNAME = ?
ORDER BY COLNO
@ -292,7 +292,7 @@ public final class Db2Agent extends BaseDatabaseAgent {
trimNullable(rs.getString("DEFAULT")),
pkColumns.contains(name),
null,
null,
rs.getString("REMARKS"),
NUMERIC_PRECISION_TYPES.contains(typeName) ? length : null,
NUMERIC_SCALE_TYPES.contains(typeName) ? scale : null,
CHARACTER_LENGTH_TYPES.contains(typeName) ? length : null

View File

@ -3,6 +3,8 @@ package com.dbx.agent.db2;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.TableInfo;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
import com.dbx.agent.test.JdbcMetadataSqlFake;
import com.dbx.agent.test.TestSupport;
@ -12,6 +14,7 @@ import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Arrays;
@ -91,6 +94,70 @@ class Db2AgentTest extends JdbcFakeExecutionBehaviorTest {
assertEquals(Arrays.asList("param:1=APP", "param:2=%S%Y%N%C%"), JdbcMetadataSqlFake.statements.subList(1, 3));
}
@Test
void constrainedTableMetadataPassesRemarks() {
Db2Agent agent = new Db2Agent();
TestSupport.setPrivateConnection(agent, preparedConnection(
row("TABNAME", "MY_TABLE", "TYPE", "T", "REMARKS", "Test table comment")
));
List<TableInfo> tables = agent.listTables("APP",
new MetadataListConstraints(null, 25, null, List.of("TABLE")));
assertEquals(1, tables.size());
assertEquals("MY_TABLE", tables.get(0).getName());
assertEquals("TABLE", tables.get(0).getTable_type());
assertEquals("Test table comment", tables.get(0).getComment());
}
@Test
void constrainedObjectMetadataPassesRemarks() {
Db2Agent agent = new Db2Agent();
TestSupport.setPrivateConnection(agent, preparedConnection(
row("OBJECT_NAME", "MY_VIEW", "OBJECT_TYPE", "VIEW", "OBJECT_COMMENT", "Test view comment")
));
List<ObjectInfo> objects = agent.listObjects("APP",
new MetadataListConstraints("my", 10, null, List.of("VIEW")));
assertEquals(1, objects.size());
assertEquals("MY_VIEW", objects.get(0).getName());
assertEquals("VIEW", objects.get(0).getObject_type());
assertEquals("Test view comment", objects.get(0).getComment());
}
private static Connection preparedConnection(Map<String, Object>... rows) {
final ResultSet resultSet = rows(rows);
return proxy(Connection.class, new MethodHandler() {
@Override
public Object handle(Method method, Object[] args) {
String name = method.getName();
if ("prepareStatement".equals(name)) {
return proxy(PreparedStatement.class, new MethodHandler() {
@Override
public Object handle(Method stmtMethod, Object[] stmtArgs) {
String m = stmtMethod.getName();
if ("setString".equals(m) || "setObject".equals(m) || "setInt".equals(m)) {
return null;
}
if ("executeQuery".equals(m)) {
return resultSet;
}
if ("close".equals(m)) {
return null;
}
return defaultValue(stmtMethod.getReturnType());
}
});
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
}
});
}
private static Connection connection(AtomicReference<String> executedSql, ResultSet resultSet) {
Statement statement = proxy(Statement.class, new MethodHandler() {
@Override
@ -131,8 +198,13 @@ class Db2AgentTest extends JdbcFakeExecutionBehaviorTest {
return index < rows.length;
}
if ("getString".equals(name)) {
Object key = args[0] instanceof Number ? rows[index].keySet().iterator().next() : args[0];
Object value = rows[index].get(key);
if (args[0] instanceof Number) {
int colIndex = ((Number) args[0]).intValue() - 1;
String key = rows[index].keySet().stream().skip(colIndex).findFirst().orElse(null);
Object value = rows[index].get(key);
return value == null ? null : String.valueOf(value);
}
Object value = rows[index].get(args[0]);
return value == null ? null : String.valueOf(value);
}
if ("close".equals(name)) {

View File

@ -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() {

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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() {

View File

@ -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,
};

View File

@ -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 }),
}));

View File

@ -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"] })]));
});
});

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) {

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",

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.",

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.",

View File

@ -1337,6 +1337,10 @@ export default withEnglishFallback({
numericColumnAlign: "数値列の配置",
numericColumnAlignLeft: "左揃え",
numericColumnAlignRight: "右揃え",
xlsxHeaderTitle: "ヘッダー形式",
xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:",
xlsxHeaderOriginal: "ヘッダーにフィールド名を使用",
xlsxHeaderComment: "ヘッダーにコメントを使用",
},
exportProgress: {
xlsxRowLimit: "XLSX は最大 {limit} 行のデータに対応しています。完全な結果を得るには CSV エクスポートを使用してください。",

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.",

View File

@ -1093,6 +1093,10 @@ export default withEnglishFallback({
exportSelectedRowsTxt: "导出选中行为 TXT",
exported: "导出完成",
exportFailed: "导出失败:{message}",
xlsxHeaderTitle: "表头格式",
xlsxHeaderPrompt: "请选择导出 Excel 时使用的表头格式:",
xlsxHeaderOriginal: "表头使用字段名称",
xlsxHeaderComment: "表头使用注释",
copied: "已复制",
copyFailed: "复制失败:{message}",
previewSqlEmpty: "暂无待预览的 SQL 更改",

View File

@ -1337,6 +1337,10 @@ export default withEnglishFallback({
numericColumnAlign: "數值列對齊",
numericColumnAlignLeft: "左對齊",
numericColumnAlignRight: "右對齊",
xlsxHeaderTitle: "表頭格式",
xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:",
xlsxHeaderOriginal: "表頭使用欄位名稱",
xlsxHeaderComment: "表頭使用註解",
},
exportProgress: {
xlsxRowLimit: "XLSX 最多支援 {limit} 列資料,請改用 CSV 匯出完整結果。",

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;
}[],

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;
}[],

View File

@ -19,6 +19,7 @@ export interface DataGridColumnInfo {
is_primary_key?: boolean;
column_default?: string | null;
extra?: string | null;
comment?: string | null;
}
export interface DataGridSaveStatementOptions {

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;

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();

View File

@ -99,6 +99,7 @@ fn write_xlsx(path: &Path, row_count: usize, column_count: usize) -> Result<(),
sheet_name: Some("Benchmark".to_string()),
columns: columns(column_count),
column_types: vec![],
column_comments: vec![],
rows: (0..row_count).map(|row_index| row(row_index, column_count)).collect(),
numeric_column_right_align: false,
})?;

View File

@ -101,6 +101,8 @@ pub struct QueryResultExportRequest {
pub export_column_types: Option<Vec<Option<String>>>,
#[serde(default)]
pub numeric_column_right_align: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column_comments: Option<Vec<Option<String>>>,
}
pub struct StagedExportTarget {
@ -207,6 +209,7 @@ fn query_sql_worksheets(request: &QueryResultExportRequest) -> Vec<XlsxWorksheet
sheet_name: Some("SQL".to_string()),
columns: vec!["SQL".to_string()],
column_types: Vec::new(),
column_comments: Vec::new(),
rows: split_excel_cell_text(&request.sql).into_iter().map(|sql| vec![Value::String(sql)]).collect(),
numeric_column_right_align: false,
}]
@ -219,11 +222,13 @@ fn start_query_result_xlsx_workbook<W: Write + Seek>(
column_types: &[String],
) -> Result<StreamingXlsxWriter<W>, String> {
let trailing_sheets = query_sql_worksheets(request);
let column_comments: &[Option<String>] = request.column_comments.as_deref().unwrap_or(&[]);
start_streaming_xlsx_workbook_with_options(
writer,
Some("Result"),
columns,
column_types,
column_comments,
&trailing_sheets,
request.date_time_format.as_deref(),
request.numeric_column_right_align,
@ -1854,6 +1859,7 @@ mod tests {
export_table_name: None,
export_column_types: None,
numeric_column_right_align: false,
column_comments: None,
}
}

View File

@ -61,6 +61,8 @@ pub struct TableExportRequest {
pub date_time_format: Option<String>,
#[serde(default)]
pub numeric_column_right_align: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column_comments: Option<Vec<Option<String>>>,
}
#[derive(Debug, Clone, Serialize)]
@ -765,6 +767,7 @@ async fn try_export_native_table_stream(
}
"xlsx" => {
let xlsx_column_types = export_column_types(request);
let column_comments: Vec<Option<String>> = request.column_comments.clone().unwrap_or_default();
let xlsx_file =
std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
let mut writer = start_streaming_xlsx_workbook_with_options(
@ -772,6 +775,7 @@ async fn try_export_native_table_stream(
Some(&request.table_name),
col_names,
&xlsx_column_types,
&column_comments,
&[],
request.date_time_format.as_deref(),
request.numeric_column_right_align,
@ -1389,6 +1393,7 @@ async fn export_table_data_core_inner(
}
"xlsx" => {
let xlsx_column_types = export_column_types(request);
let column_comments: Vec<Option<String>> = request.column_comments.clone().unwrap_or_default();
// Create a dedicated file handle for the streaming XLSX writer
// instead of cloning the outer BufWriter's handle. This avoids
// sharing a file descriptor between two independent buffers.
@ -1399,6 +1404,7 @@ async fn export_table_data_core_inner(
Some(&request.table_name),
&col_names,
&xlsx_column_types,
&column_comments,
&[],
request.date_time_format.as_deref(),
request.numeric_column_right_align,
@ -1880,6 +1886,7 @@ mod tests {
row_limit,
date_time_format: None,
numeric_column_right_align: false,
column_comments: None,
};
ExternalDriverExportFixture { state, request, calls, output, dir }
@ -2041,6 +2048,7 @@ mod tests {
row_limit: Some(1000),
date_time_format: None,
numeric_column_right_align: false,
column_comments: None,
};
let sql = table_cursor_sql(
@ -2090,6 +2098,7 @@ mod tests {
row_limit: None,
date_time_format: None,
numeric_column_right_align: false,
column_comments: None,
};
let sql = table_cursor_sql(&request, &DatabaseType::Oracle, &columns, &primary_keys);
assert_eq!(sql, "SELECT \"ID\", \"NAME\" FROM \"APP\".\"USERS\"");
@ -2403,6 +2412,7 @@ mod tests {
sheet_name: Some("employees".to_string()),
columns: vec!["id".to_string(), "name".to_string(), "salary".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![
vec![json!(1), json!("Alice"), json!(75000.50)],
vec![json!(2), json!("Bob"), json!(82000)],

View File

@ -5860,6 +5860,7 @@ mod tests {
sheet_name: Some("First".to_string()),
columns: vec!["id".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![serde_json::json!(1)]],
numeric_column_right_align: false,
},
@ -5867,6 +5868,7 @@ mod tests {
sheet_name: Some("Second".to_string()),
columns: vec!["name".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![serde_json::json!("Ada")]],
numeric_column_right_align: false,
},
@ -5904,6 +5906,7 @@ mod tests {
sheet_name: Some("First".to_string()),
columns: vec!["id".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![serde_json::json!(1)]],
numeric_column_right_align: false,
},
@ -5911,6 +5914,7 @@ mod tests {
sheet_name: Some("Second".to_string()),
columns: vec!["name".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![serde_json::json!("Ada")], vec![serde_json::json!("Grace")]],
numeric_column_right_align: false,
},
@ -6053,6 +6057,7 @@ mod tests {
sheet_name: Some("Rows".to_string()),
columns: vec!["id".to_string(), "name".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![
vec![serde_json::json!(1), serde_json::json!("Ada")],
vec![serde_json::json!(2), serde_json::json!("Grace")],
@ -6409,6 +6414,7 @@ mod tests {
sheet_name: Some("Rows".to_string()),
columns: vec!["report".to_string(), "ignored".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![
vec![serde_json::json!("id"), serde_json::json!("name")],
vec![serde_json::json!(1), serde_json::json!("Ada")],
@ -6833,6 +6839,7 @@ mod tests {
sheet_name: Some("Rows".to_string()),
columns: vec!["report".to_string(), "ignored".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![
vec![serde_json::json!("id"), serde_json::json!("name")],
vec![serde_json::json!(1), serde_json::json!("Ada")],

View File

@ -16,6 +16,8 @@ pub struct XlsxWorksheetData {
pub columns: Vec<String>,
#[serde(default)]
pub column_types: Vec<String>,
#[serde(default)]
pub column_comments: Vec<Option<String>>,
pub rows: Vec<Vec<Value>>,
#[serde(default)]
pub numeric_column_right_align: bool,
@ -37,8 +39,15 @@ pub struct StreamingXlsxWriter<W: Write + Seek> {
/// Estimate column widths from header names only (used by the streaming path
/// where full row data is not available up-front). Each width is clamped to
/// [10, 60] to stay within reasonable bounds.
fn estimate_header_widths(columns: &[String]) -> Vec<usize> {
columns.iter().map(|col| (col.chars().count() + 2).clamp(10, 60)).collect()
fn estimate_header_widths(columns: &[String], column_comments: &[Option<String>]) -> Vec<usize> {
columns
.iter()
.enumerate()
.map(|(index, col)| {
let header_text = column_comments.get(index).and_then(|c| c.as_deref()).unwrap_or(col.as_str());
(header_text.chars().count() + 2).clamp(10, 60)
})
.collect()
}
/// Build the `<cols>` XML fragment from a width slice.
@ -52,14 +61,23 @@ fn cols_xml(widths: &[usize]) -> String {
.collect()
}
/// Resolve the effective header text: prefer a non-empty column comment, fall
/// back to the original column name.
fn effective_header(column: &str, comment: Option<&str>) -> String {
comment.filter(|c| !c.is_empty()).unwrap_or(column).to_string()
}
/// Build a single `<row>` XML fragment for the header row (row 1).
pub(crate) fn header_row_xml(columns: &[String]) -> String {
pub(crate) fn header_row_xml(columns: &[String], column_comments: &[Option<String>]) -> String {
format!(
"<row r=\"1\">{}</row>",
columns
.iter()
.enumerate()
.map(|(index, col)| cell_xml(Some(&Value::String(col.clone())), 0, index, Some(1)))
.map(|(index, col)| {
let header = effective_header(col, column_comments.get(index).and_then(|c| c.as_deref()));
cell_xml(Some(&Value::String(header)), 0, index, Some(1))
})
.collect::<String>()
)
}
@ -107,7 +125,7 @@ pub(crate) fn start_streaming_xlsx_workbook<W: Write + Seek>(
columns: &[String],
column_types: &[String],
) -> Result<StreamingXlsxWriter<W>, String> {
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], None, false)
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], &[], None, false)
}
#[cfg(test)]
@ -118,7 +136,16 @@ pub(crate) fn start_streaming_xlsx_workbook_with_trailing_sheets<W: Write + Seek
column_types: &[String],
trailing_sheets: &[XlsxWorksheetData],
) -> Result<StreamingXlsxWriter<W>, String> {
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, trailing_sheets, None, false)
start_streaming_xlsx_workbook_with_options(
writer,
sheet_name,
columns,
column_types,
&[],
trailing_sheets,
None,
false,
)
}
pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
@ -126,6 +153,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
sheet_name: Option<&str>,
columns: &[String],
column_types: &[String],
column_comments: &[Option<String>],
trailing_sheets: &[XlsxWorksheetData],
date_time_format: Option<&str>,
numeric_right_align: bool,
@ -134,13 +162,14 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
sheet_name: sheet_name.map(str::to_string),
columns: columns.to_vec(),
column_types: column_types.to_vec(),
column_comments: column_comments.to_vec(),
rows: Vec::new(),
numeric_column_right_align: numeric_right_align,
};
let all_sheets = std::iter::once(primary_sheet).chain(trailing_sheets.iter().cloned()).collect::<Vec<_>>();
let sheet_names = normalize_unique_sheet_names(&all_sheets);
let sheet_count = sheet_names.len();
let widths = estimate_header_widths(columns);
let widths = estimate_header_widths(columns, column_comments);
let mut zip = zip::ZipWriter::new(writer);
write_zip_entry(&mut zip, "[Content_Types].xml", &content_types_xml_for_sheet_count(sheet_count))?;
@ -168,7 +197,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
cols = cols_xml(&widths),
);
zip.write_all(sheet_header.as_bytes()).map_err(|err| err.to_string())?;
zip.write_all(header_row_xml(columns).as_bytes()).map_err(|err| err.to_string())?;
zip.write_all(header_row_xml(columns, column_comments).as_bytes()).map_err(|err| err.to_string())?;
Ok(StreamingXlsxWriter {
zip,
@ -289,12 +318,13 @@ fn value_text(value: Option<&Value>) -> String {
}
}
fn estimate_column_widths(columns: &[String], rows: &[Vec<Value>]) -> Vec<usize> {
fn estimate_column_widths(columns: &[String], column_comments: &[Option<String>], rows: &[Vec<Value>]) -> Vec<usize> {
columns
.iter()
.enumerate()
.map(|(col_index, column)| {
let max_len = std::iter::once(column.chars().count().min(60))
.map(|(col_index, col)| {
let header_text = effective_header(col, column_comments.get(col_index).and_then(|c| c.as_deref()));
let max_len = std::iter::once(header_text.chars().count().min(60))
.chain(rows.iter().take(100).map(|row| value_text(row.get(col_index)).chars().count().min(60)))
.fold(8usize, usize::max);
(max_len + 2).clamp(10, 60)
@ -465,7 +495,7 @@ fn typed_cell_xml(
fn worksheet_xml(data: &XlsxWorksheetData) -> String {
let total_rows = data.rows.len() + 1;
let range = sheet_range(data.columns.len(), total_rows);
let widths = estimate_column_widths(&data.columns, &data.rows);
let widths = estimate_column_widths(&data.columns, &data.column_comments, &data.rows);
let cols_xml = widths
.iter()
@ -475,14 +505,7 @@ fn worksheet_xml(data: &XlsxWorksheetData) -> String {
})
.collect::<String>();
let header_xml = format!(
"<row r=\"1\">{}</row>",
data.columns
.iter()
.enumerate()
.map(|(index, col)| cell_xml(Some(&Value::String(col.clone())), 0, index, Some(1)))
.collect::<String>()
);
let header_xml = header_row_xml(&data.columns, &data.column_comments);
let body_xml = data
.rows
@ -782,6 +805,7 @@ mod tests {
sheet_name: Some("Users".to_string()),
columns: vec!["id".to_string(), "name".to_string(), "active".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![json!(1), json!("Ada & Bob"), json!(true)], vec![json!(2), json!(null), json!(false)]],
numeric_column_right_align: false,
})
@ -807,6 +831,7 @@ mod tests {
sheet_name: Some("Amounts".to_string()),
columns: vec!["quantity".to_string(), "amount".to_string(), "code".to_string()],
column_types: vec!["decimal(10,5)".to_string(), "numeric".to_string(), "varchar".to_string()],
column_comments: vec![],
rows: vec![vec![json!("1.00000"), json!("2800.000000"), json!("00123")]],
numeric_column_right_align: false,
})
@ -838,6 +863,7 @@ mod tests {
"numeric".to_string(),
"timestamp with time zone".to_string(),
],
column_comments: vec![],
rows: vec![vec![
json!("2024-02-25"),
json!("2024-02-25 13:02:15"),
@ -886,6 +912,7 @@ mod tests {
"double".to_string(),
"decimal(18,6)".to_string(),
],
column_comments: vec![],
rows: vec![vec![
json!("2"),
json!("42"),
@ -921,6 +948,7 @@ mod tests {
sheet_name: Some("Precision".to_string()),
columns: vec!["large_id".to_string(), "precise_amount".to_string()],
column_types: vec!["bigint".to_string(), "decimal(30,10)".to_string()],
column_comments: vec![],
rows: vec![vec![json!("9223372036854775807"), json!("123456789012345.6789000000")]],
numeric_column_right_align: false,
})
@ -937,6 +965,7 @@ mod tests {
sheet_name: Some("bad/name:with*chars?and-a-very-long-tail".to_string()),
columns: vec!["value".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![json!("ok")]],
numeric_column_right_align: false,
})
@ -953,6 +982,7 @@ mod tests {
sheet_name: Some("Result 1".to_string()),
columns: vec!["id".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![json!(1)]],
numeric_column_right_align: false,
},
@ -960,6 +990,7 @@ mod tests {
sheet_name: Some("Result 2".to_string()),
columns: vec!["name".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![json!("Ada")]],
numeric_column_right_align: false,
},
@ -1032,6 +1063,7 @@ mod tests {
&columns,
&column_types,
&[],
&[],
Some("YYYY/MM/DD HH:mm:ss.SSS"),
false,
)
@ -1057,6 +1089,7 @@ mod tests {
sheet_name: Some("SQL".to_string()),
columns: vec!["SQL".to_string()],
column_types: vec![],
column_comments: vec![],
rows: vec![vec![json!("SELECT id, name FROM users")]],
numeric_column_right_align: false,
};
@ -1087,6 +1120,7 @@ mod tests {
sheet_name: Some("Aligned".to_string()),
columns: vec!["amount".to_string(), "label".to_string()],
column_types: vec!["decimal(10,2)".to_string(), "varchar(50)".to_string()],
column_comments: vec![],
rows: vec![vec![json!(1.5), json!("row")]],
numeric_column_right_align: true,
})
@ -1103,6 +1137,7 @@ mod tests {
sheet_name: Some("Disabled".to_string()),
columns: vec!["amount".to_string(), "label".to_string()],
column_types: vec!["decimal(10,2)".to_string(), "varchar(50)".to_string()],
column_comments: vec![],
rows: vec![vec![json!(1.5), json!("row")]],
numeric_column_right_align: false,
})
@ -1142,6 +1177,7 @@ mod tests {
sheet_name: Some("CrossDb".to_string()),
columns: column_types.iter().map(|t| t.to_lowercase()).collect(),
column_types: column_types.clone(),
column_comments: vec![],
rows: vec![row],
numeric_column_right_align: true,
})

View File

@ -102,6 +102,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);

View File

@ -170,6 +170,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);
@ -258,6 +259,7 @@ async fn live_mysql_xlsx_export_can_outlive_query_timeout_while_rows_keep_arrivi
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let rows_exported = AtomicU64::new(0);

View File

@ -139,6 +139,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() {
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);
@ -217,6 +218,7 @@ async fn live_postgres_query_result_xlsx_preserves_temporal_cell_types() {
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
@ -288,6 +290,7 @@ async fn live_postgres_truncated_batch_result_export_replays_safe_temp_setup() {
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let csv_rows = AtomicU64::new(0);
@ -362,6 +365,7 @@ async fn live_postgres_xlsx_export_can_outlive_query_timeout_while_rows_keep_arr
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let rows_exported = AtomicU64::new(0);
@ -427,6 +431,7 @@ async fn live_postgres_stream_still_times_out_without_progress_and_recovers() {
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let started_at = Instant::now();

View File

@ -454,6 +454,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() {
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let done_seen = AtomicBool::new(false);

View File

@ -114,6 +114,7 @@ async fn live_sqlserver_xlsx_export_can_outlive_query_timeout_while_rows_keep_ar
date_time_format: None,
export_table_name: None,
export_column_types: None,
column_comments: None,
numeric_column_right_align: false,
};
let rows_exported = AtomicU64::new(0);

View File

@ -7,6 +7,13 @@ import { copyToClipboard } from "@/lib/common/clipboard";
import * as api from "@/lib/backend/api";
vi.mock("vue-i18n", () => ({
createI18n: () => ({
global: {
locale: { value: "en" },
setLocaleMessage: vi.fn(),
},
install: vi.fn(),
}),
useI18n: () => ({
t: (key: string) => key,
}),

View File

@ -39,7 +39,16 @@ vi.mock("@/lib/common/clipboard", () => clipboardMock);
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => runtimeMock.isTauri }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: dialogMock.save }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: toastMock }) }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: translateMock }) }));
vi.mock("vue-i18n", () => ({
createI18n: () => ({
global: {
locale: { value: "en" },
setLocaleMessage: vi.fn(),
},
install: vi.fn(),
}),
useI18n: () => ({ t: translateMock }),
}));
const { defaultDataGridExportFileName, useDataGridExport } = await import("../../apps/desktop/src/composables/useDataGridExport.ts");
@ -422,7 +431,7 @@ test("complete local query result XLSX export does not re-execute the query", as
assert.equal(fullExportResult.mock.calls.length, 0);
assert.equal(queryResultExportRequest.mock.calls.length, 0);
assert.equal(apiMock.startQueryResultExport.mock.calls.length, 0);
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1, 5), ["Export", ["id", "name"], ["int4", "text"], completeLocalResult.rows]);
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1, 6), ["Export", ["id", "name"], ["int4", "text"], undefined, completeLocalResult.rows]);
});
test("MySQL joined query SQL export keeps result aliases instead of source column names", async () => {
@ -521,10 +530,11 @@ test("complete local query result export removes only internal hidden columns",
await composable.exportXlsx();
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1, 5), [
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0]?.slice(1, 6), [
"Export",
["id", "name"],
["int4", "text"],
undefined,
[
[1, "Ada"],
[2, "Lin"],
@ -549,7 +559,7 @@ test("complete local CSV, XLSX, and TXT exports honor the enabled row limit", as
assert.equal(apiMock.exportQueryResultCsv.mock.calls[0]?.[2].length, 100);
await composable.exportXlsx();
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0]?.[4].length, 100);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0]?.[5].length, 100);
const download = installTextDownloadCapture();
try {
@ -695,7 +705,8 @@ test("selected query result XLSX export uses the current source label as the she
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][1], "aaa.apis");
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][2], ["id", "name"]);
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][3], ["bigint(20)", "varchar(64)"]);
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][4], [[1, "Ada"]]);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][4], undefined);
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][5], [[1, "Ada"]]);
});
test("selected query result XLSX export forwards the numericColumnRightAlign setting to the backend", async () => {
@ -706,13 +717,13 @@ test("selected query result XLSX export forwards the numericColumnRightAlign set
await composable.exportXlsx([1]);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls.length, 1);
// Argument 5 is `numericColumnRightAlign`, and must reflect the persisted
// Argument 6 is `numericColumnRightAlign`, and must reflect the persisted
// setting rather than always defaulting to true.
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][5], false);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][6], false);
settingsStore.updateEditorSettings({ numericColumnRightAlign: true });
await composable.exportXlsx([1]);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[1][5], true);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[1][6], true);
});
test("streaming query result XLSX export carries numericColumnRightAlign in the backend request", async () => {

View File

@ -10,6 +10,8 @@ pub struct QueryResultXlsxExportRequest {
pub columns: Vec<String>,
#[serde(default)]
pub column_types: Vec<String>,
#[serde(default)]
pub column_comments: Vec<Option<String>>,
pub rows: Vec<Vec<Value>>,
#[serde(default)]
pub numeric_column_right_align: bool,
@ -29,6 +31,7 @@ pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) ->
sheet_name: request.sheet_name,
columns: request.columns,
column_types: request.column_types,
column_comments: request.column_comments,
rows: request.rows,
numeric_column_right_align: request.numeric_column_right_align,
};