From 994ae28aefcfeb8a4ddefb873e8792eaf0d241bd Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 17 Jul 2026 17:13:09 +0800 Subject: [PATCH] fix(data-grid): preserve table page size on refresh --- apps/desktop/src/components/grid/DataGrid.vue | 5 +- .../__tests__/useDataGridActions.spec.ts | 103 +++++++++++++++++- .../src/composables/useDataGridActions.ts | 7 +- .../queryStore.tableDataRefresh.spec.ts | 26 ++++- apps/desktop/src/stores/queryStore.ts | 3 +- packages/app-tests/queryResultToolbar.test.ts | 8 ++ 6 files changed, 138 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 71101f042..77ef9b02a 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -69,6 +69,7 @@ import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/ import { createColumnDrafts } from "@/lib/table/tableStructureEditorState"; import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructureEditorSql"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; +import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { uuid } from "@/lib/common/utils"; import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration"; import { compactHeaderColumnType, resolveHeaderColumnType } from "@/lib/dataGrid/dataGridColumnType"; @@ -2251,7 +2252,7 @@ watch( ); // --- Pagination --- -const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize)); +const pageSize = ref(normalizeResultPageSize(props.context === "table-data" ? (props.pageLimit ?? tableOpenPageLimit()) : settingsStore.editorSettings.pageSize)); const currentPage = ref(1); const pageSizeOptions = computed(() => resultPageSizeMenuOptions(pageSize.value)); const customPageSizeInput = ref(String(pageSize.value)); @@ -2270,6 +2271,8 @@ watch(pageSize, (value) => { watch( () => settingsStore.editorSettings.pageSize, (value) => { + // Table-data segments keep their own pagination state instead of following SQL result settings. + if (props.context === "table-data") return; pageSize.value = normalizeResultPageSize(value, pageSize.value); }, ); diff --git a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts index f5743c00f..17ceba9d9 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts @@ -4,9 +4,12 @@ import { useDataGridActions } from "@/composables/useDataGridActions"; import type { QueryTab } from "@/types/database"; const mocks = vi.hoisted(() => ({ + buildTableSelectSql: vi.fn(), buildSortedQuerySql: vi.fn(), executeTabSql: vi.fn(), getConfig: vi.fn(), + setExecuting: vi.fn(), + updateSql: vi.fn(), })); vi.mock("vue-i18n", () => ({ @@ -17,6 +20,11 @@ vi.mock("@/lib/backend/api", () => ({ buildSortedQuerySql: mocks.buildSortedQuerySql, })); +vi.mock("@/lib/table/tableSelectSql", () => ({ + buildTableSelectSql: mocks.buildTableSelectSql, + quoteTableDataIdentifier: (_databaseType: string, name: string) => `"${name}"`, +})); + vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: mocks.getConfig, @@ -26,12 +34,8 @@ vi.mock("@/stores/connectionStore", () => ({ vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ executeTabSql: mocks.executeTabSql, - }), -})); - -vi.mock("@/stores/settingsStore", () => ({ - useSettingsStore: () => ({ - editorSettings: { pageSize: 100 }, + setExecuting: mocks.setExecuting, + updateSql: mocks.updateSql, }), })); @@ -39,13 +43,100 @@ vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }), })); +function tableDataTab(patch: Partial = {}): QueryTab { + return { + id: "tab-1", + connectionId: "postgres-1", + database: "app", + title: "users", + sql: "SELECT * FROM public.users", + result: { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, + mode: "data", + isDirty: false, + isExecuting: false, + isCancelling: false, + isExplaining: false, + tableMetaUpdatedAt: Date.now(), + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }, + ...patch, + } as QueryTab; +} + describe("useDataGridActions", () => { beforeEach(() => { vi.clearAllMocks(); mocks.getConfig.mockReturnValue({ id: "postgres-1", db_type: "postgres" }); + mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM public.users LIMIT 100 OFFSET 0"); mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" }); }); + it("uses the table-data default when toolbar reload has no saved pagination", async () => { + const tab = tableDataTab(); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh"); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 100, + offset: 0, + }), + ); + expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 100 OFFSET 0", expect.objectContaining({ pagination: { limit: 100, offset: 0 } })); + expect(mocks.executeTabSql.mock.calls[0]?.[2]).not.toHaveProperty("preserveTotalRowCountDuringExecution"); + }); + + it("preserves the toolbar page segment and offset for table-data refresh", async () => { + const tab = tableDataTab({ + resultPageLimit: 25, + resultPageOffset: 50, + }); + mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 25 OFFSET 50"); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onReloadData(tab.sql, "", "", "", 25, 50, "refresh"); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ limit: 25, offset: 50 })); + expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 25 OFFSET 50", expect.objectContaining({ pagination: { limit: 25, offset: 50 } })); + expect(mocks.executeTabSql.mock.calls[0]?.[2]).not.toHaveProperty("preserveTotalRowCountDuringExecution"); + }); + + it("keeps SQL result toolbar reload free of table pagination defaults", async () => { + const tab = { + id: "tab-1", + connectionId: "postgres-1", + database: "app", + title: "Query", + sql: "SELECT 1", + result: { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }, + mode: "query", + isDirty: false, + isExecuting: false, + isCancelling: false, + isExplaining: false, + } as QueryTab; + const actions = useDataGridActions(computed(() => tab)); + + await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh"); + + expect(mocks.buildTableSelectSql).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).toHaveBeenCalledWith( + "tab-1", + "SELECT 1", + expect.objectContaining({ + resultBaseSql: "SELECT 1", + resultSortedSql: undefined, + preserveResultDuringExecution: true, + }), + ); + }); + it("excludes hidden primary keys and remaps the selected column for database sorting", async () => { const tab = { id: "tab-1", diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index d4fd3ea29..76cf5cb71 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -2,8 +2,8 @@ import { type ComputedRef } from "vue"; import { useI18n } from "vue-i18n"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; -import { useSettingsStore } from "@/stores/settingsStore"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; +import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import * as api from "@/lib/backend/api"; @@ -36,7 +36,6 @@ export function useDataGridActions(activeTab: ComputedRef) const { toast } = useToast(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); - const settingsStore = useSettingsStore(); function quoteIdent(tab: QueryTab, name: string): string { const config = connectionStore.getConfig(tab.connectionId); @@ -60,7 +59,7 @@ export function useDataGridActions(activeTab: ComputedRef) columns: tableMeta?.columns.map((column) => column.name), primaryKeys, includeRowId: useRowId, - limit: options.limit ?? settingsStore.editorSettings.pageSize, + limit: options.limit ?? tab.resultPageLimit ?? tableOpenPageLimit(), ...options, }); } @@ -120,7 +119,7 @@ export function useDataGridActions(activeTab: ComputedRef) const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`; if (tab.mode === "data" && tableMetaForDataTab(tab)) { tab.whereInput = whereInput ?? ""; - const pageLimit = limit ?? settingsStore.editorSettings.pageSize; + const pageLimit = limit ?? tab.resultPageLimit ?? tableOpenPageLimit(); const pageOffset = offset ?? 0; console.info("[DBX][reloadData:start]", { traceId, diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts index 9330b00a0..bf254f1b7 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts @@ -28,7 +28,7 @@ vi.mock("@/stores/connectionStore", () => ({ vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ - editorSettings: { pageSize: 100 }, + editorSettings: { pageSize: 1000 }, }), })); @@ -162,6 +162,30 @@ describe("queryStore table data refresh", () => { expect(store.tabs.find((tab) => tab.id === secondTabId)?.result).toBeUndefined(); }); + it("uses the table-open default when a refreshed data tab has no saved pagination", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("pg-1", "app", "users", "data", "public"); + store.setTableMeta(tabId, { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + + await expect(store.refreshDataTab(tabId)).resolves.toBe(true); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 100, + offset: 0, + }), + ); + expect(mocks.executeMulti).toHaveBeenCalledTimes(1); + expect(store.tabs.find((tab) => tab.id === tabId)?.resultPageLimit).toBe(100); + }); + it("rejects a repeated refresh while SQL construction is in progress", async () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index c23c67e16..3e9f31a3f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1863,7 +1863,6 @@ export const useQueryStore = defineStore("query", () => { const tableMeta = tableMetaForDataTab(tab); if (!tableMeta?.tableName) return false; - const settingsStore = useSettingsStore(); const connStore = useConnectionStore(); const conn = connStore.getConfig(tab.connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(conn); @@ -1871,7 +1870,7 @@ export const useQueryStore = defineStore("query", () => { const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys; const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined; const orderBy = tab.orderByInput?.trim() || sortOrder; - const limit = tab.resultPageLimit ?? settingsStore.editorSettings.pageSize ?? tableOpenPageLimit(); + const limit = tab.resultPageLimit ?? tableOpenPageLimit(); const offset = tab.resultPageOffset ?? 0; const refreshPreparationId = uuid(); diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts index 58f362ce1..eabd23dac 100644 --- a/packages/app-tests/queryResultToolbar.test.ts +++ b/packages/app-tests/queryResultToolbar.test.ts @@ -71,6 +71,14 @@ test("DataGrid exposes persistent result toolbar slots", () => { assert.match(dataGrid, /hasResultToolbarLeadingSlot\.value \|\|[\s\S]*hasResultToolbarActionsSlot\.value/); }); +test("table-data toolbar refresh keeps page size independent from SQL editor settings", () => { + const dataGrid = source(dataGridPath); + + assert.match(dataGrid, /props\.context === "table-data" \? \(props\.pageLimit \?\? tableOpenPageLimit\(\)\) : settingsStore\.editorSettings\.pageSize/); + assert.match(dataGrid, /if \(props\.context === "table-data"\) return;[\s\S]*pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/); + assert.match(dataGrid, /emit\("reload", props\.sql, searchText\.value, currentWhereInput\(\), currentOrderBy\(\), pageSize\.value, \(currentPage\.value - 1\) \* pageSize\.value, "refresh"\)/); +}); + test("standalone result views use the same compact toolbar breakpoint", () => { const contentArea = source(contentAreaPath); const dataGrid = source(dataGridPath);