mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(es): paginate past 10k documents via pit and search_after
This commit is contained in:
@@ -22,7 +22,7 @@ import type { DynamoDbIndexInfo, DynamoDbTableDescription } from "@/lib/backend/
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
||||
import { clampDocumentPage, clampElasticsearchRequestLimit, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
|
||||
import { ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW, clampDocumentPage, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
|
||||
import { canGoNextDocumentPage, isSameDocumentQueryTotalCountRequest, resolveDocumentQueryTotals, type DocumentQueryTotalCountRequest } from "@/lib/document/documentQueryTotals";
|
||||
import {
|
||||
arrayObjectAncestorPathForDocumentField,
|
||||
@@ -238,6 +238,7 @@ const pageTotal = computed(() => paginationTotal.value);
|
||||
const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value))));
|
||||
const canGoNextPage = computed(() => {
|
||||
if (documentStoreProvider.value.kind === "dynamodb") return dynamodbHasNextCursor.value;
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") return elasticsearchHasNextCursor.value;
|
||||
return canGoNextDocumentPage({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
@@ -247,7 +248,7 @@ const canGoNextPage = computed(() => {
|
||||
});
|
||||
const documentRequestLimit = computed(() => {
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch") return pageSize.value;
|
||||
return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
|
||||
return Math.min(pageSize.value, ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW);
|
||||
});
|
||||
|
||||
const tableFindPaneStyle = computed(() => {
|
||||
@@ -289,6 +290,8 @@ const dynamodbIndexName = ref("__table__");
|
||||
const dynamodbPageCursors = ref<Array<string | undefined>>([undefined]);
|
||||
const dynamodbHasNextCursor = ref(false);
|
||||
const dynamodbExactTotal = ref<number | undefined>();
|
||||
const elasticsearchPageCursors = ref<Array<string | undefined>>([undefined]);
|
||||
const elasticsearchHasNextCursor = ref(false);
|
||||
let dynamodbExactCountKey: string | null = null;
|
||||
|
||||
const dynamodbIndexOptions = computed<Array<{ value: string; label: string; index?: DynamoDbIndexInfo }>>(() => [
|
||||
@@ -440,7 +443,7 @@ const gridResult = computed<QueryResult>(() => {
|
||||
|
||||
async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
if (kind !== "mongodb" && kind !== "dynamodb") return undefined;
|
||||
if (kind !== "mongodb" && kind !== "dynamodb" && kind !== "elasticsearch") return undefined;
|
||||
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
@@ -456,44 +459,53 @@ async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExporte
|
||||
let exportedCopyDocuments: JsonRecord[] | undefined = kind === "mongodb" ? [] : undefined;
|
||||
let totalRows: number | null = null;
|
||||
let cursor: string | undefined;
|
||||
let lastCursor: string | undefined;
|
||||
const seenCursors = new Set<string>();
|
||||
|
||||
while (exportedDocuments.length < rowLimit) {
|
||||
const requestLimit = Math.min(batchSize, kind === "dynamodb" ? 1000 : Number.POSITIVE_INFINITY, rowLimit - exportedDocuments.length);
|
||||
if (requestLimit <= 0) break;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, kind === "dynamodb" ? 0 : exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId, cursor);
|
||||
const pageDocuments = result.documents.slice(0, requestLimit).map(asRecord);
|
||||
exportedDocuments.push(...pageDocuments);
|
||||
try {
|
||||
while (exportedDocuments.length < rowLimit) {
|
||||
const requestLimit = Math.min(batchSize, kind === "dynamodb" ? 1000 : kind === "elasticsearch" ? ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW : Number.POSITIVE_INFINITY, rowLimit - exportedDocuments.length);
|
||||
if (requestLimit <= 0) break;
|
||||
if (cursor) lastCursor = cursor;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, kind === "dynamodb" || kind === "elasticsearch" ? 0 : exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId, cursor, kind === "elasticsearch");
|
||||
const pageDocuments = result.documents.slice(0, requestLimit).map(asRecord);
|
||||
exportedDocuments.push(...pageDocuments);
|
||||
|
||||
if (kind === "mongodb" && exportedCopyDocuments) {
|
||||
if (result.extended_documents?.length === result.documents.length) {
|
||||
exportedCopyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length).map(asRecord));
|
||||
} else {
|
||||
exportedCopyDocuments = undefined;
|
||||
if (kind === "mongodb" && exportedCopyDocuments) {
|
||||
if (result.extended_documents?.length === result.documents.length) {
|
||||
exportedCopyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length).map(asRecord));
|
||||
} else {
|
||||
exportedCopyDocuments = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if ((kind === "mongodb" || kind === "elasticsearch") && result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
|
||||
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
|
||||
|
||||
if (kind === "dynamodb" || kind === "elasticsearch") {
|
||||
cursor = result.next_cursor;
|
||||
if (!cursor) break;
|
||||
if (seenCursors.has(cursor)) throw new Error(kind === "dynamodb" ? t("dynamodb.repeatedCursor") : "Elasticsearch cursor repeated during export");
|
||||
seenCursors.add(cursor);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reachedExactTotal = result.total_is_exact !== false && exportedDocuments.length >= result.total;
|
||||
if (pageDocuments.length === 0 || pageDocuments.length < requestLimit || reachedExactTotal) break;
|
||||
}
|
||||
|
||||
if (kind === "mongodb" && result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
|
||||
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
|
||||
|
||||
if (kind === "dynamodb") {
|
||||
cursor = result.next_cursor;
|
||||
if (!cursor) break;
|
||||
if (seenCursors.has(cursor)) throw new Error(t("dynamodb.repeatedCursor"));
|
||||
seenCursors.add(cursor);
|
||||
continue;
|
||||
} finally {
|
||||
if (kind === "elasticsearch") {
|
||||
const cursorToClose = lastCursor ?? cursor;
|
||||
if (cursorToClose) void closeElasticsearchCursor(cursorToClose);
|
||||
}
|
||||
|
||||
const reachedExactTotal = result.total_is_exact !== false && exportedDocuments.length >= result.total;
|
||||
if (pageDocuments.length === 0 || pageDocuments.length < requestLimit || reachedExactTotal) break;
|
||||
}
|
||||
|
||||
if (kind === "dynamodb") {
|
||||
if (kind === "dynamodb" || kind === "elasticsearch") {
|
||||
const truncatedByLimit = !!cursor && exportedDocuments.length >= rowLimit;
|
||||
totalRows = truncatedByLimit ? null : exportedDocuments.length;
|
||||
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
|
||||
if (truncatedByLimit) {
|
||||
toast(t("dynamodb.exportLimitReached", { count: rowLimit }), 6000);
|
||||
toast(kind === "dynamodb" ? t("dynamodb.exportLimitReached", { count: rowLimit }) : `Elasticsearch export limit reached (${rowLimit})`, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,7 +513,7 @@ async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExporte
|
||||
if (result.columns.length === 0) result.columns = gridResult.value.columns;
|
||||
result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : undefined;
|
||||
result.affected_rows = exportedDocuments.length;
|
||||
result.truncated = kind === "dynamodb" && !!cursor && exportedDocuments.length >= rowLimit;
|
||||
result.truncated = (kind === "dynamodb" || kind === "elasticsearch") && !!cursor && exportedDocuments.length >= rowLimit;
|
||||
result.has_more = result.truncated;
|
||||
return result;
|
||||
}
|
||||
@@ -744,6 +756,22 @@ function resetDynamoDbPagination(options: { preserveExactCount?: boolean } = {})
|
||||
if (!options.preserveExactCount) resetDynamoDbExactCount();
|
||||
}
|
||||
|
||||
async function closeElasticsearchCursor(cursor?: string) {
|
||||
if (!cursor) return;
|
||||
try {
|
||||
await api.closeQuerySession(props.connectionId, props.database, cursor);
|
||||
} catch (error) {
|
||||
console.warn("[DBX] failed to close Elasticsearch cursor", error);
|
||||
}
|
||||
}
|
||||
|
||||
function resetElasticsearchPagination() {
|
||||
const cursor = [...elasticsearchPageCursors.value].reverse().find((candidate): candidate is string => !!candidate);
|
||||
if (cursor) void closeElasticsearchCursor(cursor);
|
||||
elasticsearchPageCursors.value = [undefined];
|
||||
elasticsearchHasNextCursor.value = false;
|
||||
}
|
||||
|
||||
function currentDocumentFilter(): string | undefined {
|
||||
const filter = currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
|
||||
if (documentStoreProvider.value.kind !== "dynamodb" || dynamodbIndexName.value === "__table__") return filter;
|
||||
@@ -808,7 +836,7 @@ const documentQueryPreview = computed(() => {
|
||||
collection: props.collection,
|
||||
filterJson: filter,
|
||||
sortJson: sortInput.value.trim(),
|
||||
skip: page.value * pageSize.value,
|
||||
skip: documentStoreProvider.value.kind === "elasticsearch" ? 0 : page.value * pageSize.value,
|
||||
limit: documentRequestLimit.value,
|
||||
});
|
||||
});
|
||||
@@ -1248,6 +1276,7 @@ function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean }
|
||||
paginationTotal.value = nextTotals.paginationTotal;
|
||||
total.value = nextTotals.total;
|
||||
totalIsExact.value = nextTotals.totalIsExact;
|
||||
resetElasticsearchPagination();
|
||||
}
|
||||
|
||||
function clampPageToPaginationTotal(): number | undefined {
|
||||
@@ -1352,16 +1381,18 @@ async function load(options: { page?: number; append?: boolean; offset?: number;
|
||||
resetElasticsearchTotals();
|
||||
}
|
||||
const sort = currentDocumentSortJson(sortInput.value);
|
||||
const cursor = storeKind === "dynamodb" ? dynamodbPageCursors.value[requestPage] : undefined;
|
||||
if (storeKind === "dynamodb" && requestPage > 0 && !cursor) {
|
||||
throw new Error(t("dynamodb.pageCursorUnavailable"));
|
||||
const cursor = storeKind === "dynamodb" ? dynamodbPageCursors.value[requestPage] : storeKind === "elasticsearch" ? elasticsearchPageCursors.value[requestPage] : undefined;
|
||||
if ((storeKind === "dynamodb" || storeKind === "elasticsearch") && requestPage > 0 && !cursor) {
|
||||
throw new Error(storeKind === "dynamodb" ? t("dynamodb.pageCursorUnavailable") : "Elasticsearch page cursor unavailable; go back to the first page and page forward again");
|
||||
}
|
||||
const skip = storeKind === "dynamodb" ? 0 : (options.offset ?? requestPage * pageSize.value);
|
||||
const requestedLimit = options.limit ?? (storeKind === "elasticsearch" ? documentPageRequestLimit(requestPage, pageSize.value, paginationTotal.value) : pageSize.value);
|
||||
// Elasticsearch rejects from+size beyond index.max_result_window even when an explicit limit
|
||||
// (e.g. from the per-tab page size control) bypassed the default-path clamp above.
|
||||
const requestLimit = storeKind === "elasticsearch" ? clampElasticsearchRequestLimit(skip, requestedLimit) : requestedLimit;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, skip, requestLimit, filter, undefined, sort, undefined, executionId, cursor);
|
||||
// Starting a fresh ES first page invalidates any previous PIT cursor stack.
|
||||
if (storeKind === "elasticsearch" && !cursor && requestPage === 0) {
|
||||
resetElasticsearchPagination();
|
||||
}
|
||||
const skip = storeKind === "dynamodb" || storeKind === "elasticsearch" ? 0 : (options.offset ?? requestPage * pageSize.value);
|
||||
const requestedLimit = options.limit ?? pageSize.value;
|
||||
const requestLimit = storeKind === "elasticsearch" ? Math.min(requestedLimit, ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW) : requestedLimit;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, skip, requestLimit, filter, undefined, sort, undefined, executionId, cursor, storeKind === "elasticsearch");
|
||||
if (documentLoadExecutionId.value !== executionId) return;
|
||||
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
|
||||
const nextDocuments =
|
||||
@@ -1387,6 +1418,10 @@ async function load(options: { page?: number; append?: boolean; offset?: number;
|
||||
dynamodbHasNextCursor.value = !!result.next_cursor;
|
||||
}
|
||||
if (storeKind === "elasticsearch") {
|
||||
const nextCursors = elasticsearchPageCursors.value.slice(0, requestPage + 1);
|
||||
nextCursors[requestPage + 1] = result.next_cursor ?? undefined;
|
||||
elasticsearchPageCursors.value = nextCursors;
|
||||
elasticsearchHasNextCursor.value = !!result.next_cursor;
|
||||
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
|
||||
} else if (storeKind === "dynamodb") {
|
||||
cancelElasticsearchCount();
|
||||
@@ -1501,28 +1536,34 @@ async function paginate(offset: number, limit: number) {
|
||||
}
|
||||
const pageSizeChanged = normalizedLimit !== pageSize.value;
|
||||
pageSize.value = normalizedLimit;
|
||||
if (documentStoreProvider.value.kind === "dynamodb" && pageSizeChanged) {
|
||||
if (pageSizeChanged && (documentStoreProvider.value.kind === "dynamodb" || documentStoreProvider.value.kind === "elasticsearch")) {
|
||||
page.value = 0;
|
||||
resetDynamoDbPagination({ preserveExactCount: true });
|
||||
if (documentStoreProvider.value.kind === "dynamodb") {
|
||||
resetDynamoDbPagination({ preserveExactCount: true });
|
||||
} else {
|
||||
resetElasticsearchPagination();
|
||||
}
|
||||
await load({ page: 0 });
|
||||
return;
|
||||
}
|
||||
const requestedPage = Math.floor(normalizedOffset / normalizedLimit);
|
||||
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
|
||||
if (documentStoreProvider.value.kind !== "dynamodb") {
|
||||
if (documentStoreProvider.value.kind !== "dynamodb" && documentStoreProvider.value.kind !== "elasticsearch") {
|
||||
await load({ page: nextPage, offset: nextPage * normalizedLimit, limit: normalizedLimit });
|
||||
return;
|
||||
}
|
||||
for (let cursorPage = 0; cursorPage <= nextPage; cursorPage += 1) {
|
||||
if (cursorPage > 0 && !dynamodbPageCursors.value[cursorPage]) {
|
||||
error.value = t("dynamodb.pageCursorUnavailable");
|
||||
const pageCursor = documentStoreProvider.value.kind === "dynamodb" ? dynamodbPageCursors.value[cursorPage] : elasticsearchPageCursors.value[cursorPage];
|
||||
if (cursorPage > 0 && !pageCursor) {
|
||||
error.value = documentStoreProvider.value.kind === "dynamodb" ? t("dynamodb.pageCursorUnavailable") : "Elasticsearch page cursor unavailable; go back to the first page and page forward again";
|
||||
return;
|
||||
}
|
||||
if (cursorPage === nextPage) {
|
||||
await load({ page: cursorPage });
|
||||
return;
|
||||
}
|
||||
if (!dynamodbPageCursors.value[cursorPage + 1]) {
|
||||
const nextCursor = documentStoreProvider.value.kind === "dynamodb" ? dynamodbPageCursors.value[cursorPage + 1] : elasticsearchPageCursors.value[cursorPage + 1];
|
||||
if (!nextCursor) {
|
||||
await load({ page: cursorPage });
|
||||
}
|
||||
}
|
||||
@@ -2041,6 +2082,7 @@ onBeforeUnmount(() => {
|
||||
documentRequestGeneration++;
|
||||
loadedDocumentQueryTotalCountRequest = undefined;
|
||||
cancelElasticsearchCount();
|
||||
resetElasticsearchPagination();
|
||||
stopDocumentLoadingTimer();
|
||||
endTableSearchSplitResize();
|
||||
});
|
||||
@@ -2317,7 +2359,7 @@ defineExpose({ focusSearch });
|
||||
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
|
||||
:pagination-total-row-count="pageTotal"
|
||||
:count-total-rows="countExactDocumentTotal"
|
||||
:full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' ? exportAllDocumentStoreDocuments : undefined"
|
||||
:full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' || documentStoreProvider.kind === 'elasticsearch' ? exportAllDocumentStoreDocuments : undefined"
|
||||
@sort="onSort"
|
||||
@reload="refreshDocuments"
|
||||
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
|
||||
|
||||
+36
-2
@@ -14,6 +14,7 @@ const backend = vi.hoisted(() => ({
|
||||
documentUpdateDocument: vi.fn(),
|
||||
documentDeleteDocument: vi.fn(),
|
||||
documentSaveMeilisearchBatch: vi.fn(),
|
||||
closeQuerySession: vi.fn(),
|
||||
}));
|
||||
|
||||
const documentJsonEditor = vi.hoisted(() => ({
|
||||
@@ -79,6 +80,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
documentUpdateDocument: backend.documentUpdateDocument,
|
||||
documentDeleteDocument: backend.documentDeleteDocument,
|
||||
documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch,
|
||||
closeQuerySession: backend.closeQuerySession,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
@@ -315,6 +317,7 @@ beforeEach(async () => {
|
||||
backend.documentUpdateDocument.mockReset();
|
||||
backend.documentDeleteDocument.mockReset();
|
||||
backend.documentSaveMeilisearchBatch.mockReset();
|
||||
backend.closeQuerySession.mockReset();
|
||||
dataGrid.fullExportResult = undefined;
|
||||
dataGrid.customSaveHandler = undefined;
|
||||
dataGrid.countTotalRows = undefined;
|
||||
@@ -889,8 +892,39 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 1, totalRows: null });
|
||||
});
|
||||
|
||||
it("does not offer the MongoDB full export callback to Elasticsearch viewers", () => {
|
||||
expect(dataGrid.fullExportResult).toBeUndefined();
|
||||
it("exports Elasticsearch documents with cursor pagination", async () => {
|
||||
settings.editorSettings.exportBatchSize = 2;
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments
|
||||
.mockResolvedValueOnce({
|
||||
documents: [
|
||||
{ _id: "one", title: "First" },
|
||||
{ _id: "two", title: "Second" },
|
||||
],
|
||||
total: 3,
|
||||
total_is_exact: true,
|
||||
next_cursor: "cursor-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
documents: [{ _id: "three", title: "Third" }],
|
||||
total: 3,
|
||||
total_is_exact: true,
|
||||
});
|
||||
|
||||
expect(dataGrid.fullExportResult).toBeTypeOf("function");
|
||||
const result = await dataGrid.fullExportResult!();
|
||||
await flushUi();
|
||||
|
||||
expect(backend.documentFindDocuments.mock.calls.map((call) => [call[3], call[4], call[10], call[11]])).toEqual([
|
||||
[0, 2, undefined, true],
|
||||
[0, 2, "cursor-1", true],
|
||||
]);
|
||||
expect(result?.rows).toEqual([
|
||||
["one", "First"],
|
||||
["two", "Second"],
|
||||
["three", "Third"],
|
||||
]);
|
||||
expect(backend.closeQuerySession).toHaveBeenCalledWith("connection-1", "", "cursor-1");
|
||||
});
|
||||
|
||||
it("identifies consistently numeric MongoDB columns for shared grid alignment", async () => {
|
||||
|
||||
+4
@@ -260,6 +260,10 @@ describe("DocumentBrowser infinite scroll (issue #6455)", () => {
|
||||
await mountBrowser(databaseType);
|
||||
|
||||
expect(backend.documentFindDocuments.mock.calls[0]!.slice(0, 5)).toEqual(["mongo-1", "test", "docs", 0, 5]);
|
||||
if (databaseType === "elasticsearch") {
|
||||
expect(backend.documentFindDocuments.mock.calls[0]![10]).toBeUndefined();
|
||||
expect(backend.documentFindDocuments.mock.calls[0]![11]).toBe(true);
|
||||
}
|
||||
expect(dataGrid.pageLimit).toBe(5);
|
||||
expect(dataGrid.pageSizePreference).toBe("table-open");
|
||||
});
|
||||
|
||||
@@ -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;");
|
||||
|
||||
@@ -1791,6 +1791,7 @@ defineExpose({
|
||||
:total-row-count="activeTab.resultTotalRowCount"
|
||||
:total-row-count-is-exact="activeTab.resultTotalRowCount !== undefined || activeTab.result.total_is_exact !== false"
|
||||
:total-row-count-loading="activeTab.resultTotalRowCountLoading"
|
||||
:page-jump-progress="activeTab.resultPageJumpProgress"
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
|
||||
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
|
||||
:query-result-export-request="
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from "vue";
|
||||
import { computed, reactive } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridActions } from "@/composables/useDataGridActions";
|
||||
import { clearTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||
@@ -130,6 +130,7 @@ describe("useDataGridActions", () => {
|
||||
beforeEach(() => {
|
||||
clearTableMetadataCache();
|
||||
vi.clearAllMocks();
|
||||
mocks.executeTabSql.mockReset();
|
||||
mocks.tabs.length = 0;
|
||||
mocks.tableOpenPageSize = 100;
|
||||
mocks.infiniteScroll = true;
|
||||
@@ -543,6 +544,71 @@ describe("useDataGridActions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("advances Elasticsearch cursors when jumping across pages", async () => {
|
||||
mocks.infiniteScroll = false;
|
||||
mocks.getConfig.mockReturnValue({ id: "elasticsearch-1", db_type: "elasticsearch" });
|
||||
const tab = reactive({
|
||||
id: "tab-1",
|
||||
connectionId: "elasticsearch-1",
|
||||
database: "",
|
||||
title: "Query",
|
||||
sql: "SELECT * FROM `dbx-app-logs-v1` AS dalv",
|
||||
resultBaseSql: "SELECT * FROM `dbx-app-logs-v1` AS dalv",
|
||||
resultPageLimit: 100,
|
||||
resultPageOffset: 0,
|
||||
resultSessionId: "cursor-1",
|
||||
resultClientSessionId: "client-1",
|
||||
result: {
|
||||
columns: ["message"],
|
||||
rows: [["page-1"]],
|
||||
affected_rows: 100_000,
|
||||
execution_time_ms: 1,
|
||||
session_id: "cursor-1",
|
||||
has_more: true,
|
||||
},
|
||||
mode: "query",
|
||||
isDirty: false,
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
} as QueryTab);
|
||||
mocks.executeTabSql.mockImplementation(async (_id, _sql, options) => {
|
||||
const pagination = options.pagination as { offset: number };
|
||||
expect(tab.resultPageJumpProgress).toEqual({
|
||||
completedRequests: pagination.offset / 100 - 1,
|
||||
totalRequests: 3,
|
||||
targetPage: 4,
|
||||
});
|
||||
if (options.retainDisplayedResult) {
|
||||
expect(tab.resultPageOffset).toBe(0);
|
||||
tab.resultSessionId = `cursor-${pagination.offset / 100 + 1}`;
|
||||
return;
|
||||
}
|
||||
tab.resultPageOffset = pagination.offset;
|
||||
tab.resultSessionId = `cursor-${pagination.offset / 100 + 1}`;
|
||||
tab.result = {
|
||||
columns: ["message"],
|
||||
rows: [[`page-${pagination.offset / 100 + 1}`]],
|
||||
affected_rows: 100_000,
|
||||
execution_time_ms: 1,
|
||||
session_id: `cursor-${pagination.offset / 100 + 1}`,
|
||||
has_more: true,
|
||||
};
|
||||
});
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onPaginate(300, 100);
|
||||
|
||||
expect(mocks.executeTabSql.mock.calls.map((call) => call[2].pagination)).toEqual([
|
||||
{ offset: 100, limit: 100, sessionId: "cursor-1", clientSessionId: "client-1" },
|
||||
{ offset: 200, limit: 100, sessionId: "cursor-2", clientSessionId: "client-1" },
|
||||
{ offset: 300, limit: 100, sessionId: "cursor-3", clientSessionId: "client-1" },
|
||||
]);
|
||||
expect(mocks.executeTabSql.mock.calls.map((call) => call[2].retainDisplayedResult)).toEqual([true, true, undefined]);
|
||||
expect(tab.resultPageOffset).toBe(300);
|
||||
expect(tab.resultPageJumpProgress).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the active multi-database result target for pagination", async () => {
|
||||
const tab = {
|
||||
id: "tab-1",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||||
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { elasticsearchCursorPageJumpRequestCount } from "@/lib/dataGrid/dataGridPagination";
|
||||
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
|
||||
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
|
||||
import * as api from "@/lib/backend/api";
|
||||
@@ -335,26 +336,100 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
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, {
|
||||
...activeQueryTargetOptions(tab),
|
||||
resultBaseSql,
|
||||
resultSortedSql: tab.resultSortedSql,
|
||||
...(hasDatabaseSort
|
||||
? {
|
||||
querySort: {
|
||||
resultColumns: sortColumns.resultColumns,
|
||||
columnIndex: sortColumns.columnIndex,
|
||||
column: tab.resultSortColumn!,
|
||||
direction: tab.resultSortDirection!,
|
||||
},
|
||||
const executePage = (pageOffset: number, pageSessionId?: string, retainDisplayedResult = false) =>
|
||||
queryStore.executeTabSql(tab.id, baseSql, {
|
||||
...activeQueryTargetOptions(tab),
|
||||
resultBaseSql,
|
||||
resultSortedSql: tab.resultSortedSql,
|
||||
...(hasDatabaseSort
|
||||
? {
|
||||
querySort: {
|
||||
resultColumns: sortColumns.resultColumns,
|
||||
columnIndex: sortColumns.columnIndex,
|
||||
column: tab.resultSortColumn!,
|
||||
direction: tab.resultSortDirection!,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
pagination: { offset: pageOffset, limit, sessionId: pageSessionId, clientSessionId: pageSessionId ? tab.resultClientSessionId : undefined },
|
||||
...appendOptions,
|
||||
preserveResultDuringExecution: true,
|
||||
preserveTotalRowCountDuringExecution: true,
|
||||
replaceActiveResultInGroup: true,
|
||||
...(retainDisplayedResult ? { retainDisplayedResult: true } : {}),
|
||||
});
|
||||
const executionTarget = queryStore.activeResultExecutionTarget(tab.id);
|
||||
const executionConnection = connectionStore.getConfig(executionTarget?.connectionId ?? tab.connectionId);
|
||||
const effectiveDbType = effectiveDatabaseTypeForConnection(executionConnection);
|
||||
const usesElasticsearchCursor = effectiveDbType === "elasticsearch" || effectiveDbType === "easysearch";
|
||||
|
||||
// Elasticsearch search_after has no random page access, so every missing
|
||||
// cursor must be fetched in order. This workaround still sends one ES
|
||||
// request per skipped page (page 1 -> 101 sends 100 requests);
|
||||
// retainDisplayedResult only hides those intermediate pages from the UI.
|
||||
const currentResultSessionId = tab.resultSessionId ?? tab.result?.session_id;
|
||||
if (usesElasticsearchCursor && !appendResult && typeof expectedNextOffset === "number" && limit === tab.resultPageLimit && currentResultSessionId) {
|
||||
let nextOffset = expectedNextOffset;
|
||||
let nextSessionId: string | undefined = currentResultSessionId;
|
||||
const currentPage = Math.floor(nextOffset / limit);
|
||||
const targetPage = Math.floor(offset / limit) + 1;
|
||||
const totalRequests = elasticsearchCursorPageJumpRequestCount(currentPage, targetPage);
|
||||
const jumpProgress =
|
||||
totalRequests > 1
|
||||
? {
|
||||
completedRequests: 0,
|
||||
totalRequests,
|
||||
targetPage,
|
||||
}
|
||||
: undefined;
|
||||
if (jumpProgress) {
|
||||
tab.resultPageJumpProgress = jumpProgress;
|
||||
}
|
||||
const activeJumpProgress = tab.resultPageJumpProgress;
|
||||
const executeCursorPage = async (pageOffset: number, pageSessionId?: string, retainDisplayedResult = false) => {
|
||||
await executePage(pageOffset, pageSessionId, retainDisplayedResult);
|
||||
if (activeJumpProgress) {
|
||||
activeJumpProgress.completedRequests += 1;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (offset < nextOffset) {
|
||||
await executeCursorPage(0, undefined, offset !== 0);
|
||||
if (offset === 0) {
|
||||
return;
|
||||
}
|
||||
: {}),
|
||||
pagination: { offset, limit, sessionId, clientSessionId: sessionId ? tab.resultClientSessionId : undefined },
|
||||
...appendOptions,
|
||||
preserveResultDuringExecution: true,
|
||||
preserveTotalRowCountDuringExecution: true,
|
||||
replaceActiveResultInGroup: true,
|
||||
});
|
||||
const restartedTab = activeTab.value;
|
||||
if (restartedTab?.id !== tab.id) {
|
||||
return;
|
||||
}
|
||||
nextOffset = limit;
|
||||
nextSessionId = restartedTab.resultSessionId;
|
||||
}
|
||||
while (nextOffset < offset) {
|
||||
if (!nextSessionId) {
|
||||
return;
|
||||
}
|
||||
await executeCursorPage(nextOffset, nextSessionId, true);
|
||||
const advancedTab = activeTab.value;
|
||||
if (advancedTab?.id !== tab.id) {
|
||||
return;
|
||||
}
|
||||
nextOffset += limit;
|
||||
nextSessionId = advancedTab.resultSessionId;
|
||||
}
|
||||
if (nextOffset === offset && nextSessionId) {
|
||||
await executeCursorPage(offset, nextSessionId);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
if (tab.resultPageJumpProgress === activeJumpProgress) {
|
||||
tab.resultPageJumpProgress = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await executePage(offset, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1539,6 +1539,12 @@ export default {
|
||||
totalRowCountEstimated: "(~{count})",
|
||||
totalRowCountLoading: "(counting...)",
|
||||
loadingMore: "Loading more data...",
|
||||
pageJumpLoading: "Jumping to page {page}",
|
||||
pageJumpProgress: "Requests completed {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "Confirm deep Elasticsearch page jump",
|
||||
esDeepPageJumpConfirmMessage:
|
||||
"Elasticsearch search_after cannot jump directly to a page. DBX must fetch missing cursors in order, and backward navigation restarts from page 1. Jumping to page {page} is expected to send {requests} queries. This may take a long time and increase cluster load. Continue?",
|
||||
esDeepPageJumpContinue: "Continue",
|
||||
allLoaded: "all loaded",
|
||||
calculateTotalRows: "Count total rows",
|
||||
calculateTotalRowsInline: "(count total rows)",
|
||||
|
||||
@@ -1480,6 +1480,12 @@ export default withEnglishFallback({
|
||||
totalRowCountEstimated: "(aprox. {count})",
|
||||
totalRowCountLoading: "(contando...)",
|
||||
loadingMore: "Cargando más datos...",
|
||||
pageJumpLoading: "Saltando a la página {page}",
|
||||
pageJumpProgress: "Solicitudes completadas {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "Confirmar salto profundo en Elasticsearch",
|
||||
esDeepPageJumpConfirmMessage:
|
||||
"search_after de Elasticsearch no puede saltar directamente a una página. DBX debe obtener en orden los cursores que faltan y, al retroceder, comienza de nuevo desde la página 1. Saltar a la página {page} enviará aproximadamente {requests} consultas. Puede tardar y aumentar la carga del clúster. ¿Continuar?",
|
||||
esDeepPageJumpContinue: "Continuar",
|
||||
allLoaded: "todo cargado",
|
||||
calculateTotalRows: "Contar filas totales",
|
||||
calculateTotalRowsInline: "(contar filas totales)",
|
||||
|
||||
@@ -1478,6 +1478,12 @@ export default withEnglishFallback({
|
||||
totalRowCountEstimated: "(circa {count})",
|
||||
totalRowCountLoading: "(conteggio...)",
|
||||
loadingMore: "Caricamento altri dati...",
|
||||
pageJumpLoading: "Passaggio alla pagina {page}",
|
||||
pageJumpProgress: "Richieste completate {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "Conferma salto profondo in Elasticsearch",
|
||||
esDeepPageJumpConfirmMessage:
|
||||
"search_after di Elasticsearch non può passare direttamente a una pagina. DBX deve recuperare in ordine i cursori mancanti e, tornando indietro, riparte dalla pagina 1. Il passaggio alla pagina {page} invierà circa {requests} query. Potrebbe richiedere tempo e aumentare il carico del cluster. Continuare?",
|
||||
esDeepPageJumpContinue: "Continua",
|
||||
allLoaded: "tutto caricato",
|
||||
calculateTotalRows: "Conta righe totali",
|
||||
calculateTotalRowsInline: "(conta righe totali)",
|
||||
|
||||
@@ -2002,6 +2002,12 @@ export default withEnglishFallback({
|
||||
truncatedHint: "結果は{count}行を読み込んだ時点で切り詰められました。フッターのページネーションで読み込み済みデータを参照できます。完全な結果のエクスポート時はデータベースへ再問い合わせします。",
|
||||
victoriaMetricsTruncatedHint: "結果が読み込み上限を超えたため、先頭の{count}行のみ読み込みました。1ページあたりの行数は読み込み済みデータだけをページ分割します。完全な結果を取得するには、時間範囲を短くするかフィルターを追加してください。",
|
||||
loadingMore: "さらにデータを読み込み中...",
|
||||
pageJumpLoading: "ページ {page} に移動中",
|
||||
pageJumpProgress: "完了したリクエスト {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "Elasticsearch のディープページ移動を確認",
|
||||
esDeepPageJumpConfirmMessage:
|
||||
"Elasticsearch の search_after では指定ページへ直接移動できません。DBX は不足しているカーソルを順番に取得し、前のページへ戻る場合は 1 ページ目からやり直します。ページ {page} への移動では約 {requests} 回のクエリが送信される見込みです。時間がかかり、クラスター負荷が増える可能性があります。続行しますか?",
|
||||
esDeepPageJumpContinue: "移動を続ける",
|
||||
allLoaded: "すべて読み込み済み",
|
||||
previewSqlEmpty: "プレビューする保留中のSQL変更はありません",
|
||||
renderMode: "レンダーモード",
|
||||
|
||||
@@ -1420,6 +1420,12 @@ export default withEnglishFallback({
|
||||
totalRowCountEstimated: "(약 {count})",
|
||||
totalRowCountLoading: "(집계 중...)",
|
||||
loadingMore: "데이터를 더 불러오는 중...",
|
||||
pageJumpLoading: "{page}페이지로 이동 중",
|
||||
pageJumpProgress: "완료된 요청 {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "Elasticsearch 심층 페이지 이동 확인",
|
||||
esDeepPageJumpConfirmMessage:
|
||||
"Elasticsearch의 search_after는 지정한 페이지로 바로 이동할 수 없습니다. DBX는 누락된 커서를 순서대로 가져와야 하며 이전 페이지로 이동할 때는 1페이지부터 다시 시작합니다. {page}페이지로 이동하면 약 {requests}개의 쿼리가 전송됩니다. 시간이 오래 걸리고 클러스터 부하가 증가할 수 있습니다. 계속하시겠습니까?",
|
||||
esDeepPageJumpContinue: "계속 이동",
|
||||
allLoaded: "모두 로드됨",
|
||||
calculateTotalRows: "전체 행 수 집계",
|
||||
calculateTotalRowsInline: "(전체 행 수 집계)",
|
||||
|
||||
@@ -1480,6 +1480,12 @@ export default withEnglishFallback({
|
||||
totalRowCountEstimated: "(aprox. {count})",
|
||||
totalRowCountLoading: "(contando...)",
|
||||
loadingMore: "Carregando mais dados...",
|
||||
pageJumpLoading: "Indo para a página {page}",
|
||||
pageJumpProgress: "Solicitações concluídas {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "Confirmar salto profundo no Elasticsearch",
|
||||
esDeepPageJumpConfirmMessage:
|
||||
"O search_after do Elasticsearch não pode ir diretamente para uma página. O DBX precisa buscar os cursores ausentes em ordem e, ao voltar, reinicia pela página 1. Ir para a página {page} deve enviar cerca de {requests} consultas. Isso pode demorar e aumentar a carga do cluster. Continuar?",
|
||||
esDeepPageJumpContinue: "Continuar",
|
||||
allLoaded: "tudo carregado",
|
||||
calculateTotalRows: "Contar total de linhas",
|
||||
calculateTotalRowsInline: "(contar total de linhas)",
|
||||
|
||||
@@ -1463,6 +1463,11 @@ export default withEnglishFallback({
|
||||
totalRowCountEstimated: "(约 {count} 行)",
|
||||
totalRowCountLoading: "(统计中...)",
|
||||
loadingMore: "加载更多数据...",
|
||||
pageJumpLoading: "正在跳转到第 {page} 页",
|
||||
pageJumpProgress: "已完成请求 {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "确认 Elasticsearch 深分页跳转",
|
||||
esDeepPageJumpConfirmMessage: "Elasticsearch 的 search_after 不能直接定位到指定页面。DBX 必须按顺序获取缺失的游标,跳回较小页码时还会从第 1 页重新开始。跳转到第 {page} 页预计会发送 {requests} 次查询,可能耗时较长并增加集群负载。是否继续?",
|
||||
esDeepPageJumpContinue: "继续跳转",
|
||||
allLoaded: "已全部加载",
|
||||
calculateTotalRows: "统计总行数",
|
||||
calculateTotalRowsInline: "(统计总行数)",
|
||||
|
||||
@@ -1479,6 +1479,11 @@ export default withEnglishFallback({
|
||||
totalRowCountEstimated: "(約 {count} 筆)",
|
||||
totalRowCountLoading: "(統計中...)",
|
||||
loadingMore: "載入更多資料...",
|
||||
pageJumpLoading: "正在跳轉到第 {page} 頁",
|
||||
pageJumpProgress: "已完成請求 {current}/{total}",
|
||||
esDeepPageJumpConfirmTitle: "確認 Elasticsearch 深分頁跳轉",
|
||||
esDeepPageJumpConfirmMessage: "Elasticsearch 的 search_after 無法直接定位到指定頁面。DBX 必須依序取得缺少的游標,跳回較小頁碼時還會從第 1 頁重新開始。跳轉到第 {page} 頁預計會傳送 {requests} 次查詢,可能耗時較長並增加叢集負載。是否繼續?",
|
||||
esDeepPageJumpContinue: "繼續跳轉",
|
||||
allLoaded: "已全部載入",
|
||||
calculateTotalRows: "統計總筆數",
|
||||
calculateTotalRowsInline: "(統計總筆數)",
|
||||
|
||||
+1
-17
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampDocumentPage, clampElasticsearchRequestLimit, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
|
||||
import { clampDocumentPage, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
|
||||
|
||||
describe("Elasticsearch document totals", () => {
|
||||
it("keeps a lower-bound search total separate from an exact background count", () => {
|
||||
@@ -39,21 +39,5 @@ describe("Elasticsearch document totals", () => {
|
||||
it("clamps the final request without exceeding the conservative page cap", () => {
|
||||
expect(clampDocumentPage(30, 333, 10_000)).toBe(30);
|
||||
expect(clampDocumentPage(31, 333, 10_000)).toBe(30);
|
||||
expect(documentPageRequestLimit(30, 333, 10_000)).toBe(10);
|
||||
expect(documentPageRequestLimit(0, 100, undefined)).toBe(100);
|
||||
});
|
||||
|
||||
it("caps the initial page request to Elasticsearch's default max_result_window even without a known total (#7231)", () => {
|
||||
// Opening an index for the first time has no paginationTotal yet; a "rows per page" preference
|
||||
// set above 10,000 must not be forwarded to Elasticsearch as-is, or the from+size search fails outright.
|
||||
expect(documentPageRequestLimit(0, 100_000, undefined)).toBe(10_000);
|
||||
expect(documentPageRequestLimit(0, 5_000, undefined)).toBe(5_000);
|
||||
});
|
||||
|
||||
it("clamps an explicit request limit against the remaining from+size window at a given skip", () => {
|
||||
expect(clampElasticsearchRequestLimit(0, 100_000)).toBe(10_000);
|
||||
expect(clampElasticsearchRequestLimit(9_000, 5_000)).toBe(1_000);
|
||||
expect(clampElasticsearchRequestLimit(20_000, 5_000)).toBe(0);
|
||||
expect(clampElasticsearchRequestLimit(0, 500)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3797,7 +3797,20 @@ export async function mongoFindOne(connectionId: string, database: string, colle
|
||||
});
|
||||
}
|
||||
|
||||
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string, cursor?: string): Promise<DocumentQueryResult> {
|
||||
export async function documentFindDocuments(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
collection: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
filter?: string,
|
||||
projection?: string,
|
||||
sort?: string,
|
||||
collation?: string,
|
||||
executionId?: string,
|
||||
cursor?: string,
|
||||
cursorPagination?: boolean,
|
||||
): Promise<DocumentQueryResult> {
|
||||
return post("/api/document-store/find-documents", {
|
||||
connectionId,
|
||||
database,
|
||||
@@ -3809,6 +3822,7 @@ export async function documentFindDocuments(connectionId: string, database: stri
|
||||
sort,
|
||||
collation,
|
||||
cursor,
|
||||
cursorPagination,
|
||||
executionId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3790,7 +3790,20 @@ export async function mongoParseShellCommand(source: string): Promise<MongoComma
|
||||
return normalizeRustMongoCommand(raw);
|
||||
}
|
||||
|
||||
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string, cursor?: string): Promise<DocumentQueryResult> {
|
||||
export async function documentFindDocuments(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
collection: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
filter?: string,
|
||||
projection?: string,
|
||||
sort?: string,
|
||||
collation?: string,
|
||||
executionId?: string,
|
||||
cursor?: string,
|
||||
cursorPagination?: boolean,
|
||||
): Promise<DocumentQueryResult> {
|
||||
return invoke("document_find_documents", {
|
||||
connectionId,
|
||||
database,
|
||||
@@ -3802,6 +3815,7 @@ export async function documentFindDocuments(connectionId: string, database: stri
|
||||
sort,
|
||||
collation,
|
||||
cursor,
|
||||
cursorPagination,
|
||||
executionId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS, elasticsearchCursorPageJumpRequestCount } from "@/lib/dataGrid/dataGridPagination";
|
||||
|
||||
describe("Elasticsearch cursor page jumps", () => {
|
||||
it("counts only missing forward cursors", () => {
|
||||
expect(elasticsearchCursorPageJumpRequestCount(1, 101)).toBe(100);
|
||||
expect(elasticsearchCursorPageJumpRequestCount(101, 102)).toBe(1);
|
||||
});
|
||||
|
||||
it("counts a backward jump from the page-one restart", () => {
|
||||
expect(elasticsearchCursorPageJumpRequestCount(104, 103)).toBe(103);
|
||||
expect(elasticsearchCursorPageJumpRequestCount(104, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("warns starting at one hundred sequential requests", () => {
|
||||
expect(elasticsearchCursorPageJumpRequestCount(1, 100)).toBeLessThan(ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS);
|
||||
expect(elasticsearchCursorPageJumpRequestCount(1, 101)).toBe(ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,21 @@ export interface CanFetchNextDataGridSegmentOptions {
|
||||
|
||||
export type DataGridInexactTotalRowCountMode = "at-least" | "estimated";
|
||||
|
||||
export const ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS = 100;
|
||||
|
||||
export function elasticsearchCursorPageJumpRequestCount(currentPage: number, targetPage: number): number {
|
||||
if (!Number.isSafeInteger(currentPage) || !Number.isSafeInteger(targetPage) || currentPage < 1 || targetPage < 1 || currentPage === targetPage) {
|
||||
return 0;
|
||||
}
|
||||
if (targetPage > currentPage) {
|
||||
return targetPage - currentPage;
|
||||
}
|
||||
|
||||
// search_after only moves forward. The current workaround rebuilds the
|
||||
// cursor from page 1 when navigating backward, including the target request.
|
||||
return targetPage;
|
||||
}
|
||||
|
||||
export function dataGridTruncationHintKey(databaseType?: DatabaseType): "grid.truncatedHint" | "grid.victoriaMetricsTruncatedHint" {
|
||||
return databaseType === "victoriametrics" ? "grid.victoriaMetricsTruncatedHint" : "grid.truncatedHint";
|
||||
}
|
||||
|
||||
@@ -45,20 +45,3 @@ export function clampDocumentPage(page: number, pageSize: number, paginationTota
|
||||
// DBX can't know a target index's actual configured value up front, so it clamps requests to the
|
||||
// out-of-the-box default rather than letting an oversized "rows per page" preference reach the cluster as-is.
|
||||
export const ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW = 10_000;
|
||||
|
||||
// Clamps a requested page size against the from+size window still available at a given skip offset,
|
||||
// regardless of where the requested limit came from (a settings default or an explicit override).
|
||||
export function clampElasticsearchRequestLimit(skip: number, limit: number): number {
|
||||
const normalizedSkip = Math.max(0, Math.floor(skip));
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
return Math.min(normalizedLimit, Math.max(0, ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW - normalizedSkip));
|
||||
}
|
||||
|
||||
export function documentPageRequestLimit(page: number, pageSize: number, paginationTotal?: number): number {
|
||||
const normalizedPageSize = Math.max(1, Math.floor(pageSize));
|
||||
const skip = Math.max(0, Math.floor(page)) * normalizedPageSize;
|
||||
const cappedPageSize = clampElasticsearchRequestLimit(skip, normalizedPageSize);
|
||||
if (paginationTotal === undefined) return cappedPageSize;
|
||||
const remaining = paginationTotal - skip;
|
||||
return remaining > 0 ? Math.min(cappedPageSize, remaining) : cappedPageSize;
|
||||
}
|
||||
|
||||
@@ -969,6 +969,63 @@ describe("queryStore hidden primary key editing", () => {
|
||||
expect(tab.result?.rows[100]).toEqual([101]);
|
||||
});
|
||||
|
||||
it("advances an Elasticsearch cursor without replacing the displayed page", async () => {
|
||||
getConnectionConfig.mockReturnValue({ id: "elasticsearch-1", name: "Elasticsearch", db_type: "elasticsearch", query_timeout_secs: 30 });
|
||||
analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "complex-query" });
|
||||
prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({
|
||||
sqlToExecute: options.sql,
|
||||
pageSql: options.sql,
|
||||
pageLimit: options.pagination.limit,
|
||||
pageOffset: options.pagination.offset,
|
||||
countSql: undefined,
|
||||
useAgentResultSession: true,
|
||||
}));
|
||||
executeMulti
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
columns: ["message"],
|
||||
rows: [["page-1"]],
|
||||
affected_rows: 20_000,
|
||||
execution_time_ms: 1,
|
||||
session_id: "cursor-1",
|
||||
has_more: true,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
columns: ["message"],
|
||||
rows: [["page-2"]],
|
||||
affected_rows: 20_000,
|
||||
execution_time_ms: 1,
|
||||
session_id: "cursor-2",
|
||||
has_more: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("elasticsearch-1", "", "Query");
|
||||
const sql = "SELECT * FROM `dbx-app-logs-v1` AS dalv";
|
||||
|
||||
await store.executeTabSql(tabId, sql, { pagination: { limit: 100, offset: 0 } });
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
const displayedResult = tab.result;
|
||||
|
||||
await store.executeTabSql(tabId, sql, {
|
||||
resultBaseSql: sql,
|
||||
pagination: { limit: 100, offset: 100, sessionId: "cursor-1", clientSessionId: tab.resultClientSessionId },
|
||||
preserveResultDuringExecution: true,
|
||||
preserveTotalRowCountDuringExecution: true,
|
||||
replaceActiveResultInGroup: true,
|
||||
retainDisplayedResult: true,
|
||||
});
|
||||
|
||||
expect(tab.result).toBe(displayedResult);
|
||||
expect(tab.result?.rows).toEqual([["page-1"]]);
|
||||
expect(tab.resultPageOffset).toBe(0);
|
||||
expect(tab.resultSessionId).toBe("cursor-2");
|
||||
});
|
||||
|
||||
it("does not start an Oracle manual transaction after cancellation during metadata loading", async () => {
|
||||
const columnsGate = deferred<Awaited<ReturnType<typeof getColumns>>>();
|
||||
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 });
|
||||
|
||||
@@ -4526,6 +4526,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
preserveTotalRowCountDuringExecution?: boolean;
|
||||
preserveActiveResultIndex?: boolean;
|
||||
replaceActiveResultInGroup?: boolean;
|
||||
retainDisplayedResult?: boolean;
|
||||
skipRedisSafetyCheck?: boolean;
|
||||
sourceOffset?: number;
|
||||
sourceTraceId?: string;
|
||||
@@ -5454,6 +5455,15 @@ export const useQueryStore = defineStore("query", () => {
|
||||
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;
|
||||
const retainedPaginationResult = options?.retainDisplayedResult === true && results.length === 1 && !isQueryExecutionErrorResult(results[0]!) ? results[0] : undefined;
|
||||
if (retainedPaginationResult) {
|
||||
// Cursor-only fetches advance the backend session without publishing
|
||||
// intermediate rows or page offsets to the visible result grid.
|
||||
current.resultSessionId = retainedPaginationResult.session_id ?? undefined;
|
||||
current.resultClientSessionId = current.resultSessionId ? executionClientSessionId : undefined;
|
||||
producedResult = true;
|
||||
return producedResult;
|
||||
}
|
||||
if (batchResume) {
|
||||
const mergedResults = mergeBatchQueryResults(batchResume.previousResults, results);
|
||||
const preferredResult = results.find((result) => isQueryExecutionErrorResult(result)) ?? results[results.length - 1] ?? mergedResults[mergedResults.length - 1];
|
||||
|
||||
@@ -1076,6 +1076,12 @@ export interface ExternalSqlFileVersion {
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
export interface QueryPageJumpProgress {
|
||||
completedRequests: number;
|
||||
totalRequests: number;
|
||||
targetPage: number;
|
||||
}
|
||||
|
||||
export interface QueryTab {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -1119,6 +1125,8 @@ export interface QueryTab {
|
||||
resultTotalRowCountLoading?: boolean;
|
||||
resultSessionId?: string;
|
||||
resultClientSessionId?: string;
|
||||
/** Ephemeral UI progress for sequential Elasticsearch cursor requests. */
|
||||
resultPageJumpProgress?: QueryPageJumpProgress;
|
||||
resultAccessedAt?: number;
|
||||
resultEstimatedBytes?: number;
|
||||
resultCacheKey?: string;
|
||||
|
||||
@@ -61,6 +61,23 @@ pub async fn find_documents(
|
||||
.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
pub async fn find_documents_with_cursor(
|
||||
client: &EasysearchClient,
|
||||
index: &str,
|
||||
limit: i64,
|
||||
filter: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
cursor: Option<&str>,
|
||||
) -> Result<DocumentQueryResult, String> {
|
||||
elasticsearch_driver::find_documents_with_cursor(&client.inner, index, limit, filter, sort, cursor)
|
||||
.await
|
||||
.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
pub async fn close_cursor(client: &EasysearchClient, cursor: &str) -> Result<(), String> {
|
||||
elasticsearch_driver::close_cursor(&client.inner, cursor).await.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
pub async fn count_documents(client: &EasysearchClient, index: &str, filter: Option<&str>) -> Result<u64, String> {
|
||||
elasticsearch_driver::count_documents(&client.inner, index, filter).await.map_err(easysearch_error)
|
||||
}
|
||||
@@ -97,7 +114,17 @@ pub async fn delete_document(
|
||||
}
|
||||
|
||||
pub async fn execute_rest_query(client: &EasysearchClient, input: &str) -> Result<QueryResult, String> {
|
||||
elasticsearch_driver::execute_rest_query_with_sql_parser(&client.inner, input, parse_sql_response)
|
||||
elasticsearch_driver::execute_rest_query_with_sql_parser(&client.inner, input, parse_sql_response, None)
|
||||
.await
|
||||
.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
pub async fn execute_rest_query_with_cursor(
|
||||
client: &EasysearchClient,
|
||||
input: &str,
|
||||
cursor: Option<&str>,
|
||||
) -> Result<QueryResult, String> {
|
||||
elasticsearch_driver::execute_rest_query_with_sql_parser(&client.inner, input, parse_sql_response, cursor)
|
||||
.await
|
||||
.map_err(easysearch_error)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -424,6 +424,7 @@ pub async fn find_documents_core(
|
||||
sort: Option<&str>,
|
||||
collation: Option<&str>,
|
||||
cursor: Option<&str>,
|
||||
cursor_pagination: bool,
|
||||
) -> Result<DocumentQueryResult, String> {
|
||||
ensure_document_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
@@ -445,12 +446,20 @@ pub async fn find_documents_core(
|
||||
PoolKind::Elasticsearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
elasticsearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
|
||||
if cursor_pagination {
|
||||
elasticsearch_driver::find_documents_with_cursor(&client, collection, limit, filter, sort, cursor).await
|
||||
} else {
|
||||
elasticsearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
|
||||
}
|
||||
}
|
||||
PoolKind::Easysearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
easysearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
|
||||
if cursor_pagination {
|
||||
easysearch_driver::find_documents_with_cursor(&client, collection, limit, filter, sort, cursor).await
|
||||
} else {
|
||||
easysearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
|
||||
}
|
||||
}
|
||||
PoolKind::Meilisearch(client) => {
|
||||
let client = client.clone();
|
||||
|
||||
@@ -225,6 +225,7 @@ pub async fn mongo_find_documents_core(
|
||||
sort,
|
||||
collation,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1997,7 +1997,11 @@ async fn do_execute_typed(
|
||||
let result = wait_for_query_opt(
|
||||
cancel_token,
|
||||
query_timeout,
|
||||
db::elasticsearch_driver::execute_rest_query(&client, &sql),
|
||||
db::elasticsearch_driver::execute_rest_query_with_cursor(
|
||||
&client,
|
||||
&sql,
|
||||
options.result_session_id.as_deref(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map(|result| truncate_result_with_max_rows(result, max_rows));
|
||||
@@ -2014,7 +2018,11 @@ async fn do_execute_typed(
|
||||
let result = wait_for_query_opt(
|
||||
cancel_token,
|
||||
query_timeout,
|
||||
db::easysearch_driver::execute_rest_query(&client, &sql),
|
||||
db::easysearch_driver::execute_rest_query_with_cursor(
|
||||
&client,
|
||||
&sql,
|
||||
options.result_session_id.as_deref(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map(|result| truncate_result_with_max_rows(result, max_rows));
|
||||
@@ -2763,6 +2771,18 @@ pub async fn close_query_session(
|
||||
.await
|
||||
.map(|value| value.get("ok").and_then(|ok| ok.as_bool()).unwrap_or(false))
|
||||
}
|
||||
PoolKind::Elasticsearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
db::elasticsearch_driver::close_cursor(&client, session_id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
PoolKind::Easysearch(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
db::easysearch_driver::close_cursor(&client, session_id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ pub struct DocumentFindRequest {
|
||||
pub sort: Option<String>,
|
||||
pub collation: Option<String>,
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cursor_pagination: bool,
|
||||
pub execution_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -330,6 +332,7 @@ pub async fn find_documents(
|
||||
req.sort.as_deref(),
|
||||
req.collation.as_deref(),
|
||||
req.cursor.as_deref(),
|
||||
req.cursor_pagination,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -444,6 +444,7 @@ pub async fn find_documents(
|
||||
req.sort.as_deref(),
|
||||
req.collation.as_deref(),
|
||||
None,
|
||||
false,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
+10
-3
@@ -214,10 +214,14 @@ test("auto-redirect: total is undefined — guard prevents redirect attempt", ()
|
||||
assert.equal(!total || (total as any) <= 0, true, "guard should prevent redirect when total is unknown");
|
||||
});
|
||||
|
||||
test("only an explicit last-page COUNT blocks the grid surface", () => {
|
||||
test("explicit loads, last-page COUNTs, and page jumps block the grid surface; background counts do not", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /const totalRowCountBusy = computed\(\(\) => props\.totalRowCountLoading === true \|\| manualTotalRowCountLoading\.value\)/);
|
||||
assert.match(source, /const gridSurfaceBusy = computed\(\(\) => isRefreshingData\.value \|\| props\.loading === true \|\| manualTotalRowCountLoading\.value\)/);
|
||||
assert.match(source, /const gridSurfaceBusy = computed\(\(\) => isRefreshingData\.value \|\| props\.loading === true \|\| manualTotalRowCountLoading\.value \|\| pageJumpBusy\.value\)/);
|
||||
assert.match(source, /const pageJumpBusy = computed\(\(\) => !!props\.pageJumpProgress && props\.pageJumpProgress\.totalRequests > 1\)/);
|
||||
// totalRowCountBusy must stay out of gridSurfaceBusy so background counts keep the grid interactive (#5967)
|
||||
const busyLine = source.match(/const gridSurfaceBusy = computed\([^\n]*/)?.[0] ?? "";
|
||||
assert.ok(!busyLine.includes("totalRowCountBusy"), "background counts must not block the grid surface");
|
||||
assert.match(source, /const gridPaginationBusy = computed\(\(\) => gridSurfaceBusy\.value \|\| totalRowCountBusy\.value\)/);
|
||||
assert.match(source, /v-if="gridSurfaceBusy"/);
|
||||
assert.match(source, /:loading="gridPaginationBusy"/);
|
||||
@@ -244,7 +248,10 @@ test("jumping to last page does not rewrite indexes before the new page loads",
|
||||
const jumpFn = source.match(/function jumpToCountedLastPage\(total: number\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
assert.match(jumpFn, /resolveDataGridPaginationTotal/);
|
||||
assert.match(jumpFn, /maxRows: paginationMaxRows\.value/);
|
||||
assert.match(jumpFn, /emit\("paginate"/);
|
||||
// Last-page jumps delegate to requestServerPageJump (ES deep-jump confirm) which still emits "paginate" without bumping currentPage
|
||||
assert.match(jumpFn, /requestServerPageJump\(lastPageNum\)/);
|
||||
const executeJumpFn = source.match(/function executeServerPageJump[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
assert.match(executeJumpFn, /emit\("paginate"/);
|
||||
assert.doesNotMatch(jumpFn, /currentPage\.value\s*=/);
|
||||
assert.match(source, /function rowNumberPageOffset/);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ pub async fn document_find_documents(
|
||||
sort: Option<String>,
|
||||
collation: Option<String>,
|
||||
cursor: Option<String>,
|
||||
cursor_pagination: Option<bool>,
|
||||
execution_id: Option<String>,
|
||||
) -> Result<DocumentQueryResult, String> {
|
||||
let app = state.inner().clone();
|
||||
@@ -77,6 +78,7 @@ pub async fn document_find_documents(
|
||||
sort.as_deref(),
|
||||
collation.as_deref(),
|
||||
cursor.as_deref(),
|
||||
cursor_pagination.unwrap_or(false),
|
||||
),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -138,6 +138,7 @@ pub async fn mongo_find_documents(
|
||||
sort,
|
||||
collation,
|
||||
None,
|
||||
None,
|
||||
execution_id,
|
||||
)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user