From be3336c1ec9aa0272643cff6e94ea2076c5a19c0 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 11:41:36 +0000 Subject: [PATCH] fix(sqlserver): keep data results interactive with messages Closes #5967 --- apps/desktop/src/components/grid/DataGrid.vue | 9 +- .../__tests__/useSqlExecution.spec.ts | 104 +++++++++++++++++- .../src/composables/useSqlExecution.ts | 7 +- apps/desktop/src/stores/queryStore.ts | 2 + packages/app-tests/dataGridPagination.test.ts | 7 +- packages/app-tests/queryStore.test.ts | 23 ++++ 6 files changed, 142 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0b1a44ab28..56453bddd2 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2760,8 +2760,9 @@ const canFetchNextInfiniteScrollSegment = computed(() => ); const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows)); const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value); -/** Full-grid busy state: refresh dispatch, query loading, or on-demand COUNT (e.g. jump to last page). */ -const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || totalRowCountBusy.value); +/** Automatic background counts keep rows interactive; explicit count navigation still blocks the surface. */ +const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || manualTotalRowCountLoading.value); +const gridPaginationBusy = computed(() => gridSurfaceBusy.value || totalRowCountBusy.value); const dataGridNativeSelectionBlockOwner = {}; watch( gridSurfaceBusy, @@ -2921,7 +2922,7 @@ function nextPage() { } function jumpPage(page: number) { - if (gridSurfaceBusy.value || infiniteScrollEnabled.value || !Number.isSafeInteger(page) || page < 1) return; + if (gridPaginationBusy.value || infiniteScrollEnabled.value || !Number.isSafeInteger(page) || page < 1) return; const targetPage = Math.min(page, maximumPage.value ?? page); if (targetPage === currentPage.value) return; if (allRowsLoaded.value) { @@ -11672,7 +11673,7 @@ const gridContextMenuItems = computed(() => { :pagination-enabled="paginationEnabled" :selection-summary="selectionSummary" :selection-summary-sum-text="selectionSummarySumText" - :loading="gridSurfaceBusy" + :loading="gridPaginationBusy" :infinite-scroll-enabled="infiniteScrollEnabled" :infinite-scroll-all-loaded="infiniteScrollAllLoaded" :page-size="pageSize" diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index 20cae81642..07b7ec649e 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -209,7 +209,7 @@ GO`; expect(activeTab.value?.result?.rows).toEqual([["x"]]); }); - it("selects a trailing SQL Server PRINT result after a data result", async () => { + it("keeps a SQL Server data result selected when a trailing message result exists", async () => { const sql = "SELECT 1 AS value; PRINT N'x';"; const activeTab = ref({ ...queryTab("dbx_sqlserver_demo"), sql }); const activeConnection = ref(connection("sqlserver")); @@ -239,11 +239,109 @@ GO`; await execution.tryExecute(); + expect(activeOutputView.value).toBe("result"); + expect(setActiveResultIndex).not.toHaveBeenCalled(); + expect(activeTab.value?.activeResultIndex).toBe(0); + expect(activeTab.value?.result?.server_message).toBeUndefined(); + expect(activeTab.value?.result?.rows).toEqual([[1]]); + }); + + it("selects a SQL Server data result after an earlier message result", async () => { + const sql = "PRINT N'x'; SELECT 1 AS value;"; + const activeTab = ref({ ...queryTab("dbx_sqlserver_demo"), sql }); + const activeConnection = ref(connection("sqlserver")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const setActiveResultIndex = vi.spyOn(queryStore, "setActiveResultIndex").mockImplementation((_id, index) => { + if (!activeTab.value?.results) return; + activeTab.value.activeResultIndex = index; + activeTab.value.result = activeTab.value.results[index]; + }); + vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => { + if (!activeTab.value) return; + const messageResult = { columns: ["Message"], column_types: ["nvarchar"], rows: [["x"]], affected_rows: 0, execution_time_ms: 1, server_message: true } as const; + const dataResult = { columns: ["value"], column_types: ["int"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }; + activeTab.value.results = [messageResult, dataResult]; + activeTab.value.activeResultIndex = 0; + activeTab.value.result = messageResult; + }); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + }); + + await execution.tryExecute(); + expect(activeOutputView.value).toBe("result"); expect(setActiveResultIndex).toHaveBeenCalledWith("tab-1", 1); expect(activeTab.value?.activeResultIndex).toBe(1); - expect(activeTab.value?.result?.server_message).toBe(true); - expect(activeTab.value?.result?.rows).toEqual([["x"]]); + expect(activeTab.value?.result?.rows).toEqual([[1]]); + }); + + it("keeps a SQL Server execution error selected when a message result also exists", async () => { + const sql = "SELECT missing_column FROM demo;"; + const activeTab = ref({ ...queryTab("dbx_sqlserver_demo"), sql }); + const activeConnection = ref(connection("sqlserver")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const setActiveResultIndex = vi.spyOn(queryStore, "setActiveResultIndex"); + vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => { + if (!activeTab.value) return; + const messageResult = { columns: ["Message"], column_types: ["nvarchar"], rows: [["before error"]], affected_rows: 0, execution_time_ms: 1, server_message: true }; + const errorResult = { columns: ["Error"], column_types: ["string"], rows: [["Invalid column name"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }; + activeTab.value.results = [messageResult, errorResult]; + activeTab.value.activeResultIndex = 1; + activeTab.value.result = errorResult; + }); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + }); + + await execution.tryExecute(); + + expect(setActiveResultIndex).not.toHaveBeenCalled(); + expect(activeTab.value?.activeResultIndex).toBe(1); + expect(activeTab.value?.result?.execution_error).toBe(true); + }); + + it("keeps ordinary SQL Server multi-result batches on their summary", async () => { + const sql = "SELECT 1 AS first_value; SELECT 2 AS second_value;"; + const activeTab = ref({ ...queryTab("dbx_sqlserver_demo"), sql }); + const activeConnection = ref(connection("sqlserver")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const setActiveResultIndex = vi.spyOn(queryStore, "setActiveResultIndex"); + vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => { + if (!activeTab.value) return; + const firstResult = { columns: ["first_value"], column_types: ["int"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }; + const secondResult = { columns: ["second_value"], column_types: ["int"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }; + activeTab.value.results = [firstResult, secondResult]; + activeTab.value.activeResultIndex = 0; + activeTab.value.result = firstResult; + }); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + }); + + await execution.tryExecute(); + + expect(activeOutputView.value).toBe("summary"); + expect(setActiveResultIndex).not.toHaveBeenCalled(); + expect(activeTab.value?.result?.rows).toEqual([[1]]); }); it("keeps the summary for ordinary SQL Server data aliased as Message", async () => { diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index caa09718c5..bd75c2701f 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -274,8 +274,13 @@ export function useSqlExecution(deps: { }); if (producedResult === false) return; const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined; + const activeSqlServerResult = tab.results && tab.activeResultIndex !== undefined ? tab.results[tab.activeResultIndex] : tab.result; if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) { - queryStore.setActiveResultIndex(tab.id, sqlServerMessageResultIndex); + if (activeSqlServerResult?.server_message === true) { + const sqlServerDataResultIndex = tab.results?.findIndex((result) => result.server_message !== true && !isQueryExecutionErrorResult(result) && result.columns.length > 0); + if (sqlServerDataResultIndex !== undefined && sqlServerDataResultIndex >= 0) queryStore.setActiveResultIndex(tab.id, sqlServerDataResultIndex); + else queryStore.setActiveResultIndex(tab.id, sqlServerMessageResultIndex); + } deps.activeOutputView.value = "result"; } else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) { deps.activeOutputView.value = "result"; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f83f59ad9d..3a0d1db442 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -5332,6 +5332,8 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortDirection = undefined; tab.resultSortMode = undefined; tab.resultSortedSql = undefined; + tab.resultTotalRowCount = undefined; + tab.resultTotalRowCountLoading = false; // results 数组未变,估算值与当前激活的 result 无关,可直接复用 touchResult(tab, Date.now(), { reuseEstimatedBytes: true }); tab.queryAnalysis = undefined; diff --git a/packages/app-tests/dataGridPagination.test.ts b/packages/app-tests/dataGridPagination.test.ts index aa542bc21a..5b3b1c85ae 100644 --- a/packages/app-tests/dataGridPagination.test.ts +++ b/packages/app-tests/dataGridPagination.test.ts @@ -180,10 +180,13 @@ test("auto-redirect: total is undefined — guard prevents redirect attempt", () assert.equal(!total || (total as any) <= 0, true, "guard should prevent redirect when total is unknown"); }); -test("last-page COUNT shows grid busy overlay before executeQuery", () => { +test("only an explicit last-page COUNT blocks the grid surface", () => { const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); - assert.match(source, /const gridSurfaceBusy = computed\(\(\) => isRefreshingData\.value \|\| props\.loading === true \|\| totalRowCountBusy\.value\)/); + assert.match(source, /const totalRowCountBusy = computed\(\(\) => props\.totalRowCountLoading === true \|\| manualTotalRowCountLoading\.value\)/); + assert.match(source, /const gridSurfaceBusy = computed\(\(\) => isRefreshingData\.value \|\| props\.loading === true \|\| manualTotalRowCountLoading\.value\)/); + assert.match(source, /const gridPaginationBusy = computed\(\(\) => gridSurfaceBusy\.value \|\| totalRowCountBusy\.value\)/); assert.match(source, /v-if="gridSurfaceBusy"/); + assert.match(source, /:loading="gridPaginationBusy"/); assert.match(source, /async function beginManualTotalRowCount/); assert.match(source, /await nextTick\(\);/); const lastPageFn = source.match(/async function lastPage\(\) \{[\s\S]*?\n\}/)?.[0] ?? ""; diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 72482cf897..7c531ea1e5 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1892,6 +1892,29 @@ test("statement result switching is scoped to the active result run", async () = assert.equal(tab.activeResultIndex, 0); }); +test("switching statement results clears the previous result count state", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + const tabId = store.createTab("conn-1", "db"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + + tab.results = [ + { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, + { columns: ["Message"], rows: [["warning"]], affected_rows: 0, execution_time_ms: 1, server_message: true }, + ]; + tab.activeResultIndex = 0; + tab.result = tab.results[0]; + tab.resultTotalRowCount = 200; + tab.resultTotalRowCountLoading = true; + + store.setActiveResultIndex(tabId, 1); + + assert.equal(tab.result, tab.results[1]); + assert.equal(tab.resultTotalRowCount, undefined); + assert.equal(tab.resultTotalRowCountLoading, false); +}); + test("normalizes unquoted Oracle query identifiers before loading editable metadata", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia());