fix(grid): append infinite scroll result segments
This commit is contained in:
1 parent
678033c82e
commit
c153e93d54
9 files changed
+303
-30
No files matched your search
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user