From 40a806a24a958852c4858f8b4d11ee9eb5dd5602 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Mon, 31 Aug 2026 23:45:40 +0800 Subject: [PATCH] fix(grid): sort paginated table data server side --- apps/desktop/src/components/grid/DataGrid.vue | 3 +- .../DataGridInfiniteScrollSort.spec.ts | 7 + .../__tests__/useDataGridActions.spec.ts | 179 ++++++++++++++++++ .../src/composables/useDataGridActions.ts | 29 ++- .../__tests__/dataGridPagination.spec.ts | 39 +++- 5 files changed, 251 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0062f64c33..4df65c47ca 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -3818,7 +3818,8 @@ function currentWhereInput(): string | undefined { } function currentOrderBy(): string | undefined { - return orderByInput.value.trim() || (sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined); + const structuredOrderBy = sortMode.value === "database" && sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined; + return orderByInput.value.trim() || structuredOrderBy; } function executeServerPageJump(targetPage: number, updateCurrentPage = false) { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridInfiniteScrollSort.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridInfiniteScrollSort.spec.ts index 124b5dfae8..3efa0d252c 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridInfiniteScrollSort.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridInfiniteScrollSort.spec.ts @@ -32,4 +32,11 @@ describe("DataGrid infinite-scroll sorting", () => { expect(sortSource).toContain("} else {\n currentPage.value = 1;\n resetGridVerticalScroll(true);\n }"); expect(sortSource.match(/emit\("sort"/g)).toHaveLength(1); }); + + it("does not expose an explicit local sort as a database order for refresh or pagination", () => { + const orderSource = functionSource("currentOrderBy", "executeServerPageJump"); + + expect(orderSource).toContain('sortMode.value === "database"'); + expect(orderSource).toContain("return orderByInput.value.trim() || structuredOrderBy;"); + }); }); diff --git a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts index d708f149a9..076334f96b 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ buildTableSelectSql: vi.fn(), buildSortedQuerySql: vi.fn(), executeTabSql: vi.fn(), + sortTabResultLocally: vi.fn(), getConfig: vi.fn(), setExecuting: vi.fn(), updateSql: vi.fn(), @@ -51,6 +52,7 @@ vi.mock("@/stores/connectionStore", () => ({ vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ executeTabSql: mocks.executeTabSql, + sortTabResultLocally: mocks.sortTabResultLocally, activeResultExecutionTarget: mocks.activeResultExecutionTarget, setExecuting: mocks.setExecuting, updateSql: mocks.updateSql, @@ -642,4 +644,181 @@ describe("useDataGridActions", () => { }), ); }); + + it("database-sorts a paginated table from the first page instead of reordering the current page", async () => { + const fullRows = [...Array.from({ length: 10 }, (_, index) => [50 + index]), ...Array.from({ length: 20 }, (_, index) => [index + 1])]; + const tab = tableDataTab({ + result: { columns: ["sort_value"], rows: fullRows.slice(0, 10), affected_rows: 30, execution_time_ms: 1 }, + resultPageLimit: 10, + resultPageOffset: 10, + whereInput: "status = 'active'", + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: [], + }, + }); + mocks.infiniteScroll = false; + mocks.buildTableSelectSql.mockImplementationOnce(async (options) => { + expect(options).toMatchObject({ orderBy: '"sort_value" ASC', whereInput: "status = 'active'", limit: 10, offset: 0 }); + return 'SELECT "sort_value" FROM public.users WHERE (status = \'active\') ORDER BY "sort_value" ASC LIMIT 10 OFFSET 0'; + }); + mocks.executeTabSql.mockImplementationOnce(async (_id, _sql, options) => { + expect(options.pagination).toEqual({ limit: 10, offset: 0 }); + const sortedRows = [...fullRows].sort((left, right) => Number(left[0]) - Number(right[0])); + tab.result = { ...tab.result!, rows: sortedRows.slice(0, 10) }; + tab.resultPageLimit = options.pagination.limit; + tab.resultPageOffset = options.pagination.offset; + }); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onSort("sort_value", 0, "asc", "status = 'active'"); + + expect(tab.result?.rows.map((row) => row[0])).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(tab.orderByInput).toBe('"sort_value" ASC'); + expect(mocks.sortTabResultLocally).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).toHaveBeenCalledWith( + "tab-1", + expect.any(String), + expect.objectContaining({ + pagination: { limit: 10, offset: 0 }, + preserveTotalRowCountDuringExecution: true, + }), + ); + }); + + it.each([ + ["asc", '"sort_value" ASC'], + ["desc", '"sort_value" DESC'], + ] as const)("immediately executes paginated table %s sorting with its page size", async (direction, orderBy) => { + const tab = tableDataTab({ + resultPageLimit: 7, + resultPageOffset: 14, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: [], + }, + }); + mocks.infiniteScroll = false; + mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted"); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onSort("sort_value", 0, direction, "status = 'active'", "database"); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy, whereInput: "status = 'active'", limit: 7, offset: 0 })); + expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 7, offset: 0 }, preserveTotalRowCountDuringExecution: true })); + expect(tab.resultSortMode).toBe("database"); + }); + + it("clears paginated table sorting by querying the first page without the generated order", async () => { + const tab = tableDataTab({ + resultSortColumn: "sort_value", + resultSortColumnIndex: 0, + resultSortDirection: "desc", + resultSortMode: "database", + orderByInput: '"sort_value" DESC', + resultPageLimit: 10, + resultPageOffset: 20, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: [], + }, + }); + mocks.infiniteScroll = false; + mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT unsorted"); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onSort("sort_value", 0, null, "status = 'active'", "database"); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined, whereInput: "status = 'active'", limit: 10, offset: 0 })); + expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT unsorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true })); + expect(tab.resultSortColumn).toBeUndefined(); + expect(tab.resultSortDirection).toBeUndefined(); + expect(tab.resultSortMode).toBeUndefined(); + expect(tab.orderByInput).toBeUndefined(); + }); + + it("keeps the database order and WHERE clause when fetching the next page", async () => { + const tab = tableDataTab({ + result: { columns: ["sort_value"], rows: Array.from({ length: 10 }, (_, index) => [index + 1]), affected_rows: 30, execution_time_ms: 1 }, + resultSortColumn: "sort_value", + resultSortColumnIndex: 0, + resultSortDirection: "asc", + resultSortMode: "database", + orderByInput: '"sort_value" ASC', + resultPageLimit: 10, + resultPageOffset: 0, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: [], + }, + }); + mocks.infiniteScroll = false; + mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted page 2"); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onPaginate(10, 10, "status = 'active'"); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"sort_value" ASC', whereInput: "status = 'active'", limit: 10, offset: 10 })); + expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted page 2", expect.objectContaining({ pagination: { offset: 10, limit: 10 }, preserveTotalRowCountDuringExecution: true })); + }); + + it("keeps explicit local sorting local without issuing a database request", async () => { + const tab = tableDataTab({ + result: { columns: ["sort_value"], rows: [[50], [60], [70]], affected_rows: 30, execution_time_ms: 1 }, + resultPageLimit: 10, + resultPageOffset: 10, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: [], + }, + }); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onSort("sort_value", 0, "asc", "status = 'active'", "local"); + + expect(mocks.sortTabResultLocally).toHaveBeenCalledWith("tab-1", "sort_value", 0, "asc"); + expect(mocks.buildTableSelectSql).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).not.toHaveBeenCalled(); + expect(tab.orderByInput).toBeUndefined(); + }); + + it("keeps SQL query result database sorting on the first page when the result is paginated", async () => { + const tab = { + id: "tab-1", + connectionId: "postgres-1", + database: "app", + title: "Query", + sql: "SELECT sort_value FROM users", + resultBaseSql: "SELECT sort_value FROM users", + resultPageLimit: 10, + resultPageOffset: 20, + result: { columns: ["sort_value"], rows: [[50]], affected_rows: 30, execution_time_ms: 1 }, + mode: "query", + isDirty: false, + isExecuting: false, + isCancelling: false, + isExplaining: false, + } as QueryTab; + mocks.buildSortedQuerySql.mockResolvedValueOnce({ ok: true, sql: "SELECT sorted" }); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onSort("sort_value", 0, "desc", undefined, "database"); + + expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true })); + }); }); diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index c59e7fc3a7..61dbcdf746 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -58,6 +58,15 @@ export function useDataGridActions(activeTab: ComputedRef) return quoteTableDataIdentifier(effectiveDatabaseTypeForConnection(config), name, connectionStore.connectionIdentifierQuote?.(tab.connectionId)); } + function tableDataPageLimit(tab: QueryTab): number { + return tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); + } + + function resultSortPagination(tab: QueryTab): { limit: number; offset: number } | undefined { + const limit = tab.resultPageLimit; + return typeof limit === "number" && limit > 0 ? { limit, offset: 0 } : undefined; + } + function buildTableSql(tab: QueryTab, options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {}): Promise { const config = connectionStore.getConfig(tab.connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(config); @@ -68,7 +77,7 @@ export function useDataGridActions(activeTab: ComputedRef) // 结果(可能是失败结果的 ["Error"]),进入 SQL 会生成非法投影; // 真实列缺失时省略 columns 让 builder 生成 SELECT * const realColumns = tab.tableMeta?.columns.length ? tab.tableMeta.columns : undefined; - const limit = options.limit ?? tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); + const limit = options.limit ?? tableDataPageLimit(tab); return buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config?.driver_profile, @@ -435,7 +444,7 @@ export function useDataGridActions(activeTab: ComputedRef) if (!tableMetaForDataTab(tab)) return; tab.whereInput = whereInput ?? ""; - const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy }); + const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy: orderBy ?? tab.orderByInput }); queryStore.updateSql(tab.id, sql); await queryStore.executeTabSql(tab.id, sql, { pagination: { offset, limit }, @@ -468,20 +477,29 @@ export function useDataGridActions(activeTab: ComputedRef) const config = connectionStore.getConfig(tab.connectionId); const quotedColumn = quoteIdent(tab, column); const orderBy = direction ? `${config?.db_type === "neo4j" ? `n.${quotedColumn}` : quotedColumn} ${direction.toUpperCase()}` : undefined; - const sql = await buildTableSql(tab, { orderBy, whereInput }); + const limit = tableDataPageLimit(tab); + const pagination = { limit, offset: 0 }; + tab.orderByInput = orderBy; + const sql = await buildTableSql(tab, { orderBy, whereInput, limit, offset: pagination.offset }); queryStore.updateSql(tab.id, sql); - await queryStore.executeTabSql(tab.id, sql, { preserveResultDuringExecution: true }); + await queryStore.executeTabSql(tab.id, sql, { + pagination, + preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, + }); return; } const baseSql = queryResultBaseSql(tab); if (!baseSql.trim()) return; + const pagination = resultSortPagination(tab); if (!direction) { await queryStore.executeTabSql(tab.id, baseSql, { ...activeQueryTargetOptions(tab), resultBaseSql: baseSql, resultSortedSql: undefined, + ...(pagination ? { pagination } : {}), preserveResultDuringExecution: true, preserveTotalRowCountDuringExecution: true, replaceActiveResultInGroup: true, @@ -502,6 +520,7 @@ export function useDataGridActions(activeTab: ComputedRef) ...activeQueryTargetOptions(tab), resultBaseSql: baseSql, resultSortedSql: sortedSql, + ...(pagination ? { pagination } : {}), preserveResultDuringExecution: true, preserveTotalRowCountDuringExecution: true, replaceActiveResultInGroup: true, @@ -531,6 +550,7 @@ export function useDataGridActions(activeTab: ComputedRef) ...activeQueryTargetOptions(tab), resultBaseSql: baseSql, resultSortedSql: built.sql, + ...(pagination ? { pagination } : {}), preserveResultDuringExecution: true, preserveTotalRowCountDuringExecution: true, replaceActiveResultInGroup: true, @@ -546,6 +566,7 @@ export function useDataGridActions(activeTab: ComputedRef) column, direction, }, + ...(pagination ? { pagination } : {}), preserveResultDuringExecution: true, preserveTotalRowCountDuringExecution: true, replaceActiveResultInGroup: true, diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGridPagination.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGridPagination.spec.ts index f522b32e74..d8c94a59b6 100644 --- a/apps/desktop/src/lib/dataGrid/__tests__/dataGridPagination.spec.ts +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGridPagination.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS, elasticsearchCursorPageJumpRequestCount } from "@/lib/dataGrid/dataGridPagination"; +import { ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS, elasticsearchCursorPageJumpRequestCount, hasCompleteLocalDataGridResult } from "@/lib/dataGrid/dataGridPagination"; describe("Elasticsearch cursor page jumps", () => { it("counts only missing forward cursors", () => { @@ -17,3 +17,40 @@ describe("Elasticsearch cursor page jumps", () => { expect(elasticsearchCursorPageJumpRequestCount(1, 101)).toBe(ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS); }); }); + +describe("complete local DataGrid results", () => { + it("recognizes complete results without treating a partial page as complete", () => { + expect( + hasCompleteLocalDataGridResult({ + isResultsContext: true, + rowCount: 3, + pageLimit: 10, + pageOffset: 0, + truncated: false, + hasMore: false, + }), + ).toBe(true); + expect( + hasCompleteLocalDataGridResult({ + isResultsContext: true, + rowCount: 10, + pageLimit: 10, + pageOffset: 0, + totalRowCount: 25, + truncated: false, + hasMore: false, + }), + ).toBe(false); + expect( + hasCompleteLocalDataGridResult({ + isResultsContext: true, + rowCount: 20, + pageLimit: 10, + pageOffset: 10, + totalRowCount: 20, + truncated: false, + hasMore: false, + }), + ).toBe(false); + }); +});