From c153e93d54f9be9a117765760e95d9b081135bc9 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 21 Jul 2026 18:43:47 +0800 Subject: [PATCH] fix(grid): append infinite scroll result segments --- apps/desktop/src/components/grid/DataGrid.vue | 38 ++++-- .../src/composables/useDataGridActions.ts | 9 +- .../src/composables/useDataGridEditor.ts | 23 ++-- apps/desktop/src/stores/queryStore.ts | 56 ++++++++- apps/desktop/src/types/database.ts | 2 + packages/app-tests/dataGridEditor.test.ts | 28 +++++ .../app-tests/dataGridInfiniteScroll.test.ts | 9 ++ packages/app-tests/queryStore.test.ts | 113 +++++++++++++++++- packages/app-tests/useDataGridActions.test.ts | 55 ++++++++- 9 files changed, 303 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 8c8fc8ce0..bb50e47b1 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2269,6 +2269,8 @@ const isInfiniteScrollPaginating = ref(false); let lastInfiniteScrollPage = 0; let infiniteScrollCheckScheduled = false; let infiniteScrollAllLoaded = false; +let infiniteScrollRequestedOffset: number | undefined; +let infiniteScrollRequestedLimit: number | undefined; // Tracks whether the current loading cycle was triggered by a refresh/rollback // (as opposed to a normal paginate). Used to decide whether to auto-redirect // when the current page no longer exists after data was deleted. @@ -2313,9 +2315,19 @@ watch( if (prevLoading && !loading && infiniteScrollLoading.value) { infiniteScrollLoading.value = false; isInfiniteScrollPaginating.value = false; - // Detect if the backend returned no new data for this page - const expectedRows = currentPage.value * pageSize.value; - if (props.result.rows.length < expectedRows) { + const requestedOffset = infiniteScrollRequestedOffset; + const requestedLimit = infiniteScrollRequestedLimit; + infiniteScrollRequestedOffset = undefined; + infiniteScrollRequestedLimit = undefined; + if (requestedOffset === undefined || props.result.appended_from_row_count !== requestedOffset) { + // Failed/stale append requests preserve the old result. Roll back the + // optimistic page marker so a later scroll can retry the same segment. + currentPage.value = Math.max(1, currentPage.value - 1); + lastInfiniteScrollPage = Math.max(0, currentPage.value - 1); + return; + } + const appendedRows = props.result.rows.length - requestedOffset; + if (props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) { infiniteScrollAllLoaded = true; } } @@ -2489,21 +2501,21 @@ function nextPage() { function infiniteScrollNextPage() { if (infiniteScrollLoading.value || props.loading) return; + const nextOffset = props.result.rows.length; + const remainingRows = infiniteScrollMaxRows.value - nextOffset; + if (remainingRows <= 0) return; + const nextLimit = Math.min(pageSize.value, remainingRows); const nextPageNum = currentPage.value + 1; - const cumulativeLimit = nextPageNum * pageSize.value; - if (cumulativeLimit > infiniteScrollMaxRows.value) return; // Stop if we already know all data is loaded if (infiniteScrollAllLoaded) return; - // Skip if we already have this many rows loaded (e.g. cached data) - if (props.result.rows.length >= cumulativeLimit) { - currentPage.value = nextPageNum; - return; - } infiniteScrollLoading.value = true; isInfiniteScrollPaginating.value = true; + infiniteScrollRequestedOffset = nextOffset; + infiniteScrollRequestedLimit = nextLimit; currentPage.value = nextPageNum; - // Load cumulative data (all rows up to current page) to append instead of replace - emit("paginate", 0, cumulativeLimit, currentWhereInput(), currentOrderBy()); + // Fetch only the missing segment. Re-reading offset 0 grows transfer and replaces + // row identities, which would invalidate pending edits while the user scrolls. + emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy()); } function checkInfiniteScroll(scroller: HTMLElement) { if (!infiniteScrollEnabled.value || infiniteScrollLoading.value || props.loading) return; @@ -2969,6 +2981,8 @@ function resetInfiniteScrollState() { currentPage.value = 1; lastInfiniteScrollPage = 0; infiniteScrollAllLoaded = false; + infiniteScrollRequestedOffset = undefined; + infiniteScrollRequestedLimit = undefined; isInfiniteScrollPaginating.value = false; infiniteScrollLoading.value = false; infiniteScrollPositions = new WeakMap(); diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 0bab40caa..d7361aea0 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -247,13 +247,16 @@ export function useDataGridActions(activeTab: ComputedRef) async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) { const tab = activeTab.value; if (!tab) return; + const appendResult = settingsStore.editorSettings.infiniteScroll && offset > 0 && offset === tab.result?.rows.length; + const appendOptions = appendResult ? { appendResult: { maxRows: settingsStore.editorSettings.infiniteScrollMaxRows } } : {}; if (tab.mode !== "data") { const sortColumns = visibleQuerySortColumns(tab.result?.columns ?? [], tab.result?.hidden_column_indexes, tab.resultSortColumnIndex ?? -1); const hasDatabaseSort = !!tab.result?.hidden_column_indexes?.length && tab.resultSortMode === "database" && !!tab.resultSortDirection && !!tab.resultSortColumn && !!sortColumns; const baseSql = hasDatabaseSort ? queryResultBaseSql(tab) : queryResultExecutionSql(tab); if (!baseSql.trim()) return; - const expectedNextOffset = (tab.resultPageOffset ?? 0) + (tab.resultPageLimit ?? limit); - const sessionId = tab.result?.has_more && tab.result?.session_id && offset === expectedNextOffset && limit === tab.resultPageLimit ? tab.result.session_id : undefined; + const expectedNextOffset = appendResult ? tab.result?.rows.length : (tab.resultPageOffset ?? 0) + (tab.resultPageLimit ?? limit); + const continuesResultSession = appendResult ? offset === expectedNextOffset : offset === expectedNextOffset && limit === tab.resultPageLimit; + const sessionId = tab.result?.has_more && tab.result?.session_id && continuesResultSession ? tab.result.session_id : undefined; const resultBaseSql = queryResultBaseSql(tab); await queryStore.executeTabSql(tab.id, baseSql, { resultBaseSql, @@ -269,6 +272,7 @@ export function useDataGridActions(activeTab: ComputedRef) } : {}), pagination: { offset, limit, sessionId }, + ...appendOptions, preserveResultDuringExecution: true, preserveTotalRowCountDuringExecution: true, replaceActiveResultInGroup: true, @@ -282,6 +286,7 @@ export function useDataGridActions(activeTab: ComputedRef) queryStore.updateSql(tab.id, sql); await queryStore.executeTabSql(tab.id, sql, { pagination: { offset, limit }, + ...appendOptions, preserveResultDuringExecution: true, preserveTotalRowCountDuringExecution: true, }); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 9ef10ee02..c3ac3c42a 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1,4 +1,4 @@ -import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, type ComputedRef, type Ref } from "vue"; +import { ref, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue"; import * as api from "@/lib/backend/api"; import type { CellValue } from "@/lib/dataGrid/cellValue"; import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; @@ -139,10 +139,15 @@ const closingPendingSnapshotTabs = new Set(); const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch"; const MAX_PENDING_CHANGES_HISTORY = 100; -function dataGridRowsIdentityChanged(previousRows: CellValue[][] | undefined, nextRows: CellValue[][]): boolean { +function dataGridRowsIdentityChanged(previousRows: CellValue[][] | undefined, nextRows: CellValue[][], appendedFromRowCount?: number): boolean { if (!previousRows) return true; - if (previousRows.length !== nextRows.length) return true; - return previousRows.some((row, index) => row !== nextRows[index]); + if (appendedFromRowCount !== previousRows.length || previousRows.length > nextRows.length) { + if (previousRows.length !== nextRows.length) return true; + return previousRows.some((row, index) => toRaw(row) !== toRaw(nextRows[index])); + } + // Infinite scrolling appends rows without changing existing source indexes. + // Preserve pending edits only when every previously loaded row is the same object. + return previousRows.some((row, index) => toRaw(row) !== toRaw(nextRows[index])); } function cacheKeyBelongsToTab(cacheKey: string, tabId: string) { @@ -1334,13 +1339,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { exitTransaction(); } - // Pending changes reference rows by sourceIndex. When the result set changes - // (e.g. different WHERE clause, pagination), stale indices point to wrong rows. + // Pending changes reference rows by sourceIndex. Replacements (different WHERE, + // sort, normal pagination, refresh) invalidate them; prefix-only appends do not. let previousResultRows = result.value.rows; watch( - () => result.value.rows, - (rows) => { - if (!dataGridRowsIdentityChanged(previousResultRows, rows)) { + () => [result.value.rows, (result.value as { appended_from_row_count?: number }).appended_from_row_count] as const, + ([rows, appendedFromRowCount]) => { + if (!dataGridRowsIdentityChanged(previousResultRows, rows, appendedFromRowCount)) { previousResultRows = rows; return; } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 1dff08060..5abd573af 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -131,6 +131,30 @@ function markQueryResultsRowsRaw(results: QueryResult[]): QueryResult[] { return results; } +function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult { + if (segment.execution_error) throw new Error(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment")); + if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) { + throw new Error("Result columns changed while loading the next segment"); + } + const remainingRows = Math.max(0, maxRows - previous.rows.length); + const appendedRowCount = Math.min(remainingRows, segment.rows.length); + const appendParallelValues = (existing: T[] | undefined, next: T[] | undefined): T[] | undefined => { + if (!existing && !next) return undefined; + return [...(existing ?? []), ...(next ?? []).slice(0, appendedRowCount)]; + }; + // Keep prior row objects intact so source-index based dirty/new/deleted state + // remains valid, while bounding the in-memory result by the configured cap. + return markQueryResultRowsRaw({ + ...segment, + appended_from_row_count: previous.rows.length, + rows: [...previous.rows, ...segment.rows.slice(0, appendedRowCount)], + mongo_documents: appendParallelValues(previous.mongo_documents, segment.mongo_documents), + mongo_copy_documents: appendParallelValues(previous.mongo_copy_documents, segment.mongo_copy_documents), + execution_time_ms: (previous.execution_time_ms ?? 0) + (segment.execution_time_ms ?? 0), + has_more: previous.rows.length + appendedRowCount >= maxRows ? false : segment.has_more, + }); +} + function markQueryResultRunsRowsRaw(resultRuns: NonNullable): NonNullable { for (const run of resultRuns) { if (run.result) markQueryResultRowsRaw(run.result); @@ -2797,6 +2821,7 @@ export const useQueryStore = defineStore("query", () => { direction: "asc" | "desc"; }; pagination?: { limit: number; offset: number; sessionId?: string }; + appendResult?: { maxRows: number }; mongoSafety?: MongoAggregateSafetyOptions; preserveResultDuringExecution?: boolean; preserveTotalRowCountDuringExecution?: boolean; @@ -3405,8 +3430,20 @@ export const useQueryStore = defineStore("query", () => { if (current?.executionId === executionId) { const activeGroupIndex = current.activeResultIndex; const activeGroupResults = current.results; + const shouldAppendResult = !!options?.appendResult && !!current.result; const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && results.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length; - if (shouldReplaceActiveResultInGroup) { + if (shouldAppendResult) { + if (results.length !== 1) throw new Error("Expected one result while loading the next segment"); + if (options.pagination?.offset !== current.result!.rows.length) { + throw new Error("Ignoring a stale result segment whose offset no longer matches the loaded rows"); + } + const appendedResult = appendQueryResultSegment(current.result!, results[0]!, options.appendResult!.maxRows); + if (Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length) { + current.results = activeGroupResults.slice(); + current.results[activeGroupIndex] = appendedResult; + } + current.result = appendedResult; + } else if (shouldReplaceActiveResultInGroup) { current.results = activeGroupResults.slice(); current.results[activeGroupIndex] = results[0]; current.result = results[0]; @@ -3425,9 +3462,12 @@ export const useQueryStore = defineStore("query", () => { current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql; current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint; current.resultSortedSql = resultSortedSql; - current.resultPageSql = pageSql; + // Appended rows form one logical result starting at the original page. + // Keep the base page state so later table refresh/cache recovery does + // not re-execute only the most recently fetched tail segment. + current.resultPageSql = shouldAppendResult ? (current.resultPageSql ?? pageSql) : pageSql; current.resultPageLimit = pageLimit; - current.resultPageOffset = pageOffset; + current.resultPageOffset = shouldAppendResult ? (current.resultPageOffset ?? 0) : pageOffset; current.resultCountSql = countSql; current.resultSessionId = current.result?.session_id ?? undefined; if (!options?.preserveTotalRowCountDuringExecution) { @@ -3451,7 +3491,7 @@ export const useQueryStore = defineStore("query", () => { }; })() : undefined; - const canAutoCalculateTotalRows = !!current.result && resultRowCount > 0 && !totalKnownFromIncompletePage && settingsStore.editorSettings.autoCalculateTotalRows && ((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget)); + const canAutoCalculateTotalRows = !options?.appendResult && !!current.result && resultRowCount > 0 && !totalKnownFromIncompletePage && settingsStore.editorSettings.autoCalculateTotalRows && ((current.mode === "query" && !!countSql) || (current.mode === "data" && !!dataCountTarget)); current.resultTotalRowCountLoading = canAutoCalculateTotalRows; // Server-side pagination without a countSql: the backend (currently // the Elasticsearch driver) already reports the true match total via @@ -3465,7 +3505,7 @@ export const useQueryStore = defineStore("query", () => { } touchResult(current); syncDisplayedResultRun(current, queryBaseSql); - if (!totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) { + if (!options?.appendResult && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) { countQueryTotalRowsInBackground({ tabId: id, connectionId: current.connectionId, @@ -3520,6 +3560,12 @@ export const useQueryStore = defineStore("query", () => { } const current = tabs.value.find((t) => t.id === id); if (current?.executionId === executionId) { + if (options?.appendResult && current.result) { + // A failed background segment must not replace the visible result or + // silently invalidate pending edits. The next explicit refresh can retry. + queryExecutionLog("warn", "append-result:preserved-after-error", { traceId, elapsed: elapsed() }); + return; + } const errorResult = toErrorResult(e); const activeGroupIndex = current.activeResultIndex; const activeGroupResults = current.results; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 32a6cf051..635bd9586 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -500,6 +500,8 @@ export interface OwnerInfo { export interface QueryResult { columns: string[]; + /** Internal marker for a result built by appending a page to existing rows. */ + appended_from_row_count?: number; /** Set for synthesized query execution failures. */ execution_error?: true; /** Zero-based index of the submitted statement that produced this result. */ diff --git a/packages/app-tests/dataGridEditor.test.ts b/packages/app-tests/dataGridEditor.test.ts index 1b3443c36..dadb87d5d 100644 --- a/packages/app-tests/dataGridEditor.test.ts +++ b/packages/app-tests/dataGridEditor.test.ts @@ -988,6 +988,34 @@ test("keeps appended empty-table rows when parent refreshes an equivalent rows a assert.equal(editor.newRows.value.length, 0); }); +test("keeps dirty new and deleted state when infinite scrolling appends rows", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const firstRow = [1, "Ada"] as CellValue[]; + const secondRow = [2, "Grace"] as CellValue[]; + const result = ref<{ columns: string[]; rows: CellValue[][]; appended_from_row_count?: number }>({ columns: ["id", "name"], rows: [firstRow, secondRow] }); + const editor = createPeopleGridEditor(computed(() => result.value)); + + editor.applyCellValue(0, 1, "Ada Lovelace"); + editor.deletedRows.value = new Set([1]); + editor.addRow(); + await nextTick(); + + result.value = { columns: ["id", "name"], rows: [firstRow, secondRow, [3, "Linus"] as CellValue[]], appended_from_row_count: 2 }; + await nextTick(); + + assert.equal(editor.dirtyRows.value.get(0)?.get(1), "Ada Lovelace"); + assert.deepEqual([...editor.deletedRows.value], [1]); + assert.equal(editor.newRows.value.length, 1); + + result.value = { columns: ["id", "name"], rows: [[1, "Ada"] as CellValue[], [2, "Grace"] as CellValue[]] }; + await nextTick(); + assert.equal(editor.dirtyRows.value.size, 0, "explicit result replacement still clears stale source indexes"); + assert.equal(editor.deletedRows.value.size, 0); + assert.equal(editor.newRows.value.length, 0); +}); + test("saving manually typed JSON from a MySQL grid normalizes smart quotes", async () => { setActivePinia(createPinia()); installBrowserTestGlobals(); diff --git a/packages/app-tests/dataGridInfiniteScroll.test.ts b/packages/app-tests/dataGridInfiniteScroll.test.ts index 60d016d54..4894c5749 100644 --- a/packages/app-tests/dataGridInfiniteScroll.test.ts +++ b/packages/app-tests/dataGridInfiniteScroll.test.ts @@ -1,4 +1,5 @@ import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; import { test } from "vitest"; import { dataGridScrollPosition, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll } from "../../apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts"; @@ -23,3 +24,11 @@ test("near-bottom check matches the grid threshold", () => { assert.equal(isDataGridNearScrollBottom({ scrollTop: 801, scrollHeight: 1000, clientHeight: 100 }), true); assert.equal(isDataGridNearScrollBottom({ scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }), false); }); + +test("infinite scroll requests only the next bounded segment", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + assert.match(source, /const nextOffset = props\.result\.rows\.length/); + assert.match(source, /Math\.min\(pageSize\.value, remainingRows\)/); + assert.doesNotMatch(source, /emit\("paginate", 0, cumulativeLimit/); + assert.match(source, /props\.result\.appended_from_row_count !== requestedOffset/); +}); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 90ef38bb0..b28ccf9e5 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { afterEach, test } from "vitest"; import { createPinia, disposePinia, getActivePinia, setActivePinia } from "pinia"; -import { isReactive } from "vue"; +import { isReactive, toRaw } from "vue"; import { decodeQueryResultArchive } from "../../apps/desktop/src/lib/query/queryResultArchive.ts"; import { analyzeEditableQueryEditability } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts"; import { resultSqlForGrid } from "../../apps/desktop/src/lib/tabs/tabPresentation.ts"; @@ -2584,6 +2584,117 @@ test("data tab execution preserves pagination offset metadata", async () => { } }); +test("append pagination preserves existing rows and respects the memory cap", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-append")); + const tabId = store.createTab("conn-append", "db", "users", "data", "public"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + const firstRow = [1] as (string | number | boolean | null)[]; + tab.result = { columns: ["id"], rows: [firstRow], affected_rows: 0, execution_time_ms: 3 }; + + globalThis.fetch = withConnectionHealthMock(async (input) => { + if (String(input) === "/api/query/execute-multi") { + return Response.json([{ columns: ["id"], rows: [[2], [3]], affected_rows: 0, execution_time_ms: 4, has_more: true }]); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 2 OFFSET 1;', { + pagination: { limit: 2, offset: 1 }, + appendResult: { maxRows: 2 }, + preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, + }); + + assert.deepEqual(tab.result?.rows, [[1], [2]]); + assert.equal(toRaw(tab.result?.rows[0]), firstRow); + assert.equal(tab.result?.execution_time_ms, 7); + assert.equal(tab.result?.has_more, false); + assert.equal(tab.resultPageOffset, 0, "later refreshes must restart from the logical result origin"); + assert.equal(tab.resultPageLimit, 2); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("failed append pagination preserves the visible result", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-append-error")); + const tabId = store.createTab("conn-append-error", "db", "users", "data", "public"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + const originalResult: QueryResult = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 3 }; + tab.result = originalResult; + + globalThis.fetch = withConnectionHealthMock(async (input) => { + if (String(input) === "/api/query/execute-multi") return new Response("segment failed", { status: 500 }); + return new Response("unexpected request", { status: 500 }); + }); + + try { + await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 2 OFFSET 1;', { + pagination: { limit: 2, offset: 1 }, + appendResult: { maxRows: 10 }, + preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, + }); + + assert.equal(toRaw(tab.result), originalResult); + assert.deepEqual(tab.result?.rows, [[1]]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("stale append offsets do not duplicate already loaded rows", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-append-stale")); + const tabId = store.createTab("conn-append-stale", "db", "users", "data", "public"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + const originalResult: QueryResult = { columns: ["id"], rows: [[1], [2]], affected_rows: 0, execution_time_ms: 3 }; + tab.result = originalResult; + + globalThis.fetch = withConnectionHealthMock(async (input) => { + if (String(input) === "/api/query/execute-multi") return Response.json([{ columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]); + return new Response("unexpected request", { status: 500 }); + }); + + try { + await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 1 OFFSET 1;', { + pagination: { limit: 1, offset: 1 }, + appendResult: { maxRows: 10 }, + preserveResultDuringExecution: true, + preserveTotalRowCountDuringExecution: true, + }); + + assert.equal(toRaw(tab.result), originalResult); + assert.deepEqual(tab.result?.rows, [[1], [2]]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("data tab default pagination uses the dedicated table-open page size", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/packages/app-tests/useDataGridActions.test.ts b/packages/app-tests/useDataGridActions.test.ts index fdcb712b7..fbdf40d63 100644 --- a/packages/app-tests/useDataGridActions.test.ts +++ b/packages/app-tests/useDataGridActions.test.ts @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; -import { computed } from "vue"; +import { computed, toRaw } from "vue"; import { createPinia, setActivePinia } from "pinia"; import { test, vi } from "vitest"; import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts"; +import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts"; import type { ColumnInfo, ConnectionConfig } from "../../apps/desktop/src/types/database.ts"; vi.mock("vue-i18n", async () => { @@ -171,6 +172,58 @@ test("data reload preserves current page offset instead of resetting to page 1", } }); +test("infinite pagination fetches and appends only the next table segment", async () => { + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + const { useDataGridActions } = await import("../../apps/desktop/src/composables/useDataGridActions.ts"); + let buildSqlOptions: any; + let executeBody: any; + + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/connection/check-health") return Response.json(null); + if (url.pathname === "/api/query/build-table-select-sql") { + buildSqlOptions = JSON.parse(String(init?.body ?? "{}"))?.options; + return Response.json("SELECT * FROM `orders` LIMIT 100 OFFSET 100"); + } + if (url.pathname === "/api/query/execute-multi") { + executeBody = JSON.parse(String(init?.body ?? "{}")); + return Response.json([{ columns: ["id"], rows: [[101], [102]], affected_rows: 0, execution_time_ms: 2 }]); + } + return new Response(`unexpected ${url.pathname}`, { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const queryStore = useQueryStore(); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ infiniteScroll: true, infiniteScrollMaxRows: 5000 }); + connectionStore.addEphemeralConnection(conn("mysql-1")); + const tabId = queryStore.createTab("mysql-1", "app", "orders", "data"); + queryStore.setTableMeta(tabId, { tableName: "orders", tableType: "TABLE", columns: [], primaryKeys: [] }); + const tab = queryStore.tabs.find((item) => item.id === tabId); + assert.ok(tab); + const firstRow = [1] as (string | number | boolean | null)[]; + tab.result = { columns: ["id"], rows: [firstRow], affected_rows: 0, execution_time_ms: 1 }; + tab.resultPageLimit = 100; + tab.resultPageOffset = 0; + + const actions = useDataGridActions(computed(() => tab)); + await actions.onPaginate(1, 100); + + assert.equal(buildSqlOptions?.offset, 1); + assert.equal(buildSqlOptions?.limit, 100); + assert.equal(executeBody.maxRows, 100, "the backend receives one segment, not a cumulative limit"); + assert.equal(tab.result?.rows.length, 3); + assert.equal(toRaw(tab.result?.rows[0]), firstRow, "existing row identity must survive append"); + assert.deepEqual(tab.result?.rows.slice(1), [[101], [102]]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("query toolbar refresh reruns the complete multi-result SQL and keeps the active result", async () => { const restoreStorage = installMemoryStorage(); const { useDataGridActions } = await import("../../apps/desktop/src/composables/useDataGridActions.ts");