From 843f9455812c0d93bb385dd9d03ce1be8a8717ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=B3=B0?= <67090734+tianyifeng-druid@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:37:05 +0800 Subject: [PATCH] feat(export): add DB2 comments to XLSX headers --- .../main/java/com/dbx/agent/db2/Db2Agent.java | 20 +-- .../java/com/dbx/agent/db2/Db2AgentTest.java | 76 +++++++++- .../src/components/editor/QueryEditor.vue | 4 +- .../components/export/XlsxHeaderDialog.vue | 57 ++++++++ .../export/__tests__/XlsxHeaderDialog.spec.ts | 33 +++++ .../src/components/layout/ContentArea.vue | 4 +- .../src/components/objects/ObjectBrowser.vue | 65 ++++++++- .../__tests__/useDataGridExport.spec.ts | 4 + .../__tests__/useDataGridExport.xlsx.spec.ts | 128 ++++++++++++++++ .../src/composables/useDataGridExport.ts | 137 ++++++++++++++---- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + apps/desktop/src/lib/backend/http.ts | 4 +- apps/desktop/src/lib/backend/tauri.ts | 6 +- apps/desktop/src/lib/dataGrid/dataGridSql.ts | 1 + apps/desktop/src/lib/export/xlsxExport.ts | 10 +- apps/desktop/src/stores/queryStore.ts | 3 +- .../dbx-core/examples/table_import_bench.rs | 1 + crates/dbx-core/src/query_result_export.rs | 6 + crates/dbx-core/src/table_export.rs | 10 ++ crates/dbx-core/src/table_import.rs | 7 + crates/dbx-core/src/xlsx_export.rs | 76 +++++++--- .../live_clickhouse_query_result_export.rs | 1 + crates/dbx-core/tests/live_mysql57.rs | 2 + .../live_postgres_query_result_export.rs | 5 + .../tests/live_sqlserver_completion.rs | 1 + .../live_sqlserver_query_result_export.rs | 1 + packages/app-tests/dataGridExport.test.ts | 7 + packages/app-tests/useDataGridExport.test.ts | 27 +++- src-tauri/src/commands/xlsx_export.rs | 3 + 35 files changed, 640 insertions(+), 87 deletions(-) create mode 100644 apps/desktop/src/components/export/XlsxHeaderDialog.vue create mode 100644 apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts create mode 100644 apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts diff --git a/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java b/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java index a9dedac4d..f43c4fdff 100644 --- a/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java +++ b/agents/drivers/db2/src/main/java/com/dbx/agent/db2/Db2Agent.java @@ -97,7 +97,7 @@ public final class Db2Agent extends BaseDatabaseAgent { public List listTables(String schema) { return unchecked(() -> { List 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 result = new ArrayList<>(); List 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 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 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 diff --git a/agents/drivers/db2/src/test/java/com/dbx/agent/db2/Db2AgentTest.java b/agents/drivers/db2/src/test/java/com/dbx/agent/db2/Db2AgentTest.java index 1eeb38d10..eac06a719 100644 --- a/agents/drivers/db2/src/test/java/com/dbx/agent/db2/Db2AgentTest.java +++ b/agents/drivers/db2/src/test/java/com/dbx/agent/db2/Db2AgentTest.java @@ -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 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 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... 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 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)) { diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index f45a41a1a..b2ec0858f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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() { diff --git a/apps/desktop/src/components/export/XlsxHeaderDialog.vue b/apps/desktop/src/components/export/XlsxHeaderDialog.vue new file mode 100644 index 000000000..c1eaf0096 --- /dev/null +++ b/apps/desktop/src/components/export/XlsxHeaderDialog.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts b/apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts new file mode 100644 index 000000000..e37c99fcb --- /dev/null +++ b/apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts @@ -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('[data-slot="dialog-close"]')!.click(); + await nextTick(); + + expect(cancelCount).toBe(1); + app.unmount(); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 040a81c28..2a0134114 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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() { diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 9e1319636..89ab4bca7 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -1,5 +1,5 @@