From dffd3025a77a7d462b0e1fed30e41522ba1c31b7 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 16 Sep 2026 00:47:21 +0800 Subject: [PATCH] fix(query): show SQL Server notices outside result-set tabs Closes #9166 --- .../src/components/layout/ContentArea.vue | 7 +- .../ContentArea.serverMessages.spec.ts | 81 +++++++++++++++++++ .../__tests__/ResultSetNavigator.spec.ts | 16 +++- .../__tests__/useSqlExecution.spec.ts | 2 +- .../useSqlExecutionMultiDbFocus.spec.ts | 25 ++++++ .../src/composables/useSqlExecution.ts | 6 +- .../query/queryResultMessages.spec.ts | 34 ++++++++ .../__tests__/tabs/tabPresentation.spec.ts | 17 +++- .../src/lib/query/queryResultDefaultView.ts | 4 +- .../src/lib/query/queryResultMessages.ts | 6 ++ apps/desktop/src/lib/tabs/tabPresentation.ts | 2 +- 11 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/components/layout/__tests__/ContentArea.serverMessages.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/query/queryResultMessages.spec.ts create mode 100644 apps/desktop/src/lib/query/queryResultMessages.ts diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index ba90cac52e..0cbb83f279 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -4,6 +4,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStor import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { canReloadUnavailableDataTab, restoredDataTabReloadFilters } from "@/lib/table/tableDataRefresh"; import { defaultViewForResult } from "@/lib/query/queryResultDefaultView"; +import { queryResultMessages } from "@/lib/query/queryResultMessages"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput"; import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery"; @@ -555,7 +556,7 @@ const batchExecutionPercent = computed(() => { return progress?.total ? Math.round((progress.completed / progress.total) * 100) : 0; }); const hasTabularResult = computed(() => { - if (props.activeTab.result?.columns.length) return true; + if (props.activeTab.result?.columns.length && props.activeTab.result.server_message !== true) return true; return visibleResultItems.value.length > 0; }); const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting); @@ -567,7 +568,7 @@ const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!p // silently loses its notices once a later statement owns the active result. const resultMessages = computed(() => { const results = props.activeTab.results?.length ? props.activeTab.results : props.activeTab.result ? [props.activeTab.result] : []; - return results.flatMap((result) => result.messages ?? []); + return results.flatMap(queryResultMessages); }); const resultMessageCount = computed(() => resultMessages.value.length); const canShowMessagesOutput = computed(() => resultMessageCount.value > 0); @@ -783,7 +784,7 @@ watch( // view when its own tab finishes executing. if (props.editorOnly) return; if (props.activeTab.isExecuting) return; - if (hasExecutionSummary.value && !hasTabularResult.value && props.activeOutputView === "result") { + if (hasExecutionSummary.value && (!hasTabularResult.value || props.activeTab.result?.server_message === true) && props.activeOutputView === "result") { const result = props.activeTab.result; emit("update:activeOutputView", props.activeTab.id, result ? defaultViewForResult(result) : "summary"); } diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.serverMessages.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.serverMessages.spec.ts new file mode 100644 index 0000000000..f009a349b2 --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.serverMessages.spec.ts @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom +import { createApp, defineComponent, h, nextTick, reactive } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { QueryResult, QueryTab } from "@/types/database"; + +vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/grid/DataGrid.vue", () => ({ default: { render: () => h("div", { "data-test": "data-grid" }) } })); + +import ContentArea from "../ContentArea.vue"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useQueryStore } from "@/stores/queryStore"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + document.body.replaceChildren(); + window.localStorage?.clear(); +}); + +async function mountResults(results: QueryResult[], activeIndex: number) { + const pinia = createPinia(); + setActivePinia(pinia); + const connection = { id: "sqlserver", name: "SQL Server", db_type: "sqlserver" as const, host: "localhost", port: 1433, username: "", password: "" }; + useConnectionStore().connections = [connection]; + const tab: QueryTab = { id: "query", title: "Query", connectionId: connection.id, database: "app", mode: "query", sql: "EXEC demo", isExecuting: false, results, result: results[activeIndex], activeResultIndex: activeIndex }; + useQueryStore().tabs.push(tab); + const state = reactive({ view: "result" as "result" | "messages" }); + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp( + defineComponent({ + setup: () => () => + h(ContentArea, { + activeTab: tab, + activeConnection: connection, + activeOutputView: state.view, + executableSql: "", + formatSqlRequest: null, + compressSqlRequest: null, + selectedSql: "", + cursorPos: 0, + resultOnly: true, + blockDangerousRedisCommands: false, + "onUpdate:activeOutputView": (_tabId: string, view: string) => { + state.view = view as typeof state.view; + }, + }), + }), + ); + app.use(pinia); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: { tabs: { resultN: "Result {n}", allResults: "All results ({count})" } } }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + cleanups.push(() => app.unmount()); + await nextTick(); + await nextTick(); + return { host, state }; +} + +describe("SQL Server messages in the result surface", () => { + const message: QueryResult = { columns: ["Message"], rows: [["before notice"]], affected_rows: 0, execution_time_ms: 1, server_message: true }; + const data: QueryResult = { columns: ["Message"], rows: [["real data"]], affected_rows: 0, execution_time_ms: 1 }; + + it("shows only three result tabs and preserves both message blocks in Messages", async () => { + const { host, state } = await mountResults([message, data, { ...message, rows: [["after notice"]] }, { ...data, rows: [] }, data], 1); + expect([...host.querySelectorAll(".result-set-scroll button")].map((button) => button.textContent?.trim())).toEqual(["Result 1", "Result 2", "Result 3"]); + state.view = "messages"; + await nextTick(); + expect(host.textContent).toContain("before notice"); + expect(host.textContent).toContain("after notice"); + expect(host.textContent).not.toContain("real data"); + }); + + it.each([false, true])("redirects restored message output without a fake grid (mixed=%s)", async (mixed) => { + const { host, state } = await mountResults(mixed ? [message, data] : [message], 0); + expect(state.view).toBe("messages"); + expect(host.textContent).toContain("before notice"); + expect(host.querySelector('[data-test="data-grid"]')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/components/layout/__tests__/ResultSetNavigator.spec.ts b/apps/desktop/src/components/layout/__tests__/ResultSetNavigator.spec.ts index a9dd4870ad..5d490b2242 100644 --- a/apps/desktop/src/components/layout/__tests__/ResultSetNavigator.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/ResultSetNavigator.spec.ts @@ -12,9 +12,9 @@ afterEach(() => { document.body.replaceChildren(); }); -async function mountNavigator() { +async function mountNavigator(inputResults?: QueryResult[]) { // Include a non-tabular result so ordinal and storage index are different. - const results = [{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[]; + const results = inputResults ?? ([{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[]); const select = vi.fn(); const container = document.createElement("div"); document.body.append(container); @@ -26,6 +26,18 @@ async function mountNavigator() { } describe("large result-set navigation", () => { + it("renders three data sets rather than five tabs when messages are interleaved", async () => { + const message: QueryResult = { columns: ["Message"], rows: [["notice"]], affected_rows: 0, execution_time_ms: 1, server_message: true }; + const data: QueryResult = { columns: ["Message"], rows: [["real data"]], affected_rows: 0, execution_time_ms: 1 }; + const { container, select } = await mountNavigator([message, data, message, { ...data, rows: [] }, data]); + const buttons = [...container.querySelectorAll(".result-set-scroll button")]; + + expect(buttons.map((button) => button.textContent?.trim())).toEqual(["Result 1", "Result 2", "Result 3"]); + expect(container.textContent).toContain("All results (3)"); + buttons[1]?.click(); + expect(select.mock.calls[0]?.[0]).toMatchObject({ index: 3, n: 2 }); + }); + it("scrolls the result-set strip with a vertical wheel and consumes boundary gestures", async () => { const { container } = await mountNavigator(); const strip = container.querySelector(".result-set-scroll")!; diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index d08cb2807c..0d57b9e407 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -429,8 +429,8 @@ GO`; await execution.tryExecute(); - expect(activeOutputView.value).toBe("result"); expect(activeTab.value?.result?.rows).toEqual([["x"]]); + expect(activeOutputView.value).toBe("messages"); }); it("keeps a SQL Server data result selected when a trailing message result exists", async () => { diff --git a/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts index 627139dd6c..a054ef4d79 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecutionMultiDbFocus.spec.ts @@ -100,6 +100,31 @@ describe("SQL Server result focus: doExecute vs executeTargetSql", () => { setActivePinia(createPinia()); }); + it("opens message-only executeTargetSql output in the messages view", async () => { + const tab = { ...queryTab(), sql: "PRINT N'x'" }; + const connection = sqlServerConnection(); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result"); + const queryStore = useQueryStore(); + const { messageResult } = sqlServerMessageFirstResults(); + vi.spyOn(queryStore, "executeTabSql").mockImplementation(async () => { + tab.result = messageResult; + return true; + }); + vi.spyOn(queryStore, "getExecutionTab").mockReturnValue(tab); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + const execution = useSqlExecution({ + activeTab: computed(() => tab as QueryTab | undefined), + activeConnection: computed(() => connection), + executableSql: computed(() => tab.sql), + activeOutputView, + }); + + await execution.executeTargetSql({ tab, connection, sql: tab.sql }); + + expect(activeOutputView.value).toBe("messages"); + expect(tab.result?.rows).toEqual([["x"]]); + }); + // ---- control: the single-connection editor path (fixed by be3336c1e) ---- it("CONTROL doExecute re-focuses the real data result when a message result was selected", async () => { const sql = "PRINT N'x'; SELECT 1 AS value;"; diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index c31700f6ec..db582e24c1 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -418,11 +418,11 @@ export function useSqlExecution(deps: { if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) { focusSqlServerDataResult(tab.id, executionDatabaseType, tab); if (executionTabStillActive) { - deps.activeOutputView.value = "result"; + deps.activeOutputView.value = tab.result?.server_message === true ? "messages" : "result"; } } else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) { if (executionTabStillActive) { - deps.activeOutputView.value = "result"; + deps.activeOutputView.value = "messages"; } } else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) { if (executionTabStillActive) { @@ -607,7 +607,7 @@ export function useSqlExecution(deps: { // 跳动(闪烁/竞态)。worker 结果已由 captureMultiDbExecutionWorkerResult 记录 // 到 source tab 的 result run 并通过 projectResultRun 投影显示,无需再切主视图。 if (!workerId && deps.activeTab.value?.id === tab.id) { - deps.activeOutputView.value = success && (latest.result?.columns.length || latest.results?.some((result) => result.columns.length)) ? "result" : "summary"; + deps.activeOutputView.value = success && latest.result?.server_message === true ? "messages" : success && (latest.result?.columns.length || latest.results?.some((result) => result.columns.length)) ? "result" : "summary"; } return finish(success ? { status: "success", errorMessage } : { status: "failed", errorMessage }); } catch (error) { diff --git a/apps/desktop/src/lib/__tests__/query/queryResultMessages.spec.ts b/apps/desktop/src/lib/__tests__/query/queryResultMessages.spec.ts new file mode 100644 index 0000000000..8337f3bb1b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/query/queryResultMessages.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { queryResultMessages } from "@/lib/query/queryResultMessages"; +import { defaultViewForResult } from "@/lib/query/queryResultDefaultView"; +import type { QueryResult } from "@/types/database"; + +const messageResult: QueryResult = { columns: ["Message"], rows: [["before"], [""]], affected_rows: 0, execution_time_ms: 1, server_message: true }; + +describe("SQL Server result messages", () => { + it("collects tagged messages around data and errors in execution order", () => { + const data: QueryResult = { columns: ["Message"], rows: [["not a notice"]], affected_rows: 0, execution_time_ms: 1 }; + const error: QueryResult = { ...data, columns: ["Error"], rows: [["failed"]], execution_error: true }; + const results = [messageResult, data, { ...messageResult, rows: [["between"]] }, error, { ...messageResult, rows: [["after"]] }]; + + expect(results.flatMap(queryResultMessages).map(({ message }) => message)).toEqual(["before", "", "between", "after"]); + expect(queryResultMessages(data)).toEqual([]); + expect(queryResultMessages(error)).toEqual([]); + expect(messageResult.rows).toEqual([["before"], [""]]); + }); + + it("preserves structured metadata without duplicating message rows", () => { + const messages = [{ severity: "WARNING", message: "warning", code: "01000", detail: "detail" }]; + expect(queryResultMessages({ ...messageResult, messages })).toBe(messages); + expect(queryResultMessages({ rows: [], messages })).toBe(messages); + expect(queryResultMessages({ ...messageResult, rows: [[null], []] })).toEqual([]); + }); + + it("opens message-only output and preserves other databases' default views", () => { + expect(defaultViewForResult(messageResult)).toBe("messages"); + expect(defaultViewForResult({ ...messageResult, rows: [] })).toBe("summary"); + expect(defaultViewForResult({ columns: [], rows: [], affected_rows: 0, messages: [{ severity: "NOTICE", message: "postgres" }] })).toBe("messages"); + expect(defaultViewForResult({ columns: [], rows: [], affected_rows: 1, messages: [{ severity: "INFO", message: "Records: 1" }] })).toBe("summary"); + expect(defaultViewForResult({ columns: [], rows: [], affected_rows: 0 })).toBe("summary"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts index 4d1b9de5db..8b469724b1 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts @@ -21,7 +21,7 @@ import { dirtyTabTitleStyle, } from "@/lib/tabs/tabPresentation"; import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint"; -import type { ConnectionConfig, QueryTab } from "@/types/database"; +import type { ConnectionConfig, QueryResult, QueryTab } from "@/types/database"; const translations: Record = { "tabs.tooltipConnection": "Connection:", @@ -97,6 +97,21 @@ describe("query result SQL selection", () => { }); describe("query result labels", () => { + it("excludes tagged server messages without renumbering storage indexes", () => { + const message: QueryResult = { columns: ["Message"], rows: [["notice"]], affected_rows: 0, execution_time_ms: 1, server_message: true }; + const data: QueryResult = { columns: ["Message"], rows: [["real data"]], affected_rows: 0, execution_time_ms: 1 }; + const empty: QueryResult = { ...data, rows: [] }; + const results = [message, data, message, empty, data]; + + expect(tabularResultItems(results).map(({ index, n }) => ({ index, n }))).toEqual([ + { index: 1, n: 1 }, + { index: 3, n: 2 }, + { index: 4, n: 3 }, + ]); + expect(tabularResultItems([message])).toEqual([]); + expect(results).toHaveLength(5); + }); + it("preserves both ends when shortening long source labels", () => { expect(middleEllipsis("easy_manager_tool.tool_monitor_data_index_item")).toBe("easy_manage...index_item"); expect(middleEllipsis("aaa.apis")).toBe("aaa.apis"); diff --git a/apps/desktop/src/lib/query/queryResultDefaultView.ts b/apps/desktop/src/lib/query/queryResultDefaultView.ts index 9db79639c0..0934b5e38a 100644 --- a/apps/desktop/src/lib/query/queryResultDefaultView.ts +++ b/apps/desktop/src/lib/query/queryResultDefaultView.ts @@ -1,4 +1,5 @@ import type { QueryResult } from "@/types/database"; +import { queryResultMessages } from "./queryResultMessages"; /** * Picks the default output view for a result that has no result set. @@ -6,7 +7,8 @@ import type { QueryResult } from "@/types/database"; * messages view; routine DML like a MySQL INSERT also carries an INFO message * ("Records: N ...") but keeps the established summary view. */ -export function defaultViewForResult(result: Pick): "messages" | "summary" { +export function defaultViewForResult(result: Pick): "messages" | "summary" { + if (result.server_message === true && queryResultMessages(result).length > 0) return "messages"; const messageOnly = result.columns.length === 0 && result.rows.length === 0 && result.affected_rows === 0 && (result.messages?.length ?? 0) > 0; return messageOnly ? "messages" : "summary"; } diff --git a/apps/desktop/src/lib/query/queryResultMessages.ts b/apps/desktop/src/lib/query/queryResultMessages.ts new file mode 100644 index 0000000000..2781e74b48 --- /dev/null +++ b/apps/desktop/src/lib/query/queryResultMessages.ts @@ -0,0 +1,6 @@ +import type { QueryMessage, QueryResult } from "@/types/database"; + +export function queryResultMessages(result: Pick): QueryMessage[] { + if (result.messages?.length || result.server_message !== true) return result.messages ?? []; + return result.rows.flatMap((row) => (row[0] == null ? [] : [{ severity: "INFO", message: String(row[0]) }])); +} diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index 9ddbcac58c..4843e37389 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -362,7 +362,7 @@ export function tabularResultItems(results: QueryResult[] | undefined): { result if (!results) return []; return results .map((result, index) => ({ result, index })) - .filter((item) => item.result.columns.length > 0) + .filter((item) => item.result.columns.length > 0 && item.result.server_message !== true) .map((item, ordinal) => { const label = queryResultStatementLabel(item.result); const displayLabel = label ? middleEllipsis(label) : undefined;