fix(grid): append infinite scroll result segments
This commit is contained in:
parent
678033c82e
commit
c153e93d54
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -247,13 +247,16 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
|||
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<QueryTab | undefined>)
|
|||
}
|
||||
: {}),
|
||||
pagination: { offset, limit, sessionId },
|
||||
...appendOptions,
|
||||
preserveResultDuringExecution: true,
|
||||
preserveTotalRowCountDuringExecution: true,
|
||||
replaceActiveResultInGroup: true,
|
||||
|
|
@ -282,6 +286,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
|||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeTabSql(tab.id, sql, {
|
||||
pagination: { offset, limit },
|
||||
...appendOptions,
|
||||
preserveResultDuringExecution: true,
|
||||
preserveTotalRowCountDuringExecution: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <T>(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<QueryTab["resultRuns"]>): NonNullable<QueryTab["resultRuns"]> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue