feat(export): add DB2 comments to XLSX headers
This commit is contained in:
parent
51b5c8ae95
commit
843f945581
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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")],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue