fix(query): show SQL Server notices outside result-set tabs

Closes #9166
This commit is contained in:
t8y2 committed 2026-09-16 00:47:21 +08:00
1 parent 8373f153a3
commit dffd3025a7
11 files changed
+188 -12

No files matched your search

@@ -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<QueryMessage[]>(() => {
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");
}
@@ -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();
});
});
@@ -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<HTMLButtonElement>(".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<HTMLElement>(".result-set-scroll")!;
@@ -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 () => {
@@ -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;";
@@ -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) {
@@ -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");
});
});
+16 -1
View File
@@ -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<string, string> = {
"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");
@@ -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<QueryResult, "columns" | "rows" | "affected_rows" | "messages">): "messages" | "summary" {
export function defaultViewForResult(result: Pick<QueryResult, "columns" | "rows" | "affected_rows" | "messages" | "server_message">): "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";
}
@@ -0,0 +1,6 @@
import type { QueryMessage, QueryResult } from "@/types/database";
export function queryResultMessages(result: Pick<QueryResult, "rows" | "messages" | "server_message">): 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]) }]));
}
+1 -1
View File
@@ -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;