fix(mongo): stabilize estimated-total pagination

This commit is contained in:
Freedom authored and GitHub committed 2026-07-31 14:51:25 +08:00
1 parent 96038d2b8a
commit 8f3fd0ebfb
23 files changed
+462 -124

No files matched your search

@@ -19,7 +19,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals";
import { canGoNextDocumentPage, isSameDocumentQueryTotalCountRequest, resolveDocumentQueryTotals, type DocumentQueryTotalCountRequest } from "@/lib/document/documentQueryTotals";
import {
arrayObjectAncestorPathForDocumentField,
buildDocumentFilterCondition,
@@ -45,6 +45,7 @@ import {
type DocumentFieldPathNode,
type DocumentFilterMode,
type DocumentFilterRule,
type DocumentStoreKind,
type ElasticsearchBoolClause,
type ElasticsearchQueryType,
} from "@/lib/app/documentStoreProvider";
@@ -144,6 +145,9 @@ let elasticsearchExactTotal: number | undefined;
let elasticsearchPaginationLowerBound: number | undefined;
let elasticsearchCountExecutionId = "";
let elasticsearchCountGeneration = 0;
type LoadedDocumentQueryTotalCountRequest = DocumentQueryTotalCountRequest & { storeKind: DocumentStoreKind };
let loadedDocumentQueryTotalCountRequest: LoadedDocumentQueryTotalCountRequest | undefined;
let documentRequestGeneration = 0;
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
const documentColumnLayoutScopeKey = computed(() =>
documentDataGridColumnLayoutScopeKey({
@@ -772,6 +776,13 @@ function elasticsearchCountFilterKey(filter: string | undefined): string {
return JSON.stringify([props.connectionId, props.database, props.collection, filter ?? ""]);
}
function isCurrentDocumentQueryTotalCountRequest(request: LoadedDocumentQueryTotalCountRequest): boolean {
if (request.generation !== documentRequestGeneration || request.connectionId !== props.connectionId || request.database !== props.database || request.collection !== props.collection || request.storeKind !== documentStoreProvider.value.kind) {
return false;
}
return loadedDocumentQueryTotalCountRequest !== undefined && isSameDocumentQueryTotalCountRequest(request, loadedDocumentQueryTotalCountRequest) && request.storeKind === loadedDocumentQueryTotalCountRequest.storeKind;
}
function cancelElasticsearchCount() {
elasticsearchCountGeneration++;
const executionId = elasticsearchCountExecutionId;
@@ -790,13 +801,12 @@ function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean }
totalIsExact.value = nextTotals.totalIsExact;
}
function clampPageToPaginationTotal(): boolean {
function clampPageToPaginationTotal(): number | undefined {
const cap = paginationTotal.value;
if (cap === undefined) return false;
if (cap === undefined) return undefined;
const nextPage = clampDocumentPage(page.value, pageSize.value, cap);
if (page.value === nextPage) return false;
page.value = nextPage;
return true;
if (page.value === nextPage) return undefined;
return nextPage;
}
function startElasticsearchExactCount(filter: string | undefined) {
@@ -817,7 +827,8 @@ function startElasticsearchExactCount(filter: string | undefined) {
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
if (clampPageToPaginationTotal()) void load();
const clampedPage = clampPageToPaginationTotal();
if (clampedPage !== undefined) void load({ page: clampedPage });
})
.catch(() => {
// The lower-bound result remains truthful when a background count fails.
@@ -862,27 +873,35 @@ function applyElasticsearchSearchTotal(searchTotal: number, isExact: boolean, fi
startElasticsearchExactCount(filter);
}
async function load() {
async function load(options: { page?: number } = {}) {
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
const requestGeneration = ++documentRequestGeneration;
const executionId = uuid();
loading.value = true;
documentLoadExecutionId.value = executionId;
documentLoadCancelling.value = false;
startDocumentLoadingTimer();
error.value = "";
const requestPage = options.page ?? page.value;
const previousSelectedIdx = selectedIdx.value;
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
try {
const connectionId = props.connectionId;
const database = props.database;
const collection = props.collection;
const storeKind = documentStoreProvider.value.kind;
const filter = currentDocumentFilter();
if (documentStoreProvider.value.kind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
const countRequest: LoadedDocumentQueryTotalCountRequest = { connectionId, database, collection, filter, generation: requestGeneration, storeKind };
if (storeKind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
resetElasticsearchTotals();
}
const sort = currentDocumentSortJson(sortInput.value);
const skip = page.value * pageSize.value;
const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
const skip = requestPage * pageSize.value;
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
if (documentLoadExecutionId.value !== executionId) return;
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
const nextDocuments =
documentStoreProvider.value.kind === "elasticsearch" && result.raw_documents?.length === result.documents.length
storeKind === "elasticsearch" && result.raw_documents?.length === result.documents.length
? result.raw_documents.map((raw, index) => {
try {
return asRecord(parseJsonPreservingLargeNumbers(raw));
@@ -893,9 +912,12 @@ async function load() {
: result.documents.map(asRecord);
const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length;
const nextCopyDocuments = hasTypePreservingCopyDocuments ? result.extended_documents!.map(asRecord) : nextDocuments;
// Commit page + rows together so stale rows never briefly show last-page indexes.
if (options.page !== undefined) page.value = options.page;
documents.value = nextDocuments;
copyDocuments.value = nextCopyDocuments;
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
loadedDocumentQueryTotalCountRequest = countRequest;
if (nextDocuments.length > 0) {
const keySet = new Set<string>();
keySet.add("_id");
@@ -906,11 +928,15 @@ async function load() {
}
lastGridColumns.value = [...keySet];
}
if (documentStoreProvider.value.kind === "elasticsearch") {
if (storeKind === "elasticsearch") {
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
} else {
cancelElasticsearchCount();
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false);
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false, {
page: requestPage,
pageSize: pageSize.value,
rowCount: nextDocuments.length,
});
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
@@ -928,6 +954,34 @@ async function load() {
}
}
async function countExactDocumentTotal(): Promise<number | undefined> {
const request = loadedDocumentQueryTotalCountRequest;
if (!request || !isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (request.storeKind === "elasticsearch") {
const exactCount = await api.elasticsearchCountDocuments(request.connectionId, request.collection, request.filter);
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (!Number.isFinite(exactCount) || exactCount < 0) {
throw new Error("invalid count");
}
elasticsearchExactTotal = exactCount;
const totals = resolveElasticsearchDocumentTotals(elasticsearchPaginationLowerBound ?? exactCount, false, exactCount);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return exactCount;
}
const exactCount = await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (!Number.isFinite(exactCount) || exactCount < 0) {
throw new Error("invalid count");
}
const totals = resolveDocumentQueryTotals(exactCount, true);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return exactCount;
}
async function refreshDocuments() {
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
@@ -959,8 +1013,8 @@ function paginate(offset: number, limit: number) {
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
pageSize.value = normalizedLimit;
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
page.value = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
void load();
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
void load({ page: nextPage });
}
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
@@ -1475,6 +1529,8 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
documentRequestGeneration++;
loadedDocumentQueryTotalCountRequest = undefined;
cancelElasticsearchCount();
stopDocumentLoadingTimer();
endTableSearchSplitResize();
@@ -1616,7 +1672,9 @@ defineExpose({ focusSearch });
:page-limit="pageSize"
:total-row-count="total"
:total-row-count-is-exact="totalIsExact"
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
:pagination-total-row-count="pageTotal"
:count-total-rows="countExactDocumentTotal"
@sort="onSort"
@reload="refreshDocuments"
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
+161 -89
View File
@@ -135,7 +135,7 @@ import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOpti
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "@/lib/dataGrid/dataGridPagination";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer";
@@ -184,7 +184,7 @@ import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFro
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor";
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
@@ -245,6 +245,7 @@ const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
const rowNumberWidth = ref(DATA_GRID_ROW_NUM_WIDTH);
const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMultiRowTranspose);
const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns);
const { isDark, themePalette } = useTheme();
@@ -300,9 +301,12 @@ interface DataGridProps {
countSql?: string;
totalRowCount?: number;
totalRowCountIsExact?: boolean;
inexactTotalRowCountMode?: DataGridInexactTotalRowCountMode;
paginationTotalRowCount?: number;
paginationEnabled?: boolean;
totalRowCountLoading?: boolean;
/** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */
countTotalRows?: () => Promise<number | undefined>;
loading?: boolean;
cacheKey?: string;
exportSql?: string;
@@ -322,6 +326,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
// Vue casts absent Boolean props to false unless a default is explicit.
// Regular grids have exact totals; document stores opt into lower-bound totals.
totalRowCountIsExact: true,
inexactTotalRowCountMode: "at-least",
paginationEnabled: true,
// Omitted row-action limits must keep normal table-data editing.
allowInsertRows: undefined,
@@ -1898,6 +1903,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to
columnStructureSignature,
measureHeaderText: measureColumnHeaderText,
headerMeasurementKey: columnHeaderMeasurementKey,
rowNumberWidth,
});
const gridStyle = computed(() => ({
...columnVars.value,
@@ -1932,7 +1938,7 @@ const {
renderedColumnWidths,
scrollLeft: gridHorizontalScrollLeft,
viewportWidth: gridViewportWidth,
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
rowNumberWidth,
headerRef,
orderedColumnIndexes: orderedDisplayableColumnIndexes,
hiddenColumnIndexes,
@@ -2453,9 +2459,10 @@ const inferredBackendTotalRowCount = computed(() => {
if (affected <= props.result.rows.length) return undefined;
return affected;
});
const serverKnownTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value);
const serverKnownTotalRowCount = computed(() => (typeof manualTotalRowCount.value === "number" ? manualTotalRowCount.value : props.totalRowCount));
const displayedTotalRowCount = computed(() => serverKnownTotalRowCount.value ?? inferredBackendTotalRowCount.value);
const totalRowCountIsExact = computed(() => props.totalRowCountIsExact !== false);
const totalRowCountIsExact = computed(() => typeof manualTotalRowCount.value === "number" || props.totalRowCountIsExact !== false);
const totalRowCountLabelKey = computed(() => dataGridTotalRowCountLabelKey(totalRowCountIsExact.value, props.inexactTotalRowCountMode));
// A backend can expose an exact display total while deliberately restricting
// offset pagination to a smaller safe range.
const paginationTotalRowCount = computed(() =>
@@ -2506,9 +2513,36 @@ const canFetchNextInfiniteScrollSegment = computed(() =>
allRowsLoaded: allRowsLoaded.value,
}),
);
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(() => !!props.connectionId && (!!props.tableMeta || !!props.countSql));
/** Full-grid busy state: query loading or on-demand COUNT (e.g. jump to last page). */
const gridSurfaceBusy = computed(() => props.loading === true || totalRowCountBusy.value);
const canCalculateTotalRowCount = computed(() => !!props.countTotalRows || (!!props.connectionId && (!!props.tableMeta || !!props.countSql)));
const showExactTotalCountAction = computed(() => canCalculateTotalRowCount.value && (totalRowCountIsExact.value === false || typeof displayedTotalRowCount.value !== "number"));
watch(
[
() =>
resolveDataGridMaxRowNumber({
infiniteScroll: infiniteScrollEnabled.value,
allRowsLoaded: allRowsLoaded.value,
currentPage: currentPage.value,
pageSize: pageSize.value,
rowCount: props.result.rows.length,
}),
tableFontSize,
tableFontFamily,
],
([maxRowNumber, fontSize, fontFamily]) => {
rowNumberWidth.value = dataGridRowNumberColumnWidth(maxRowNumber, fontSize, (text) => {
if (typeof document === "undefined") return undefined;
if (columnHeaderMeasureContext === undefined) columnHeaderMeasureContext = document.createElement("canvas").getContext("2d");
if (!columnHeaderMeasureContext) return undefined;
columnHeaderMeasureContext.font = `400 ${fontSize}px ${fontFamily}`;
return Math.ceil(columnHeaderMeasureContext.measureText(text).width);
});
},
{ immediate: true },
);
// When a refresh/rollback completes and the current page exceeds the last
// available page (e.g. data was deleted while viewing), auto-navigate to the
// last available page instead of showing an empty page.
@@ -2582,7 +2616,7 @@ function currentOrderBy(): string | undefined {
}
watch(
() => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? "", props.result],
() => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? ""],
() => {
manualTotalRowCount.value = undefined;
// Reset infinite-scroll allLoaded when query context changes
@@ -2684,42 +2718,73 @@ function applyCustomPageSize() {
changePageSize(normalizeResultPageSize(customPageSizeInput.value, pageSize.value));
}
function jumpToCountedLastPage(total: number) {
if (total <= 0) return;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
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());
}
async function beginManualTotalRowCount(): Promise<boolean> {
if (manualTotalRowCountLoading.value) return false;
manualTotalRowCountLoading.value = true;
// Flush busy UI (overlay / spinner) before the slow COUNT starts.
await nextTick();
return true;
}
async function lastPage() {
if (infiniteScrollEnabled.value) return;
if (hasKnownPaginationTotalRowCount.value) {
const total = paginationTotalRowCount.value ?? 0;
if (total <= 0) return;
const lastPageNum = Math.ceil(total / pageSize.value);
if (lastPageNum <= currentPage.value) return;
currentPage.value = lastPageNum;
resetGridVerticalScroll(true);
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
return;
}
if (allRowsLoaded.value) {
const total = props.result.rows.length;
if (total <= 0) return;
const lastPageNum = Math.ceil(total / pageSize.value);
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
if (lastPageNum <= currentPage.value) return;
currentPage.value = lastPageNum;
resetGridVerticalScroll(true);
return;
}
if (!props.connectionId) return;
const countTarget = await buildCurrentCountTarget();
const sql = countTarget?.sql;
if (!sql) return;
try {
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId)));
const total = Number(result.rows?.[0]?.[0] ?? 0);
if (total <= 0) return;
const lastPageNum = Math.ceil(total / pageSize.value);
if (lastPageNum <= currentPage.value) return;
currentPage.value = lastPageNum;
resetGridVerticalScroll(true);
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
} catch {
// COUNT query failed — ignore silently
// Navicat-style: always re-COUNT when jumping to the last page.
if (props.countTotalRows) {
if (!(await beginManualTotalRowCount())) return;
try {
const total = await props.countTotalRows();
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return;
manualTotalRowCount.value = total;
jumpToCountedLastPage(total);
// Keep the busy overlay until the parent query loading flag can take over.
await nextTick();
} catch (e: any) {
toast(t("grid.calculateTotalRowsFailed", { message: e?.message || String(e) }), 5000);
} finally {
manualTotalRowCountLoading.value = false;
}
return;
}
if (props.connectionId && (props.countSql || props.tableMeta)) {
if (!(await beginManualTotalRowCount())) return;
try {
const countTarget = await buildCurrentCountTarget();
const sql = countTarget?.sql;
if (!sql) return;
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId)));
const total = Number(result.rows?.[0]?.[0] ?? 0);
if (!Number.isFinite(total) || total < 0) return;
manualTotalRowCount.value = total;
jumpToCountedLastPage(total);
await nextTick();
} catch {
// COUNT query failed — ignore silently
} finally {
manualTotalRowCountLoading.value = false;
}
return;
}
if (hasKnownPaginationTotalRowCount.value) {
jumpToCountedLastPage(paginationTotalRowCount.value ?? 0);
}
}
@@ -2741,9 +2806,16 @@ async function buildCurrentCountTarget(): Promise<{ sql: string; schema?: string
}
async function calculateTotalRowCount() {
if (!props.connectionId || manualTotalRowCountLoading.value) return;
manualTotalRowCountLoading.value = true;
if (!(await beginManualTotalRowCount())) return;
try {
if (props.countTotalRows) {
const total = await props.countTotalRows();
if (typeof total === "number" && Number.isFinite(total) && total >= 0) {
manualTotalRowCount.value = total;
}
return;
}
if (!props.connectionId) return;
const countTarget = await buildCurrentCountTarget();
if (!countTarget?.sql) return;
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", countTarget.sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId)));
@@ -4385,10 +4457,16 @@ function formatCellCached(value: CellValue, columnIndex?: number): string {
return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex));
}
function rowNumberPageOffset(): number {
if (infiniteScrollEnabled.value) return 0;
if (typeof props.pageOffset === "number" && props.pageOffset >= 0) return props.pageOffset;
return (currentPage.value - 1) * pageSize.value;
}
function rowNumberText(item: RowItem | undefined): string {
if (!item) return "";
if (item.isDraft) return "*";
return String(infiniteScrollEnabled.value ? item.displayIndex + 1 : item.displayIndex + 1 + (currentPage.value - 1) * pageSize.value);
return String(item.displayIndex + 1 + rowNumberPageOffset());
}
function draftCellPlaceholder(item: RowItem | undefined, columnIndex: number): string | null {
@@ -4546,12 +4624,12 @@ function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowInd
const scroller = dataGridSelectionScroller();
if (!scroller) return null;
const rect = scroller.getBoundingClientRect();
const clampedX = Math.min(rect.right - 1, Math.max(rect.left + DATA_GRID_ROW_NUM_WIDTH + 1, clientX));
const clampedX = Math.min(rect.right - 1, Math.max(rect.left + rowNumberWidth.value + 1, clientX));
const clampedY = Math.min(rect.bottom - 1, Math.max(rect.top + 1, clientY));
if (useCanvasGridRows.value) {
const rowIndex = Math.floor((scroller.scrollTop + clampedY - rect.top) / CANVAS_DATA_GRID_ROW_HEIGHT);
const visibleColIdx = canvasColumnAt(scroller.scrollLeft + clampedX - rect.left - DATA_GRID_ROW_NUM_WIDTH);
const visibleColIdx = canvasColumnAt(scroller.scrollLeft + clampedX - rect.left - rowNumberWidth.value);
if (rowIndex < 0 || rowIndex >= displayRowCount.value || visibleColIdx < 0) return null;
const item = displayItemAt(rowIndex);
return item ? { rowIndex: item.displayIndex, colIndex: visibleColIdx } : null;
@@ -4578,7 +4656,7 @@ function dataGridRowFromClientPoint(_clientX: number, clientY: number): number |
return item?.displayIndex ?? null;
}
const target = document.elementFromPoint(rect.left + Math.min(DATA_GRID_ROW_NUM_WIDTH / 2, rect.width / 2), clampedY);
const target = document.elementFromPoint(rect.left + Math.min(rowNumberWidth.value / 2, rect.width / 2), clampedY);
const row = target instanceof Element ? target.closest<HTMLElement>("[data-row-index]") : null;
const rowIndex = Number(row?.dataset.rowIndex);
return Number.isInteger(rowIndex) ? rowIndex : null;
@@ -4681,14 +4759,14 @@ function canvasHitTest(event: MouseEvent): { rowIndex: number; visibleColIdx: nu
const y = event.clientY - rect.top;
const rowIndex = Math.floor((scroller.scrollTop + y) / CANVAS_DATA_GRID_ROW_HEIGHT);
if (rowIndex < 0 || rowIndex >= displayRowCount.value) return null;
if (x < DATA_GRID_ROW_NUM_WIDTH) return { rowIndex, visibleColIdx: -1, rowNumber: true };
if (x < rowNumberWidth.value) return { rowIndex, visibleColIdx: -1, rowNumber: true };
// 冻结列区域不受 scrollLeft 影响,需要特殊处理
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
let contentX: number;
if (frozenWidth > 0 && x - DATA_GRID_ROW_NUM_WIDTH < frozenWidth) {
contentX = x - DATA_GRID_ROW_NUM_WIDTH;
if (frozenWidth > 0 && x - rowNumberWidth.value < frozenWidth) {
contentX = x - rowNumberWidth.value;
} else {
contentX = scroller.scrollLeft + x - DATA_GRID_ROW_NUM_WIDTH;
contentX = scroller.scrollLeft + x - rowNumberWidth.value;
}
const visibleColIdx = canvasColumnAt(contentX);
if (visibleColIdx < 0) return null;
@@ -4908,7 +4986,7 @@ function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) {
if (colWidth === undefined) return null;
// 冻结列不受 scrollLeft 影响
const isFrozen = visibleColIdx < frozenColumnCount.value;
const left = DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - (isFrozen ? 0 : gridHorizontalScrollLeft.value);
const left = rowNumberWidth.value + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - (isFrozen ? 0 : gridHorizontalScrollLeft.value);
return {
left,
top: rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - canvasScrollTop.value,
@@ -4931,7 +5009,7 @@ function canvasEditingCellIsVisible() {
if (!rect) return false;
const viewportWidth = canvasEffectiveViewportWidth();
const viewportHeight = canvasEffectiveViewportHeight();
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
const clippedLeft = Math.max(rowNumberWidth.value, rect.left);
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
return rect.top + rect.height > 0 && rect.top < viewportHeight && clippedRight - clippedLeft > 0;
}
@@ -4974,7 +5052,7 @@ const canvasEditingCellStyle = computed(() => {
const cell = canvasEditingCell.value;
if (!cell) return {};
const viewportWidth = canvasEffectiveViewportWidth();
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left);
const clippedLeft = Math.max(rowNumberWidth.value, cell.rect.left);
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, cell.rect.left + cell.rect.width) : cell.rect.left + cell.rect.width;
return {
left: `${clippedLeft}px`,
@@ -4996,7 +5074,7 @@ const canvasDetailButtonCell = computed(() => {
if (!rect) return null;
const viewportWidth = canvasEffectiveViewportWidth();
const viewportHeight = canvasEffectiveViewportHeight();
const visibleLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
const visibleLeft = Math.max(rowNumberWidth.value, rect.left);
const visibleRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
const canQuickDownload = canQuickDownloadCellValue(target.rowIndex, target.col);
const minWidth = canQuickDownload ? 46 : 24;
@@ -5010,7 +5088,7 @@ const canvasDetailButtonStyle = computed(() => {
const actionWidth = cell.canQuickDownload ? 44 : 22;
const edgeGap = 6;
return {
left: `${Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left + cell.rect.width - actionWidth - edgeGap)}px`,
left: `${Math.max(rowNumberWidth.value, cell.rect.left + cell.rect.width - actionWidth - edgeGap)}px`,
top: `${cell.rect.top + cell.rect.height / 2}px`,
};
});
@@ -5045,7 +5123,7 @@ function drawCanvasGrid() {
columnPreviewOffsets: columnHeaderPreviewOffsets.value,
columnPreviewSourceVisibleIndex: columnHeaderPreviewSourceVisibleIndex.value,
visibleColumnIndexes: visibleColumnIndexes.value,
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
rowNumberWidth: rowNumberWidth.value,
hoverCell: canvasHoverCell.value,
isScrolling: isScrolling.value,
editingCell: editingCell.value,
@@ -5058,8 +5136,7 @@ function drawCanvasGrid() {
cellIsSelected,
cellCanHover: canEditCellItem,
infiniteScrollEnabled: infiniteScrollEnabled.value,
pageSize: pageSize.value,
currentPage: currentPage.value,
pageOffset: rowNumberPageOffset(),
frozenColumnCount: frozenColumnCount.value,
columnAligns: columnAligns.value,
rightAlignedActionCell: canvasRightAlignedActionCell.value,
@@ -5924,11 +6001,11 @@ function scrollGridColumnIntoView(visibleColIdx: number) {
const colLeft = columnContentOffsetLeft(visibleColIdx);
const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0);
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
const viewportLeft = scroller.scrollLeft + DATA_GRID_ROW_NUM_WIDTH + frozenWidth;
const viewportLeft = scroller.scrollLeft + rowNumberWidth.value + frozenWidth;
const viewportRight = scroller.scrollLeft + scroller.clientWidth;
if (colLeft < viewportLeft) {
scroller.scrollLeft = Math.max(0, colLeft - DATA_GRID_ROW_NUM_WIDTH - frozenWidth);
scroller.scrollLeft = Math.max(0, colLeft - rowNumberWidth.value - frozenWidth);
} else if (colRight > viewportRight) {
scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth);
}
@@ -7470,45 +7547,40 @@ function stopLoadingElapsedTimer() {
function startLoadingElapsedTimer() {
stopLoadingElapsedTimer();
if (!dataGridIsActive || !props.loading) return;
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
_loadingStart = Date.now();
loadingElapsed.value = 0;
const updateOnNextFrame = () => {
if (!dataGridIsActive || !props.loading) return;
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
loadingElapsed.value = Date.now() - _loadingStart;
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
};
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
}
watch(
() => props.loading,
(isLoading) => {
stopLoadingElapsedTimer();
if (isDebugLoggingEnabled()) {
logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
}
if (isLoading) {
startLoadingElapsedTimer();
} else {
if (isDebugLoggingEnabled()) {
nextTick(() => {
requestAnimationFrame(() => {
logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
});
watch(gridSurfaceBusy, (isLoading) => {
stopLoadingElapsedTimer();
if (isDebugLoggingEnabled()) {
logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
}
if (isLoading) {
startLoadingElapsedTimer();
} else if (isDebugLoggingEnabled()) {
nextTick(() => {
requestAnimationFrame(() => {
logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
}
}
},
);
});
});
}
});
onActivated(() => {
startLoadingElapsedTimer();
@@ -8949,7 +9021,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</template>
</RecycleScroller>
<!-- Infinite scroll loading indicator for RecycleScroller -->
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !loading" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !gridSurfaceBusy" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
<Loader2 class="w-3 h-3 animate-spin mr-1" />
{{ t("grid.loadingMore") }}
</div>
@@ -8959,7 +9031,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<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="loading" class="absolute inset-0 z-20 bg-background/50 flex items-center justify-center">
<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">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ formatElapsedSeconds(loadingElapsed) }}s</span>
@@ -9349,11 +9421,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<span v-if="hasData" class="shrink-0">
{{ t(showTruncationWarning ? "grid.loadedRows" : "grid.totalRows", { count: result.rows.length }) }}
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountIsExact === false ? "grid.totalRowCountAtLeast" : "grid.totalRowCount", { count: displayedTotalRowCount }) }}</span>
<span v-else-if="totalRowCountBusy" class="text-muted-foreground/70">
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountLabelKey, { count: displayedTotalRowCount }) }}</span>
<span v-if="totalRowCountBusy" class="text-muted-foreground/70">
{{ t("grid.totalRowCountLoading") }}
</span>
<button v-else-if="canCalculateTotalRowCount" type="button" class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
<button v-else-if="showExactTotalCountAction" type="button" class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
{{ t("grid.calculateTotalRowsInline") }}
</button>
</span>
@@ -9385,7 +9457,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:pagination-enabled="paginationEnabled"
:selection-summary="selectionSummary"
:selection-summary-sum-text="selectionSummarySumText"
:loading="loading"
:loading="gridSurfaceBusy"
:infinite-scroll-enabled="infiniteScrollEnabled"
:infinite-scroll-all-loaded="infiniteScrollAllLoaded"
:page-size="pageSize"
@@ -86,11 +86,11 @@ const emit = defineEmits<{
</Tooltip>
</div>
</LightDropdown>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
<span class="shrink-0 tabular-nums">{{ currentPage }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
</template>
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
</div>
@@ -212,6 +212,10 @@ describe("DataGridPagination", () => {
expect(previousPage).toHaveBeenCalledOnce();
expect(nextPage).toHaveBeenCalledOnce();
expect(lastPage).toHaveBeenCalledOnce();
await mounted.setProps({ loading: true });
const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
expect(busyNavigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
});
it("hides pagination controls when the data source does not support paging", () => {
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { dataGridTotalRowCountLabelKey } from "@/lib/dataGrid/dataGridPagination";
import DataGrid from "../DataGrid.vue";
type VuePropDefinition = { default?: unknown };
@@ -8,5 +9,12 @@ describe("DataGrid total row count exactness", () => {
it("treats totals as exact unless a caller explicitly marks them as a lower bound", () => {
const component = DataGrid as unknown as VueComponentWithProps;
expect(component.props?.totalRowCountIsExact?.default).toBe(true);
expect(component.props?.inexactTotalRowCountMode?.default).toBe("at-least");
});
it("keeps lower-bound and estimated total labels distinct", () => {
expect(dataGridTotalRowCountLabelKey(true, "estimated")).toBe("grid.totalRowCount");
expect(dataGridTotalRowCountLabelKey(false, "at-least")).toBe("grid.totalRowCountAtLeast");
expect(dataGridTotalRowCountLabelKey(false, "estimated")).toBe("grid.totalRowCountEstimated");
});
});