feat(es): paginate past 10k documents via pit and search_after
This commit is contained in:
1 parent
1d71a5fc23
commit
73a7b3f2c7
35 files changed
+1622
-153
No files matched your search
@@ -49,6 +49,7 @@ import {
|
||||
Settings2,
|
||||
WandSparkles,
|
||||
Camera,
|
||||
AlertTriangle,
|
||||
} from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -79,7 +80,7 @@ import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
|
||||
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
|
||||
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
|
||||
import GridSnapshotDialog from "@/components/grid/GridSnapshotDialog.vue";
|
||||
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, ReferenceKeyInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database";
|
||||
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, ReferenceKeyInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef, QueryPageJumpProgress } from "@/types/database";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
|
||||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||||
@@ -205,7 +206,18 @@ import {
|
||||
isToggleTransposeShortcut,
|
||||
} from "@/lib/editor/keyboardShortcuts";
|
||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, showDataGridRerunTotalCountAction, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
|
||||
import {
|
||||
canFetchNextDataGridSegment,
|
||||
canGoNextDataGridPage,
|
||||
dataGridTotalRowCountLabelKey,
|
||||
dataGridTruncationHintKey,
|
||||
ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS,
|
||||
elasticsearchCursorPageJumpRequestCount,
|
||||
hasCompleteLocalDataGridResult,
|
||||
resolveDataGridPaginationTotal,
|
||||
showDataGridRerunTotalCountAction,
|
||||
type DataGridInexactTotalRowCountMode,
|
||||
} from "@/lib/dataGrid/dataGridPagination";
|
||||
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
||||
import {
|
||||
createResultScopedPendingRequests,
|
||||
@@ -481,6 +493,7 @@ interface DataGridProps {
|
||||
paginationTotalRowCount?: number;
|
||||
paginationEnabled?: boolean;
|
||||
totalRowCountLoading?: boolean;
|
||||
pageJumpProgress?: QueryPageJumpProgress;
|
||||
/** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */
|
||||
countTotalRows?: () => Promise<number | undefined>;
|
||||
loading?: boolean;
|
||||
@@ -3548,6 +3561,13 @@ watch(
|
||||
);
|
||||
const manualTotalRowCount = ref<number | undefined>(undefined);
|
||||
const manualTotalRowCountLoading = ref(false);
|
||||
const esDeepPageJumpConfirmOpen = ref(false);
|
||||
const pendingEsDeepPageJump = ref<{ targetPage: number; requestCount: number; updateCurrentPage: boolean }>();
|
||||
watch(esDeepPageJumpConfirmOpen, (open) => {
|
||||
if (!open) {
|
||||
pendingEsDeepPageJump.value = undefined;
|
||||
}
|
||||
});
|
||||
const showTruncationWarning = computed(() => props.result.truncated === true && typeof props.pageLimit !== "number" && props.result.has_more !== true);
|
||||
const truncationHintKey = computed(() => dataGridTruncationHintKey(resolvedDatabaseType.value));
|
||||
// affected_rows reported by the backend can be larger than the rows we
|
||||
@@ -3624,8 +3644,14 @@ const canFetchNextInfiniteScrollSegment = computed(() =>
|
||||
);
|
||||
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows));
|
||||
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
|
||||
const pageJumpBusy = computed(() => !!props.pageJumpProgress && props.pageJumpProgress.totalRequests > 1);
|
||||
const pageJumpProgressPercent = computed(() => {
|
||||
const progress = props.pageJumpProgress;
|
||||
if (!progress || progress.totalRequests <= 0) return 0;
|
||||
return Math.min(100, Math.round((progress.completedRequests / progress.totalRequests) * 100));
|
||||
});
|
||||
/** Automatic background counts keep rows interactive; explicit count navigation still blocks the surface. */
|
||||
const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || manualTotalRowCountLoading.value);
|
||||
const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || manualTotalRowCountLoading.value || pageJumpBusy.value);
|
||||
const gridPaginationBusy = computed(() => gridSurfaceBusy.value || totalRowCountBusy.value);
|
||||
const dataGridNativeSelectionBlockOwner = {};
|
||||
watch(
|
||||
@@ -3740,6 +3766,40 @@ function currentOrderBy(): string | undefined {
|
||||
return orderByInput.value.trim() || (sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined);
|
||||
}
|
||||
|
||||
function executeServerPageJump(targetPage: number, updateCurrentPage = false) {
|
||||
const offset = (targetPage - 1) * pageSize.value;
|
||||
if (!Number.isSafeInteger(offset)) {
|
||||
return;
|
||||
}
|
||||
if (updateCurrentPage) {
|
||||
currentPage.value = targetPage;
|
||||
}
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", offset, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
}
|
||||
|
||||
function requestServerPageJump(targetPage: number, updateCurrentPage = false) {
|
||||
const usesElasticsearchCursor = isResultsContext.value && (resolvedDatabaseType.value === "elasticsearch" || resolvedDatabaseType.value === "easysearch");
|
||||
const requestCount = elasticsearchCursorPageJumpRequestCount(currentPage.value, targetPage);
|
||||
if (usesElasticsearchCursor && requestCount >= ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS) {
|
||||
pendingEsDeepPageJump.value = { targetPage, requestCount, updateCurrentPage };
|
||||
esDeepPageJumpConfirmOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
executeServerPageJump(targetPage, updateCurrentPage);
|
||||
}
|
||||
|
||||
function confirmEsDeepPageJump() {
|
||||
const pending = pendingEsDeepPageJump.value;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
pendingEsDeepPageJump.value = undefined;
|
||||
esDeepPageJumpConfirmOpen.value = false;
|
||||
executeServerPageJump(pending.targetPage, pending.updateCurrentPage);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? ""],
|
||||
(values, previousValues) => {
|
||||
@@ -3779,10 +3839,10 @@ function firstPage() {
|
||||
emit("paginate", 0, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
}
|
||||
function prevPage() {
|
||||
if (currentPage.value <= 1) return;
|
||||
currentPage.value--;
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
if (currentPage.value <= 1) {
|
||||
return;
|
||||
}
|
||||
requestServerPageJump(currentPage.value - 1, true);
|
||||
}
|
||||
function nextPage() {
|
||||
if (!canGoNextPage.value) return;
|
||||
@@ -3792,18 +3852,19 @@ function nextPage() {
|
||||
}
|
||||
|
||||
function jumpPage(page: number) {
|
||||
if (gridPaginationBusy.value || infiniteScrollEnabled.value || !Number.isSafeInteger(page) || page < 1) return;
|
||||
if (gridPaginationBusy.value || infiniteScrollEnabled.value || !Number.isSafeInteger(page) || page < 1) {
|
||||
return;
|
||||
}
|
||||
const targetPage = Math.min(page, maximumPage.value ?? page);
|
||||
if (targetPage === currentPage.value) return;
|
||||
if (targetPage === currentPage.value) {
|
||||
return;
|
||||
}
|
||||
if (allRowsLoaded.value) {
|
||||
currentPage.value = targetPage;
|
||||
resetGridVerticalScroll(true);
|
||||
return;
|
||||
}
|
||||
const offset = (targetPage - 1) * pageSize.value;
|
||||
if (!Number.isSafeInteger(offset)) return;
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", offset, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
requestServerPageJump(targetPage);
|
||||
}
|
||||
|
||||
function infiniteScrollNextPage() {
|
||||
@@ -3861,13 +3922,16 @@ function applyCustomPageSize() {
|
||||
|
||||
function jumpToCountedLastPage(total: number) {
|
||||
const paginationTotal = resolveDataGridPaginationTotal({ paginationTotalRowCount: total, totalRowCountIsExact: true, maxRows: paginationMaxRows.value });
|
||||
if (paginationTotal === undefined || paginationTotal <= 0) return;
|
||||
if (paginationTotal === undefined || paginationTotal <= 0) {
|
||||
return;
|
||||
}
|
||||
const lastPageNum = Math.max(1, Math.ceil(paginationTotal / pageSize.value));
|
||||
if (lastPageNum <= currentPage.value) return;
|
||||
if (lastPageNum <= currentPage.value) {
|
||||
return;
|
||||
}
|
||||
// Do not bump currentPage before the new page loads — otherwise stale rows
|
||||
// briefly render with last-page indexes (e.g. 12001-13000) and flash a fake full page.
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
requestServerPageJump(lastPageNum);
|
||||
}
|
||||
|
||||
async function beginManualTotalRowCount(): Promise<boolean> {
|
||||
@@ -13705,8 +13769,25 @@ function openGridSnapshot() {
|
||||
<div v-if="hasGridVerticalOverflow" ref="gridVerticalScrollbarTrackRef" class="data-grid-vertical-scrollbar" @pointerdown="startGridVerticalScrollbarDrag">
|
||||
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
|
||||
</div>
|
||||
<div v-if="gridSurfaceBusy" class="absolute inset-0 z-20 bg-background/50 flex items-center justify-center">
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
|
||||
<div v-if="gridSurfaceBusy" class="absolute inset-0 z-20 flex items-center justify-center" :class="pageJumpProgress ? 'bg-background/35 backdrop-blur-[1px]' : 'bg-background/50'">
|
||||
<div v-if="pageJumpProgress" class="w-72 max-w-[calc(100%-2rem)] rounded-lg border bg-background/95 p-3.5 shadow-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage }) }}</div>
|
||||
<div class="mt-0.5 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests }) }}</span>
|
||||
<span class="shrink-0 tabular-nums">{{ formatElapsedSeconds(loadingElapsed) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="pageJumpProgress.totalRequests" :aria-valuenow="pageJumpProgress.completedRequests">
|
||||
<div class="h-full rounded-full bg-primary transition-[width] duration-200 ease-out" :style="{ width: `${pageJumpProgressPercent}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span class="tabular-nums">{{ formatElapsedSeconds(loadingElapsed) }}s</span>
|
||||
</div>
|
||||
@@ -14337,6 +14418,29 @@ function openGridSnapshot() {
|
||||
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" :column-comments="columnCommentMap" @copy="copyText" />
|
||||
<GridSnapshotDialog v-model:open="gridSnapshotOpen" :source="gridSnapshotSource" />
|
||||
|
||||
<Dialog v-model:open="esDeepPageJumpConfirmOpen">
|
||||
<DialogContent class="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle class="h-5 w-5" />
|
||||
{{ t("grid.esDeepPageJumpConfirmTitle") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="py-3 text-sm leading-6 text-muted-foreground">
|
||||
{{
|
||||
t("grid.esDeepPageJumpConfirmMessage", {
|
||||
page: pendingEsDeepPageJump?.targetPage,
|
||||
requests: pendingEsDeepPageJump?.requestCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="esDeepPageJumpConfirmOpen = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button @click="confirmEsDeepPageJump">{{ t("grid.esDeepPageJumpContinue") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="generateIncrementDialogOpen">
|
||||
<DialogContent class="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -135,6 +135,19 @@ beforeEach(() => {
|
||||
localStorage.removeItem("dbx-filter-builder-value-shortcut-hint-days");
|
||||
});
|
||||
describe("DataGrid canvas surfaces", () => {
|
||||
it("asks before an expensive Elasticsearch cursor jump", () => {
|
||||
expect(dataGridSource).toContain("requestCount >= ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS");
|
||||
expect(dataGridSource).toContain('t("grid.esDeepPageJumpConfirmMessage"');
|
||||
expect(dataGridSource).toContain('@click="confirmEsDeepPageJump"');
|
||||
});
|
||||
|
||||
it("shows stable request progress while an Elasticsearch page jump is running", () => {
|
||||
expect(dataGridSource).toContain('t("grid.pageJumpLoading", { page: pageJumpProgress.targetPage })');
|
||||
expect(dataGridSource).toContain('t("grid.pageJumpProgress", { current: pageJumpProgress.completedRequests, total: pageJumpProgress.totalRequests })');
|
||||
expect(dataGridSource).toContain('role="progressbar"');
|
||||
expect(dataGridSource).toContain(':style="{ width: `${pageJumpProgressPercent}%` }"');
|
||||
});
|
||||
|
||||
it("uses the stable overlay for viewport and device-pixel measurement", () => {
|
||||
expect(dataGridSource).toContain("function canvasMeasurementSurface(): HTMLElement | null");
|
||||
expect(dataGridSource).toContain("return canvasOverlayRef.value ?? null;");
|
||||
|
||||
Reference in new issue
Block a user