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")!;