fix(query): show SQL Server notices outside result-set tabs
Closes #9166
This commit is contained in:
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");
|
||||
}
|
||||
|
||||
+81
@@ -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
@@ -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]) }]));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user